mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
docs: show a client reading people and open updates (#2524)
docs/reference/daemon-http-clients.md now has a recipe for a remote client that needs people and their updates. It covers: - the registered participants and the default person (`GET /api/gateway/participants`); - a person's open informational updates (`GET /api/queue/open-human-updates`, with its 1-100 limit, person view and `truncated` flag); - one rig's outbox (`outbox/list?rig=`). It also states the limits: the default person isn't authentication, an update isn't a delivered receipt, and a person view's window can short a page. A new test drives the documented requests against the real routes.
This commit is contained in:
1 parent
826bc1cc5c
commit
de3b0658a6
2 files changed
+208
No files matched your search
@@ -84,6 +84,65 @@ All three are server-sent events (`text/event-stream`):
|
||||
`queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue`,
|
||||
`inbox.absorbed` and `inbox.denied`.
|
||||
|
||||
## People, asks and updates without Slack
|
||||
|
||||
`GET /api/gateway/participants` returns the same JSON projection as `rig gateway participants --json`:
|
||||
`{ ok, participants, default, organizations, groups, warnings, participantFile }`. Pick a person by `kind: "person"`
|
||||
and use their `id` for a Feed `view`, or their `address` for the queue's `destinationSession`. The effective
|
||||
`default` is `{ id, source }`, or `{ id: null, reason }`; it does not establish who is using the client.
|
||||
Participant records omit connector credential pointers. Check `warnings` and `participantFile.error` for a partial
|
||||
registry read; an unreadable human registry returns HTTP 503. See [Gateway participants](gateway-participants.md).
|
||||
|
||||
| Read | Meaning |
|
||||
| --- | --- |
|
||||
| `GET /api/queue/list?destinationSession=<address>&state=pending,in-progress,blocked&limit=100` | Open rows for exactly that address. `humanIntent: "update"` identifies an FYI; other values are asks. Each row has `qitemId`, `state`, `summary` and `body`. |
|
||||
| `GET /api/queue/open-human-updates?view=<person-id>&limit=20` | Open human FYIs, even when no Slack delivery receipt exists. Returns `{ items, limit, truncated, perspective? }`. |
|
||||
| `GET /api/queue/<qitemId>` and `/<qitemId>/transitions` | The current row and its history, including closed state and recorded outcome. Closing or acknowledging a row is different from delivering it. |
|
||||
| `GET /api/queue/human-updates?view=<person-id>&limit=20` | Existing delivered-update history: explicit FYIs with a recorded Slack posting receipt, including closed rows. Items add `deliveredAt` and `deliveryReceipt`. It is not the open-update inbox. |
|
||||
|
||||
Both update lists accept integer `limit` values from 1 to 100 (default 20); invalid values return HTTP 400.
|
||||
Open FYIs are ordered by priority, then newest first. `view=all` (also the default) shows all perspectives;
|
||||
`view=default` selects the effective default person, or all when no default is available. A named view that cannot
|
||||
resolve a person has `perspective.basis: "unknown"` and no items when a registry is available. With no registered
|
||||
people there is no perspective filter: inspect `perspective` rather than assuming the requested view was applied.
|
||||
A person's view also includes instance or unassigned items when that person is the default. These are views on a
|
||||
trusted daemon, not per-person authentication or access control; existing daemon and browser access rules still apply.
|
||||
|
||||
Always display `truncated: true` as incomplete, even if fewer than `limit` items arrive. A person-view scan examines
|
||||
at most 1,001 open updates (101 for delivered history) before filtering; older matching items may be outside that
|
||||
window. These endpoints have no pagination cursor. Reading an open update never posts it to Slack, acknowledges it,
|
||||
or manufactures a delivery receipt.
|
||||
|
||||
For example, this Node client takes a daemon URL, a registered person id and a rig name. It uses no Slack API and
|
||||
makes only reads; use the daemon discovery and access rules above. In an ES module, `process.argv.slice(1)` is the
|
||||
argument list for `node --input-type=module -e '<code>' <url> <person-id> <rig>`:
|
||||
|
||||
```js
|
||||
// client-people-example
|
||||
const [base, personId, rig] = process.argv.slice(1);
|
||||
async function get(path) {
|
||||
const response = await fetch(new URL(path, base));
|
||||
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
const registry = await get("/api/gateway/participants");
|
||||
const person = registry.participants.find(p => p.kind === "person" && p.id === personId);
|
||||
if (!person) throw new Error("Choose a registered person id");
|
||||
const rows = await get(`/api/queue/list?destinationSession=${encodeURIComponent(person.address)}&state=pending,in-progress,blocked&limit=100`);
|
||||
const updates = await get(`/api/queue/open-human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
|
||||
const receipts = await get(`/api/queue/human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
|
||||
const rigHistory = await get(`/api/queue/list?rig=${encodeURIComponent(rig)}&limit=100`);
|
||||
const messages = await get(`/api/queue/outbox/list?rig=${encodeURIComponent(rig)}&limit=100`);
|
||||
console.log(JSON.stringify({ participants: registry.participants, default: registry.default,
|
||||
warnings: registry.warnings, participantFile: registry.participantFile,
|
||||
asks: rows.filter(row => row.humanIntent !== "update"), updates, receipts, rigHistory, messages }, null, 2));
|
||||
```
|
||||
|
||||
The queue and outbox lists are bounded snapshots, not subscriptions. A full queue list may need a larger `limit`.
|
||||
An outbox entry's `deliveryState` reports the send attempt; only `delivered` confirms delivery. A queue transition,
|
||||
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
|
||||
`outbox/list?senderSession=<session>` read remains available.
|
||||
|
||||
## Route details
|
||||
|
||||
- **A rig's queue history:** `GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// A client in a separate Node process uses documented HTTP reads against the production
|
||||
// gateway/queue routes and browser boundary. No connector, native agent or shared daemon.
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { once } from "node:events";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository, type QueueItem } from "../src/domain/queue-repository.js";
|
||||
import { gatewayRoutes } from "../src/routes/gateway.js";
|
||||
import { setDefaultPerson } from "../src/domain/gateway/participants.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
import { queueRoutes } from "../src/routes/queue.js";
|
||||
import { browserBoundary } from "../src/middleware/browser-boundary.js";
|
||||
import { addHumanFragment, loadHumanRegistry } from "../src/domain/gateway/human-registry.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
const reference = readFileSync(new URL("../../../docs/reference/daemon-http-clients.md", import.meta.url), "utf8");
|
||||
const client = reference.match(/```js\n(\/\/ client-people-example\n[\s\S]*?)```/)?.[1];
|
||||
if (!client) throw new Error("Documented client example not found");
|
||||
const pathClient = "const [base,path]=process.argv.slice(1); const r=await fetch(base+path); " +
|
||||
"if(!r.ok) throw new Error('HTTP '+r.status); console.log(JSON.stringify(await r.json()));";
|
||||
const boundaryClient = "const [base,paths]=process.argv.slice(1); const statuses=[]; " +
|
||||
"for(const path of JSON.parse(paths)) statuses.push((await fetch(base+path,{headers:{Origin:'https://unrelated.invalid'}})).status); " +
|
||||
"console.log(JSON.stringify(statuses));";
|
||||
|
||||
async function fixture() {
|
||||
const home = mkdtempSync(join(tmpdir(), "client-people-reads-"));
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
const db = createDb(); migrate(db, ALL_MIGRATIONS);
|
||||
const queue = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => loadHumanRegistry(home), validateRig: () => true });
|
||||
const outbox = new OutboxHandler(db);
|
||||
const app = new Hono();
|
||||
app.use("/api/*", browserBoundary({ webUiEnabled: false, bearerTokens: [], discoverSelfNames: async () => [] }));
|
||||
app.use("*", async (c, next) => { c.set("queueRepo" as never, queue as never); c.set("outboxHandler" as never, outbox as never); await next(); });
|
||||
app.route("/api/queue", queueRoutes());
|
||||
app.route("/api/gateway", gatewayRoutes({ home }));
|
||||
const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 });
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("No owned HTTP listener");
|
||||
const base = "http://127.0.0.1:" + address.port;
|
||||
const execute = async (code: string, args: string[]) => {
|
||||
const result = await run(process.execPath, ["--input-type=module", "-e", code, base, ...args],
|
||||
{ timeout: 10_000, env: {}, maxBuffer: 1024 * 1024 });
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
const read = (person: string) => execute(client, [person, "fixture"]);
|
||||
const register = (id: string, slack = false) => {
|
||||
const result = addHumanFragment({ entityId: id, class: "human", displayName: id,
|
||||
address: id + "@external", connectorBindings: slack
|
||||
? [{ kind: "slack", connectorRef: "fixture-slack", secretsRef: "vault://fixture/never-return",
|
||||
role: "primary", handle: "U" + id.toUpperCase() }] : [], prefs: { deliveryClass: "B" } }, home);
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
};
|
||||
const row = async (id: string, destination: string, intent: "decision" | "update" = "update", state = "pending") => {
|
||||
const item = await queue.create({ qitemId: id, sourceSession: "author@fixture", destinationSession: destination,
|
||||
body: "Body " + id, summary: "Summary " + id, humanIntent: intent, evidenceRef: "proof/" + id, nudge: false });
|
||||
if (state !== "pending") db.prepare("UPDATE queue_items SET state = ?, resolution = ? WHERE qitem_id = ?")
|
||||
.run(state, state === "done" ? "Acknowledged" : null, id);
|
||||
return queue.getById(item.qitemId)!;
|
||||
};
|
||||
return { home, db, queue, outbox, register, row, read, path: (path: string) => execute(pathClient, [path]), boundary: (paths: string[]) => execute(boundaryClient, [JSON.stringify(paths)]), close: async () => {
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
||||
expect(server.listening).toBe(false);
|
||||
db.close(); vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true });
|
||||
} };
|
||||
}
|
||||
|
||||
it("the documented separate-process client reads people, asks, FYIs, closure and receipts without Slack", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.register("alex"); f.register("sam", true);
|
||||
expect(setDefaultPerson("alex", f.home).ok).toBe(true);
|
||||
await f.row("ask", "alex@external", "decision");
|
||||
await f.row("fyi", "alex@external");
|
||||
await f.row("working", "alex@external", "update", "in-progress");
|
||||
await f.row("acknowledged", "alex@external", "update", "done");
|
||||
await f.row("slack-open", "sam@external");
|
||||
await f.row("posted", "sam@external", "update", "done");
|
||||
f.queue.transitionLog.append({ qitemId: "posted", state: "done", actorSession: "author@fixture",
|
||||
transitionNote: "slack-owner-notification-posted 1000.0001" });
|
||||
for (const [outboxId, senderSession, destinationSession] of [
|
||||
["inside", "author@fixture", "worker@fixture"],
|
||||
["inbound", "author@elsewhere", "worker@fixture"],
|
||||
["unrelated", "author@elsewhere", "worker@elsewhere"],
|
||||
]) f.outbox.record({ outboxId, senderSession, destinationSession, body: outboxId });
|
||||
f.outbox.markDelivered("inside");
|
||||
await f.queue.create({ qitemId: "unrelated", sourceSession: "author@elsewhere",
|
||||
destinationSession: "worker@elsewhere", body: "Other rig", nudge: false });
|
||||
const before = f.db.serialize();
|
||||
const result = await f.read("alex");
|
||||
expect(result.participants.map((p: { id: string }) => p.id)).toEqual(["alex", "sam"]);
|
||||
expect(result.default).toEqual({ id: "alex", source: "chosen" });
|
||||
expect(result.asks.map((q: QueueItem) => q.qitemId)).toEqual(["ask"]);
|
||||
expect(result.asks[0]).toMatchObject({ state: "pending", humanIntent: "decision" });
|
||||
expect(result.updates.items.map((q: QueueItem) => q.qitemId).sort()).toEqual(["fyi", "working"]);
|
||||
expect(result.updates).toMatchObject({ limit: 20, truncated: false,
|
||||
perspective: { viewing: "alex@external", basis: "chosen-here" } });
|
||||
for (const item of result.updates.items) {
|
||||
expect(item.humanIntent).toBe("update");
|
||||
expect(item).not.toHaveProperty("deliveredAt"); expect(item).not.toHaveProperty("deliveryReceipt");
|
||||
}
|
||||
expect(result.receipts.items).toEqual([]);
|
||||
expect(result.rigHistory.map((q: QueueItem) => q.qitemId).sort())
|
||||
.toEqual(["acknowledged", "ask", "fyi", "posted", "slack-open", "working"]);
|
||||
expect(result.rigHistory.find((q: QueueItem) => q.qitemId === "acknowledged"))
|
||||
.toMatchObject({ state: "done", resolution: "Acknowledged" });
|
||||
expect(result.messages.map((m: { outboxId: string }) => m.outboxId).sort()).toEqual(["inbound", "inside"]);
|
||||
expect(result.messages.find((m: { outboxId: string }) => m.outboxId === "inside"))
|
||||
.toMatchObject({ deliveryState: "delivered", deliveredAt: expect.any(String) });
|
||||
expect(result.messages.find((m: { outboxId: string }) => m.outboxId === "inbound"))
|
||||
.toMatchObject({ deliveryState: "pending", deliveredAt: null });
|
||||
const perSeat = await f.path("/api/queue/outbox/list?senderSession=author%40elsewhere");
|
||||
expect(perSeat.map((m: { outboxId: string }) => m.outboxId).sort()).toEqual(["inbound", "unrelated"]);
|
||||
const slack = await f.read("sam");
|
||||
expect(slack.asks).toEqual([]);
|
||||
expect(slack.updates.items.map((q: QueueItem) => q.qitemId)).toEqual(["slack-open"]);
|
||||
expect(slack.receipts.items).toMatchObject([{ qitemId: "posted", state: "done", humanIntent: "update",
|
||||
deliveredAt: expect.any(String), deliveryReceipt: "slack-owner-notification-posted 1000.0001" }]);
|
||||
expect(JSON.stringify([result, slack])).not.toContain("vault://");
|
||||
expect(JSON.stringify([result, slack])).not.toContain("secretsRef");
|
||||
expect(f.db.serialize()).toEqual(before);
|
||||
console.info("[client recipe]", JSON.stringify({ default: result.default, asks: result.asks.map((q: QueueItem) => q.qitemId),
|
||||
updates: result.updates.items.map((q: QueueItem) => q.qitemId).sort(), receiptsWithoutSlack: result.receipts.items.length,
|
||||
closed: "acknowledged", rigHistory: result.rigHistory.length, rigOutbox: result.messages.map((m: { outboxId: string }) => m.outboxId).sort(),
|
||||
slackReceipts: slack.receipts.items.map((q: QueueItem) => q.qitemId), readOnly: true, credentialPointers: false }));
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
|
||||
it("the documented read families retain the browser-origin boundary", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.register("alex");
|
||||
const before = f.db.serialize();
|
||||
const statuses = await f.boundary(["/api/gateway/participants", "/api/queue/list?destinationSession=alex%40external",
|
||||
"/api/queue/open-human-updates?view=alex", "/api/queue/human-updates?view=alex", "/api/queue/outbox/list?rig=fixture"]);
|
||||
expect(statuses).toEqual([403, 403, 403, 403, 403]);
|
||||
expect(f.db.serialize()).toEqual(before);
|
||||
console.info("[client boundary]", JSON.stringify({ statuses, readOnly: true }));
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
Reference in new issue
Block a user