Compare commits

...
Author SHA1 Message Date
dev-driver a4edbd726d feat(tui): answer the Feed as one named person, chosen with rig tui --as
With several named people, the TUI no longer answers every ask as its own
recipient. The Feed answers as one person: the one chosen with
`rig tui --as <person>` (an id or address), else the default person. It
says who, and that it's a choice made in that terminal, not a verified
identity. Answer acts are offered only on that person's own asks; other
people's asks are still shown.

- /api/attention takes `as` and serves `viewer` (person, basis, chosen).
  With no registered person, or an unreadable registry, there's no viewer
  and asks are answered as their recipient, as before. An ask to a bare
  human seat stays answerable.
- A choice that names no registered person, or several people with no
  default, answers nothing on a person's ask, and the Feed says how to
  choose.
- The TUI shows a correspondence message from the daemon as its notice.
2026-10-24 02:09:55 +00:00
dev-driver 6ae95dc8d4 feat(gateway): keep another person's answer as correspondence, not the asked person's
With several named people, one person answering, approving or resolving what
was asked of another now speaks for themselves. Their words are kept on the
row as a note in their own name, and the caller is told so plainly. The ask
keeps its state, its recorded answers and its reply id, emits no resolution,
and still waits for the person it asked, whose own answer completes it as
usual.

- answer: a different registered person's words or choice are recorded as
  correspondence; the asked person's answer path is unchanged.
- approve: a different registered person's approve of a person's ask leaves
  it open.
- resolve: a question parked on a person stays parked when someone else
  resolves it.
- Slack: a different person's typed reply to a parked question no longer
  resumes it; their reply still reaches the agent as their own message. A
  click by someone else stays ignored, as before.

Agents, the asked person under another spelling, and asks to no registered
person keep today's behaviour.
2026-10-24 02:04:18 +00:00
dev-driver 07307c238c feat(gateway): register several named people, with an explicit default person
rig gateway human add now registers a second (and further) distinct person
instead of refusing. When the first extra person is added, the existing person
is marked the default (a `default: true` field on their fragment), so anything
that needs "the person" without naming one keeps reaching them.

- One rule, selectDefaultHuman / defaultHumanFor: workspace.operator_seat_name
  when it names a registered person, else the marked default, else the only
  person. With several people and none chosen there is no answer, never the
  first file.
- The five places that took "the only person" or entities[0] use it: workflow
  person selection, Mission Control's queue, the wake ladder's person rung
  (undeliverable:no-default-human when none is chosen), the digest of notices
  (asks and updates already go to their own recipients), and host pairing.
- rig gateway human set <id> default yes|no moves or clears the default; two
  defaults are a registry error; default must be true or false.
- list marks the default; show says where Slack posts land (a shared channel,
  never private: there's no DM route) and whether the person is the default.
- The single-human advisory now appears only with several people and no
  default, and says how to choose.
2026-10-24 01:44:28 +00:00
25 changed files with 681 additions and 85 deletions

No files matched your search

+1 -1
View File
@@ -241,7 +241,7 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig tui` | — | `--shared` |
| `rig tui` | — | `--shared`, `--as <person>` |
| `rig tui commands` | — | `--json` |
### package
+8 -24
View File
@@ -189,26 +189,6 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
addHumanFragment: typeof AddHumanFragment;
parseBindingSpec: (spec: string) => { ok: true; binding: Record<string, unknown> } | { ok: false; error: string };
};
// Fix-r1 F1 (R2 blocking, A1/R5): the add VERB is the single-human boundary. A second
// DISTINCT human is refused BEFORE any parse or write — the several-fragment state A1
// describes as hand-authored must never be produced by the product's own surface.
// Fail closed: an unverifiable registry refuses too (never add into an unknown state).
const existing = registry.listHumans();
if (!existing.ok) {
console.error(`refused: cannot verify the single-human boundary — ${existing.error}`);
process.exitCode = 1;
return;
}
if (existing.humans.length > 0 && !existing.humans.some((h) => h.entityId === entityId)) {
const ids = existing.humans.map((h) => h.entityId).join(", ");
console.error(
`refused: a human is already configured (${ids}). OpenRig supports one configured human, so \`rig gateway human add\` manages one; ` +
`to register someone else, remove the existing one first (\`rig gateway human remove <entityId>\`). Fragments hand-authored under ${registry.humansDir()} ` +
`are displayed as found, with an advisory, but no command manages several.`,
);
process.exitCode = 1;
return;
}
const bindings: Record<string, unknown>[] = [];
for (const spec of opts.binding) {
const b = parseBindingSpec(spec);
@@ -236,7 +216,9 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
return { value: res, after: { digest }, effect: before.ok && registry.channelStateDigest(before.record.connectorBindings.map(({ inboundResolvable: _derived, ...binding }) => binding)) === digest ? "no-op" : "applied" };
},
});
console.log(JSON.stringify({ ok: true, entityId, path: result.value.path, receipt: result.receipt }));
console.log(JSON.stringify({ ok: true, entityId, path: result.value.path, receipt: result.receipt,
...(result.value.madeDefault ? { madeDefault: result.value.madeDefault,
note: `${result.value.madeDefault} is now the default person (asks that name no one keep going to them); change it with rig gateway human set <id> default yes` } : {}) }));
} catch (error) { console.error(`refused: ${(error as Error).message}`); process.exitCode = 1; }
})
.addHelpText("after", `
@@ -259,7 +241,7 @@ Example:
human
.command("list")
.description("Show the configured human (OpenRig supports one; extra hand-authored fragments are listed with an advisory)")
.description("Show the registered people, marking the default (who an ask goes to when nothing names a person)")
.option("--json", "Complete record(s) as JSON")
.action(async (opts: { json?: boolean }) => {
const { listHumans } = await import("@openrig/daemon/gateway-human-registry");
@@ -274,7 +256,7 @@ Example:
if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"); return; }
for (const h of humans) {
const inbound = h.bindings.inboundResolvable ? "" : " [outbound-only]";
console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}) delivery=${h.deliveryReadiness.state}${inbound}`);
console.log(`${h.entityId}${h.default ? " (default)" : ""} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}) delivery=${h.deliveryReadiness.state}${inbound}`);
if (!h.deliveryReadiness.ready) console.log(` delivery: ${h.deliveryReadiness.reason}${h.deliveryReadiness.nextAction ? `; next: ${h.deliveryReadiness.nextAction}` : ""}`);
console.log(` ${"unknown" in h.quiet ? `quiet: unknown (${h.quiet.unknown})` : describeQuiet(h.quiet)}`);
}
@@ -530,6 +512,8 @@ Examples:
console.log(` delivery-class: ${r.prefs.deliveryClass.value} (${r.prefs.deliveryClass.source})`);
console.log(` away: ${r.prefs.away.value} (${r.prefs.away.source})`);
console.log(` slack-subscription: ${r.slackSubscription.value} (${r.slackSubscription.source})`);
console.log(` slack-delivery: ${r.slackDelivery.where}`);
console.log(` default: ${r.default ? "yes (asks that name no one go to this person)" : "no"}`);
r.connectorBindings.forEach((b, i) => {
console.log(` binding.${i}: ${b.kind}:${b.connectorRef} role=${b.role}${b.handle ? ` handle=${b.handle}` : " [outbound-only]"}`);
});
@@ -539,7 +523,7 @@ Examples:
human
.command("set <entityId> <field> <value>")
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n>")
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), default (yes|no: the person an ask goes to when nothing names one), binding.<n>")
.option("--reason <reason>", "Reason recorded with connector binding changes", "update human binding")
.option("--actor <actor>", "Named operator when outside a managed seat")
.action(async (entityId: string, field: string, value: string, opts: { reason: string; actor?: string }) => {
+4 -1
View File
@@ -44,6 +44,7 @@ export function tuiCommand(io: FrontDoorIo & {
const cmd = new Command("tui")
.description("open the status dashboard, not agent chat (--shared is the OpenRig view fallback)")
.option("--shared", "dashboard-only fallback if the OpenRig view window cannot open; detach with Ctrl-b d")
.option("--as <person>", "answer requests in the Feed as this registered person (id or address); without it, the default person. A choice made in this terminal, not a verified identity; requests to other people are shown but not answered here")
.addHelpText("after", "\nThe dashboard shows status and navigation; it is not the operator's or advisor's conversation.\nTo show the person the OpenRig TUI and operator (after install, or whenever they want their agents): rig terminal open saved:kernel --window. The agent runs it on the daemon desktop; it opens a new terminal tab/window with herdr, otherwise plain tmux. Herdr needs a terminal the person can see; changing this dashboard to :terminals does not open one. Over headless SSH, give the exact connection/attach command for a new terminal/tab, as in the getting-started guide. Check the result and visible content, or say what cannot be verified; on a desktop, do not finish by printing a command for the person to type. Only if the window cannot open, rig tui --shared is the dashboard-only fallback. Explain the failure; not every failure prints an operator attach command. Use the getting-started guide to derive the current operator binding if the person chooses manual attachment. Existing herdr/cmux workspaces can still use --provider without --window.\nThe shared dashboard keeps its view when you detach. On an older kernel, or after quitting the TUI, run rig tui in that terminal once. No agent or terminal is started by --shared.");
cmd
@@ -70,7 +71,7 @@ export function tuiCommand(io: FrontDoorIo & {
});
cmd
.action(async (opts: { shared?: boolean }) => {
.action(async (opts: { shared?: boolean; as?: string }) => {
const stdoutIsTTY = io.stdoutIsTTY ?? process.stdout.isTTY === true;
if (!stdoutIsTTY || (opts.shared && !(io.stdinIsTTY ?? process.stdin.isTTY === true))) {
// Same TTY-awareness on stdout as the bare-`rig` front door — degrade, never
@@ -85,6 +86,7 @@ export function tuiCommand(io: FrontDoorIo & {
}
if (opts.shared) {
const err = io.err ?? ((line: string) => process.stderr.write(line + "\n"));
if (opts.as) err("--as applies to a TUI opened here; the shared dashboard keeps the person it was opened with.");
const exit = io.exit ?? ((code: number) => process.exit(code));
try {
const target = await (io.sharedTarget ?? sharedTuiTarget)();
@@ -98,6 +100,7 @@ export function tuiCommand(io: FrontDoorIo & {
}
return;
}
if (opts.as) process.env["OPENRIG_TUI_AS"] = opts.as;
await openMissionControl(io);
});
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createServer, type Server } from "node:http";
import { readdirSync, readFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { createProgram } from "../src/index.js";
import { gatewayCommand, daemonQueueRows, type HumanRowsLookup } from "../src/commands/gateway.js";
import { humansDir, addHumanFragment, loadHumanRegistry } from "@openrig/daemon/gateway-human-registry";
@@ -188,18 +188,18 @@ describe("rig gateway human lifecycle verbs (S12)", () => {
}
});
it("A1 advisory receipt: with several hand-authored fragments list --json renders all + the one-human advisory", async () => {
// Fix-r1 F1: the SECOND human arrives by hand-authoring (the registry surface), never
// through the add verb — the verb is the single-human boundary.
seedSecondHuman("ana");
it("list --json renders several people, and the advisory appears only when none is the default", async () => {
seedSecondHuman("ana"); // through add: mike becomes the default
const { setHumanField } = await import("@openrig/daemon/gateway-human-registry");
expect(setHumanField("mike", "default", "no").ok).toBe(true);
logSpy.mockClear();
const p = program();
p.exitOverride();
await p.parseAsync(["node", "rig", "gateway", "human", "list", "--json"]);
const out = JSON.parse(logSpy.mock.calls.at(-1)![0] as string) as { ok: boolean; humans: unknown[]; advisory?: string };
expect(out.ok).toBe(true);
expect(out.humans).toHaveLength(2); // honest display
expect(out.advisory).toContain("one configured human"); // never a management surface
expect(out.humans).toHaveLength(2);
expect(out.advisory).toContain("none is the default");
});
it("show --json carries authored-vs-default provenance and the fragment path", async () => {
@@ -261,28 +261,27 @@ describe("rig gateway human lifecycle verbs (S12)", () => {
expect(existsSync(join(humansDir(home), "mike.yaml"))).toBe(true);
});
// ── fix-r1 F1: the add verb IS the single-human boundary ──
// ── several named people (decision-20261024-0028, superseding A1's single-human add) ──
it("F1: a second DISTINCT add REFUSES with teaching (existing human named, remove and hand-authoring pointed at) and writes ZERO fragment bytes", async () => {
const dirBefore = readdirSync(humansDir(home)).sort();
const mikeBytes = readFileSync(join(humansDir(home), "mike.yaml"), "utf8");
it("a second DISTINCT add registers them, keeps the existing person as the default, and says so", async () => {
const mikeBefore = readFileSync(join(humansDir(home), "mike.yaml"), "utf8");
const p = program();
p.exitOverride();
try { await p.parseAsync([
await p.parseAsync([
"node", "rig", "gateway", "human", "add", "ana",
"--display-name", "Ana",
"--binding", "slack:main:vault://slack/ana:primary",
"--delivery-class", "A",
]); } catch { /* exitCode path */ }
expect(process.exitCode).toBe(1);
const err = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(err).toContain("mike"); // the existing human, named
expect(err).toContain("gateway human remove"); // how to register someone else
expect(err).not.toMatch(/\d+\.\d+\.\d+/); // no promise of a release that manages several
expect(err).toContain("hand-author"); // the sanctioned several-fragment path
// Zero new fragment bytes: directory unchanged, existing fragment byte-identical.
expect(readdirSync(humansDir(home)).sort()).toEqual(dirBefore);
expect(readFileSync(join(humansDir(home), "mike.yaml"), "utf8")).toBe(mikeBytes);
]);
expect(process.exitCode).toBeUndefined();
const out = JSON.parse(logSpy.mock.calls.at(-1)![0] as string) as { ok: boolean; madeDefault?: string; note?: string };
expect(out).toMatchObject({ ok: true, madeDefault: "mike" });
expect(out.note).toContain("default person");
expect(existsSync(join(humansDir(home), "ana.yaml"))).toBe(true);
// mike's fragment gains only the default mark.
expect(readFileSync(join(humansDir(home), "mike.yaml"), "utf8")).toBe(mikeBefore + "default: true\n");
const loaded = loadHumanRegistry(home);
expect(loaded.ok && loaded.entities.map((e) => [e.entityId, e.default === true])).toEqual([["ana", false], ["mike", true]]);
});
it("F1 companion: re-add of the SAME human with --replace stays allowed (boundary blocks distinct humans only)", async () => {
+5
View File
@@ -35,6 +35,11 @@ export interface AttentionRead {
sources: Array<{ source: string; state: "available" | "unavailable" | "partial"; detail: string }>;
detail: AttentionDetail | null;
detailError: string | null;
/** Who this Feed answers as: the person chosen where it's read (`rig tui --as`), else the default person. A local
* choice, never a verified identity. Answer acts are offered only on that person's own asks. `person` is null when
* the choice names no registered person, or several people are registered and none is the default. Absent when no
* person is registered (or the registry can't be read): asks are then answered as their recipient, as before. */
viewer?: { person: string | null; basis: "chosen-here" | "default" | "none"; chosen?: string };
}
// Consumers must use the same lexical human classification as queue selection.
@@ -36,7 +36,7 @@ export const ENTITY_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/;
export const ADDRESS_DOMAIN = "external";
// Closed key sets — a typo'd field must fail LOUD, never silently degrade behavior.
const ALLOWED_FRAGMENT_KEYS = new Set(["entityId", "class", "displayName", "address", "connectorBindings", "prefs", "subscriptions"]);
const ALLOWED_FRAGMENT_KEYS = new Set(["entityId", "class", "displayName", "address", "connectorBindings", "prefs", "subscriptions", "default"]);
const ALLOWED_BINDING_KEYS = new Set(["kind", "connectorRef", "secretsRef", "role", "handle"]);
// A connector handle (M1 A6 v3 schema 9e468b2f): the platform-native id of the human ON that
// connector (e.g. a Slack user id). Constrained so it can never forge a session ref (no ':' / '@'
@@ -145,6 +145,9 @@ export interface HumanFragment {
prefs: HumanPrefs;
/** Attention subscriptions (one per connector for now); none means the default Slack subscription. */
subscriptions?: AttentionSubscription[];
/** The default person: who an ask goes to when nothing names a person (workflow gates, Mission Control's queue,
* escalations). At most one person carries it. Add sets it on the existing person when a second one is added. */
default?: true;
}
export type ValidateResult =
@@ -162,7 +165,10 @@ export function validateHumanFragment(raw: unknown): ValidateResult {
if (!isObj(raw)) return { ok: false, error: "human fragment must be a mapping" };
const uk = unknownKey(raw, ALLOWED_FRAGMENT_KEYS);
if (uk) return { ok: false, error: `unknown fragment key "${uk}" — allowed: ${[...ALLOWED_FRAGMENT_KEYS].join(", ")} (a typo must not silently degrade)` };
const { entityId, class: cls, displayName, address, connectorBindings, prefs, subscriptions: rawSubscriptions } = raw;
const { entityId, class: cls, displayName, address, connectorBindings, prefs, subscriptions: rawSubscriptions, default: rawDefault } = raw;
if (rawDefault !== undefined && typeof rawDefault !== "boolean") {
return { ok: false, error: `default must be true or false, not "${String(rawDefault)}"` };
}
if (typeof entityId !== "string" || !ENTITY_ID_PATTERN.test(entityId)) {
return { ok: false, error: `entityId "${String(entityId)}" must be a lowercase slug (a-z0-9._- , no leading/trailing separator)` };
@@ -262,7 +268,8 @@ export function validateHumanFragment(raw: unknown): ValidateResult {
return {
ok: true,
fragment: { entityId, class: "human", displayName, address, connectorBindings: bindings, prefs: validatedPrefs, ...(subscriptions ? { subscriptions } : {}) },
fragment: { entityId, class: "human", displayName, address, connectorBindings: bindings, prefs: validatedPrefs, ...(subscriptions ? { subscriptions } : {}),
...(rawDefault === true ? { default: true as const } : {}) },
};
}
@@ -295,6 +302,8 @@ function projectionBody(entities: readonly HumanFragment[]): string {
function validateEntityCollection(entities: readonly HumanFragment[]): string | undefined {
const entityIds = new Set<string>();
const handleOwner = new Map<string, string>();
const defaults = entities.filter((entity) => entity.default === true).map((entity) => entity.entityId);
if (defaults.length > 1) return `more than one default person (${defaults.join(", ")}) — mark one with rig gateway human set <id> default yes`;
for (const entity of entities) {
if (entityIds.has(entity.entityId)) {
return `duplicate human entityId "${entity.entityId}" — one fragment owns one identity`;
@@ -406,7 +415,7 @@ export function writeProjection(home: string = getOpenRigHome()): { ok: true; pa
}
export type AddHumanResult =
| { ok: true; path: string; fragment: HumanFragment }
| { ok: true; path: string; fragment: HumanFragment; madeDefault?: string }
| { ok: false; error: string };
/** The verb-add writer: validate (add-time) -> atomic write the ONE fragment file ->
@@ -441,6 +450,18 @@ export function addHumanFragment(
return { ok: false, error: `${b.kind} handle "${b.handle}" is already registered to human "${owner}" — a handle maps to exactly one human (registration conflict)` };
}
}
const others = existing.entities.filter((e) => e.entityId !== v.fragment.entityId);
if (v.fragment.default === true && others.some((e) => e.default === true)) {
return { ok: false, error: `another person is already the default (${others.find((e) => e.default === true)!.entityId}) — add without default, then rig gateway human set <id> default yes` };
}
// A second person never becomes "the person" by accident: when the one existing person isn't marked yet, they become
// the default before the newcomer is written, so whatever reached them before still does.
let madeDefault: string | undefined;
if (others.length === 1 && !others[0]!.default && v.fragment.default !== true) {
const marked = markDefault(others[0]!.entityId, home);
if (!marked.ok) return { ok: false, error: `cannot keep ${others[0]!.entityId} as the default person before adding another: ${marked.error}` };
madeDefault = others[0]!.entityId;
}
const w = atomicWrite(file, stringifyYaml(v.fragment));
if (!w.ok) return { ok: false, error: w.error };
// A new or replaced record may author any availability: it's a change of its own.
@@ -448,7 +469,26 @@ export function addHumanFragment(
if (!change.ok) return { ok: false, error: `fragment written but ${change.error}` };
const proj = writeProjection(home);
if (!proj.ok) return { ok: false, error: `fragment written but re-projection failed: ${proj.error}` };
return { ok: true, path: file, fragment: v.fragment };
return { ok: true, path: file, fragment: v.fragment, ...(madeDefault ? { madeDefault } : {}) };
}
/** Write `default: true` into one person's fragment (raw, so their authored shape survives) and take it off everyone
* else's. Each write is the same validated atomic fragment write the verbs use; the caller re-projects. */
function markDefault(entityId: string, home: string): { ok: true } | { ok: false; error: string } {
for (const id of knownEntityIds(home)) {
const f = readFragment(id, home);
if (!f.ok) return f;
const want = id === entityId;
if ((f.raw.default === true) === want) continue;
const raw = { ...f.raw };
if (want) raw.default = true;
else delete raw.default;
const v = validateHumanFragment(raw);
if (!v.ok) return { ok: false, error: v.error };
const w = atomicWrite(f.path, stringifyYaml(raw));
if (!w.ok) return w;
}
return { ok: true };
}
// ── S12 lifecycle beyond add: list / show / set / remove (OPR.0.5.5.12) ──
@@ -484,16 +524,18 @@ export interface HumanSummary {
availability: NonNullable<HumanPrefs["availability"]>;
bindings: HumanBindingsSummary;
fragmentPath: string;
/** true for the default person (see HumanFragment.default). */
default: boolean;
}
export type ListHumansResult =
| { ok: true; humans: HumanSummary[]; advisory?: string }
| { ok: false; error: string };
/** Amendment A1 (founder R5): the human surface is SINGLE-HUMAN. Several fragments render
* honestly, but enumeration is display, never management — the advisory names the boundary. */
/** Several named people are supported (decision-20261024-0028, superseding A1's single-human surface). With several
* and no default marked or selected, nothing that needs "the person" can guess: the advisory says how to choose. */
export const MULTI_HUMAN_ADVISORY =
"several human fragments exist; OpenRig supports one configured human, so they are displayed as found and no command manages several";
"several people are registered and none is the default, so anything that needs one person (a workflow gate, Mission Control's queue, an escalation) won't pick one: run rig gateway human set <id> default yes";
/** A field value with its provenance: authored in the fragment, or filled by a default. */
export interface ProvenancedValue<T> {
@@ -512,6 +554,10 @@ export interface EffectiveHumanRecord {
};
/** The Slack subscription's preset filter (`every-ask` unless authored). */
slackSubscription: ProvenancedValue<SubscriptionPreset>;
/** Where this person's Slack posts land, said plainly: a shared channel, never private (there's no DM route yet). */
slackDelivery: { private: false; where: string };
/** true for the default person (see HumanFragment.default). */
default: boolean;
connectorBindings: Array<HumanConnectorBinding & { inboundResolvable: boolean }>;
}
@@ -589,9 +635,10 @@ export function listHumans(home: string = getOpenRigHome()): ListHumansResult {
inboundResolvable: e.connectorBindings.some((b) => b.handle !== undefined),
},
fragmentPath: fragmentPathFor(e.entityId, home),
default: e.default === true,
};
});
return humans.length > 1 ? { ok: true, humans, advisory: MULTI_HUMAN_ADVISORY } : { ok: true, humans };
return humans.length > 1 && !humans.some((h) => h.default) ? { ok: true, humans, advisory: MULTI_HUMAN_ADVISORY } : { ok: true, humans };
}
/** Read one fragment RAW (for authored-vs-default provenance) + validated. */
@@ -630,13 +677,20 @@ export function showHuman(entityId: string, home: string = getOpenRigHome()): Sh
},
},
slackSubscription: (({ preset, source }) => ({ value: preset, source }))(slackSubscriptionFor(f.fragment)),
slackDelivery: { private: false, where: SLACK_DELIVERY_WHERE },
default: f.fragment.default === true,
connectorBindings: f.fragment.connectorBindings.map((b) => ({ ...b, inboundResolvable: b.handle !== undefined })),
},
};
}
/** A person's Slack posts go to the asking rig's mapped channel (or the default channel), with a mention when the post
* interrupts. Anyone in that channel can read them. */
export const SLACK_DELIVERY_WHERE =
"the asking rig's mapped Slack channel (or the default channel), with a mention when the post interrupts; the channel is shared, so anyone in it can read the ask (there's no private message route)";
const SETTABLE_FIELDS_TEACHING =
"settable fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (every-ask|urgent-only, or unset for every-ask), binding.<n> (binding.<n> takes a full kind:connectorRef:secretsRef:role[:handle=<id>] spec)";
"settable fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (every-ask|urgent-only, or unset for every-ask), default (yes|no: the person asks go to when nothing names one), binding.<n> (binding.<n> takes a full kind:connectorRef:secretsRef:role[:handle=<id>] spec)";
/** Cross-fragment handle-uniqueness: the same pre-write check add runs (one platform id =
* one human), extracted so set enforces it identically. */
@@ -707,6 +761,23 @@ export function setHumanField(
const raw = f.raw;
const rawPrefs = isObj(raw.prefs) ? (raw.prefs as Record<string, unknown>) : {};
if (field === "default") {
// yes moves the default here (taking it off whoever had it); no clears it from this person.
if (value !== "yes" && value !== "no") return { ok: false, error: `default takes yes or no — ${SETTABLE_FIELDS_TEACHING}` };
if (value === "yes") {
const marked = markDefault(entityId, home);
if (!marked.ok) return marked;
} else if (raw.default === true) {
delete raw.default;
const w = atomicWrite(f.path, stringifyYaml(raw));
if (!w.ok) return { ok: false, error: w.error };
}
const proj = writeProjection(home);
if (!proj.ok) return { ok: false, error: `fragment written but re-projection failed: ${proj.error}` };
const after = readFragment(entityId, home);
return after.ok ? { ok: true, path: after.path, fragment: after.fragment } : after;
}
if (field === "display-name") {
if (value.length === 0) return { ok: false, error: "display-name must be a non-empty string" };
raw.displayName = value;
@@ -952,7 +1023,7 @@ export function loadHumanRegistry(home: string = getOpenRigHome(), opts: { readO
* Identity comes from the registry entity, never from a domain/suffix allow-list. */
export function resolveRegisteredHumanAddress(
sessionRef: string | null | undefined,
entities: readonly HumanFragment[],
entities: readonly Pick<HumanFragment, "entityId" | "address">[],
): string | null {
if (!sessionRef) return null;
const parsed = parseSessionName(sessionRef);
@@ -964,3 +1035,18 @@ export function resolveRegisteredHumanAddress(
if (!identity) return null;
return entities.find((entity) => entity.entityId === identity)?.address ?? null;
}
/** Who "the person" is when nothing names one: the explicitly selected person (`workspace.operator_seat_name`, when it
* names a registered person), else the person marked default, else the only person. With several people and neither,
* there's no answer, never a guess (the first file isn't a choice). */
export function selectDefaultHuman(
entities: readonly Pick<HumanFragment, "entityId" | "address" | "default">[],
selected?: string | null,
): { address: string; basis: "selected" | "default" | "only-person" } | null {
const chosen = resolveRegisteredHumanAddress(selected?.trim() || null, entities);
if (chosen) return { address: chosen, basis: "selected" };
const marked = entities.filter((entity) => entity.default === true);
if (marked.length === 1) return { address: marked[0]!.address, basis: "default" };
if (entities.length === 1) return { address: entities[0]!.address, basis: "only-person" };
return null;
}
@@ -10,6 +10,7 @@ import { DispatchBuffer } from "./dispatch-buffer.js";
import type { DispatchResult } from "./dispatcher.js";
import type { LoadResult } from "./human-registry.js";
import { loadHumanRegistry } from "./human-registry.js";
import { defaultHumanFor } from "../workflow-human-destination.js";
import { loadConfig } from "./slack/config.js";
import { quietHoldFor } from "./human-quiet-windows.js";
import { OUTBOUND_OP } from "./slack/outbound-driver.js";
@@ -81,9 +82,11 @@ export function makeOperatorDeliveryEngine(deps: {
return { decision: "unavailable:human-registry", resolved: false, dispatched: false };
}
if (!reg.ok) return { decision: "unavailable:human-registry", resolved: false, dispatched: false };
const human = reg.entities[0];
// The escalation goes to the default person, never simply the first file: with several people and none chosen,
// it's undeliverable and says so.
const human = defaultHumanFor(reg.entities);
if (!human) {
return { decision: "undeliverable:no-registered-human", resolved: false, dispatched: false };
return { decision: reg.entities.length ? "undeliverable:no-default-human" : "undeliverable:no-registered-human", resolved: false, dispatched: false };
}
const cfg = loadConfig(deps.home);
const decision = decideDelivery({
@@ -97,6 +97,9 @@ export function makeHumanReplyResolver(
const source = queueRepo.getById(input.qitemId);
if (source?.humanIntent === "update") return "not-applicable";
if (!contract) return "not-applicable";
// A different person's reply to a question parked on someone else already reached the agent as their own message
// (the inbound row, linked to the question). It doesn't resume the question, which stays parked on its person.
if (source?.state === "blocked" && queueRepo.askedSomeoneElse(input.actorSession, source.blockedOn)) return "not-applicable";
const uncertainDecision = `${input.decision}\n\n---\nOpenRig timing context (Slack reply ${JSON.stringify([input.actorSession, input.messageTs ?? null])}): This reply resumed the question, but its ordering against the posted question could not be confirmed. Resuming is not approval; read the person's words in context.`;
// An acknowledgement retry must not resolve again. Match this reply's durable
// context, not an older episode's generic resolved marker.
@@ -21,7 +21,7 @@
// are the primary read path. Filesystem/CLI fallbacks are for graceful
// degradation only.
import { loadHumanRegistry } from "../gateway/human-registry.js";
import { loadHumanRegistry, selectDefaultHuman } from "../gateway/human-registry.js";
import type Database from "better-sqlite3";
import type { QueueRepository, QueueItem, QueueState } from "../queue-repository.js";
import type { ViewProjector } from "../view-projector.js";
@@ -137,7 +137,8 @@ export class MissionControlReadLayer {
getDefaultOperatorSession(): string {
if (this.defaultOperatorSession) return this.defaultOperatorSession;
const registry = loadHumanRegistry();
return registry.ok && registry.entities.length === 1 ? registry.entities[0]!.address : "";
// The configured operator wins above; otherwise the person marked default, or the only one, never the first of several.
return registry.ok ? selectDefaultHuman(registry.entities)?.address ?? "" : "";
}
async readView(
@@ -105,6 +105,9 @@ export interface MissionControlActionResult {
auditedAt: string;
/** For `answer`: recorded with questions still open, or answered (returned to the asker, and the ask closed). */
answer?: { outcome: "recorded" | "answered"; unanswered: string[] };
/** A different person acted on what was asked of `askedPerson`: kept on the row as their correspondence, never as the
* asked person's answer, so nothing was answered, closed or unparked. `message` says so to the caller. */
correspondence?: { askedPerson: string; message: string };
}
interface WriteContractDeps {
@@ -147,6 +150,11 @@ export class MissionControlWriteContract {
}
const source = this.requireMutableQitem(input.qitemId);
const approvedForSomeoneElse = input.verb === "approve" && source.humanIntent !== "update"
? this.queueRepo.askedSomeoneElse(input.actorSession, source.destinationSession) : null;
if (approvedForSomeoneElse) {
return this.recordCorrespondence(input, source, approvedForSomeoneElse, input.reason?.trim() ? `approved: ${input.reason.trim()}` : "approved");
}
if ((input.verb === "route" || input.verb === "handoff") && !input.destinationSession) {
throw new MissionControlWriteContractError(
@@ -402,6 +410,8 @@ export class MissionControlWriteContract {
{ qitemId: input.qitemId, state: source.state, blockedOn: source.blockedOn },
);
}
const parkedOnSomeoneElse = this.queueRepo.askedSomeoneElse(input.actorSession, source.blockedOn);
if (parkedOnSomeoneElse) return this.recordCorrespondence(input, source, parkedOnSomeoneElse, decision);
const evaluatedAt = this.now().toISOString();
const beforeSnapshot = snapshotQitem(source);
@@ -508,6 +518,19 @@ export class MissionControlWriteContract {
`qitem ${input.qitemId} is ${ask.state}, not waiting on an answer (it may already be answered), so nothing was recorded or sent.`,
{ qitemId: input.qitemId, state: ask.state });
}
const askedSomeoneElse = ask.humanIntent === "update" ? null : this.queueRepo.askedSomeoneElse(input.actorSession, ask.destinationSession);
if (askedSomeoneElse) {
const choice = input.questionId !== undefined || input.optionId !== undefined;
const words = choice
? formatHumanAnswers((ask.humanQuestions ?? []).filter((q) => q.id === input.questionId), { [input.questionId ?? ""]: input.optionId ?? "" })[0]
?? `chose ${input.optionId ?? "nothing"} for ${input.questionId ?? "no question"}`
: input.decision?.trim() ?? "";
if (!words) {
throw new MissionControlWriteContractError("decision_required",
"verb=answer needs a choice (questionId and optionId) or the person's words (decision)", { verb: input.verb });
}
return this.recordCorrespondence(input, ask, askedSomeoneElse, words);
}
if (ask.destinationSession !== input.actorSession || ask.humanIntent === "update" || !this.queueRepo.isPersonAddress(ask.destinationSession)) {
throw new MissionControlWriteContractError("answer_not_recorded",
`qitem ${input.qitemId} isn't an ask to ${input.actorSession}, so ${input.actorSession} can't answer it.`, { qitemId: input.qitemId });
@@ -577,6 +600,41 @@ export class MissionControlWriteContract {
return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [] }, replyId);
}
/**
* A different registered person answering, approving or resolving what was asked of someone else speaks for
* themselves. Their words go on the row as a note in their own name, and the caller is told so. The row keeps its
* state, its answers and its reply id, and emits no resolution, so the asked person can still answer it as usual.
*/
private recordCorrespondence(input: MissionControlActionInput, item: QueueItem, askedPerson: string, words: string): MissionControlActionResult {
const note = `Correspondence from ${input.actorSession}, not ${askedPerson}'s answer: ${words}`;
const waiting = item.state === "blocked" ? `still parked on ${askedPerson}` : `still waiting for ${askedPerson}'s answer`;
const message = `Recorded as correspondence from ${input.actorSession}, not as ${askedPerson}'s answer. It's kept on ${input.qitemId} `
+ `in your name; nothing was answered, closed or unparked, and ${input.qitemId} is ${waiting}.`;
const evaluatedAt = this.now().toISOString();
const before = snapshotQitem(item);
let actionEntry: ReturnType<MissionControlActionLog["record"]> | null = null;
try {
this.eventBus.withNotifyEnvelope((register) => {
register(this.queueRepo.updateWithinTransaction({ qitemId: input.qitemId, actorSession: input.actorSession,
transitionNote: note, identityProvenance: input.identityProvenance ?? null }).persistedEvent);
const after = this.queueRepo.getById(input.qitemId);
actionEntry = this.actionLog.record({ actionVerb: "annotate", qitemId: input.qitemId, actorSession: input.actorSession,
actedAt: evaluatedAt, beforeState: before, afterState: after ? snapshotQitem(after) : null, annotation: note,
auditNotes: { ...(input.auditNotes ?? {}), correspondence: { requestedVerb: input.verb, askedPerson } },
identityProvenance: input.identityProvenance ?? null });
register(this.eventBus.persistWithinTransaction({ type: "mission_control.action_executed",
actionId: actionEntry.actionId, actionVerb: "annotate", qitemId: input.qitemId, actorSession: input.actorSession }));
});
} catch (err) {
if (err instanceof MissionControlActionLogError) {
throw new MissionControlWriteContractError(err.code, err.message, err.details);
}
throw err;
}
return { actionId: actionEntry!.actionId, verb: input.verb, qitemId: input.qitemId, closedQitem: this.queueRepo.getById(input.qitemId),
createdQitemId: null, notifyAttempted: false, notifyResult: null, auditedAt: evaluatedAt, correspondence: { askedPerson, message } };
}
/** The audit row and event for one answer (its queue writes are the repository's own). */
private recordAnswer(input: MissionControlActionInput, before: Record<string, unknown>, reason: string,
answer: NonNullable<MissionControlActionResult["answer"]>, createdQitemId: string | null = null): MissionControlActionResult {
@@ -20,6 +20,7 @@ import type { Policy, PolicyJob, PolicyEvaluation } from "./types.js";
import { quietHoldFor } from "../gateway/human-quiet-windows.js";
import { WatchdogJobsRepository } from "../watchdog-jobs-repository.js";
import { holdForQuiet } from "./quiet-release.js";
import { defaultHumanFor } from "../workflow-human-destination.js";
export const DELIVERY_DIGEST_FLUSH_POLICY = "delivery-digest-flush";
@@ -43,7 +44,8 @@ export interface RunDeliveryDigestFlushInput {
export async function runDeliveryDigestFlush(input: RunDeliveryDigestFlushInput): Promise<{ dispatched: number; members: number }> {
const reg = input.registry.loadHumanRegistry(input.home);
if (!reg.ok || !reg.entities || reg.entities.length === 0) return { dispatched: 0, members: 0 };
const human = reg.entities[0]!;
// Asks and updates below go to their own recipients; the digest of notices goes to the default person.
const human = defaultHumanFor(reg.entities);
const ports = makeQueuePorts(input.queueRepo, {
loadHumanRegistry: () => reg,
@@ -110,6 +112,8 @@ export async function runDeliveryDigestFlush(input: RunDeliveryDigestFlushInput)
if (result.ok) completeDispatched++;
}
if (!members.length) return { dispatched: completeDispatched, members: selected.length };
// Several people and none chosen: the notices wait (they stay selectable) rather than going to whoever sorts first.
if (!human) return { dispatched: completeDispatched, members: selected.length };
const memberReceipts = members.map((m) => ({
qitemId: m.qitemId,
@@ -1925,6 +1925,21 @@ export class QueueRepository {
return registry.ok && resolveRegisteredHumanAddress(destination, registry.entities) !== null;
}
/** The registered people this queue resolves against. */
humanRegistry(): LoadResult {
return this.loadHumanRegistryFn();
}
/** The asked person, when a different registered person is acting on what was asked of them; else null. An agent, the
* asked person under another spelling, and an ask to no registered person aren't someone else. */
askedSomeoneElse(actorSession: string, askedSession: string | null | undefined): string | null {
const registry = this.loadHumanRegistryFn();
if (!registry.ok) return null;
const asked = resolveRegisteredHumanAddress(askedSession, registry.entities);
const actor = resolveRegisteredHumanAddress(actorSession, registry.entities);
return asked && actor && actor !== asked ? asked : null;
}
private validateReplyTo(replyTo: string, humanIntent: QueueCreateInput["humanIntent"]): void {
if (humanIntent !== "update") {
throw new QueueRepositoryError("reply_to_requires_update", "replyTo is accepted only with humanIntent update; a decision keeps its own thread so its reply stays unambiguous.");
@@ -1,4 +1,4 @@
import { loadHumanRegistry, resolveRegisteredHumanAddress, type LoadResult } from "./gateway/human-registry.js";
import { loadHumanRegistry, resolveRegisteredHumanAddress, selectDefaultHuman, type HumanFragment, type LoadResult } from "./gateway/human-registry.js";
import { SettingsStore } from "./user-settings/settings-store.js";
export class WorkflowHumanDestinationError extends Error {
@@ -33,9 +33,23 @@ export function resolveWorkflowHumanDestination(
throw new WorkflowHumanDestinationError({ state: "unregistered", selected, addresses },
"workspace.operator_seat_name does not select a registered human. Inspect rig gateway human list --json and select the intended registered address.");
}
if (addresses.length === 1) return addresses[0]!;
const fallback = selectDefaultHuman(loaded.entities);
if (fallback) return fallback.address;
throw new WorkflowHumanDestinationError({ state: addresses.length ? "ambiguous" : "missing", addresses },
"Workflow human fallback needs one registered human. Inspect rig gateway human list --json; when several exist, explicitly select workspace.operator_seat_name. No human destination was invented.");
"Workflow human fallback needs one registered human, or a default when several exist. Inspect rig gateway human list --json; mark one with rig gateway human set <id> default yes, or select workspace.operator_seat_name. No human destination was invented.");
}
/** The person for anything that needs one without naming one (an escalation, a digest of notices, a pairing approval):
* workspace.operator_seat_name when it names a registered person, else the person marked default, else the only one.
* null when several people are registered and none is chosen: callers say so rather than pick the first. */
export function defaultHumanFor<T extends Pick<HumanFragment, "entityId" | "address" | "default">>(
entities: readonly T[],
configured: () => unknown = () => new SettingsStore().resolveOne("workspace.operator_seat_name").value,
): T | null {
let selected: unknown = null;
try { selected = configured(); } catch { selected = null; }
const pick = selectDefaultHuman(entities, typeof selected === "string" ? selected : null);
return pick ? entities.find((entity) => entity.address === pick.address) ?? null : null;
}
/** Explicit injected destinations remain available to embedders and isolated fixtures. */
+17 -1
View File
@@ -5,6 +5,8 @@ import type { AttentionRead, AttentionItem } from "../attention-surface.js";
import type { QueueRepository } from "../domain/queue-repository.js";
import { isHumanSeatSessionRef } from "../domain/session-name.js";
import { unansweredQuestions } from "../domain/human-questions.js";
import { resolveRegisteredHumanAddress } from "../domain/gateway/human-registry.js";
import { defaultHumanFor } from "../domain/workflow-human-destination.js";
import type { HealthProjectionService } from "../domain/health-detectors.js";
import type { OperatingPostureService } from "../domain/rig-mode/operating-posture.js";
import { listProjects, insideProject, projectMission, type ProjectRead } from "../domain/workspace/project-read.js";
@@ -38,6 +40,20 @@ export function attentionRoutes(): Hono {
const asked = new Set(rows.slice(0, 1000).filter(q => queue.isCurrentHumanAsk(q.qitemId)).map(q => q.qitemId));
const quiet = new Set(rows.slice(0, 1000).filter(q => asked.has(q.qitemId) && queue.humanAskWithoutAlert(q.qitemId)).map(q => q.qitemId));
const current = rows.slice(0, 1000).filter(q => isHumanSeatSessionRef(q.destinationSession) || asked.has(q.qitemId) || q.state === "blocked" && isHumanSeatSessionRef(q.blockedOn ?? ""));
// The person this Feed answers as: the one chosen where it's read, else the default person. It's a local choice, not
// a verified identity, so it's served for the reader to show. An ask to another registered person isn't theirs to
// answer here; an ask to no registered person (a bare human seat) stays answerable, as before.
const registry = queue.humanRegistry();
const people = registry.ok ? registry.entities : [];
const chosen = c.req.query("as")?.trim();
const viewerPerson = chosen
? resolveRegisteredHumanAddress(chosen, people) ?? people.find(p => p.entityId === chosen)?.address ?? null
: defaultHumanFor(people)?.address ?? null;
if (people.length) result.viewer = { person: viewerPerson, basis: chosen ? "chosen-here" : viewerPerson ? "default" : "none", ...(chosen ? { chosen } : {}) };
const answersHere = (recipient: string) => {
const person = resolveRegisteredHumanAddress(recipient, people);
return !person || person === viewerPerson;
};
// Whether the request went to Slack: its own receipt first, then whether it was ever to be sent.
const slackLine = (qitemId: string): string => {
const delivery = queue.deliveryOutcomeFor(qitemId);
@@ -61,7 +77,7 @@ export function attentionRoutes(): Hono {
// mission control's answer (a choice, their own words, or sending a complete set's recorded answers back), which
// Approve would close without returning. They can also open the asker's terminal (Chat). A row parked on them is
// resolved another way, so it carries neither.
...(toPerson && q.state === "pending" ? { answer: { verb: questions ? "answer" as const : "approve" as const, actorSession: q.destinationSession } } : {}),
...(toPerson && q.state === "pending" && answersHere(q.destinationSession) ? { answer: { verb: questions ? "answer" as const : "approve" as const, actorSession: q.destinationSession } } : {}),
...(toPerson ? { asker: q.sourceSession } : {}),
...(questions ? { humanQuestions: questions, humanAnswers: q.humanAnswers ?? {} } : {}),
summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Request summary unavailable", urgency: q.priority, unblocks, at: q.tsUpdated, scope: p ? `project ${p.id}` : "instance · project unknown", project: p ? { id: p.id, root: p.root } : null, source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
+5 -2
View File
@@ -47,6 +47,7 @@ import type { SettingsStore } from "../domain/user-settings/settings-store.js";
import type { QueueRepository } from "../domain/queue-repository.js";
import { existsSync } from "node:fs";
import { loadHumanRegistry, type LoadResult } from "../domain/gateway/human-registry.js";
import { defaultHumanFor } from "../domain/workflow-human-destination.js";
const PAIR_TTL_MS = 10 * 60 * 1000;
const PAIR_PRUNE_GRACE_MS = 60 * 60 * 1000;
@@ -163,10 +164,12 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
const body = (await c.req.json<{ requester?: string; human?: string }>().catch(() => ({}))) as { requester?: string; human?: string };
const registry = (opts?.humanRegistry ?? loadHumanRegistry)();
if (!registry.ok) return c.json({ error: "pair_human_registry_unavailable", message: registry.error }, 409);
const humans = body.human ? registry.entities.filter((human) => human.address === body.human) : registry.entities;
// Named, or else the default person (the selected or marked one, or the only one); never the first of several.
const fallback = body.human ? null : defaultHumanFor(registry.entities);
const humans = body.human ? registry.entities.filter((human) => human.address === body.human) : fallback ? [fallback] : registry.entities;
if (humans.length !== 1) return c.json({
error: "pair_human_required", addresses: registry.entities.map((human) => human.address),
message: "Pair approval needs one registered human. Inspect rig gateway human list --json; select --human <entityId>@external when several exist. No approval row was created.",
message: "Pair approval needs one registered human, or a default when several exist. Inspect rig gateway human list --json; select --human <entityId>@external, or mark one with rig gateway human set <id> default yes. No approval row was created.",
}, 409);
// Prune stale issued pairs past the grace window (keep recently expired entries
@@ -14,6 +14,7 @@ import {
projectionPath,
projectHumans,
loadHumanRegistry,
selectDefaultHuman,
type InflightItem,
} from "../src/domain/gateway/human-registry.js";
import { DispatchBuffer } from "../src/domain/gateway/dispatch-buffer.js";
@@ -101,7 +102,8 @@ describe("gateway human lifecycle (S12, amended A1: single-human surface): list
});
it("A1 advisory receipt: several fragments render HONESTLY plus the one-human advisory (display, never management)", () => {
seedAna(home); // hand-authored second human — the amendment's multi-fragment case
seedAna(home); // a second person, through add: mike (already there) becomes the default
expect(setHumanField("mike", "default", "no", home).ok).toBe(true); // and with no default, the advisory says how to choose
const res = listHumans(home);
expect(res.ok).toBe(true);
if (!res.ok) return;
@@ -111,8 +113,8 @@ describe("gateway human lifecycle (S12, amended A1: single-human surface): list
expect(ana.away).toBe(true);
expect(ana.bindings.count).toBe(2);
expect(ana.bindings.inboundResolvable).toBe(true);
expect(res.advisory).toContain("one configured human");
expect(res.advisory).not.toMatch(/\d+\.\d+\.\d+/); // no promise of a release that manages several
expect(res.advisory).toContain("none is the default");
expect(res.advisory).toContain("rig gateway human set <id> default yes");
});
it("list is a READ: it does not touch the projection file (byte receipt)", () => {
@@ -368,3 +370,73 @@ describe("gateway human lifecycle (S12, amended A1: single-human surface): list
expect(archived.length).toBe(2);
});
});
describe("several named people: the default person, and delivery said plainly", () => {
let home: string;
beforeEach(() => { home = mkdtempSync(join(tmpdir(), "rig-named-people-")); seedMike(home); });
afterEach(() => rmSync(home, { recursive: true, force: true }));
it("adding a second person keeps the first as the default, so whatever reached them still does", () => {
const ana = addHumanFragment({ entityId: "ana", class: "human", displayName: "Ana", address: "ana@external",
connectorBindings: [{ kind: "slack", connectorRef: "main", secretsRef: "vault://slack/ana", role: "primary", handle: "U0ANA" }],
prefs: { deliveryClass: "B" } }, home);
expect(ana).toMatchObject({ ok: true, madeDefault: "mike" });
const list = listHumans(home);
expect(list.ok && list.humans.map((h) => [h.entityId, h.default])).toEqual([["ana", false], ["mike", true]]);
expect(list.ok && list.advisory).toBeFalsy();
const loaded = loadHumanRegistry(home);
expect(loaded.ok && selectDefaultHuman(loaded.entities)).toEqual({ address: "mike@external", basis: "default" });
// A third person changes nothing about who the default is.
expect(addHumanFragment({ entityId: "bo", class: "human", displayName: "Bo", address: "bo@external",
connectorBindings: [{ kind: "slack", connectorRef: "main", secretsRef: "vault://slack/bo", role: "primary" }],
prefs: { deliveryClass: "C" } }, home)).toMatchObject({ ok: true });
const again = loadHumanRegistry(home);
expect(again.ok && again.entities.filter((e) => e.default).map((e) => e.entityId)).toEqual(["mike"]);
});
it("set default yes moves the default; no clears it; anything else is refused with the fields", () => {
seedAna(home);
expect(setHumanField("ana", "default", "yes", home).ok).toBe(true);
let loaded = loadHumanRegistry(home);
expect(loaded.ok && loaded.entities.filter((e) => e.default).map((e) => e.entityId)).toEqual(["ana"]);
expect(setHumanField("ana", "default", "no", home).ok).toBe(true);
loaded = loadHumanRegistry(home);
expect(loaded.ok && loaded.entities.some((e) => e.default)).toBe(false);
expect(loaded.ok && selectDefaultHuman(loaded.entities)).toBeNull(); // several, none chosen: no guess
const bad = setHumanField("ana", "default", "maybe", home);
expect(bad.ok).toBe(false);
if (!bad.ok) expect(bad.error).toContain("default takes yes or no");
});
it("two people both marked default is a registry error, and default must be true or false", () => {
seedAna(home);
writeFileSync(join(humansDir(home), "ana.yaml"), readFileSync(join(humansDir(home), "ana.yaml"), "utf8") + "default: true\n");
const proj = projectHumans(home);
expect(proj.ok).toBe(false);
if (!proj.ok) expect(proj.error).toContain("more than one default person");
writeFileSync(join(humansDir(home), "ana.yaml"), readFileSync(join(humansDir(home), "ana.yaml"), "utf8").replace("default: true\n", "default: maybe\n"));
const bad = projectHumans(home);
expect(bad.ok).toBe(false);
if (!bad.ok) expect(bad.error).toContain("default must be true or false");
});
it("the default person is the selected one, else the marked one, else the only one", () => {
const one = [{ entityId: "mike", address: "mike@external" }];
const two = [{ entityId: "mike", address: "mike@external" }, { entityId: "ana", address: "ana@external", default: true as const }];
expect(selectDefaultHuman(one)).toEqual({ address: "mike@external", basis: "only-person" });
expect(selectDefaultHuman(two)).toEqual({ address: "ana@external", basis: "default" });
expect(selectDefaultHuman(two, "mike@external")).toEqual({ address: "mike@external", basis: "selected" });
expect(selectDefaultHuman(two, "someone@external")).toEqual({ address: "ana@external", basis: "default" }); // unregistered: ignored
expect(selectDefaultHuman([one[0]!, { entityId: "ana", address: "ana@external" }])).toBeNull();
expect(selectDefaultHuman([])).toBeNull();
});
it("show says where Slack posts land (a shared channel, never private) and whether this is the default person", () => {
seedAna(home);
const mike = showHuman("mike", home);
expect(mike.ok && mike.record).toMatchObject({ default: true, slackDelivery: { private: false } });
expect(mike.ok && mike.record.slackDelivery.where).toContain("shared");
const ana = showHuman("ana", home);
expect(ana.ok && ana.record.default).toBe(false);
});
});
@@ -259,6 +259,13 @@ describe("pair-request — the target-side issuance handshake (FR-6)", () => {
expect(repo.list({ limit: 100 })[0]?.destinationSession).toBe("blair@external");
});
it("with several people and one marked default, an unnamed pairing approval goes to the default person", async () => {
const target = buildApp(repo, BEARER, () => ({ ok: true, entities: [human, { ...human, entityId: "blair", address: "blair@external", default: true as const }] }));
const response = await target.request("/api/hosts/pair-request", { method: "POST", body: "{}" });
expect(response.status).toBe(200);
expect(repo.list({ limit: 100 })[0]?.destinationSession).toBe("blair@external");
});
it("issuance mints the ONE human approval moment: a human-routed qitem with code + summary + evidence_ref", async () => {
const create = vi.spyOn(repo, "create");
const { code, approvalQitemId } = await issue();
@@ -0,0 +1,123 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Database from "better-sqlite3";
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 { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
import { makeHumanReplyResolver } from "../src/domain/gateway/slack/slack-subsystem.js";
import type { HumanFragment, LoadResult } from "../src/domain/gateway/human-registry.js";
// Two named people: when one answers, approves or resolves what was asked of the other, it's kept as their
// correspondence, in their own name, and the ask stays the asked person's to answer. Agents keep today's behaviour.
const person = (entityId: string): HumanFragment => ({
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
connectorBindings: [{ kind: "slack", connectorRef: "main", secretsRef: `vault://slack/${entityId}`, role: "primary", handle: `U${entityId.toUpperCase()}` }],
prefs: { deliveryClass: "B", availability: "available" },
});
const ANA = "human-ana@external";
const MIKE = "human-mike@external";
const QUESTIONS = [{ id: "q1", question: "Which name?", options: [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta" }] }];
describe("one person acting on another person's ask is correspondence", () => {
let db: Database.Database;
let repo: QueueRepository;
let contract: MissionControlWriteContract;
beforeEach(() => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
const registry: LoadResult = { ok: true, entities: [person("human-ana"), person("human-mike")] };
const bus = new EventBus(db);
repo = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => registry });
contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
});
afterEach(() => db.close());
const askAna = (extra: { humanQuestions?: typeof QUESTIONS } = {}) => repo.create({ sourceSession: "orch-lead@r",
destinationSession: ANA, body: "Which name ships?", summary: "Name", evidenceRef: "/proof/name.md", nudge: false, ...extra });
const replies = (row: QueueItem) => repo.list({ limit: 500 }).filter((q) => q.replyTo === row.qitemId);
const lastNote = (row: QueueItem) => repo.listTransitions(row.qitemId).at(-1);
const resolutions = (row: QueueItem) => repo.listTransitions(row.qitemId).filter((t) => t.ownerNotificationKind === "human-decision-resolved");
it("an answer in another person's words is kept in their name, and the asked person still answers it", async () => {
const ask = await askAna();
const result = await contract.act({ verb: "answer", qitemId: ask.qitemId, actorSession: MIKE, decision: "Beta, I think" });
expect(result.correspondence).toEqual({ askedPerson: ANA, message: expect.stringContaining(`not as ${ANA}'s answer`) });
expect(result.correspondence?.message).toContain("nothing was answered, closed or unparked");
expect(repo.getById(ask.qitemId)).toMatchObject({ state: "pending", humanAnswers: null });
expect(lastNote(ask)).toMatchObject({ actorSession: MIKE, state: "pending",
transitionNote: `Correspondence from ${MIKE}, not ${ANA}'s answer: Beta, I think` });
expect(replies(ask)).toEqual([]);
expect(resolutions(ask)).toEqual([]);
// Ana's own answer completes it as usual: one reply in her name, and the ask closes.
expect(await contract.act({ verb: "answer", qitemId: ask.qitemId, actorSession: ANA, decision: "Alpha" }))
.toMatchObject({ answer: { outcome: "answered" } });
expect(repo.getById(ask.qitemId)?.state).toBe("done");
expect(replies(ask).map((r) => r.sourceSession)).toEqual([ANA]);
});
it("another person's choice is kept as words, never as the ask's answers", async () => {
const ask = await askAna({ humanQuestions: QUESTIONS });
const result = await contract.act({ verb: "answer", qitemId: ask.qitemId, actorSession: MIKE, questionId: "q1", optionId: "b" });
expect(result.correspondence?.askedPerson).toBe(ANA);
expect(lastNote(ask)?.transitionNote).toBe(`Correspondence from ${MIKE}, not ${ANA}'s answer: Which name?: Beta`);
expect(repo.getById(ask.qitemId)?.humanAnswers ?? {}).toEqual({});
expect(await contract.act({ verb: "answer", qitemId: ask.qitemId, actorSession: ANA, questionId: "q1", optionId: "a" }))
.toMatchObject({ answer: { outcome: "answered" } });
expect(repo.getById(ask.qitemId)).toMatchObject({ state: "done", humanAnswers: { q1: "a" } });
});
it("another person's approve leaves the ask open for the asked person", async () => {
const ask = await askAna();
const result = await contract.act({ verb: "approve", qitemId: ask.qitemId, actorSession: MIKE, reason: "looks fine" });
expect(result.correspondence?.askedPerson).toBe(ANA);
expect(repo.getById(ask.qitemId)?.state).toBe("pending");
expect(lastNote(ask)?.transitionNote).toBe(`Correspondence from ${MIKE}, not ${ANA}'s answer: approved: looks fine`);
await contract.act({ verb: "approve", qitemId: ask.qitemId, actorSession: ANA });
expect(repo.getById(ask.qitemId)).toMatchObject({ state: "done", closureReason: "no-follow-on" });
});
it("another person's resolve leaves a parked question parked on its person", async () => {
const parked = await repo.create({ sourceSession: "orch-lead@r", destinationSession: "dev@r", body: "work", nudge: false });
repo.update({ qitemId: parked.qitemId, actorSession: "dev@r", state: "blocked", blockedOn: "human-ana@host", summary: "Parked",
evidenceRef: "/proof/parked.md", transitionNote: "parked on Ana" });
const result = await contract.act({ verb: "resolve", qitemId: parked.qitemId, actorSession: MIKE, decision: "go ahead", notify: false });
expect(result.correspondence?.message).toContain("is still parked on human-ana@external");
expect(repo.getById(parked.qitemId)).toMatchObject({ state: "blocked", blockedOn: "human-ana@host" });
expect(lastNote(parked)).toMatchObject({ actorSession: MIKE, transitionNote: `Correspondence from ${MIKE}, not ${ANA}'s answer: go ahead` });
expect(resolutions(parked)).toEqual([]);
await contract.act({ verb: "resolve", qitemId: parked.qitemId, actorSession: ANA, decision: "go ahead", notify: false });
expect(repo.getById(parked.qitemId)?.state).toBe("in-progress");
expect(resolutions(parked).map((t) => t.actorSession)).toEqual([ANA]);
});
it("a different person's Slack reply to a parked question doesn't resume it", async () => {
const parked = await repo.create({ sourceSession: "orch-lead@r", destinationSession: "dev@r", body: "work", nudge: false });
repo.update({ qitemId: parked.qitemId, actorSession: "dev@r", state: "blocked", blockedOn: "human-ana@host", summary: "Parked",
evidenceRef: "/proof/parked.md", transitionNote: "parked on Ana" });
const before = repo.listTransitions(parked.qitemId).length;
expect(await makeHumanReplyResolver(repo, contract)({ qitemId: parked.qitemId, actorSession: MIKE, decision: "go ahead" }))
.toBe("not-applicable");
expect(repo.getById(parked.qitemId)?.state).toBe("blocked");
expect(repo.listTransitions(parked.qitemId)).toHaveLength(before);
});
it("agents keep today's behaviour: an agent can't answer a person's ask, and its approve still closes", async () => {
const ask = await askAna();
await expect(contract.act({ verb: "answer", qitemId: ask.qitemId, actorSession: "orch-lead@r", decision: "x" }))
.rejects.toMatchObject({ code: "answer_not_recorded" });
const result = await contract.act({ verb: "approve", qitemId: ask.qitemId, actorSession: "orch-lead@r" });
expect(result.correspondence).toBeUndefined();
expect(repo.getById(ask.qitemId)?.state).toBe("done");
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type Database from "better-sqlite3";
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 } from "../src/domain/queue-repository.js";
import { makeOperatorDeliveryEngine } from "../src/domain/gateway/operator-delivery-engine.js";
import { defaultHumanFor } from "../src/domain/workflow-human-destination.js";
import type { HumanFragment, LoadResult } from "../src/domain/gateway/human-registry.js";
// Several named people: anything that needs "the person" without naming one reaches the default person (the selected,
// else the marked, else the only one), never whoever sorts first; with several and none chosen it says so.
const person = (entityId: string, extra: Partial<HumanFragment> = {}): HumanFragment => ({
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
connectorBindings: [{ kind: "slack", connectorRef: "main", secretsRef: `vault://slack/${entityId}`, role: "primary", handle: `U${entityId.toUpperCase()}` }],
prefs: { deliveryClass: "B", availability: "available" }, ...extra,
});
describe("defaultHumanFor", () => {
it("prefers the selected person, then the marked default, then the only person; several and none chosen is null", () => {
const ana = person("ana"), mike = person("mike", { default: true });
expect(defaultHumanFor([ana, mike], () => "ana@external")?.entityId).toBe("ana");
expect(defaultHumanFor([ana, mike], () => "")?.entityId).toBe("mike");
expect(defaultHumanFor([ana], () => "")?.entityId).toBe("ana");
expect(defaultHumanFor([ana, person("bo")], () => "")).toBeNull();
expect(defaultHumanFor([ana, mike], () => { throw new Error("settings unreadable"); })?.entityId).toBe("mike");
});
});
describe("the wake ladder's person rung with several people", () => {
let home: string;
let db: Database.Database;
let repo: QueueRepository;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "rig-named-default-"));
db = createDb();
migrate(db, ALL_MIGRATIONS);
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
});
afterEach(() => { db.close(); rmSync(home, { recursive: true, force: true }); });
const escalate = async (entities: HumanFragment[]) => {
const refs: string[] = [];
const registry: LoadResult = { ok: true, entities };
const engine = makeOperatorDeliveryEngine({ home, queueRepo: repo, registry: { loadHumanRegistry: () => registry },
dispatch: (_op, ref) => { refs.push(ref); return { ok: true, decisionId: `d${refs.length}` }; } });
const row = await repo.create({ sourceSession: "lead@rig", destinationSession: "worker@rig", body: "stuck work", summary: "stuck", nudge: false });
return { result: await engine.dispatchEscalation(row, "blocked"), refs };
};
it("escalates to the marked default person, not the first registered file", async () => {
// "ana" sorts first; "mike" is the default.
const { result, refs } = await escalate([person("ana"), person("mike", { default: true })]);
expect(result).toMatchObject({ dispatched: true });
expect(refs).toEqual(["mike@external"]);
});
it("with several people and none chosen, it's undeliverable and says why, and nothing is sent", async () => {
const { result, refs } = await escalate([person("ana"), person("mike")]);
expect(result).toMatchObject({ decision: "undeliverable:no-default-human", dispatched: false, resolved: false });
expect(refs).toEqual([]);
});
});
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Database from "better-sqlite3";
import { Hono } from "hono";
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 { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
import { attentionRoutes } from "../src/routes/attention.js";
import { missionControlRoutes } from "../src/routes/mission-control.js";
import type { HumanFragment, LoadResult } from "../src/domain/gateway/human-registry.js";
import type { AttentionRead } from "../src/attention-surface.js";
// The Feed answers as one person: the one chosen where it's read (rig tui --as), else the default person. It says so, and
// says it's a local choice; it offers answer acts only on that person's own asks, and shows everyone else's.
const person = (entityId: string, extra: Partial<HumanFragment> = {}): HumanFragment => ({
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
connectorBindings: [{ kind: "slack", connectorRef: "main", secretsRef: `vault://slack/${entityId}`, role: "primary", handle: `U${entityId.toUpperCase()}` }],
prefs: { deliveryClass: "B", availability: "available" }, ...extra,
});
const ANA = "human-ana@external";
const MIKE = "human-mike@external";
describe("the Feed answers as one named person", () => {
let db: Database.Database;
let repo: QueueRepository;
let registry: LoadResult;
let app: Hono;
beforeEach(() => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
registry = { ok: true, entities: [person("human-ana", { default: true }), person("human-mike")] };
const bus = new EventBus(db);
repo = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => registry });
const writeContract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
app = new Hono();
app.use("*", async (c, next) => {
c.set("queueRepo" as never, repo as never);
c.set("eventBus" as never, bus as never);
c.set("missionControlWriteContract" as never, writeContract as never);
await next();
});
app.route("/api/attention", attentionRoutes());
app.route("/api/mission-control", missionControlRoutes());
});
afterEach(() => db.close());
const ask = (to: string) => repo.create({ sourceSession: "orch-lead@r", destinationSession: to, body: `for ${to}`,
summary: `Ask ${to}`, evidenceRef: "/proof/ask.md", nudge: false });
/** The TUI's own client and Feed model over this daemon, opened as `asPerson` (or the default person). */
async function feed(asPerson?: string) {
const { DaemonClient } = await import("../../tui/src/daemon-client.js");
const { attentionLines } = await import("../../tui/src/attention/attention-model.js");
const { createViewState, emptySnapshot } = await import("../../tui/src/state.js");
const client = new DaemonClient({ baseUrl: "http://tui.test", asPerson,
fetchImpl: ((input: string, init?: RequestInit) => app.request(String(input), init)) as typeof fetch });
const read = await client.humanAttention() as AttentionRead;
const text = attentionLines(createViewState({ instanceId: "fixture" }).get(), { ...emptySnapshot(), attentionRead: read }, 400)
.map((l) => l.text).join("\n");
const answerOn = (row: QueueItem) => read.items.find((i) => i.id === `queue:${row.qitemId}`)?.answer;
return { client, read, text, answerOn };
}
it("by default it's the default person, answering their own asks and showing the other person's", async () => {
const toAna = await ask(ANA), toMike = await ask(MIKE);
const f = await feed();
expect(f.read.viewer).toEqual({ person: ANA, basis: "default" });
expect(f.text).toContain(`Answering as ${ANA}, the default person: a choice made in this terminal, not a verified identity.`);
expect(f.answerOn(toAna)).toEqual({ verb: "approve", actorSession: ANA });
expect(f.read.items.map((i) => i.id)).toContain(`queue:${toMike.qitemId}`);
expect(f.answerOn(toMike)).toBeUndefined();
});
it("opened as another person, it answers their asks as them, and their approve closes their ask", async () => {
const toAna = await ask(ANA), toMike = await ask(MIKE);
const f = await feed("human-mike");
expect(f.read.viewer).toEqual({ person: MIKE, basis: "chosen-here", chosen: "human-mike" });
expect(f.text).toContain(`Answering as ${MIKE}, chosen with rig tui --as`);
expect(f.answerOn(toAna)).toBeUndefined();
expect(f.answerOn(toMike)).toEqual({ verb: "approve", actorSession: MIKE });
await f.client.missionControlAction({ qitemId: toMike.qitemId, ...f.answerOn(toMike)! });
expect(repo.getById(toMike.qitemId)?.state).toBe("done");
expect(repo.getById(toAna.qitemId)?.state).toBe("pending");
});
it("a choice that names no registered person answers nothing, and says why", async () => {
const toAna = await ask(ANA);
const f = await feed("nobody");
expect(f.read.viewer).toEqual({ person: null, basis: "none", chosen: "nobody" });
expect(f.text).toContain("nobody isn't a registered person");
expect(f.answerOn(toAna)).toBeUndefined();
});
it("several people and no default: nothing is answered as a guess, and it says how to choose", async () => {
registry = { ok: true, entities: [person("human-ana"), person("human-mike")] };
const toAna = await ask(ANA);
const f = await feed();
expect(f.read.viewer).toEqual({ person: null, basis: "none" });
expect(f.text).toContain("Several people are registered and none is the default");
expect(f.answerOn(toAna)).toBeUndefined();
});
});
@@ -36,6 +36,7 @@ const registryWith = (preset?: "every-ask" | "urgent-only") => ({
ok: true as const,
entities: [{
entityId: HUMAN, class: "human" as const, displayName: "Founder", address: `${HUMAN}@external`,
default: true as const,
connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UFOUNDER" }],
prefs: { deliveryClass: "B" as const, availability: "available" as const },
...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}),
@@ -298,11 +299,11 @@ describe("an ask to a person is on Steering and the TUI; Slack gets it by their
});
/** The TUI itself over these readers: its daemon client, its Feed model, and an act's write as its driver loop sends it. */
async function tui(app: Hono) {
async function tui(app: Hono, asPerson?: string) {
const { DaemonClient } = await import("../../tui/src/daemon-client.js");
const { attentionLines, replyAct } = await import("../../tui/src/attention/attention-model.js");
const { createViewState, emptySnapshot } = await import("../../tui/src/state.js");
const client = new DaemonClient({ baseUrl: "http://tui.test", fetchImpl: ((input: string, init?: RequestInit) => app.request(String(input), init)) as typeof fetch });
const client = new DaemonClient({ baseUrl: "http://tui.test", asPerson, fetchImpl: ((input: string, init?: RequestInit) => app.request(String(input), init)) as typeof fetch });
const open = async (row: QueueItem) => {
const id = `queue:${row.qitemId}`;
const read = await client.humanAttention(id) as AttentionRead;
@@ -371,8 +372,10 @@ describe("an ask to a person is on Steering and the TUI; Slack gets it by their
expect(replies(typed)[0]).toMatchObject({ destinationSession: "orch-lead@r" });
expect(replies(typed)[0]!.body).toContain(words);
expect(resolved(typed)).toBe(1);
// An ask to a registered person's alias, with no questions: their words close it the same way.
expect(await t.reply(alias, "Call it Delta")).toMatchObject({ answer: { outcome: "answered" } });
// An ask to the other registered person, by their alias, isn't answered from the default person's TUI; opened as them,
// their words close it the same way.
expect((await t.open(alias)).read.items.find((i) => i.id === `queue:${alias.qitemId}`)?.answer).toBeUndefined();
expect(await (await tui(readers(), "operator-admin")).reply(alias, "Call it Delta")).toMatchObject({ answer: { outcome: "answered" } });
expect(repo.getById(alias.qitemId)).toMatchObject({ state: "done" });
expect(replies(alias)[0]!.body).toContain("Call it Delta");
expect(resolved(alias)).toBe(1);
@@ -13,7 +13,7 @@ import { RigRepository } from "../src/domain/rig-repository.js";
import { PodRepository } from "../src/domain/pod-repository.js";
import { WorkflowRuntime } from "../src/domain/workflow-runtime.js";
import { resolveWorkflowHumanDestination } from "../src/domain/workflow-human-destination.js";
import { addHumanFragment, writeProjection, projectionPath, type HumanFragment } from "../src/domain/gateway/human-registry.js";
import { addHumanFragment, setHumanField, writeProjection, projectionPath, type HumanFragment } from "../src/domain/gateway/human-registry.js";
import { makeEnsureStuckExceptionItem } from "../src/domain/workflow-exception-escalation.js";
import { workflowRoutes } from "../src/routes/workflow.js";
@@ -96,7 +96,11 @@ describe.each(["serial", "dependency"])("workflow registered-human selection at
it("an explicit existing setting selects among humans and is re-read for the next episode", () => {
add("owner-one"); add("owner-two");
expect(() => resolveWorkflowHumanDestination()).toThrow(/explicitly select/);
// Adding the second person kept the first as the default, so the workflow still reaches them.
expect(resolveWorkflowHumanDestination()).toBe("owner-one@external");
// With the default cleared, several people and no choice is refused, never the first file.
expect(setHumanField("owner-one", "default", "no").ok).toBe(true);
expect(() => resolveWorkflowHumanDestination()).toThrow(/a default when several exist/);
vi.stubEnv("OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME", "owner-two@external");
expect(resolveWorkflowHumanDestination()).toBe("owner-two@external");
vi.stubEnv("OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME", "owner-one@external");
@@ -106,7 +110,7 @@ describe.each(["serial", "dependency"])("workflow registered-human selection at
});
it.each(["missing", "ambiguous", "registry-unavailable"])("%s selection returns 409 and rolls back the failed close without a phantom row", async (state) => {
if (state === "ambiguous") { add("owner-one"); add("owner-two"); }
if (state === "ambiguous") { add("owner-one"); add("owner-two"); expect(setHumanField("owner-one", "default", "no").ok).toBe(true); }
if (state === "registry-unavailable") { add("owner-one"); writeFileSync(projectionPath(), "not a registry"); }
const i = await start();
const app = new Hono();
@@ -45,6 +45,7 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
const read = snap.attentionRead;
const lines: ContentLine[] = [{ text: "FEED · Instance / All humans" }];
if (!read) return wrapDetailLines([...lines, { text: "Unavailable: Feed sources have not answered." }, ...snap.readErrors.map(text => ({ text }))], width);
if (read.viewer) lines.push({ text: viewerLine(read.viewer) });
if (state.attentionOpen) {
const d = read.detail;
lines.push(listItem("Back", { type: "back" }));
@@ -119,6 +120,18 @@ export function replyAct(read: AttentionRead | null | undefined, openId: string
return { type: "act", act: "answer", qitemId: item.id.slice("queue:".length), verb: "answer", actorSession: item.answer.actorSession, decision: text.trim() };
}
/** Who this Feed answers as. It's chosen here, never verified, so it says so; another person's asks aren't answered here. */
function viewerLine(viewer: NonNullable<AttentionRead["viewer"]>): string {
if (viewer.person) {
return `Answering as ${viewer.person}, ${viewer.basis === "chosen-here" ? "chosen with rig tui --as" : "the default person"}: a choice made in this terminal, `
+ "not a verified identity. Requests to other people are shown, not answered here; open the TUI as them with rig tui --as <person>.";
}
return viewer.chosen
? `${viewer.chosen} isn't a registered person (rig gateway human list), so requests to people aren't answered here.`
: "Several people are registered and none is the default, so requests to people aren't answered here: open the TUI as one "
+ "with rig tui --as <person>, or mark a default with rig gateway human set <id> default yes.";
}
/** A question's recorded answer, by own key only (a question id like "constructor" isn't an answer). */
function answerOf(item: AttentionItem, questionId: string) {
const answers = item.humanAnswers ?? {};
+8 -3
View File
@@ -13,6 +13,8 @@ export interface DaemonClientOptions {
baseUrl?: string;
fetchImpl?: typeof fetch;
headers?: Record<string, string> | (() => Record<string, string>);
/** The person this TUI answers as (`rig tui --as`); the Feed falls back to the default person. A local choice. */
asPerson?: string;
}
export class StartupRequestError extends Error {
@@ -69,16 +71,18 @@ export class DaemonClient {
private readonly fetchImpl: typeof fetch;
private optionalFetchImpl: typeof fetch;
private readonly headerSource: Record<string, string> | (() => Record<string, string>);
private readonly asPerson: string | undefined;
constructor(options: DaemonClientOptions = {}) {
this.baseUrl = (options.baseUrl ?? process.env["OPENRIG_URL"] ?? "http://127.0.0.1:7433").replace(/\/$/, "");
this.fetchImpl = options.fetchImpl ?? fetch;
this.optionalFetchImpl = this.fetchImpl;
this.headerSource = options.headers ?? {};
this.asPerson = options.asPerson?.trim() || undefined;
}
forPage(page: PageRead, signal: AbortSignal): DaemonClient {
const client = new DaemonClient({ baseUrl: this.baseUrl, headers: this.headerSource,
const client = new DaemonClient({ baseUrl: this.baseUrl, headers: this.headerSource, asPerson: this.asPerson,
fetchImpl: page.fetch(this.fetchImpl, signal) });
client.optionalFetchImpl = page.fetch(this.fetchImpl, signal, true);
return client;
@@ -390,7 +394,8 @@ export class DaemonClient {
* lookup: blockedOn is a qitem POINTER, so the blocking agent is that qitem's
* owner (destinationSession). NOT a new endpoint. */
humanAttention(item?: string | null) {
return this.get(`/api/attention${item ? `?item=${encodeURIComponent(item)}` : ""}`);
const query = new URLSearchParams({ ...(item ? { item } : {}), ...(this.asPerson ? { as: this.asPerson } : {}) }).toString();
return this.get(`/api/attention${query ? `?${query}` : ""}`);
}
queueItem(qitemId: string, options: { optional?: boolean } = {}) {
return this.get(`/api/queue/${encodeURIComponent(qitemId)}`, options.optional ? this.optionalFetchImpl : this.fetchImpl);
@@ -404,7 +409,7 @@ export class DaemonClient {
/** The mission-control write Steering uses to answer a request (POST /api/mission-control/action). */
missionControlAction(input: { verb: "approve" | "answer"; qitemId: string; actorSession: string; questionId?: string; optionId?: string; decision?: string }) {
return this.post("/api/mission-control/action", input) as Promise<{ verb: string; qitemId: string; closedQitem: unknown;
answer?: { outcome: "recorded" | "answered"; unanswered: string[] } }>;
answer?: { outcome: "recorded" | "answered"; unanswered: string[] }; correspondence?: { askedPerson: string; message: string } }>;
}
attentionAggregate() {
return this.get(`/api/queue/attention-aggregate`);
+4 -2
View File
@@ -78,7 +78,8 @@ async function run(): Promise<void> {
startupHeaders = cli.terminalAuthHeaders;
} catch { /* the startup read will expose an unavailable/unauthorized prerequisite */ }
}
const client = demo ? null : new DaemonClient({ baseUrl: argOf(args, "--url"), headers: startupHeaders });
const client = demo ? null : new DaemonClient({ baseUrl: argOf(args, "--url"), headers: startupHeaders,
asPerson: argOf(args, "--as") ?? process.env["OPENRIG_TUI_AS"] });
let startup: StartupController | null = null;
let nativeAttached = false;
let launching = false;
@@ -443,7 +444,8 @@ async function run(): Promise<void> {
} else if (action.act === "answer") {
const { act: _act, type: _type, ...input } = action;
const result = await client.missionControlAction(input);
view.dispatch({ type: "notice", message: action.verb === "approve"
view.dispatch({ type: "notice", message: result.correspondence ? result.correspondence.message
: action.verb === "approve"
? `Approved ${action.qitemId} as ${action.actorSession}: the request is closed on its own row.`
: result.answer?.outcome === "recorded" ? `Recorded on ${action.qitemId}. Still to answer: ${result.answer.unanswered.join("; ")}`
: `Answered ${action.qitemId} as ${action.actorSession}: the answer went back to the asker, and the request is closed.` });