mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc36d9c810 | ||
|
|
c36ed35a39 | ||
|
|
b37906355d | ||
|
|
ade2430a77 | ||
|
|
f6ba7f46d2 | ||
|
|
d0e19d062c | ||
|
|
89c2bef395 | ||
|
|
ac921bbd28 | ||
|
|
abe5b5689d |
No files matched your search
@@ -188,7 +188,7 @@ the sections that were not read are `null`.
|
||||
|---|---|---|
|
||||
| `rig gateway` | — | — |
|
||||
| `rig gateway human` | — | — |
|
||||
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>` |
|
||||
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
|
||||
| `rig gateway human list` | — | `--json` |
|
||||
| `rig gateway human quiet` | — | — |
|
||||
| `rig gateway human quiet set <entityId>` | — | `--daily <window>`<br>`--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
|
||||
@@ -208,7 +208,23 @@ the sections that were not read are `null`.
|
||||
| `rig gateway human show <entityId>` | — | `--json` |
|
||||
| `rig gateway human set <entityId> <field> <value>` | — | `--reason <reason>`<br>`--actor <actor>` |
|
||||
| `rig gateway human remove <entityId>` | — | `--force` |
|
||||
| `rig gateway channel` | — | — |
|
||||
| `rig gateway channel add <id>` | — | `--display-name <name>` **required**<br>`--binding <slack:connectorRef:secretsRef:channelId>` **required**<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--replace` |
|
||||
| `rig gateway channel remove <id>` | — | `--force` |
|
||||
| `rig gateway org` | — | — |
|
||||
| `rig gateway org set <id>` | — | `--name <name>`<br>`--policy <text>`<br>`--policy-file <path>` |
|
||||
| `rig gateway group` | — | — |
|
||||
| `rig gateway group set <id>` | — | `--organization <id>`<br>`--name <name>`<br>`--policy <text>`<br>`--policy-file <path>` |
|
||||
| `rig gateway participants` | — | `--organization <id>`<br>`--json` |
|
||||
| `rig gateway default [entityId]` | — | `--clear` |
|
||||
|
||||
- Several people may be registered. Organizations, groups, readable policy, shared channels and the default person are
|
||||
kept in `$OPENRIG_HOME/gateway/participants.yaml`, never in a person's fragment, so an older daemon still loads every
|
||||
person after a rollback. `human set <entityId> organization|groups|policy <value>` writes there (`unset` clears;
|
||||
`@<path>` reads a policy file). `participants --json` carries each participant's `kind`, `address`, `organization`,
|
||||
`groups`, `default` and `policy` (a list of `{ source, text }`, organization first), plus `default`, `organizations`,
|
||||
`groups`, `warnings` and `participantFile`. `default` with no argument prints the current default and its source
|
||||
(`chosen` or `only-person`), or `null` with the reason. See `docs/reference/gateway-participants.md`.
|
||||
- `human set <entityId> slack-subscription <value>` — sets the person's Slack subscription: `every-ask` (the default) posts every ask to them to Slack; `urgent-only` posts only asks created with `--alert`, and other asks wait on Steering and the TUI; `unset` removes the authored subscription so the default applies. `human show` prints the value with its origin, `authored` or `default` (`slack-subscription: every-ask (default)`); `--json` carries it as `record.slackSubscription` `{ value, source }`.
|
||||
|
||||
### parked
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Gateway participants: several people, shared channels, organizations and policy
|
||||
|
||||
OpenRig's gateway can register **several people** and **shared channels**, group them into **organizations** and
|
||||
**groups**, give each readable **policy** text, and name a **default person**. Agents read the policy and apply it;
|
||||
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
|
||||
do.
|
||||
|
||||
## Participants
|
||||
|
||||
A participant is either:
|
||||
- **a person**, registered with `rig gateway human add` as before, with their own Slack binding; or
|
||||
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
|
||||
sender.
|
||||
|
||||
People and channels share one address space: a channel can't take a person's id.
|
||||
|
||||
```bash
|
||||
# An organization and a group inside it (one command each, create or update)
|
||||
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
|
||||
rig gateway group set support --organization acme --name "Support" --policy-file ./support-policy.md
|
||||
|
||||
# People: the second and later ones are allowed now
|
||||
rig gateway human add sarah --display-name "Sarah" \
|
||||
--binding "slack:main:file:/etc/openrig/slack.env:primary:handle=U0SARAH" --delivery-class B \
|
||||
--organization acme --group support --policy "Sarah approves refunds up to 500." --default
|
||||
|
||||
# A shared channel
|
||||
rig gateway channel add acme-customers --display-name "Acme customers" \
|
||||
--binding "slack:main:file:/etc/openrig/slack.env:C0ACME" --organization acme
|
||||
|
||||
# See everyone, with organization, groups, the default and each participant's policy
|
||||
rig gateway participants
|
||||
rig gateway participants --organization acme --json
|
||||
rig gateway human show sarah
|
||||
```
|
||||
|
||||
Change a person's placement with `rig gateway human set <id> organization|groups|policy <value>` (`unset` clears it;
|
||||
for policy, `@<path>` reads a file). Remove a channel with `rig gateway channel remove <id>`, which refuses while
|
||||
non-terminal rows are addressed to it unless you pass `--force`.
|
||||
|
||||
## Policy is context, not enforcement
|
||||
|
||||
A participant's **effective policy** is its organization's text, then each of its groups', then its own, each labelled
|
||||
with where it came from. Agents read it (`rig gateway participants --json`, `rig gateway human show --json`) and apply
|
||||
it with judgment. It doesn't grant or block anything by itself, and a participant's name never implies a policy.
|
||||
|
||||
## The default person
|
||||
|
||||
Some things need a person without naming one: an escalation of stuck work, the digest of notices, Mission Control's
|
||||
my-queue, a workflow's human step when no person is configured for it, and a pairing approval without `--human`. They
|
||||
go to:
|
||||
1. the default person, when one is chosen (`rig gateway default <id>`, or `--default` on `human add`);
|
||||
2. otherwise the only registered person, so a one-person install needs no choice;
|
||||
3. otherwise nobody, and each says so: an escalation reports `undeliverable:no-default-person`, the notice digest
|
||||
waits, and the others name the command to run.
|
||||
|
||||
`rig gateway default` shows the current default; `rig gateway default --clear` clears the choice. Choosing a default
|
||||
never changes `workspace.operator_seat_name`, which keeps its own meaning.
|
||||
|
||||
## In the TUI: whose queue, and who acts
|
||||
|
||||
The person at the terminal is the **primary user**: the default person, or, with several people and none chosen, the
|
||||
person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
|
||||
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
|
||||
install that never chose one, it opens on the topology, as before.
|
||||
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
|
||||
person's. The Feed's first line says whose queue it shows and who acts.
|
||||
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the primary user as the actor,
|
||||
and the person asked in the audit notes and the transition. The asking seat reads, for example, "Answered by
|
||||
mike@external, the primary user at the terminal, on sarah@external's ask". With several people and none chosen, the
|
||||
actor is `human@host`.
|
||||
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
|
||||
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
|
||||
refused (`answer_not_recorded`) and nothing changes.
|
||||
- With no one registered, the TUI and its acts are as before.
|
||||
|
||||
## Where it's stored
|
||||
|
||||
- **People** stay exactly as before: one file per person under `$OPENRIG_HOME/gateway/humans/`, plus the generated
|
||||
`humans.generated.yaml`. Nothing new is written into them.
|
||||
- **Everything else** (the default, organizations, groups, each participant's placement and policy, and shared
|
||||
channels) is in `$OPENRIG_HOME/gateway/participants.yaml`, written by the commands above.
|
||||
- **A problem in `participants.yaml` never disables the person registry.** People still receive and answer their asks.
|
||||
The commands above report the problem, and with several people no default is chosen until it's fixed.
|
||||
- **A person removed by hand** leaves a warning in `rig gateway participants`, not a broken file. `rig gateway human
|
||||
remove` also removes the person's placement, and the default if it was them.
|
||||
|
||||
## Upgrading
|
||||
|
||||
For a one-person install, nothing to do: it keeps working as before, that person stays the default, pending rows keep
|
||||
their addresses, and no new file is written until you use one of the commands above.
|
||||
|
||||
A TUI older than the daemon still works: it sends the person asked as the actor, which is still their own act.
|
||||
|
||||
**If several people are already registered** (earlier versions listed hand-added people with an advisory), choose a
|
||||
default with `rig gateway default <id>` after upgrading. Until you do, escalations report
|
||||
`undeliverable:no-default-person` instead of reaching whoever's id sorted first, and the notice digest waits.
|
||||
|
||||
## Rolling back to 0.7.1 or 0.7.0
|
||||
|
||||
Older versions don't read `participants.yaml`, and the person files carry nothing they don't understand, so:
|
||||
- **every registered person still loads**, and each can still answer their own asks from Slack;
|
||||
- **another person's reply is guarded less:** on an older version, a different person's Slack reply in the thread of a
|
||||
question parked on someone else can resume that question, and 0.7.0 can record such a reply to a pending ask as an
|
||||
answer row (it still can't close the ask). Those protections are newer than 0.7.1;
|
||||
- **there's no default:** escalations and the notice digest go to the first person by id, Mission Control's my-queue
|
||||
has no default person, and pairing needs `--human`;
|
||||
- **the TUI** opens on the topology and records a terminal answer as the person asked, as before. Rows already
|
||||
answered by the primary user keep their notes;
|
||||
- **shared channels are unknown:** rows addressed to one become undeliverable on the older version;
|
||||
- **organizations, groups and policy aren't shown.**
|
||||
|
||||
Upgrading again restores all of it, because the older version never changes `participants.yaml`.
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { resolveSenderSession, SENDER_FALLBACK } from "../sender-identity.js";
|
||||
// The human registry is home-state owned by the daemon; the verb LAZY-imports the narrow
|
||||
@@ -182,32 +183,30 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
.option("--replace", "Explicitly replace an existing human (no silent overwrite)")
|
||||
.option("--reason <reason>", "Reason recorded with connector binding changes", "register human delivery")
|
||||
.option("--actor <actor>", "Named operator when outside a managed seat")
|
||||
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string }) => {
|
||||
.option("--organization <id>", "The person's organization (create it first with rig gateway org set)")
|
||||
.option("--group <id>", "A group the person belongs to (repeatable; create it first with rig gateway group set)", (v: string, acc: string[] = []) => { acc.push(v); return acc; })
|
||||
.option("--policy <text>", "Readable policy for agents working with this person (text, not enforced)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.option("--default", "Make this person the default: the one escalations, the notice digest and other unaddressed needs go to")
|
||||
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
|
||||
// LAZY import the narrow daemon surface at invocation (dep rail 2).
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const { addHumanFragment, parseBindingSpec } = registry as unknown as {
|
||||
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;
|
||||
// Several people may be registered. Their organization, groups, policy and the default go in the participant side
|
||||
// file, never the fragment, so an older daemon still loads every person after a rollback.
|
||||
const policy = readPolicyOption(opts);
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
const placed = opts.organization !== undefined || opts.group !== undefined || policy.value !== undefined;
|
||||
if (placed) {
|
||||
// Check the organization and groups exist before writing anything, so a typo doesn't leave a half-placed person.
|
||||
const side = registry.readParticipantsFile();
|
||||
if (!side.ok) { console.error(`refused: ${side.error}`); process.exitCode = 1; return; }
|
||||
if (opts.organization && !side.file.organizations[opts.organization]) { console.error(`refused: no organization "${opts.organization}" — create it with rig gateway org set ${opts.organization} --name …`); process.exitCode = 1; return; }
|
||||
const unknownGroup = (opts.group ?? []).find((group) => !side.file.groups[group]);
|
||||
if (unknownGroup) { console.error(`refused: no group "${unknownGroup}" — create it with rig gateway group set ${unknownGroup} --organization … --name …`); process.exitCode = 1; return; }
|
||||
}
|
||||
const bindings: Record<string, unknown>[] = [];
|
||||
for (const spec of opts.binding) {
|
||||
@@ -236,7 +235,19 @@ 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 }));
|
||||
// The person is registered; their placement and the default are a second, separate write.
|
||||
const followUps: Record<string, unknown> = {};
|
||||
if (placed) {
|
||||
const res = registry.setPersonPlacement(entityId, { organization: opts.organization, groups: opts.group, policy: policy.value });
|
||||
followUps.placement = res.ok ? { path: res.path } : { error: res.error };
|
||||
}
|
||||
if (opts.default) {
|
||||
const res = registry.setDefaultPerson(entityId);
|
||||
followUps.default = res.ok ? { path: res.path } : { error: res.error };
|
||||
}
|
||||
const failed = Object.values(followUps).some((value) => "error" in (value as object));
|
||||
console.log(JSON.stringify({ ok: !failed, entityId, path: result.value.path, receipt: result.receipt, ...followUps }));
|
||||
if (failed) { console.error(`the person was registered, but part of their placement wasn't saved; fix it with rig gateway human set ${entityId} … or rig gateway default ${entityId}`); process.exitCode = 1; }
|
||||
} catch (error) { console.error(`refused: ${(error as Error).message}`); process.exitCode = 1; }
|
||||
})
|
||||
.addHelpText("after", `
|
||||
@@ -259,7 +270,7 @@ Example:
|
||||
|
||||
human
|
||||
.command("list")
|
||||
.description("Show the configured human (OpenRig supports one; extra hand-authored fragments are listed with an advisory)")
|
||||
.description("List the registered people, with delivery readiness and quiet hours (rig gateway participants adds organizations, groups, policy and shared channels)")
|
||||
.option("--json", "Complete record(s) as JSON")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const { listHumans } = await import("@openrig/daemon/gateway-human-registry");
|
||||
@@ -270,7 +281,7 @@ Example:
|
||||
return { ...record, deliveryReadiness: await humanReadiness(record.entityId),
|
||||
quiet: quiet.ok ? quiet.quiet : { unknown: `quiet hours could not be read from the daemon: ${quiet.reason}` } };
|
||||
}));
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, humans, ...(res.advisory ? { advisory: res.advisory } : {}) })); return; }
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; }
|
||||
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]";
|
||||
@@ -278,7 +289,6 @@ Example:
|
||||
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)}`);
|
||||
}
|
||||
if (res.advisory) console.log(`advisory: ${res.advisory}`);
|
||||
});
|
||||
|
||||
const quiet = human.command("quiet").description("Quiet hours: while on, routine asks to this person are held, not posted; urgent ones (tagged escalation) still reach them. Held asks post once, each on its own, when quiet ends");
|
||||
@@ -520,13 +530,22 @@ Examples:
|
||||
.description("Show the effective record: fragment values + which defaults filled the rest, with provenance")
|
||||
.option("--json", "Complete record as JSON")
|
||||
.action(async (entityId: string, opts: { json?: boolean }) => {
|
||||
const { showHuman } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const { showHuman, participantsView } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const res = showHuman(entityId);
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
const r = { ...res.record, deliveryReadiness: await humanReadiness(entityId) };
|
||||
// The person's organization, groups, policy and whether they're the default come from the participant side file.
|
||||
const view = participantsView();
|
||||
const placement = view.ok ? view.participants.find((p) => p.id === entityId) : undefined;
|
||||
const participant = placement
|
||||
? { organization: placement.organization, groups: placement.groups, default: placement.default, policy: placement.policy }
|
||||
: { unavailable: view.ok ? "not found" : view.error };
|
||||
const sideFileError = view.ok ? view.sideFile.error : undefined;
|
||||
const r = { ...res.record, participant, ...(sideFileError ? { participantFileError: sideFileError } : {}), deliveryReadiness: await humanReadiness(entityId) };
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, record: r })); return; }
|
||||
console.log(`${r.entityId} (${r.address}) — "${r.displayName}"`);
|
||||
console.log(` fragment: ${r.fragmentPath}`);
|
||||
if (placement) for (const line of describePlacement(placement)) console.log(line);
|
||||
if (sideFileError) console.log(` participant file: ${sideFileError}`);
|
||||
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})`);
|
||||
@@ -539,11 +558,26 @@ 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), binding.<n>; and, kept in the participant side file: organization (an id, or unset), groups (comma-separated ids, or unset), policy (text, @<path> to read a file, or unset)")
|
||||
.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 }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
if (field === "organization" || field === "groups" || field === "policy") {
|
||||
const clear = value === "unset";
|
||||
let input: Record<string, unknown>;
|
||||
if (field === "organization") input = { organization: clear ? null : value };
|
||||
else if (field === "groups") input = { groups: clear ? null : value.split(",").map((g) => g.trim()).filter(Boolean) };
|
||||
else {
|
||||
const policy = clear ? { ok: true as const, value: null } : value.startsWith("@") ? readPolicyOption({ policyFile: value.slice(1) }) : { ok: true as const, value };
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
input = { policy: policy.value };
|
||||
}
|
||||
const res = registry.setPersonPlacement(entityId, input);
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, entityId, field, path: res.path }));
|
||||
return;
|
||||
}
|
||||
if (field.startsWith("binding.")) {
|
||||
const before = registry.showHuman(entityId);
|
||||
if (!before.ok) { console.error(`refused: ${before.error}`); process.exitCode = 1; return; }
|
||||
@@ -600,7 +634,141 @@ Examples:
|
||||
];
|
||||
const res = removeHumanFragment(entityId, { force: !!opts.force, inflight });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, removed: res.removed, archivedPath: res.archivedPath, ...(res.orphanRecordPath ? { orphanRecordPath: res.orphanRecordPath } : {}) }));
|
||||
// Their placement and, if it was them, the default go too; a later read would ignore them anyway, with a warning.
|
||||
const { removeParticipantEntry } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const side = removeParticipantEntry(entityId, undefined, { kind: "person" });
|
||||
console.log(JSON.stringify({ ok: true, removed: res.removed, archivedPath: res.archivedPath, ...(res.orphanRecordPath ? { orphanRecordPath: res.orphanRecordPath } : {}),
|
||||
participantFile: side.ok ? { path: side.path } : { error: side.error } }));
|
||||
});
|
||||
|
||||
// ── Participants beyond people (0.7.3): shared channels, organizations, groups, readable policy and the default. All of
|
||||
// it lives in gateway/participants.yaml, which older daemons don't read, so a rollback still loads every person.
|
||||
|
||||
const channel = cmd.command("channel").description("Shared-channel participants: one vetted Slack channel is one participant (<id>@external), not a person per sender");
|
||||
channel
|
||||
.command("add <id>")
|
||||
.description("Register a shared channel")
|
||||
.requiredOption("--display-name <name>", "Human-readable name, e.g. \"Acme customers\"")
|
||||
.requiredOption("--binding <slack:connectorRef:secretsRef:channelId>", "The channel: connector kind (slack), connector label, vault pointer for its secrets (never the secret), and the Slack channel id")
|
||||
.option("--organization <id>", "Its organization (create it first with rig gateway org set)")
|
||||
.option("--group <id>", "A group it belongs to (repeatable)", (v: string, acc: string[] = []) => { acc.push(v); return acc; })
|
||||
.option("--policy <text>", "Readable policy for agents answering this channel (text, not enforced)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.option("--replace", "Explicitly replace an existing channel (no silent overwrite)")
|
||||
.action(async (id: string, opts: { displayName: string; binding: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; replace?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const policy = readPolicyOption(opts);
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
const parts = opts.binding.split(":");
|
||||
if (parts.length < 4 || parts[0] !== "slack") { console.error("refused: --binding must be slack:<connectorRef>:<secretsRef>:<channelId>"); process.exitCode = 1; return; }
|
||||
const res = registry.setSharedChannel(id, {
|
||||
displayName: opts.displayName,
|
||||
channel: { connector: "slack", connectorRef: parts[1]!, secretsRef: parts.slice(2, -1).join(":"), channelId: parts[parts.length - 1]! },
|
||||
organization: opts.organization, groups: opts.group, policy: policy.value,
|
||||
}, { replace: !!opts.replace });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, id, address: `${id}@${registry.ADDRESS_DOMAIN}`, path: res.path }));
|
||||
});
|
||||
channel
|
||||
.command("remove <id>")
|
||||
.description("Remove a shared channel: refuses while non-terminal rows are addressed to it (--force removes anyway)")
|
||||
.option("--force", "Remove despite KNOWN non-terminal rows")
|
||||
.action(async (id: string, opts: { force?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const side = registry.readParticipantsFile();
|
||||
if (!side.ok) { console.error(`refused: ${side.error}`); process.exitCode = 1; return; }
|
||||
if (side.file.participants[id]?.kind !== "shared-channel") { console.error(`refused: no shared channel "${id}"`); process.exitCode = 1; return; }
|
||||
const address = `${id}@${registry.ADDRESS_DOMAIN}`;
|
||||
const rowsRes = await queueRows(address);
|
||||
if (!rowsRes.ok) { console.error(`refused: the queue could not be checked for rows addressed to ${address} (${rowsRes.error}); --force doesn't override an unchecked board`); process.exitCode = 1; return; }
|
||||
if (rowsRes.rows.length && !opts.force) {
|
||||
console.error(`refused: ${rowsRes.rows.length} non-terminal row(s) are addressed to ${address} (${rowsRes.rows.map((r) => r.id).join(", ")}); pass --force to remove anyway`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const res = registry.removeParticipantEntry(id, undefined, { kind: "shared-channel" });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, removed: id, path: res.path, ...(rowsRes.rows.length ? { orphanedRows: rowsRes.rows.map((r) => r.id) } : {}) }));
|
||||
});
|
||||
|
||||
const org = cmd.command("org").description("Organizations: the outer layer of participants, with readable policy agents apply");
|
||||
org
|
||||
.command("set <id>")
|
||||
.description("Create or update an organization")
|
||||
.option("--name <name>", "Display name (required when new)")
|
||||
.option("--policy <text>", "Readable policy for agents working with this organization (text, not enforced; \"\" clears it)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.action(async (id: string, opts: { name?: string; policy?: string; policyFile?: string }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const policy = readPolicyOption(opts);
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
const res = registry.setOrganization(id, { name: opts.name, ...(policy.value !== undefined ? { policy: policy.value || null } : {}) });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, organization: id, path: res.path }));
|
||||
});
|
||||
|
||||
const group = cmd.command("group").description("Groups inside an organization, with readable policy agents apply");
|
||||
group
|
||||
.command("set <id>")
|
||||
.description("Create or update a group")
|
||||
.option("--organization <id>", "Its organization (required when new)")
|
||||
.option("--name <name>", "Display name (required when new)")
|
||||
.option("--policy <text>", "Readable policy for agents working with this group (text, not enforced; \"\" clears it)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.action(async (id: string, opts: { organization?: string; name?: string; policy?: string; policyFile?: string }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const policy = readPolicyOption(opts);
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
const res = registry.setGroup(id, { organization: opts.organization, name: opts.name, ...(policy.value !== undefined ? { policy: policy.value || null } : {}) });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, group: id, path: res.path }));
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("participants")
|
||||
.description("List people and shared channels, with organization, groups, policy and the default")
|
||||
.option("--organization <id>", "Only this organization's participants")
|
||||
.option("--json", "Complete records as JSON, including organizations and groups")
|
||||
.action(async (opts: { organization?: string; json?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const view = registry.participantsView();
|
||||
if (!view.ok) { console.error(`refused: ${view.error}`); process.exitCode = 1; return; }
|
||||
const shown = opts.organization ? view.participants.filter((p) => p.organization?.id === opts.organization) : view.participants;
|
||||
const defaultPerson = view.default.person ? { id: view.default.person.entityId, source: view.default.source } : { id: null, reason: view.default.source === "none" ? view.default.reason : null };
|
||||
if (opts.json) {
|
||||
const side = registry.readParticipantsFile();
|
||||
console.log(JSON.stringify({ ok: true, participants: shown, default: defaultPerson,
|
||||
organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
|
||||
warnings: view.warnings, participantFile: view.sideFile }));
|
||||
return;
|
||||
}
|
||||
if (!shown.length) console.log(opts.organization ? `no participants in organization "${opts.organization}"` : "no participants yet: rig gateway human add … registers a person, rig gateway channel add … a shared channel");
|
||||
for (const p of shown) {
|
||||
console.log(`${p.id} ${p.kind} "${p.displayName}" ${p.address}${p.channel ? ` channel ${p.channel.channelId}` : ""}`);
|
||||
for (const line of describePlacement(p)) console.log(line);
|
||||
}
|
||||
console.log(defaultPerson.id ? `default: ${defaultPerson.id} (${defaultPerson.source === "chosen" ? "chosen" : "the only person"})` : `default: none — ${defaultPerson.reason}`);
|
||||
for (const warning of view.warnings) console.log(`warning: ${warning}`);
|
||||
if (view.sideFile.error) console.log(`participant file: ${view.sideFile.error}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("default [entityId]")
|
||||
.description("Show or choose the default person: the one escalations, the notice digest, Mission Control's my-queue and pairing go to when no person is named")
|
||||
.option("--clear", "Clear the chosen default (with one person, they stay the default)")
|
||||
.action(async (entityId: string | undefined, opts: { clear?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
if (entityId || opts.clear) {
|
||||
if (entityId && opts.clear) { console.error("refused: pass an id or --clear, not both"); process.exitCode = 1; return; }
|
||||
const res = registry.setDefaultPerson(opts.clear ? null : entityId!);
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
}
|
||||
const people = registry.projectHumans();
|
||||
if (!people.ok) { console.error(`refused: ${people.error}`); process.exitCode = 1; return; }
|
||||
const chosen = registry.resolveDefaultPerson(people.entities);
|
||||
console.log(JSON.stringify(chosen.person
|
||||
? { ok: true, default: chosen.person.entityId, address: chosen.person.address, source: chosen.source }
|
||||
: { ok: true, default: null, reason: chosen.source === "none" ? chosen.reason : null }));
|
||||
});
|
||||
|
||||
return cmd;
|
||||
@@ -623,3 +791,26 @@ function describeApplication(a: Record<string, any>): string {
|
||||
const effects = (a.effects as Array<Record<string, any>>).map((e) => ` ${e.consumer}: ${e.state}${e.detail ? ` (${e.detail})` : ""}`);
|
||||
return [head, ...(effects.length ? effects : [" no effects"])].join("\n");
|
||||
}
|
||||
|
||||
/** --policy or --policy-file (never both): the readable policy text agents read. Absent is undefined. */
|
||||
function readPolicyOption(opts: { policy?: string; policyFile?: string }): { ok: true; value: string | undefined } | { ok: false; error: string } {
|
||||
if (opts.policy !== undefined && opts.policyFile !== undefined) return { ok: false, error: "pass --policy or --policy-file, not both" };
|
||||
if (opts.policyFile === undefined) return { ok: true, value: opts.policy };
|
||||
try {
|
||||
return { ok: true, value: readFileSync(opts.policyFile, "utf8") };
|
||||
} catch (error) {
|
||||
return { ok: false, error: `--policy-file: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** One participant's organization, groups, default and policy, in plain lines. */
|
||||
function describePlacement(p: { organization: { id: string; name: string } | null; groups: Array<{ id: string; name: string }>; default: boolean; policy: Array<{ source: string; text: string }> }): string[] {
|
||||
const lines = [
|
||||
` organization: ${p.organization ? `${p.organization.id} (${p.organization.name})` : "none"}`,
|
||||
` groups: ${p.groups.length ? p.groups.map((g) => `${g.id} (${g.name})`).join(", ") : "none"}`,
|
||||
];
|
||||
if (p.default) lines.push(" default: yes (escalations, the notice digest and other unaddressed needs go to this person)");
|
||||
if (!p.policy.length) lines.push(" policy: none");
|
||||
for (const part of p.policy) lines.push(` policy, from ${part.source}:`, ...part.text.trimEnd().split("\n").map((line) => ` ${line}`));
|
||||
return lines;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { readdirSync, 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";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
// OPR.0.5.5.12 — `rig gateway human list|show|set|remove` verb wiring. Same harness as the
|
||||
// add-verb test: temp OPENRIG_HOME, real command end-to-end, fs effects asserted at source.
|
||||
@@ -25,8 +26,7 @@ async function runAdd(name: string, extra: string[] = []): Promise<void> {
|
||||
]);
|
||||
}
|
||||
|
||||
/** The hand-authoring path (registry surface, not the verb) — how a second fragment
|
||||
* legitimately comes to exist under A1. */
|
||||
/** A second person through the registry surface, without the verb. */
|
||||
function seedSecondHuman(name: string): void {
|
||||
const res = addHumanFragment({
|
||||
entityId: name,
|
||||
@@ -188,9 +188,7 @@ 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.
|
||||
it("with several people, list --json renders all of them, with no single-person advisory", async () => {
|
||||
seedSecondHuman("ana");
|
||||
logSpy.mockClear();
|
||||
const p = program();
|
||||
@@ -198,8 +196,8 @@ describe("rig gateway human lifecycle verbs (S12)", () => {
|
||||
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).not.toHaveProperty("advisory");
|
||||
});
|
||||
|
||||
it("show --json carries authored-vs-default provenance and the fragment path", async () => {
|
||||
@@ -261,28 +259,41 @@ 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 ──
|
||||
// ── 0.7.3 participants: several people, organizations, groups, policy, shared channels and the default ──
|
||||
|
||||
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();
|
||||
it("registers a second person in an organization and group as the default, and a shared channel; fragments keep v0.7.0's keys", async () => {
|
||||
const mikeBytes = readFileSync(join(humansDir(home), "mike.yaml"), "utf8");
|
||||
const run = async (...args: string[]) => { const p = program(); p.exitOverride(); await p.parseAsync(["node", "rig", "gateway", ...args]); };
|
||||
await run("org", "set", "acme", "--name", "Acme", "--policy", "Answer within a working day.");
|
||||
await run("group", "set", "support", "--organization", "acme", "--name", "Support");
|
||||
await run("human", "add", "ana", "--display-name", "Ana", "--binding", "slack:main:vault://slack/ana:primary",
|
||||
"--delivery-class", "A", "--organization", "acme", "--group", "support", "--policy", "Ana approves refunds.", "--default");
|
||||
await run("channel", "add", "acme-customers", "--display-name", "Acme customers", "--binding", "slack:main:vault://slack/bot:C0ACME",
|
||||
"--organization", "acme");
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
logSpy.mockClear();
|
||||
await run("participants", "--json");
|
||||
const out = JSON.parse(logSpy.mock.calls.at(-1)![0] as string) as { participants: Array<Record<string, any>>; default: { id: string } };
|
||||
// Sorted by id, people and channels together.
|
||||
expect(out.participants.map((p) => [p.id, p.kind])).toEqual([["acme-customers", "shared-channel"], ["ana", "person"], ["mike", "person"]]);
|
||||
expect(out.default).toMatchObject({ id: "ana", source: "chosen" });
|
||||
const ana = out.participants.find((p) => p.id === "ana")!;
|
||||
expect(ana.policy.map((part: { source: string }) => part.source)).toEqual(["organization acme (Acme)", "participant"]);
|
||||
// Nothing new went into a person's fragment: mike's is byte-identical, ana's carries v0.7.0's keys only.
|
||||
expect(readFileSync(join(humansDir(home), "mike.yaml"), "utf8")).toBe(mikeBytes);
|
||||
expect(Object.keys(parseYaml(readFileSync(join(humansDir(home), "ana.yaml"), "utf8")) as object).sort())
|
||||
.toEqual(["address", "class", "connectorBindings", "displayName", "entityId", "prefs"]);
|
||||
});
|
||||
|
||||
it("refuses a person in an organization that doesn't exist, before writing their fragment", async () => {
|
||||
const dirBefore = readdirSync(humansDir(home)).sort();
|
||||
const p = program();
|
||||
p.exitOverride();
|
||||
try { 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 */ }
|
||||
try { await p.parseAsync(["node", "rig", "gateway", "human", "add", "ana", "--display-name", "Ana",
|
||||
"--binding", "slack:main:vault://slack/ana:primary", "--delivery-class", "A", "--organization", "nope"]); } 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(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain('no organization "nope"');
|
||||
expect(readdirSync(humansDir(home)).sort()).toEqual(dirBefore);
|
||||
expect(readFileSync(join(humansDir(home), "mike.yaml"), "utf8")).toBe(mikeBytes);
|
||||
});
|
||||
|
||||
it("F1 companion: re-add of the SAME human with --replace stays allowed (boundary blocks distinct humans only)", async () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ description: "Use when project policy calls for a human decision or update, a hu
|
||||
metadata:
|
||||
cli_surfaces_referenced:
|
||||
- gateway human list
|
||||
- gateway participants
|
||||
- gateway default
|
||||
- gateway human show
|
||||
- gateway human mode room
|
||||
- gateway human mode decide
|
||||
@@ -32,13 +34,22 @@ Discover the registered participants and inspect the chosen human:
|
||||
|
||||
```bash
|
||||
rig gateway human list --json
|
||||
rig gateway participants --json
|
||||
rig gateway human show <entityId> --json
|
||||
```
|
||||
|
||||
`participants` lists people and shared channels (one vetted channel is one
|
||||
participant), each with its organization, groups, whether it's the default, and
|
||||
its readable policy: the organization's, each group's and its own text, each
|
||||
labelled with where it came from. Read the policy of whoever you contact and
|
||||
apply it; it's context for your judgment, not a permission system, and a name
|
||||
is never a reason to assume a policy.
|
||||
|
||||
Use the returned `address` (`<entityId>@external`), not a username, remembered
|
||||
seat, connector handle, or guessed kernel address. Where several humans exist,
|
||||
use the decision ownership in Project World. An absent or ambiguous registration
|
||||
needs a named registration correction, not a fallback address.
|
||||
seat, connector handle, or guessed kernel address. Where several people exist,
|
||||
use the decision ownership in Project World; the default person (`rig gateway
|
||||
default`) is only for needs that name nobody. An absent or ambiguous
|
||||
registration needs a named registration correction, not a fallback address.
|
||||
|
||||
Check readiness: configured, enabled, active, ready, reason, and next action.
|
||||
`indeterminate` is not ready. Follow the reported next inspection; do not enable
|
||||
|
||||
@@ -4,6 +4,8 @@ description: "Use when project policy calls for a human decision or update, a hu
|
||||
metadata:
|
||||
cli_surfaces_referenced:
|
||||
- gateway human list
|
||||
- gateway participants
|
||||
- gateway default
|
||||
- gateway human show
|
||||
- gateway human mode room
|
||||
- gateway human mode decide
|
||||
@@ -32,13 +34,22 @@ Discover the registered participants and inspect the chosen human:
|
||||
|
||||
```bash
|
||||
rig gateway human list --json
|
||||
rig gateway participants --json
|
||||
rig gateway human show <entityId> --json
|
||||
```
|
||||
|
||||
`participants` lists people and shared channels (one vetted channel is one
|
||||
participant), each with its organization, groups, whether it's the default, and
|
||||
its readable policy: the organization's, each group's and its own text, each
|
||||
labelled with where it came from. Read the policy of whoever you contact and
|
||||
apply it; it's context for your judgment, not a permission system, and a name
|
||||
is never a reason to assume a policy.
|
||||
|
||||
Use the returned `address` (`<entityId>@external`), not a username, remembered
|
||||
seat, connector handle, or guessed kernel address. Where several humans exist,
|
||||
use the decision ownership in Project World. An absent or ambiguous registration
|
||||
needs a named registration correction, not a fallback address.
|
||||
seat, connector handle, or guessed kernel address. Where several people exist,
|
||||
use the decision ownership in Project World; the default person (`rig gateway
|
||||
default`) is only for needs that name nobody. An absent or ambiguous
|
||||
registration needs a named registration correction, not a fallback address.
|
||||
|
||||
Check readiness: configured, enabled, active, ready, reason, and next action.
|
||||
`indeterminate` is not ready. Follow the reported next inspection; do not enable
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface AttentionItem {
|
||||
* one-tap Approve), or `answer` for an ask with questions, which Approve would close without returning their answers.
|
||||
* `answer` takes a choice or their own words, or sends a complete set's recorded answers back, and the answer goes back
|
||||
* to the asker as Slack's answers do. */
|
||||
answer?: { verb: "approve" | "answer"; actorSession: string };
|
||||
answer?: { verb: "approve" | "answer" | "resolve"; actorSession: string };
|
||||
/** The seat that asked, which the person can open to answer in their own words (the Feed card's Chat). */
|
||||
asker?: string;
|
||||
/** The row's questions and the answers recorded so far, as the queue row holds them. */
|
||||
@@ -35,6 +35,18 @@ export interface AttentionRead {
|
||||
sources: Array<{ source: string; state: "available" | "unavailable" | "partial"; detail: string }>;
|
||||
detail: AttentionDetail | null;
|
||||
detailError: string | null;
|
||||
/** Whose queue this Feed shows (`view`), and who acts on it. A view, never an identity: acts on what it shows carry the
|
||||
* primary user's authority, the person at the terminal, whoever's queue it is. `requested` names a view that matched no
|
||||
* registered person. Absent when no person is registered. */
|
||||
perspective?: {
|
||||
viewing: string | null;
|
||||
basis: "all" | "default" | "chosen-here" | "unknown";
|
||||
requested?: string;
|
||||
/** Where the default person came from: the participants file (`chosen`), the only registered person, or nowhere. The TUI
|
||||
* opens on the default's Feed only when one was chosen. */
|
||||
defaultSource: "chosen" | "only-person" | "none";
|
||||
actingAs: { person: string | null; authority: "primary-user" };
|
||||
};
|
||||
}
|
||||
|
||||
// Consumers must use the same lexical human classification as queue selection.
|
||||
|
||||
@@ -273,6 +273,23 @@ export function humansDir(home: string = getOpenRigHome()): string {
|
||||
export function projectionPath(home: string = getOpenRigHome()): string {
|
||||
return join(home, "gateway", "humans.generated.yaml");
|
||||
}
|
||||
/** The participant side file (participants.ts owns its contents). Older daemons never read it. */
|
||||
export function participantsPath(home: string = getOpenRigHome()): string {
|
||||
return join(home, "gateway", "participants.yaml");
|
||||
}
|
||||
|
||||
/** People and shared channels share one address space (<id>@external): the shared channel registered under `entityId`,
|
||||
* if the participant file names one. A file that doesn't parse names none here — it never blocks registering a person,
|
||||
* and its own error is reported where it's read. */
|
||||
function sharedChannelWithId(entityId: string, home: string): string | null {
|
||||
const path = participantsPath(home);
|
||||
if (!existsSync(path)) return null;
|
||||
let raw: unknown;
|
||||
try { raw = parseYaml(readFileSync(path, "utf8")); } catch { return null; }
|
||||
const entry = isObj(raw) && isObj(raw.participants) ? raw.participants[entityId] : undefined;
|
||||
if (!isObj(entry) || entry.kind !== "shared-channel") return null;
|
||||
return typeof entry.displayName === "string" ? entry.displayName : entityId;
|
||||
}
|
||||
|
||||
/** @deprecated Historical spelling retained for readers of old records only.
|
||||
* Never a routing default: an address must resolve to an actual registry entity. */
|
||||
@@ -384,7 +401,7 @@ export function resolveSlackHandle(
|
||||
};
|
||||
}
|
||||
|
||||
function atomicWrite(path: string, body: string): { ok: true } | { ok: false; error: string } {
|
||||
export function atomicWrite(path: string, body: string): { ok: true } | { ok: false; error: string } {
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp-${process.pid}`;
|
||||
@@ -425,6 +442,11 @@ export function addHumanFragment(
|
||||
if (!opts.replace && existsSync(file)) {
|
||||
return { ok: false, error: `human "${v.fragment.entityId}" already exists at ${file} — pass an explicit replace to update it (no silent overwrite)` };
|
||||
}
|
||||
// A person can't take a shared channel's id: they'd share <id>@external, and the participant file would stop reading.
|
||||
const channel = sharedChannelWithId(v.fragment.entityId, home);
|
||||
if (channel !== null) {
|
||||
return { ok: false, error: `"${v.fragment.entityId}" is already the shared channel "${channel}" (${v.fragment.entityId}@${ADDRESS_DOMAIN}); choose another id for the person, or remove the channel first with rig gateway channel remove ${v.fragment.entityId}` };
|
||||
}
|
||||
// A6 v3 pin-1: reject a handle already claimed by a DIFFERENT human BEFORE writing (never leave a
|
||||
// conflicting fragment on disk with a failed re-projection). projectHumans is the load-time backstop.
|
||||
const existing = projectHumans(home);
|
||||
@@ -487,14 +509,9 @@ export interface HumanSummary {
|
||||
}
|
||||
|
||||
export type ListHumansResult =
|
||||
| { ok: true; humans: HumanSummary[]; advisory?: string }
|
||||
| { ok: true; humans: HumanSummary[] }
|
||||
| { 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. */
|
||||
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";
|
||||
|
||||
/** A field value with its provenance: authored in the fragment, or filled by a default. */
|
||||
export interface ProvenancedValue<T> {
|
||||
value: T;
|
||||
@@ -591,7 +608,8 @@ export function listHumans(home: string = getOpenRigHome()): ListHumansResult {
|
||||
fragmentPath: fragmentPathFor(e.entityId, home),
|
||||
};
|
||||
});
|
||||
return humans.length > 1 ? { ok: true, humans, advisory: MULTI_HUMAN_ADVISORY } : { ok: true, humans };
|
||||
// Several people are a supported registry (0.7.3): the default and their placement live in gateway/participants.yaml.
|
||||
return { ok: true, humans };
|
||||
}
|
||||
|
||||
/** Read one fragment RAW (for authored-vs-default provenance) + validated. */
|
||||
|
||||
@@ -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 { resolveDefaultPerson } from "./participants.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 = resolveDefaultPerson(reg.entities, deps.home).person;
|
||||
if (!human) {
|
||||
return { decision: "undeliverable:no-registered-human", resolved: false, dispatched: false };
|
||||
return { decision: reg.entities.length ? "undeliverable:no-default-person" : "undeliverable:no-registered-human", resolved: false, dispatched: false };
|
||||
}
|
||||
const cfg = loadConfig(deps.home);
|
||||
const decision = decideDelivery({
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// The gateway's participant side file (0.7.3 participant foundation).
|
||||
//
|
||||
// Person fragments under gateway/humans/ keep their closed key set exactly as v0.7.0 reads it, so after a rollback an
|
||||
// older daemon still loads every person. Everything newer lives here, in <home>/gateway/participants.yaml, which v0.7.0
|
||||
// and v0.7.1 never read: the default person, organizations and their groups, each participant's organization, groups
|
||||
// and readable policy, and shared-channel participants (one vetted channel is one participant, not a person per sender).
|
||||
//
|
||||
// Policy is text agents read and apply. Nothing here enforces it, and nothing derives it from names.
|
||||
//
|
||||
// A broken side file never disables the person registry: asks, answers and inbound admission keep working from the
|
||||
// fragments, this file's own features report the error, and with several people no default is chosen. A reference to a
|
||||
// person whose fragment is gone is a warning and is ignored, never a reason to refuse the whole file.
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
import { getOpenRigHome } from "../../openrig-compat.js";
|
||||
import { ADDRESS_DOMAIN, ENTITY_ID_PATTERN, HANDLE_PATTERN, atomicWrite, participantsPath, projectHumans, type HumanFragment } from "./human-registry.js";
|
||||
|
||||
// The path lives beside the person registry, which reads it to keep a person off a shared channel's id.
|
||||
export { participantsPath };
|
||||
|
||||
export const PARTICIPANTS_FILE_VERSION = 1;
|
||||
|
||||
export interface ParticipantOrganization { name: string; policy?: string }
|
||||
export interface ParticipantGroup { organization: string; name: string; policy?: string }
|
||||
export interface SharedChannelBinding { connector: "slack"; connectorRef: string; secretsRef: string; channelId: string }
|
||||
interface Placement { organization?: string; groups?: string[]; policy?: string }
|
||||
export type PersonEntry = Placement & { kind: "person" };
|
||||
export type SharedChannelEntry = Placement & { kind: "shared-channel"; displayName: string; channel: SharedChannelBinding };
|
||||
export type ParticipantEntry = PersonEntry | SharedChannelEntry;
|
||||
|
||||
export interface ParticipantsFile {
|
||||
version: typeof PARTICIPANTS_FILE_VERSION;
|
||||
/** The person for anything that needs one without naming one. Never a shared channel. */
|
||||
default?: string;
|
||||
organizations: Record<string, ParticipantOrganization>;
|
||||
groups: Record<string, ParticipantGroup>;
|
||||
participants: Record<string, ParticipantEntry>;
|
||||
}
|
||||
|
||||
export const emptyParticipantsFile = (): ParticipantsFile => ({ version: PARTICIPANTS_FILE_VERSION, organizations: {}, groups: {}, participants: {} });
|
||||
|
||||
const FILE_KEYS = new Set(["version", "default", "organizations", "groups", "participants"]);
|
||||
const ORGANIZATION_KEYS = new Set(["name", "policy"]);
|
||||
const GROUP_KEYS = new Set(["organization", "name", "policy"]);
|
||||
const PERSON_KEYS = new Set(["kind", "organization", "groups", "policy"]);
|
||||
const CHANNEL_KEYS = new Set(["kind", "displayName", "channel", "organization", "groups", "policy"]);
|
||||
const CHANNEL_BINDING_KEYS = new Set(["connector", "connectorRef", "secretsRef", "channelId"]);
|
||||
|
||||
const isObj = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
const unknownKey = (obj: Record<string, unknown>, allowed: Set<string>) => Object.keys(obj).find((key) => !allowed.has(key));
|
||||
const nonEmpty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
|
||||
|
||||
export type ValidateParticipantsResult =
|
||||
| { ok: true; file: ParticipantsFile; warnings: string[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Structure is checked strictly: a typo must not silently change what agents read. References to people are checked
|
||||
* against `personIds` (the registered fragments) when given: a person entry or default whose fragment is gone is
|
||||
* dropped with a warning; a shared channel that takes a person's id is an error (one address space). */
|
||||
export function validateParticipantsFile(raw: unknown, personIds?: ReadonlySet<string>): ValidateParticipantsResult {
|
||||
if (!isObj(raw)) return { ok: false, error: "the participants file must be a mapping" };
|
||||
const top = unknownKey(raw, FILE_KEYS);
|
||||
if (top) return { ok: false, error: `unknown key "${top}" — allowed: ${[...FILE_KEYS].join(", ")}` };
|
||||
if (raw.version !== PARTICIPANTS_FILE_VERSION) return { ok: false, error: `version must be ${PARTICIPANTS_FILE_VERSION} (found ${String(raw.version)})` };
|
||||
const warnings: string[] = [];
|
||||
const mapping = (key: "organizations" | "groups" | "participants") => {
|
||||
const value = raw[key];
|
||||
if (value === undefined || value === null) return {};
|
||||
return isObj(value) ? value : null;
|
||||
};
|
||||
|
||||
const organizations: Record<string, ParticipantOrganization> = {};
|
||||
const rawOrganizations = mapping("organizations");
|
||||
if (!rawOrganizations) return { ok: false, error: "organizations must be a mapping of <id>: { name, policy? }" };
|
||||
for (const [id, value] of Object.entries(rawOrganizations)) {
|
||||
const where = `organizations.${id}`;
|
||||
if (!ENTITY_ID_PATTERN.test(id)) return { ok: false, error: `${where}: the id must be a lowercase slug (a-z0-9._-)` };
|
||||
if (!isObj(value)) return { ok: false, error: `${where} must be a mapping { name, policy? }` };
|
||||
const extra = unknownKey(value, ORGANIZATION_KEYS);
|
||||
if (extra) return { ok: false, error: `${where} has unknown key "${extra}" — allowed: name, policy` };
|
||||
if (!nonEmpty(value.name)) return { ok: false, error: `${where}.name must be a non-empty string` };
|
||||
if (value.policy !== undefined && typeof value.policy !== "string") return { ok: false, error: `${where}.policy must be text` };
|
||||
organizations[id] = { name: value.name, ...(value.policy !== undefined ? { policy: value.policy as string } : {}) };
|
||||
}
|
||||
|
||||
const groups: Record<string, ParticipantGroup> = {};
|
||||
const rawGroups = mapping("groups");
|
||||
if (!rawGroups) return { ok: false, error: "groups must be a mapping of <id>: { organization, name, policy? }" };
|
||||
for (const [id, value] of Object.entries(rawGroups)) {
|
||||
const where = `groups.${id}`;
|
||||
if (!ENTITY_ID_PATTERN.test(id)) return { ok: false, error: `${where}: the id must be a lowercase slug (a-z0-9._-)` };
|
||||
if (!isObj(value)) return { ok: false, error: `${where} must be a mapping { organization, name, policy? }` };
|
||||
const extra = unknownKey(value, GROUP_KEYS);
|
||||
if (extra) return { ok: false, error: `${where} has unknown key "${extra}" — allowed: organization, name, policy` };
|
||||
if (typeof value.organization !== "string" || !organizations[value.organization]) {
|
||||
return { ok: false, error: `${where}.organization "${String(value.organization)}" isn't a known organization` };
|
||||
}
|
||||
if (!nonEmpty(value.name)) return { ok: false, error: `${where}.name must be a non-empty string` };
|
||||
if (value.policy !== undefined && typeof value.policy !== "string") return { ok: false, error: `${where}.policy must be text` };
|
||||
groups[id] = { organization: value.organization, name: value.name, ...(value.policy !== undefined ? { policy: value.policy as string } : {}) };
|
||||
}
|
||||
|
||||
const participants: Record<string, ParticipantEntry> = {};
|
||||
const rawParticipants = mapping("participants");
|
||||
if (!rawParticipants) return { ok: false, error: "participants must be a mapping of <id>: { kind, ... }" };
|
||||
const channelOwner = new Map<string, string>();
|
||||
for (const [id, value] of Object.entries(rawParticipants)) {
|
||||
const where = `participants.${id}`;
|
||||
if (!ENTITY_ID_PATTERN.test(id)) return { ok: false, error: `${where}: the id must be a lowercase slug (a-z0-9._-)` };
|
||||
if (!isObj(value)) return { ok: false, error: `${where} must be a mapping { kind, ... }` };
|
||||
if (value.kind !== "person" && value.kind !== "shared-channel") {
|
||||
return { ok: false, error: `${where}.kind "${String(value.kind)}" must be person or shared-channel` };
|
||||
}
|
||||
const extra = unknownKey(value, value.kind === "person" ? PERSON_KEYS : CHANNEL_KEYS);
|
||||
if (extra) return { ok: false, error: `${where} has unknown key "${extra}" for a ${value.kind}` };
|
||||
const placement: Placement = {};
|
||||
if (value.organization !== undefined) {
|
||||
if (typeof value.organization !== "string" || !organizations[value.organization]) {
|
||||
return { ok: false, error: `${where}.organization "${String(value.organization)}" isn't a known organization` };
|
||||
}
|
||||
placement.organization = value.organization;
|
||||
}
|
||||
if (value.groups !== undefined) {
|
||||
if (!Array.isArray(value.groups)) return { ok: false, error: `${where}.groups must be a list of group ids` };
|
||||
const seen = new Set<string>();
|
||||
for (const group of value.groups) {
|
||||
if (typeof group !== "string" || !groups[group]) return { ok: false, error: `${where}.groups: "${String(group)}" isn't a known group` };
|
||||
if (seen.has(group)) return { ok: false, error: `${where}.groups lists "${group}" twice` };
|
||||
seen.add(group);
|
||||
}
|
||||
placement.groups = [...seen];
|
||||
}
|
||||
if (value.policy !== undefined) {
|
||||
if (typeof value.policy !== "string") return { ok: false, error: `${where}.policy must be text` };
|
||||
placement.policy = value.policy;
|
||||
}
|
||||
if (value.kind === "person") {
|
||||
if (personIds && !personIds.has(id)) {
|
||||
warnings.push(`${where}: no person "${id}" is registered, so this entry is ignored`);
|
||||
continue;
|
||||
}
|
||||
participants[id] = { kind: "person", ...placement };
|
||||
continue;
|
||||
}
|
||||
if (personIds?.has(id)) return { ok: false, error: `${where}: "${id}" is already a person's id; a shared channel needs its own (they share ${id}@${ADDRESS_DOMAIN})` };
|
||||
if (!nonEmpty(value.displayName)) return { ok: false, error: `${where}.displayName must be a non-empty string` };
|
||||
const channel = value.channel;
|
||||
if (!isObj(channel)) return { ok: false, error: `${where}.channel must be { connector: slack, connectorRef, secretsRef, channelId }` };
|
||||
const channelExtra = unknownKey(channel, CHANNEL_BINDING_KEYS);
|
||||
if (channelExtra) return { ok: false, error: `${where}.channel has unknown key "${channelExtra}"` };
|
||||
if (channel.connector !== "slack") return { ok: false, error: `${where}.channel.connector must be slack` };
|
||||
if (!nonEmpty(channel.connectorRef)) return { ok: false, error: `${where}.channel.connectorRef must be a non-empty string` };
|
||||
if (!nonEmpty(channel.secretsRef)) return { ok: false, error: `${where}.channel.secretsRef must be a vault pointer, never the secret` };
|
||||
if (typeof channel.channelId !== "string" || !HANDLE_PATTERN.test(channel.channelId)) {
|
||||
return { ok: false, error: `${where}.channel.channelId "${String(channel.channelId)}" must be a Slack channel id (letters and digits)` };
|
||||
}
|
||||
const prior = channelOwner.get(channel.channelId);
|
||||
if (prior) return { ok: false, error: `${where}: channel ${channel.channelId} is already the participant "${prior}"` };
|
||||
channelOwner.set(channel.channelId, id);
|
||||
participants[id] = {
|
||||
kind: "shared-channel", displayName: value.displayName,
|
||||
channel: { connector: "slack", connectorRef: channel.connectorRef, secretsRef: channel.secretsRef, channelId: channel.channelId },
|
||||
...placement,
|
||||
};
|
||||
}
|
||||
|
||||
const file: ParticipantsFile = { version: PARTICIPANTS_FILE_VERSION, organizations, groups, participants };
|
||||
if (raw.default !== undefined && raw.default !== null) {
|
||||
if (typeof raw.default !== "string") return { ok: false, error: "default must be a person's id" };
|
||||
if (participants[raw.default]?.kind === "shared-channel") return { ok: false, error: `default "${raw.default}" is a shared channel; the default is a person` };
|
||||
if (personIds && !personIds.has(raw.default)) warnings.push(`default: no person "${raw.default}" is registered, so no default is chosen`);
|
||||
else file.default = raw.default;
|
||||
}
|
||||
return { ok: true, file, warnings };
|
||||
}
|
||||
|
||||
export type ReadParticipantsResult =
|
||||
| { ok: true; file: ParticipantsFile; exists: boolean; warnings: string[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Read and validate the side file. Absent is an empty file (a one-person install needs none). */
|
||||
export function readParticipantsFile(home: string = getOpenRigHome(), personIds?: ReadonlySet<string>): ReadParticipantsResult {
|
||||
const path = participantsPath(home);
|
||||
if (!existsSync(path)) return { ok: true, file: emptyParticipantsFile(), exists: false, warnings: [] };
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = parseYaml(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
return { ok: false, error: `the participants file at ${path} doesn't parse: ${(err as Error).message}` };
|
||||
}
|
||||
const validated = validateParticipantsFile(raw, personIds);
|
||||
if (!validated.ok) return { ok: false, error: `the participants file at ${path} is invalid: ${validated.error}` };
|
||||
return { ok: true, file: validated.file, exists: true, warnings: validated.warnings };
|
||||
}
|
||||
|
||||
const FILE_HEADER =
|
||||
"# OpenRig gateway participants: the default person, organizations, groups, readable policy and shared channels.\n" +
|
||||
"# Written by `rig gateway` verbs and read by OpenRig 0.7.3 and later. Older daemons don't read this file, and\n" +
|
||||
"# person records stay in gateway/humans/, so a rollback still loads every person.\n";
|
||||
|
||||
const sortedRecord = <T>(record: Record<string, T>): Record<string, T> =>
|
||||
Object.fromEntries(Object.keys(record).sort().map((key) => [key, record[key]!]));
|
||||
|
||||
/** The canonical bytes: sections in a fixed order, ids sorted, so a write is stable and diffable. */
|
||||
export function participantsFileBody(file: ParticipantsFile): string {
|
||||
const body: Record<string, unknown> = { version: file.version };
|
||||
if (file.default) body.default = file.default;
|
||||
body.organizations = sortedRecord(file.organizations);
|
||||
body.groups = sortedRecord(file.groups);
|
||||
body.participants = sortedRecord(file.participants);
|
||||
return FILE_HEADER + stringifyYaml(body);
|
||||
}
|
||||
|
||||
export type UpdateParticipantsResult =
|
||||
| { ok: true; path: string; file: ParticipantsFile; warnings: string[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** The one writer: read the current file against the registered people, apply `change`, validate the result the same
|
||||
* way a read does, then write it atomically. `change` returns an error string to refuse. */
|
||||
export function updateParticipantsFile(
|
||||
change: (file: ParticipantsFile, people: readonly HumanFragment[]) => ParticipantsFile | string,
|
||||
home: string = getOpenRigHome(),
|
||||
): UpdateParticipantsResult {
|
||||
const people = projectHumans(home);
|
||||
if (!people.ok) return { ok: false, error: `can't check participants against the person registry, which is invalid: ${people.error}` };
|
||||
const personIds = new Set(people.entities.map((entity) => entity.entityId));
|
||||
const current = readParticipantsFile(home, personIds);
|
||||
if (!current.ok) return current;
|
||||
const next = change(structuredClone(current.file), people.entities);
|
||||
if (typeof next === "string") return { ok: false, error: next };
|
||||
const validated = validateParticipantsFile(JSON.parse(JSON.stringify(next)), personIds);
|
||||
if (!validated.ok) return { ok: false, error: validated.error };
|
||||
// The read already dropped entries for people removed by hand, so a reference to an unregistered person here is this
|
||||
// change's own mistake: refuse it rather than write an entry nobody can use.
|
||||
if (validated.warnings.length > 0) return { ok: false, error: validated.warnings.join("; ") };
|
||||
const path = participantsPath(home);
|
||||
const written = atomicWrite(path, participantsFileBody(validated.file));
|
||||
// The warnings name the stale entries this write left out.
|
||||
return written.ok ? { ok: true, path, file: validated.file, warnings: current.warnings } : written;
|
||||
}
|
||||
|
||||
export type DefaultPerson<T extends Pick<HumanFragment, "entityId"> = HumanFragment> =
|
||||
| { person: T; source: "chosen" | "only-person" }
|
||||
| { person: null; source: "none"; reason: string };
|
||||
|
||||
/** The person for anything that needs one without naming one (an escalation, the notice digest, Mission Control's
|
||||
* my-queue, a workflow's human step, a pairing approval): the side file's default, else the only registered person,
|
||||
* else none, with the reason. Never the first of several, and never workspace.operator_seat_name. */
|
||||
export function resolveDefaultPerson<T extends Pick<HumanFragment, "entityId">>(entities: readonly T[], home: string = getOpenRigHome()): DefaultPerson<T> {
|
||||
const side = readParticipantsFile(home, new Set(entities.map((entity) => entity.entityId)));
|
||||
const chosen = side.ok && side.file.default ? entities.find((entity) => entity.entityId === side.file.default) : undefined;
|
||||
if (chosen) return { person: chosen, source: "chosen" };
|
||||
if (entities.length === 1) return { person: entities[0]!, source: "only-person" };
|
||||
if (entities.length === 0) return { person: null, source: "none", reason: "no person is registered" };
|
||||
return {
|
||||
person: null, source: "none",
|
||||
reason: side.ok
|
||||
? "several people are registered and none is the default; choose one with `rig gateway default <id>`"
|
||||
: `several people are registered and the default can't be read (${side.error})`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PolicyPart { source: string; text: string }
|
||||
|
||||
/** A participant's readable policy, outermost first: its organization's, each of its groups', then its own. Each part
|
||||
* says where it came from, so an agent can weigh and quote it. */
|
||||
export function effectivePolicy(id: string, file: ParticipantsFile): PolicyPart[] {
|
||||
const entry = file.participants[id];
|
||||
if (!entry) return [];
|
||||
const parts: PolicyPart[] = [];
|
||||
const organization = entry.organization ? file.organizations[entry.organization] : undefined;
|
||||
if (organization?.policy) parts.push({ source: `organization ${entry.organization} (${organization.name})`, text: organization.policy });
|
||||
for (const groupId of entry.groups ?? []) {
|
||||
const group = file.groups[groupId];
|
||||
if (group?.policy) parts.push({ source: `group ${groupId} (${group.name})`, text: group.policy });
|
||||
}
|
||||
if (entry.policy) parts.push({ source: "participant", text: entry.policy });
|
||||
return parts;
|
||||
}
|
||||
|
||||
export interface ParticipantView {
|
||||
id: string;
|
||||
address: string;
|
||||
kind: "person" | "shared-channel";
|
||||
displayName: string;
|
||||
organization: { id: string; name: string } | null;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
default: boolean;
|
||||
policy: PolicyPart[];
|
||||
channel?: { connector: "slack"; channelId: string };
|
||||
}
|
||||
|
||||
export type ParticipantsViewResult =
|
||||
| { ok: true; participants: ParticipantView[]; default: DefaultPerson; warnings: string[]; sideFile: { path: string; exists: boolean; error?: string } }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Every person and shared channel, with organization, groups, the default and effective policy. An unreadable side
|
||||
* file still lists the people (from their fragments), and says why the rest is missing. */
|
||||
export function participantsView(home: string = getOpenRigHome()): ParticipantsViewResult {
|
||||
const people = projectHumans(home);
|
||||
if (!people.ok) return { ok: false, error: people.error };
|
||||
const personIds = new Set(people.entities.map((entity) => entity.entityId));
|
||||
const side = readParticipantsFile(home, personIds);
|
||||
const file = side.ok ? side.file : emptyParticipantsFile();
|
||||
const chosen = resolveDefaultPerson(people.entities, home);
|
||||
const placementOf = (id: string) => {
|
||||
const entry = file.participants[id];
|
||||
const organization = entry?.organization ? { id: entry.organization, name: file.organizations[entry.organization]!.name } : null;
|
||||
const groups = (entry?.groups ?? []).map((groupId) => ({ id: groupId, name: file.groups[groupId]!.name }));
|
||||
return { organization, groups, policy: effectivePolicy(id, file) };
|
||||
};
|
||||
const views: ParticipantView[] = people.entities.map((entity) => ({
|
||||
id: entity.entityId, address: entity.address, kind: "person", displayName: entity.displayName,
|
||||
...placementOf(entity.entityId), default: chosen.person?.entityId === entity.entityId,
|
||||
}));
|
||||
for (const [id, entry] of Object.entries(file.participants)) {
|
||||
if (entry.kind !== "shared-channel") continue;
|
||||
views.push({
|
||||
id, address: `${id}@${ADDRESS_DOMAIN}`, kind: "shared-channel", displayName: entry.displayName,
|
||||
...placementOf(id), default: false, channel: { connector: entry.channel.connector, channelId: entry.channel.channelId },
|
||||
});
|
||||
}
|
||||
views.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||
return {
|
||||
ok: true, participants: views, default: chosen, warnings: side.ok ? side.warnings : [],
|
||||
sideFile: { path: participantsPath(home), exists: side.ok ? side.exists : true, ...(side.ok ? {} : { error: side.error }) },
|
||||
};
|
||||
}
|
||||
|
||||
// ── The writes behind the `rig gateway` verbs. Each goes through updateParticipantsFile, so each is validated the same
|
||||
// way a read is and written atomically.
|
||||
|
||||
export interface PlacementInput { organization?: string | null; groups?: string[] | null; policy?: string | null }
|
||||
|
||||
const applyPlacement = (entry: Placement, input: PlacementInput): void => {
|
||||
if (input.organization !== undefined) { if (input.organization === null) delete entry.organization; else entry.organization = input.organization; }
|
||||
if (input.groups !== undefined) { if (input.groups === null || input.groups.length === 0) delete entry.groups; else entry.groups = input.groups; }
|
||||
if (input.policy !== undefined) { if (input.policy === null || input.policy === "") delete entry.policy; else entry.policy = input.policy; }
|
||||
};
|
||||
|
||||
/** A person's organization, groups and policy (null clears one). The person must already be registered. */
|
||||
export function setPersonPlacement(entityId: string, input: PlacementInput, home: string = getOpenRigHome()): UpdateParticipantsResult {
|
||||
return updateParticipantsFile((file, people) => {
|
||||
if (!people.some((person) => person.entityId === entityId)) return `no person "${entityId}" is registered`;
|
||||
const entry: PersonEntry = file.participants[entityId]?.kind === "person" ? file.participants[entityId] as PersonEntry : { kind: "person" };
|
||||
applyPlacement(entry, input);
|
||||
// A person with nothing placed needs no entry: the fragment already makes them a participant.
|
||||
if (entry.organization || entry.groups || entry.policy) file.participants[entityId] = entry;
|
||||
else delete file.participants[entityId];
|
||||
return file;
|
||||
}, home);
|
||||
}
|
||||
|
||||
/** Choose the default person, or clear it (null). */
|
||||
export function setDefaultPerson(entityId: string | null, home: string = getOpenRigHome()): UpdateParticipantsResult {
|
||||
return updateParticipantsFile((file, people) => {
|
||||
if (entityId === null) { delete file.default; return file; }
|
||||
if (!people.some((person) => person.entityId === entityId)) return `no person "${entityId}" is registered; the default is a registered person`;
|
||||
file.default = entityId;
|
||||
return file;
|
||||
}, home);
|
||||
}
|
||||
|
||||
/** Register or replace a shared-channel participant. `replace` is explicit: an existing channel is never overwritten
|
||||
* silently. */
|
||||
export function setSharedChannel(
|
||||
id: string,
|
||||
input: PlacementInput & { displayName: string; channel: SharedChannelBinding },
|
||||
opts: { replace?: boolean } = {},
|
||||
home: string = getOpenRigHome(),
|
||||
): UpdateParticipantsResult {
|
||||
return updateParticipantsFile((file) => {
|
||||
const existing = file.participants[id];
|
||||
if (existing && existing.kind !== "shared-channel") return `"${id}" is a person`;
|
||||
if (existing && !opts.replace) return `shared channel "${id}" already exists — pass an explicit replace to update it (no silent overwrite)`;
|
||||
const entry: SharedChannelEntry = { kind: "shared-channel", displayName: input.displayName, channel: input.channel };
|
||||
applyPlacement(entry, input);
|
||||
file.participants[id] = entry;
|
||||
return file;
|
||||
}, home);
|
||||
}
|
||||
|
||||
/** Remove a participant's side-file entry (a shared channel, or a person's placement), and the default if it named
|
||||
* them. With `kind`, only an entry of that kind is removed: `human remove` passes "person", so it never deletes a
|
||||
* shared channel that happens to share the id. */
|
||||
export function removeParticipantEntry(
|
||||
id: string,
|
||||
home: string = getOpenRigHome(),
|
||||
opts: { kind?: ParticipantEntry["kind"] } = {},
|
||||
): UpdateParticipantsResult {
|
||||
return updateParticipantsFile((file) => {
|
||||
if (!opts.kind || file.participants[id]?.kind === opts.kind) delete file.participants[id];
|
||||
if (file.default === id && opts.kind !== "shared-channel") delete file.default;
|
||||
return file;
|
||||
}, home);
|
||||
}
|
||||
|
||||
/** Create or update an organization. Its policy is text agents read. */
|
||||
export function setOrganization(id: string, input: { name?: string; policy?: string | null }, home: string = getOpenRigHome()): UpdateParticipantsResult {
|
||||
return updateParticipantsFile((file) => {
|
||||
const current = file.organizations[id];
|
||||
const name = input.name ?? current?.name;
|
||||
if (!name) return `organization "${id}" is new, so it needs a name`;
|
||||
const next: ParticipantOrganization = { name };
|
||||
const policy = input.policy === undefined ? current?.policy : input.policy;
|
||||
if (policy) next.policy = policy;
|
||||
file.organizations[id] = next;
|
||||
return file;
|
||||
}, home);
|
||||
}
|
||||
|
||||
/** Create or update a group inside an organization. */
|
||||
export function setGroup(id: string, input: { organization?: string; name?: string; policy?: string | null }, home: string = getOpenRigHome()): UpdateParticipantsResult {
|
||||
return updateParticipantsFile((file) => {
|
||||
const current = file.groups[id];
|
||||
const organization = input.organization ?? current?.organization;
|
||||
const name = input.name ?? current?.name;
|
||||
if (!organization || !name) return `group "${id}" is new, so it needs an organization and a name`;
|
||||
const next: ParticipantGroup = { organization, name };
|
||||
const policy = input.policy === undefined ? current?.policy : input.policy;
|
||||
if (policy) next.policy = policy;
|
||||
file.groups[id] = next;
|
||||
return file;
|
||||
}, home);
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
// degradation only.
|
||||
|
||||
import { loadHumanRegistry } from "../gateway/human-registry.js";
|
||||
import { resolveDefaultPerson } from "../gateway/participants.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { QueueRepository, QueueItem, QueueState } from "../queue-repository.js";
|
||||
import type { ViewProjector } from "../view-projector.js";
|
||||
@@ -136,8 +137,9 @@ export class MissionControlReadLayer {
|
||||
* operator seat even when the kernel rig isn't booted yet. */
|
||||
getDefaultOperatorSession(): string {
|
||||
if (this.defaultOperatorSession) return this.defaultOperatorSession;
|
||||
// The configured operator wins above; otherwise the default person (chosen, or the only one), never the first of several.
|
||||
const registry = loadHumanRegistry();
|
||||
return registry.ok && registry.entities.length === 1 ? registry.entities[0]!.address : "";
|
||||
return registry.ok ? resolveDefaultPerson(registry.entities).person?.address ?? "" : "";
|
||||
}
|
||||
|
||||
async readView(
|
||||
|
||||
@@ -48,8 +48,15 @@ import {
|
||||
MissionControlActionLogError,
|
||||
type MissionControlVerb,
|
||||
} from "./mission-control-action-log.js";
|
||||
import { isHumanSeatSession } from "../human-route-enforcer.js";
|
||||
import { isHumanSeatSessionRef } from "../session-name.js";
|
||||
import { humanAnswerText, formatHumanAnswers, unansweredQuestions, type HumanAnswers } from "../human-questions.js";
|
||||
import { resolveDefaultPerson } from "../gateway/participants.js";
|
||||
|
||||
/** The primary user when several people are registered and none is the default: the generic human seat the web UI also
|
||||
* records. */
|
||||
const UNNAMED_PRIMARY_USER = "human@host";
|
||||
/** Verbs that settle what a person was asked. */
|
||||
const SETTLING_VERBS = new Set<MissionControlVerb>(["approve", "deny", "resolve", "answer"]);
|
||||
|
||||
export class MissionControlWriteContractError extends Error {
|
||||
constructor(
|
||||
@@ -85,6 +92,10 @@ export interface MissionControlActionInput {
|
||||
optionId?: string;
|
||||
/** Operator-supplied audit context. */
|
||||
auditNotes?: Record<string, unknown>;
|
||||
/** `primary-user`: the person at the terminal acts (the TUI), whoever was asked. The actor recorded is the primary user
|
||||
* (the default person), never the claimed actor, and the audit notes say whose ask it was. Only a request with no
|
||||
* transport identity carries it. */
|
||||
authority?: "primary-user";
|
||||
/**
|
||||
* For handoff: opt-in best-effort wake. Default true (PL-004 Phase A R1
|
||||
* pattern: durable + waking by default; operators opt out for cold queues).
|
||||
@@ -146,6 +157,8 @@ export class MissionControlWriteContract {
|
||||
}
|
||||
|
||||
const source = this.requireMutableQitem(input.qitemId);
|
||||
const acting = this.asActor(input, this.queueRepo.isPersonAddress(source.destinationSession) ? source.destinationSession : null);
|
||||
input = acting.input;
|
||||
|
||||
if ((input.verb === "route" || input.verb === "handoff") && !input.destinationSession) {
|
||||
throw new MissionControlWriteContractError(
|
||||
@@ -173,7 +186,7 @@ export class MissionControlWriteContract {
|
||||
closureTarget: closure.closureTarget ?? undefined,
|
||||
handedOffTo: closure.handedOffTo,
|
||||
blockedOn: closure.blockedOn,
|
||||
transitionNote: `mission-control:${input.verb}${input.reason ? ` (${input.reason})` : ""}`,
|
||||
transitionNote: `mission-control:${input.verb}${input.reason ? ` (${input.reason})` : ""}${acting.note}`,
|
||||
});
|
||||
register(closeResult.persistedEvent);
|
||||
|
||||
@@ -392,7 +405,8 @@ export class MissionControlWriteContract {
|
||||
{ qitemId: input.qitemId },
|
||||
);
|
||||
}
|
||||
if (source.state !== "blocked" || !isHumanSeatSession(source.blockedOn)) {
|
||||
// Parked on a person: a legacy human seat or a registered person's address, as the Feed lists it.
|
||||
if (source.state !== "blocked" || !source.blockedOn || !isHumanSeatSessionRef(source.blockedOn)) {
|
||||
throw new MissionControlWriteContractError(
|
||||
"qitem_not_leg1_parked",
|
||||
`verb=resolve requires a leg-1 parked qitem (state=blocked with a human-seat blocked_on); ` +
|
||||
@@ -401,6 +415,8 @@ export class MissionControlWriteContract {
|
||||
{ qitemId: input.qitemId, state: source.state, blockedOn: source.blockedOn },
|
||||
);
|
||||
}
|
||||
const acting = this.asActor(input, source.blockedOn);
|
||||
input = acting.input;
|
||||
|
||||
const evaluatedAt = this.now().toISOString();
|
||||
const beforeSnapshot = snapshotQitem(source);
|
||||
@@ -417,7 +433,7 @@ export class MissionControlWriteContract {
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
state: "in-progress",
|
||||
transitionNote: decision,
|
||||
transitionNote: `${decision}${acting.note}`,
|
||||
ownerNotificationKind: "human-decision-resolved",
|
||||
});
|
||||
register(updateResult.persistedEvent);
|
||||
@@ -501,17 +517,20 @@ export class MissionControlWriteContract {
|
||||
if (!ask) {
|
||||
throw new MissionControlWriteContractError("qitem_not_found", `qitem ${input.qitemId} not found`, { qitemId: input.qitemId });
|
||||
}
|
||||
if (ask.destinationSession !== input.actorSession || ask.humanIntent === "update" || !this.queueRepo.isPersonAddress(ask.destinationSession)) {
|
||||
const primaryUser = input.authority === "primary-user";
|
||||
input = this.asActor(input, ask.destinationSession).input;
|
||||
// The asked person answers, or the primary user does on anyone's ask.
|
||||
if ((ask.destinationSession !== input.actorSession && !primaryUser) || 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 });
|
||||
}
|
||||
const before = snapshotQitem(ask);
|
||||
const questions = ask.humanQuestions ?? [];
|
||||
const existing = this.queueRepo.getHumanAnswer(input.qitemId, input.actorSession);
|
||||
const existing = this.queueRepo.getHumanAnswer(input.qitemId, input.actorSession, { primaryUser });
|
||||
if (existing && (ask.state === "pending" || ask.state === "done")) {
|
||||
const decision = questions.length && unansweredQuestions(questions, ask.humanAnswers ?? {}).length === 0
|
||||
? formatHumanAnswers(questions, ask.humanAnswers ?? {}).join("; ") : humanAnswerText(existing.body);
|
||||
if (ask.state === "pending" && !this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision })) {
|
||||
if (ask.state === "pending" && !this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision, primaryUser })) {
|
||||
throw new MissionControlWriteContractError("answer_not_closed", `The answer is already in ${existing.qitemId}, but ${input.qitemId} didn't close.`, { qitemId: input.qitemId });
|
||||
}
|
||||
return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [],
|
||||
@@ -531,7 +550,7 @@ export class MissionControlWriteContract {
|
||||
let reply: string;
|
||||
if (chose) {
|
||||
const recorded = this.queueRepo.recordHumanAnswer({ qitemId: input.qitemId, actorSession: input.actorSession,
|
||||
questionId: input.questionId ?? "", optionId: input.optionId ?? "" });
|
||||
questionId: input.questionId ?? "", optionId: input.optionId ?? "", primaryUser });
|
||||
if (recorded.status !== "recorded") {
|
||||
throw new MissionControlWriteContractError("answer_not_recorded", `The answer wasn't recorded: ${recorded.reason}.`,
|
||||
{ qitemId: input.qitemId, reason: recorded.reason });
|
||||
@@ -552,12 +571,14 @@ export class MissionControlWriteContract {
|
||||
const earlier = formatHumanAnswers(questions, ask.humanAnswers ?? {});
|
||||
reply = `${words}\n\n---\n${earlier.length ? `Earlier answers: ${earlier.join("; ")}\n` : ""}In reply to: ${input.qitemId}${questions.length ? " (its humanAnswers field holds these words under its first unanswered question)" : ""}`;
|
||||
}
|
||||
// The asker reads who answered, and whose ask it was: never the asked person when someone else answered.
|
||||
const forAsked = ask.destinationSession !== input.actorSession ? ask.destinationSession : null;
|
||||
let returned: QueueItem;
|
||||
try {
|
||||
returned = await this.queueRepo.createHumanAnswer({
|
||||
sourceSession: input.actorSession, destinationSession: ask.sourceSession,
|
||||
body: `${reply}\nAnswered by ${input.actorSession} in OpenRig.`,
|
||||
summary: `${input.actorSession} answered: ${ask.summary ?? input.qitemId}`,
|
||||
body: `${reply}\nAnswered by ${input.actorSession}${forAsked ? `, the primary user at the terminal, on ${forAsked}'s ask` : ""} in OpenRig.`,
|
||||
summary: `${input.actorSession} answered${forAsked ? ` ${forAsked}'s ask` : ""}: ${ask.summary ?? input.qitemId}`,
|
||||
tags: ["human-answer", ...(ask.tags ?? []).filter((t) => /^(project|mission|slice):/.test(t))],
|
||||
inboundReplyTo: input.qitemId, ...(input.notify === false ? { nudge: false } : {}),
|
||||
});
|
||||
@@ -570,7 +591,7 @@ export class MissionControlWriteContract {
|
||||
const replyId = returned.qitemId;
|
||||
const decision = chose || resume
|
||||
? formatHumanAnswers(questions, this.queueRepo.getById(input.qitemId)?.humanAnswers ?? {}).join("; ") : humanAnswerText(returned.body);
|
||||
if (!this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision })
|
||||
if (!this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision, primaryUser })
|
||||
&& this.queueRepo.getById(input.qitemId)?.state !== "done") {
|
||||
throw new MissionControlWriteContractError("answer_not_closed",
|
||||
`The answer went back to ${ask.sourceSession} as ${replyId}, but qitem ${input.qitemId} didn't close: it changed meanwhile.`,
|
||||
@@ -598,6 +619,30 @@ export class MissionControlWriteContract {
|
||||
notifyAttempted: false, notifyResult: null, auditedAt: evaluatedAt, answer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Who acts, and on whose ask. With primary-user authority the actor is the primary user: the default person, or, with
|
||||
* several people and none chosen, the generic human seat. With no one registered it's the claimed actor, as before. The
|
||||
* audit notes record the authority and the person asked, and the transition says so when they differ. Without it, a
|
||||
* registered person can't settle what was asked of another registered person: that's someone else's answer.
|
||||
*/
|
||||
private asActor(input: MissionControlActionInput, asked: string | null): { input: MissionControlActionInput; note: string } {
|
||||
if (input.authority !== "primary-user") {
|
||||
const someoneElse = SETTLING_VERBS.has(input.verb) ? this.queueRepo.askedSomeoneElse(input.actorSession, asked) : null;
|
||||
if (someoneElse) {
|
||||
throw new MissionControlWriteContractError("answer_not_recorded",
|
||||
`qitem ${input.qitemId} was asked of ${someoneElse}, not ${input.actorSession}, so ${input.actorSession} can't settle it.`,
|
||||
{ qitemId: input.qitemId, asked: someoneElse });
|
||||
}
|
||||
return { input, note: "" };
|
||||
}
|
||||
const registry = this.queueRepo.humanRegistry();
|
||||
const people = registry.ok ? registry.entities : [];
|
||||
const actor = people.length ? resolveDefaultPerson(people).person?.address ?? UNNAMED_PRIMARY_USER : input.actorSession;
|
||||
const auditNotes = { ...input.auditNotes, authority: "primary-user", asked };
|
||||
const note = asked && asked !== actor ? `; by ${actor}, the primary user at the terminal, on ${asked}'s ask` : "";
|
||||
return { input: { ...input, actorSession: actor, auditNotes }, note };
|
||||
}
|
||||
|
||||
private requireMutableQitem(qitemId: string): QueueItem {
|
||||
const source = this.queueRepo.getById(qitemId);
|
||||
if (!source) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { makeQueuePorts } from "../gateway/slack/queue-access.js";
|
||||
import type { OwnerNotificationLevel } from "../queue-transition-log.js";
|
||||
import { OUTBOUND_OP } from "../gateway/slack/outbound-driver.js";
|
||||
import { DispatchBuffer } from "../gateway/dispatch-buffer.js";
|
||||
import { resolveDefaultPerson } from "../gateway/participants.js";
|
||||
import type { Policy, PolicyJob, PolicyEvaluation } from "./types.js";
|
||||
import { quietHoldFor } from "../gateway/human-quiet-windows.js";
|
||||
import { WatchdogJobsRepository } from "../watchdog-jobs-repository.js";
|
||||
@@ -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 = resolveDefaultPerson(reg.entities, input.home).person;
|
||||
|
||||
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,
|
||||
|
||||
@@ -1696,11 +1696,12 @@ export class QueueRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/** First committed return of a direct ask, including pre-shared-ID Slack replies. */
|
||||
getHumanAnswer(qitemId: string, actorSession: string): QueueItem | null {
|
||||
/** First committed return of a direct ask, including pre-shared-ID Slack replies. `primaryUser`: the actor is the
|
||||
* primary user, who may answer anyone's ask. */
|
||||
getHumanAnswer(qitemId: string, actorSession: string, opts: { primaryUser?: boolean } = {}): QueueItem | null {
|
||||
const ask = this.getById(qitemId);
|
||||
if (!this.hasReplyToColumn || !ask || ask.destinationSession !== actorSession
|
||||
|| ask.humanIntent === "update" || !this.isPersonAddress(actorSession)) return null;
|
||||
if (!this.hasReplyToColumn || !ask || (ask.destinationSession !== actorSession && !opts.primaryUser)
|
||||
|| ask.humanIntent === "update" || !this.isPersonAddress(ask.destinationSession)) return null;
|
||||
const row = this.db.prepare(`SELECT qitem_id FROM queue_items
|
||||
WHERE reply_to = ? AND source_session = ? AND destination_session = ?
|
||||
AND (EXISTS (SELECT 1 FROM json_each(queue_items.tags) WHERE value = 'human-answer')
|
||||
@@ -1997,6 +1998,11 @@ export class QueueRepository {
|
||||
return registry.ok && resolveRegisteredHumanAddress(destination, registry.entities) !== null;
|
||||
}
|
||||
|
||||
/** The registered people this queue resolves addresses 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 {
|
||||
@@ -3591,11 +3597,11 @@ export class QueueRepository {
|
||||
* preserving earlier clicks and leaving the other questions unanswered. A completed
|
||||
* button set is already final; its resolve continuation must not overwrite those answers.
|
||||
*/
|
||||
resolveDirectHumanReply(input: { qitemId: string; actorSession: string; decision: string }): boolean {
|
||||
resolveDirectHumanReply(input: { qitemId: string; actorSession: string; decision: string; primaryUser?: boolean }): boolean {
|
||||
const result = this.db.transaction(() => {
|
||||
const item = this.getById(input.qitemId);
|
||||
if (item?.state !== "pending" || item.humanIntent === "update"
|
||||
|| item.destinationSession !== input.actorSession
|
||||
|| (item.destinationSession !== input.actorSession && !input.primaryUser)
|
||||
|| !this.isPersonAddress(item.destinationSession)) return null;
|
||||
|
||||
const text = input.decision.trim();
|
||||
@@ -3603,7 +3609,8 @@ export class QueueRepository {
|
||||
? unansweredQuestions(item.humanQuestions ?? [], item.humanAnswers ?? {})
|
||||
: [];
|
||||
const question = unanswered[0];
|
||||
let transitionNote = "direct human reply received";
|
||||
let transitionNote = item.destinationSession === input.actorSession ? "direct human reply received"
|
||||
: `reply from ${input.actorSession}, the primary user at the terminal, on ${item.destinationSession}'s ask; not answered by ${item.destinationSession}`;
|
||||
if (question) {
|
||||
const typedReply: TypedHumanReply = {
|
||||
kind: "typed-reply", text, placement: "first-unanswered", unansweredCount: unanswered.length - 1,
|
||||
@@ -3635,7 +3642,7 @@ export class QueueRepository {
|
||||
* resolve) with exactly what the seat will read. Anything else is not-applicable (a forged
|
||||
* id, a stranger, a decision already resolved), never an error the socket must retry.
|
||||
*/
|
||||
recordHumanAnswer(input: { qitemId: string; actorSession: string; questionId: string; optionId: string }): RecordHumanAnswerResult {
|
||||
recordHumanAnswer(input: { qitemId: string; actorSession: string; questionId: string; optionId: string; primaryUser?: boolean }): RecordHumanAnswerResult {
|
||||
if (!this.hasHumanQuestionsColumn) return { status: "not-applicable", reason: "schema" };
|
||||
return this.db.transaction(() => {
|
||||
const item = this.getById(input.qitemId);
|
||||
@@ -3649,7 +3656,7 @@ export class QueueRepository {
|
||||
return { status: "not-applicable" as const, reason: `state-${item.state}`, ...(onRecord ? { answerOnRecord: true as const } : {}),
|
||||
...(decidedForThem ? { decidedForThem } : {}) };
|
||||
}
|
||||
if (item.destinationSession !== input.actorSession) return { status: "not-applicable" as const, reason: "not-the-asked-human" };
|
||||
if (item.destinationSession !== input.actorSession && !input.primaryUser) return { status: "not-applicable" as const, reason: "not-the-asked-human" };
|
||||
const question = item.humanQuestions.find((q) => q.id === input.questionId);
|
||||
if (!question?.options.some((o) => o.id === input.optionId)) return { status: "not-applicable" as const, reason: "unknown-option" };
|
||||
const recorded = item.humanAnswers ?? {};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { loadHumanRegistry, resolveRegisteredHumanAddress, type LoadResult } from "./gateway/human-registry.js";
|
||||
import { resolveDefaultPerson } from "./gateway/participants.js";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
|
||||
export class WorkflowHumanDestinationError extends Error {
|
||||
@@ -33,9 +34,10 @@ 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 = resolveDefaultPerson(loaded.entities).person;
|
||||
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 participants --json; choose a default with rig gateway default <id>. No human destination was invented.");
|
||||
}
|
||||
|
||||
/** Explicit injected destinations remain available to embedders and isolated fixtures. */
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
// surface (dep rail 2) instead of carrying a second copy — ONE source, no twin-parity pin.
|
||||
export * from "./domain/gateway/human-registry.js";
|
||||
export { runChannelOperation, channelStateDigest } from "./domain/gateway/channel-operations.js";
|
||||
export * from "./domain/gateway/participants.js";
|
||||
@@ -11,14 +11,18 @@ import { listProjects, insideProject, projectMission, type ProjectRead } from ".
|
||||
import { readMissionReadiness } from "../domain/proof/judgments.js";
|
||||
import { WorkflowInstanceStore } from "../domain/workflow-instance-store.js";
|
||||
import { WorkflowStepTrailLog } from "../domain/workflow-step-trail-log.js";
|
||||
import { resolveDefaultPerson } from "../domain/gateway/participants.js";
|
||||
import { resolveRegisteredHumanAddress } from "../domain/gateway/human-registry.js";
|
||||
|
||||
export function attentionRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
app.get("/", c => {
|
||||
const wanted = c.req.query("item");
|
||||
const result: AttentionRead = { scope: "instance", readAt: new Date().toISOString(), items: [], sources: [], detail: null, detailError: null };
|
||||
// Whether an item is in the queue this Feed shows; an item opened by id is shown in full whatever the view.
|
||||
let inView = (_recipient: string | null | undefined): boolean => true;
|
||||
const add = (item: AttentionItem, lines: () => string[], files: () => Array<{ label: string; path: string }> = () => []) => {
|
||||
if (!result.items.some(i => i.id === item.id)) result.items.push(item);
|
||||
if (!result.items.some(i => i.id === item.id) && inView(item.recipient)) result.items.push(item);
|
||||
if (wanted === item.id) result.detail = { item, lines: lines(), files: files() };
|
||||
};
|
||||
const unavailable = (source: string, error: unknown) => result.sources.push({ source, state: "unavailable", detail: error instanceof Error ? error.message : String(error) });
|
||||
@@ -29,6 +33,32 @@ export function attentionRoutes(): Hono {
|
||||
} catch (e) { unavailable("project catalog", e); }
|
||||
const queue = c.get("queueRepo" as never) as QueueRepository | undefined;
|
||||
const posture = c.get("operatingPosture" as never) as OperatingPostureService | undefined;
|
||||
// The Feed's perspective, whose queue it shows (`view`: a person, `default` or `all`). A view, never an identity: acts on
|
||||
// what it shows carry the primary user's authority, the person at the terminal, whoever's queue it is. With no person
|
||||
// registered the Feed is as before.
|
||||
const registry = queue?.humanRegistry();
|
||||
const entities = registry?.ok ? registry.entities : [];
|
||||
if (entities.length) {
|
||||
const person = (session: string | null | undefined) => resolveRegisteredHumanAddress(session, entities);
|
||||
const requested = c.req.query("view")?.trim() || undefined;
|
||||
// The primary user is the default person: the participants file's `default`, else the only registered person.
|
||||
const primaryUser = resolveDefaultPerson(entities);
|
||||
const primary = primaryUser.person?.address ?? null;
|
||||
const named = requested !== undefined && requested !== "all" && requested !== "default";
|
||||
const chosen = named && requested ? person(requested) ?? person(`${requested}@external`) : null;
|
||||
const viewing = requested === "default" ? primary : chosen;
|
||||
const basis: NonNullable<AttentionRead["perspective"]>["basis"] = requested === "default" ? (primary ? "default" : "all")
|
||||
: named ? (chosen ? "chosen-here" : "unknown") : "all";
|
||||
result.perspective = { viewing, basis, ...(basis === "unknown" ? { requested } : {}), defaultSource: primaryUser.source,
|
||||
actingAs: { person: primary, authority: "primary-user" } };
|
||||
// A person's items are in their view. Items for no registered person (instance facts, a bare human seat) are the
|
||||
// primary user's, so they're in theirs.
|
||||
if (basis === "unknown") inView = () => false;
|
||||
else if (basis !== "all") inView = recipient => {
|
||||
const owner = person(recipient);
|
||||
return owner ? owner === viewing : viewing === primary;
|
||||
};
|
||||
}
|
||||
try {
|
||||
if (!queue) throw new Error("Queue repository unavailable");
|
||||
const rows = queue.listAttention({ limit: 1001 });
|
||||
@@ -60,8 +90,9 @@ export function attentionRoutes(): Hono {
|
||||
// An ask addressed to the person is answered on this row, as them: Steering's Approve, or, for an ask with questions,
|
||||
// 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.
|
||||
// resolved with their decision in words, which unparks it for its owner.
|
||||
...(toPerson && q.state === "pending" ? { answer: { verb: questions ? "answer" as const : "approve" as const, actorSession: q.destinationSession } } : {}),
|
||||
...(!toPerson && q.state === "blocked" && q.blockedOn && isHumanSeatSessionRef(q.blockedOn) ? { answer: { verb: "resolve" as const, actorSession: q.blockedOn } } : {}),
|
||||
...(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)}` };
|
||||
|
||||
@@ -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 { resolveDefaultPerson } from "../domain/gateway/participants.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 (chosen, or the only one); never the first of several.
|
||||
const fallback = body.human ? null : resolveDefaultPerson(registry.entities).person;
|
||||
const humans = body.human ? registry.entities.filter((human) => human.address === body.human) : fallback ? [fallback] : [];
|
||||
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 participants --json; select --human <entityId>@external, or choose a default with rig gateway default <id>. No approval row was created.",
|
||||
}, 409);
|
||||
|
||||
// Prune stale issued pairs past the grace window (keep recently expired entries
|
||||
|
||||
@@ -328,6 +328,7 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono {
|
||||
optionId?: string;
|
||||
notify?: boolean;
|
||||
auditNotes?: Record<string, unknown>;
|
||||
authority?: string;
|
||||
hostId?: string;
|
||||
}>()
|
||||
.catch(() => ({} as never));
|
||||
@@ -434,6 +435,9 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono {
|
||||
...(typeof body.optionId === "string" ? { optionId: body.optionId } : {}),
|
||||
notify: body.notify,
|
||||
auditNotes: body.auditNotes,
|
||||
// The person at the terminal (the TUI sends no seat header): the actor is the primary user, whoever was asked.
|
||||
// A seat's own transport identity is never replaced by it.
|
||||
...(body.authority === "primary-user" && identity.provenance === "claimed:v1" ? { authority: "primary-user" as const } : {}),
|
||||
});
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// The Feed's perspective: whose queue it shows. Viewing a colleague's queue is a view, never an identity: whatever is shown,
|
||||
// acts on it carry the primary user's authority, the person at the terminal (the default person). Real SQLite (all
|
||||
// migrations), people registered in a temporary home, the real queue and the real attention route.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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 } from "../src/domain/queue-repository.js";
|
||||
import { addHumanFragment } from "../src/domain/gateway/human-registry.js";
|
||||
import { setDefaultPerson } from "../src/domain/gateway/participants.js";
|
||||
import { attentionRoutes } from "../src/routes/attention.js";
|
||||
import type { AttentionRead } from "../src/attention-surface.js";
|
||||
|
||||
describe("the Feed shows one person's queue, or everyone's, and acts with the primary user's authority whichever it shows", () => {
|
||||
let db: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "attention-perspective-"));
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
});
|
||||
afterEach(() => { vi.unstubAllEnvs(); db.close(); rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
const register = (...ids: string[]) => {
|
||||
for (const entityId of ids) expect(addHumanFragment({
|
||||
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
|
||||
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: `vault://slack/${entityId}`, role: "primary", handle: `U${entityId.toUpperCase()}` }],
|
||||
prefs: { deliveryClass: "B" },
|
||||
}, home)).toMatchObject({ ok: true });
|
||||
};
|
||||
const askTo = (destinationSession: string, summary: string) =>
|
||||
repo.create({ sourceSession: "orch-lead@r", destinationSession, body: `${summary} body`, summary, evidenceRef: "/fixture/ask", nudge: false });
|
||||
async function read(query = ""): Promise<AttentionRead> {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("queueRepo" as never, repo as never); await next(); });
|
||||
app.route("/api/attention", attentionRoutes());
|
||||
return await (await app.request(`/api/attention${query}`)).json() as AttentionRead;
|
||||
}
|
||||
const asked = (r: AttentionRead) => r.items.filter((i) => i.id.startsWith("queue:")).map((i) => i.summary).sort();
|
||||
|
||||
it("with no view it shows every person's asks, as before, and says who acts", async () => {
|
||||
register("mike", "sarah");
|
||||
await askTo("mike@external", "Mike's ask");
|
||||
await askTo("sarah@external", "Sarah's ask");
|
||||
const all = await read();
|
||||
expect(asked(all)).toEqual(["Mike's ask", "Sarah's ask"]);
|
||||
// Two people and no default: the primary user acts, unnamed.
|
||||
expect(all.perspective).toEqual({ viewing: null, basis: "all", defaultSource: "none", actingAs: { person: null, authority: "primary-user" } });
|
||||
});
|
||||
|
||||
it("viewing one person shows only their asks, and doesn't change who acts or how", async () => {
|
||||
register("mike", "sarah");
|
||||
expect(setDefaultPerson("mike", home).ok).toBe(true);
|
||||
const mike = await askTo("mike@external", "Mike's ask");
|
||||
const sarah = await askTo("sarah@external", "Sarah's ask");
|
||||
const all = await read("?view=all");
|
||||
for (const view of ["sarah", "sarah@external"]) {
|
||||
const r = await read(`?view=${encodeURIComponent(view)}`);
|
||||
expect(asked(r)).toEqual(["Sarah's ask"]);
|
||||
// Viewing Sarah is not acting as Sarah: Mike, the default, acts.
|
||||
expect(r.perspective).toEqual({ viewing: "sarah@external", basis: "chosen-here", defaultSource: "chosen",
|
||||
actingAs: { person: "mike@external", authority: "primary-user" } });
|
||||
// The act on Sarah's ask is the same one the unfiltered Feed offers.
|
||||
expect(r.items.find((i) => i.id === `queue:${sarah.qitemId}`)).toEqual(all.items.find((i) => i.id === `queue:${sarah.qitemId}`));
|
||||
}
|
||||
// An item opened by id is still shown in full, whichever queue is viewed.
|
||||
const opened = await read(`?view=sarah&item=${encodeURIComponent(`queue:${mike.qitemId}`)}`);
|
||||
expect(opened.detail?.item.id).toBe(`queue:${mike.qitemId}`);
|
||||
expect(asked(all)).toEqual(["Mike's ask", "Sarah's ask"]);
|
||||
});
|
||||
|
||||
it("with a chosen default, the default view is that person's, and says it was chosen", async () => {
|
||||
register("mike", "sarah");
|
||||
expect(setDefaultPerson("sarah", home).ok).toBe(true);
|
||||
await askTo("mike@external", "Mike's ask");
|
||||
await askTo("sarah@external", "Sarah's ask");
|
||||
const r = await read("?view=default");
|
||||
expect(asked(r)).toEqual(["Sarah's ask"]);
|
||||
expect(r.perspective).toEqual({ viewing: "sarah@external", basis: "default", defaultSource: "chosen",
|
||||
actingAs: { person: "sarah@external", authority: "primary-user" } });
|
||||
});
|
||||
|
||||
it("with one person and no chosen default, they're the default and the primary user, and it says no one chose them", async () => {
|
||||
register("mike");
|
||||
await askTo("mike@external", "Mike's ask");
|
||||
const r = await read("?view=default");
|
||||
expect(asked(r)).toEqual(["Mike's ask"]);
|
||||
expect(r.perspective).toEqual({ viewing: "mike@external", basis: "default", defaultSource: "only-person",
|
||||
actingAs: { person: "mike@external", authority: "primary-user" } });
|
||||
});
|
||||
|
||||
it("with several people and no default, the default view is everyone's, and says so", async () => {
|
||||
register("mike", "sarah");
|
||||
await askTo("mike@external", "Mike's ask");
|
||||
await askTo("sarah@external", "Sarah's ask");
|
||||
const r = await read("?view=default");
|
||||
expect(asked(r)).toEqual(["Mike's ask", "Sarah's ask"]);
|
||||
expect(r.perspective).toMatchObject({ viewing: null, basis: "all", defaultSource: "none" });
|
||||
});
|
||||
|
||||
it("a view naming no registered person shows no one's asks, and names what was asked for", async () => {
|
||||
register("mike");
|
||||
await askTo("mike@external", "Mike's ask");
|
||||
const r = await read("?view=ana");
|
||||
expect(asked(r)).toEqual([]);
|
||||
expect(r.perspective).toEqual({ viewing: null, basis: "unknown", requested: "ana", defaultSource: "only-person",
|
||||
actingAs: { person: "mike@external", authority: "primary-user" } });
|
||||
});
|
||||
|
||||
it("a row parked on a person is resolved from the Feed in words, as an ask to them is answered", async () => {
|
||||
register("mike", "sarah");
|
||||
const work = await repo.create({ sourceSession: "orch-lead@r", destinationSession: "designer@rig", body: "Layout", summary: "Layout", nudge: false });
|
||||
await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "sarah@external" } as never);
|
||||
const item = (await read("?view=sarah")).items.find((i) => i.id === `queue:${work.qitemId}`);
|
||||
expect(item).toMatchObject({ recipient: "sarah@external", answer: { verb: "resolve", actorSession: "sarah@external" } });
|
||||
});
|
||||
|
||||
it("with no registered person there is no perspective, and the Feed is as before", async () => {
|
||||
await askTo("human@host", "A seat's ask");
|
||||
const r = await read("?view=default");
|
||||
expect(r.perspective).toBeUndefined();
|
||||
expect(asked(r)).toEqual(["A seat's ask"]);
|
||||
});
|
||||
});
|
||||
@@ -100,19 +100,18 @@ 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
|
||||
it("several people list as a supported registry, sorted, with no single-person advisory (0.7.3 participants)", () => {
|
||||
seedAna(home);
|
||||
const res = listHumans(home);
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
expect(res.humans.map((h) => h.entityId)).toEqual(["ana", "mike"]); // honest, sorted display
|
||||
expect(res.humans.map((h) => h.entityId)).toEqual(["ana", "mike"]);
|
||||
const ana = res.humans[0]!;
|
||||
expect(ana.deliveryClass).toBe("A");
|
||||
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).not.toHaveProperty("advisory");
|
||||
});
|
||||
|
||||
it("list is a READ: it does not touch the projection file (byte receipt)", () => {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { addHumanFragment, humansDir, projectionPath } from "../src/domain/gateway/human-registry.js";
|
||||
import {
|
||||
participantsPath, participantsView, readParticipantsFile, removeParticipantEntry, resolveDefaultPerson, updateParticipantsFile,
|
||||
} from "../src/domain/gateway/participants.js";
|
||||
import { projectHumans } from "../src/domain/gateway/human-registry.js";
|
||||
|
||||
// The participant side file: organizations, groups, readable policy, shared channels and the default person live in
|
||||
// gateway/participants.yaml, never in a person's fragment, so a v0.7.0 daemon (which refuses a fragment with any key it
|
||||
// doesn't know) still loads every person after a rollback.
|
||||
|
||||
const V070_FRAGMENT_KEYS = ["address", "class", "connectorBindings", "displayName", "entityId", "prefs"];
|
||||
const addPerson = (home: string, entityId: string, handle: string) => {
|
||||
const added = addHumanFragment({
|
||||
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
|
||||
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: `vault://slack/${entityId}`, role: "primary", handle }],
|
||||
prefs: { deliveryClass: "B" },
|
||||
}, home);
|
||||
expect(added.ok, added.ok ? "" : added.error).toBe(true);
|
||||
};
|
||||
const ORG_POLICY = "Answer Acme's customers within a working day. Never promise a date.";
|
||||
|
||||
describe("the participant side file", () => {
|
||||
let home: string;
|
||||
beforeEach(() => { home = mkdtempSync(join(tmpdir(), "participants-")); });
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
const organize = () => updateParticipantsFile((file) => {
|
||||
file.organizations.acme = { name: "Acme", policy: ORG_POLICY };
|
||||
file.groups.support = { organization: "acme", name: "Support", policy: "Support answers billing questions." };
|
||||
file.participants.sarah = { kind: "person", organization: "acme", groups: ["support"], policy: "Sarah approves refunds." };
|
||||
file.participants["acme-customers"] = {
|
||||
kind: "shared-channel", displayName: "Acme customers", organization: "acme", groups: ["support"],
|
||||
channel: { connector: "slack", connectorRef: "primary", secretsRef: "vault://slack/bot", channelId: "C0ACME" },
|
||||
};
|
||||
file.default = "sarah";
|
||||
return file;
|
||||
}, home);
|
||||
|
||||
it("registers people and a shared channel with organization, groups, policy and a default, and lists and shows them", () => {
|
||||
addPerson(home, "mike", "UMIKE");
|
||||
addPerson(home, "sarah", "USARAH");
|
||||
expect(organize()).toMatchObject({ ok: true, warnings: [] });
|
||||
|
||||
const view = participantsView(home);
|
||||
if (!view.ok) throw new Error(view.error);
|
||||
// Sorted by id, people and channels together.
|
||||
expect(view.participants.map((p) => [p.id, p.kind, p.address, p.default])).toEqual([
|
||||
["acme-customers", "shared-channel", "acme-customers@external", false],
|
||||
["mike", "person", "mike@external", false],
|
||||
["sarah", "person", "sarah@external", true],
|
||||
]);
|
||||
const sarah = view.participants.find((p) => p.id === "sarah")!;
|
||||
expect(sarah).toMatchObject({ organization: { id: "acme", name: "Acme" }, groups: [{ id: "support", name: "Support" }] });
|
||||
// Policy is text, outermost first, each part saying where it came from.
|
||||
expect(sarah.policy).toEqual([
|
||||
{ source: "organization acme (Acme)", text: ORG_POLICY },
|
||||
{ source: "group support (Support)", text: "Support answers billing questions." },
|
||||
{ source: "participant", text: "Sarah approves refunds." },
|
||||
]);
|
||||
expect(view.participants.find((p) => p.id === "acme-customers")).toMatchObject({ channel: { connector: "slack", channelId: "C0ACME" } });
|
||||
});
|
||||
|
||||
it("never touches a person's fragment or the generated projection: both stay byte-identical, with v0.7.0's keys only", () => {
|
||||
addPerson(home, "mike", "UMIKE");
|
||||
addPerson(home, "sarah", "USARAH");
|
||||
const fragmentsBefore = readdirSync(humansDir(home)).filter((f) => f.endsWith(".yaml")).map((f) => readFileSync(join(humansDir(home), f), "utf8"));
|
||||
const projectionBefore = readFileSync(projectionPath(home), "utf8");
|
||||
expect(organize().ok).toBe(true);
|
||||
expect(readdirSync(humansDir(home)).filter((f) => f.endsWith(".yaml")).map((f) => readFileSync(join(humansDir(home), f), "utf8"))).toEqual(fragmentsBefore);
|
||||
expect(readFileSync(projectionPath(home), "utf8")).toBe(projectionBefore);
|
||||
for (const body of fragmentsBefore) expect(Object.keys(parseYaml(body) as object).sort()).toEqual(V070_FRAGMENT_KEYS);
|
||||
// The new facts are all in the side file, outside the fragment folder.
|
||||
expect(participantsPath(home)).toBe(join(home, "gateway", "participants.yaml"));
|
||||
});
|
||||
|
||||
it("picks the default: the chosen person, else the only person, else none with the reason, never the first of several", () => {
|
||||
addPerson(home, "mike", "UMIKE");
|
||||
const people = () => { const p = projectHumans(home); if (!p.ok) throw new Error(p.error); return p.entities; };
|
||||
expect(resolveDefaultPerson(people(), home)).toMatchObject({ source: "only-person", person: { entityId: "mike" } });
|
||||
addPerson(home, "sarah", "USARAH");
|
||||
const none = resolveDefaultPerson(people(), home);
|
||||
expect(none).toMatchObject({ source: "none", person: null });
|
||||
expect(none.source === "none" && none.reason).toContain("rig gateway default");
|
||||
expect(updateParticipantsFile((file) => ({ ...file, default: "sarah" }), home).ok).toBe(true);
|
||||
expect(resolveDefaultPerson(people(), home)).toMatchObject({ source: "chosen", person: { entityId: "sarah" } });
|
||||
});
|
||||
|
||||
it("refuses a shared channel as the default, a channel that takes a person's id, an unknown group, and an unknown key", () => {
|
||||
addPerson(home, "mike", "UMIKE");
|
||||
expect(organize()).toMatchObject({ ok: false });
|
||||
addPerson(home, "sarah", "USARAH");
|
||||
expect(organize().ok).toBe(true);
|
||||
const refuse = (change: Parameters<typeof updateParticipantsFile>[0]) => {
|
||||
const result = updateParticipantsFile(change, home);
|
||||
expect(result.ok).toBe(false);
|
||||
return result.ok ? "" : result.error;
|
||||
};
|
||||
expect(refuse((file) => ({ ...file, default: "acme-customers" }))).toContain("shared channel");
|
||||
expect(refuse((file) => { file.participants.mike = { kind: "shared-channel", displayName: "x", channel: { connector: "slack", connectorRef: "p", secretsRef: "v", channelId: "C1" } }; return file; }))
|
||||
.toContain("already a person's id");
|
||||
expect(refuse((file) => { file.participants.mike = { kind: "person", groups: ["nope"] }; return file; })).toContain("isn't a known group");
|
||||
expect(refuse((file) => ({ ...file, role: "admin" } as never))).toContain('unknown key "role"');
|
||||
});
|
||||
|
||||
it("a person removed by hand leaves a warning, not a broken file; the rest still reads", () => {
|
||||
addPerson(home, "mike", "UMIKE");
|
||||
addPerson(home, "sarah", "USARAH");
|
||||
expect(organize().ok).toBe(true);
|
||||
unlinkSync(join(humansDir(home), "sarah.yaml"));
|
||||
const read = readParticipantsFile(home, new Set(["mike"]));
|
||||
if (!read.ok) throw new Error(read.error);
|
||||
expect(read.file.default).toBeUndefined();
|
||||
expect(read.file.participants.sarah).toBeUndefined();
|
||||
expect(read.file.participants["acme-customers"]).toBeDefined();
|
||||
expect(read.warnings).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("an unreadable side file still lists every person and says why the rest is missing; the person registry keeps working", () => {
|
||||
addPerson(home, "mike", "UMIKE");
|
||||
addPerson(home, "sarah", "USARAH");
|
||||
writeFileSync(participantsPath(home), "version: 1\norganizations: [not, a, mapping]\n");
|
||||
const view = participantsView(home);
|
||||
if (!view.ok) throw new Error(view.error);
|
||||
expect(view.participants.map((p) => p.id)).toEqual(["mike", "sarah"]);
|
||||
expect(view.sideFile.error).toContain("organizations must be a mapping");
|
||||
expect(view.default).toMatchObject({ source: "none" });
|
||||
expect(projectHumans(home).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a person who'd take a shared channel's id, before any write: the channel, the default and the file stay intact", () => {
|
||||
addPerson(home, "mike", "UMIKE");
|
||||
addPerson(home, "sarah", "USARAH");
|
||||
expect(organize().ok).toBe(true);
|
||||
const sideBefore = readFileSync(participantsPath(home), "utf8");
|
||||
const peopleBefore = readdirSync(humansDir(home)).sort();
|
||||
const added = addHumanFragment({
|
||||
entityId: "acme-customers", class: "human", displayName: "Someone", address: "acme-customers@external",
|
||||
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: "vault://slack/someone", role: "primary", handle: "USOMEONE" }],
|
||||
prefs: { deliveryClass: "B" },
|
||||
}, home);
|
||||
expect(added.ok).toBe(false);
|
||||
expect(added.ok ? "" : added.error).toContain('already the shared channel "Acme customers"');
|
||||
expect(readdirSync(humansDir(home)).sort()).toEqual(peopleBefore);
|
||||
expect(readFileSync(participantsPath(home), "utf8")).toBe(sideBefore);
|
||||
const view = participantsView(home);
|
||||
if (!view.ok) throw new Error(view.error);
|
||||
expect(view.sideFile.error).toBeUndefined();
|
||||
expect(view.default).toMatchObject({ source: "chosen", person: { entityId: "sarah" } });
|
||||
expect(view.participants.find((p) => p.id === "acme-customers")?.kind).toBe("shared-channel");
|
||||
});
|
||||
|
||||
it("removing a person's entry never removes a shared channel with that id", () => {
|
||||
addPerson(home, "mike", "UMIKE");
|
||||
addPerson(home, "sarah", "USARAH");
|
||||
expect(organize().ok).toBe(true);
|
||||
expect(removeParticipantEntry("acme-customers", home, { kind: "person" }).ok).toBe(true);
|
||||
const read = readParticipantsFile(home, new Set(["mike", "sarah"]));
|
||||
if (!read.ok) throw new Error(read.error);
|
||||
expect(read.file.participants["acme-customers"]?.kind).toBe("shared-channel");
|
||||
expect(read.file.default).toBe("sarah");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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 { addHumanFragment } from "../src/domain/gateway/human-registry.js";
|
||||
import { setDefaultPerson } from "../src/domain/gateway/participants.js";
|
||||
import { makeOperatorDeliveryEngine } from "../src/domain/gateway/operator-delivery-engine.js";
|
||||
import { MissionControlReadLayer } from "../src/domain/mission-control/mission-control-read-layer.js";
|
||||
|
||||
// With several people registered, anything that needs a person without naming one goes to the default person, never
|
||||
// to whoever's fragment sorts first; with none chosen, it says so. One person stays the default without any choice.
|
||||
|
||||
const person = (home: string, entityId: string) => expect(addHumanFragment({
|
||||
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
|
||||
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: `vault://slack/${entityId}`, role: "primary", handle: `U${entityId.toUpperCase()}` }],
|
||||
prefs: { deliveryClass: "B" },
|
||||
}, home)).toMatchObject({ ok: true });
|
||||
|
||||
describe("the default person, for escalations and Mission Control's my-queue", () => {
|
||||
let home: string;
|
||||
let db: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "default-consumers-"));
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
db = new Database(":memory:"); migrate(db, ALL_MIGRATIONS);
|
||||
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
});
|
||||
afterEach(() => { vi.unstubAllEnvs(); db.close(); rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
const escalate = async () => {
|
||||
const sentTo: string[] = [];
|
||||
const engine = makeOperatorDeliveryEngine({ home, queueRepo: repo,
|
||||
dispatch: (_op, ref, _payload, opts) => { sentTo.push(ref); return { ok: true, decisionId: opts?.decisionId ?? "d" }; } });
|
||||
const row = await repo.create({ sourceSession: "sender@fixture", destinationSession: "worker@fixture", body: "stuck work", summary: "stuck", nudge: false });
|
||||
const result = await engine.dispatchEscalation(row as never, "blocked");
|
||||
return { result, sentTo };
|
||||
};
|
||||
const myQueueDefault = () =>
|
||||
new MissionControlReadLayer({ queueRepo: repo, viewProjector: {}, fleetCliCapability: {} } as never).getDefaultOperatorSession();
|
||||
|
||||
it("one person: the escalation and my-queue go to them, with nothing chosen", async () => {
|
||||
person(home, "mike");
|
||||
expect((await escalate()).sentTo).toEqual(["mike@external"]);
|
||||
expect(myQueueDefault()).toBe("mike@external");
|
||||
});
|
||||
|
||||
it("several people and none chosen: the escalation is undeliverable and says why; my-queue has no default", async () => {
|
||||
person(home, "mike"); person(home, "sarah");
|
||||
const { result, sentTo } = await escalate();
|
||||
expect(result).toMatchObject({ decision: "undeliverable:no-default-person", dispatched: false });
|
||||
expect(sentTo).toEqual([]);
|
||||
expect(myQueueDefault()).toBe("");
|
||||
});
|
||||
|
||||
it("several people with a default: both go to the default, not the first by id", async () => {
|
||||
person(home, "mike"); person(home, "sarah");
|
||||
expect(setDefaultPerson("sarah", home).ok).toBe(true);
|
||||
expect((await escalate()).sentTo).toEqual(["sarah@external"]);
|
||||
expect(myQueueDefault()).toBe("sarah@external");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// Primary-user actions: the person at the terminal acts on anyone's ask, and the record says both who acted and who was
|
||||
// asked. A registered person without that authority can't settle another person's ask. Real SQLite (all migrations),
|
||||
// people registered in a temporary home, and the real Mission Control route and write contract, called with no seat
|
||||
// header, as the TUI calls it.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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 } from "../src/domain/queue-repository.js";
|
||||
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
|
||||
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
|
||||
import { missionControlRoutes } from "../src/routes/mission-control.js";
|
||||
import { addHumanFragment } from "../src/domain/gateway/human-registry.js";
|
||||
import { setDefaultPerson } from "../src/domain/gateway/participants.js";
|
||||
|
||||
const ASKER = "writer@rig";
|
||||
|
||||
describe("the primary user acts on anyone's ask, recorded as themselves on that person's ask", () => {
|
||||
let db: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
let home: string;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "primary-user-actions-"));
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const bus = new EventBus(db);
|
||||
repo = new QueueRepository(db, bus, { validateRig: () => true });
|
||||
const contract = 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, contract as never); await next(); });
|
||||
app.route("/api/mission-control", missionControlRoutes());
|
||||
});
|
||||
afterEach(() => { vi.unstubAllEnvs(); db.close(); rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
const register = (...ids: string[]) => {
|
||||
for (const entityId of ids) expect(addHumanFragment({
|
||||
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
|
||||
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: `vault://slack/${entityId}`, role: "primary", handle: `U${entityId.toUpperCase()}` }],
|
||||
prefs: { deliveryClass: "B" },
|
||||
}, home)).toMatchObject({ ok: true });
|
||||
};
|
||||
const ask = (to: string, extra: Record<string, unknown> = {}) => repo.create({ sourceSession: ASKER, destinationSession: to, body: "Ship the draft?",
|
||||
summary: "Ship the draft?", humanIntent: "decision", evidenceRef: "/fixture/ask", nudge: false, ...extra } as never);
|
||||
async function act(body: Record<string, unknown>, headers: Record<string, string> = {}) {
|
||||
const response = await app.request("/api/mission-control/action", { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(body) });
|
||||
return { status: response.status, body: await response.json() as Record<string, unknown> };
|
||||
}
|
||||
const lastTransition = (qitemId: string) => db.prepare("SELECT state, actor_session, transition_note FROM queue_transitions WHERE qitem_id = ? ORDER BY transition_id DESC LIMIT 1")
|
||||
.get(qitemId) as { state: string; actor_session: string; transition_note: string };
|
||||
const audit = (qitemId: string) => {
|
||||
const row = db.prepare("SELECT action_verb, actor_session, audit_notes_json FROM mission_control_actions WHERE qitem_id = ? ORDER BY rowid DESC LIMIT 1")
|
||||
.get(qitemId) as { action_verb: string; actor_session: string; audit_notes_json: string | null };
|
||||
return { verb: row.action_verb, actor: row.actor_session, notes: row.audit_notes_json ? JSON.parse(row.audit_notes_json) : null };
|
||||
};
|
||||
|
||||
it("answers Sarah's ask in the primary user's words: Mike answered, on Sarah's ask, and the asker reads exactly that", async () => {
|
||||
register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true);
|
||||
const sarahs = await ask("sarah@external");
|
||||
// The TUI claims the actor it shows; the daemon records the primary user whatever the claim.
|
||||
const result = await act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "sarah@external", authority: "primary-user", decision: "Ship it" });
|
||||
expect(result.status).toBe(200);
|
||||
expect(repo.getById(sarahs.qitemId)?.state).toBe("done");
|
||||
expect(lastTransition(sarahs.qitemId)).toMatchObject({ state: "done", actor_session: "mike@external" });
|
||||
expect(lastTransition(sarahs.qitemId).transition_note).toContain("mike@external, the primary user at the terminal, on sarah@external's ask; not answered by sarah@external");
|
||||
expect(audit(sarahs.qitemId)).toEqual({ verb: "answer", actor: "mike@external", notes: { authority: "primary-user", asked: "sarah@external" } });
|
||||
const reply = repo.list({ limit: 50 }).find((item) => item.replyTo === sarahs.qitemId)!;
|
||||
expect(reply).toMatchObject({ sourceSession: "mike@external", destinationSession: ASKER, summary: "mike@external answered sarah@external's ask: Ship the draft?" });
|
||||
expect(reply.body).toContain("Answered by mike@external, the primary user at the terminal, on sarah@external's ask in OpenRig.");
|
||||
});
|
||||
|
||||
it("records a choice on a question ask the same way", async () => {
|
||||
register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true);
|
||||
const sarahs = await ask("sarah@external", { humanQuestions: [{ id: "go", question: "Ship?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] }] });
|
||||
const result = await act({ verb: "answer", qitemId: sarahs.qitemId, actorSession: "mike@external", authority: "primary-user", questionId: "go", optionId: "yes" });
|
||||
expect(result.status).toBe(200);
|
||||
expect(repo.getById(sarahs.qitemId)).toMatchObject({ state: "done", humanAnswers: { go: "yes" } });
|
||||
expect(audit(sarahs.qitemId).notes).toEqual({ authority: "primary-user", asked: "sarah@external" });
|
||||
expect(repo.list({ limit: 50 }).find((item) => item.replyTo === sarahs.qitemId)?.sourceSession).toBe("mike@external");
|
||||
});
|
||||
|
||||
it("approves Sarah's pending ask and resolves a row parked on her, recorded as Mike on her ask", async () => {
|
||||
register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true);
|
||||
const pending = await ask("sarah@external");
|
||||
expect((await act({ verb: "approve", qitemId: pending.qitemId, actorSession: "mike@external", authority: "primary-user" })).status).toBe(200);
|
||||
expect(lastTransition(pending.qitemId)).toMatchObject({ state: "done", actor_session: "mike@external" });
|
||||
expect(lastTransition(pending.qitemId).transition_note).toBe("mission-control:approve; by mike@external, the primary user at the terminal, on sarah@external's ask");
|
||||
expect(audit(pending.qitemId)).toEqual({ verb: "approve", actor: "mike@external", notes: { authority: "primary-user", asked: "sarah@external" } });
|
||||
|
||||
const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", nudge: false });
|
||||
await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "human-sarah@host" } as never);
|
||||
expect((await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "mike@external", authority: "primary-user", decision: "Use the wide layout" })).status).toBe(200);
|
||||
expect(lastTransition(work.qitemId)).toMatchObject({ state: "in-progress", actor_session: "mike@external",
|
||||
transition_note: "Use the wide layout; by mike@external, the primary user at the terminal, on human-sarah@host's ask" });
|
||||
expect(audit(work.qitemId).notes).toEqual({ authority: "primary-user", asked: "human-sarah@host" });
|
||||
});
|
||||
|
||||
it("resolves a row parked on a registered person's address, which the Feed lists, and only with the authority", async () => {
|
||||
register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true);
|
||||
const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", nudge: false });
|
||||
await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "sarah@external" } as never);
|
||||
expect(await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "mike@external", decision: "Wide" })).toMatchObject({ status: 400, body: { error: "answer_not_recorded" } });
|
||||
expect((await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "mike@external", authority: "primary-user", decision: "Wide" })).status).toBe(200);
|
||||
expect(lastTransition(work.qitemId)).toMatchObject({ state: "in-progress", actor_session: "mike@external",
|
||||
transition_note: "Wide; by mike@external, the primary user at the terminal, on sarah@external's ask" });
|
||||
});
|
||||
|
||||
it("with several people and no default, the primary user is unnamed and recorded as the generic human seat", async () => {
|
||||
register("mike", "sarah");
|
||||
const sarahs = await ask("sarah@external");
|
||||
expect((await act({ verb: "approve", qitemId: sarahs.qitemId, actorSession: "sarah@external", authority: "primary-user" })).status).toBe(200);
|
||||
expect(audit(sarahs.qitemId)).toEqual({ verb: "approve", actor: "human@host", notes: { authority: "primary-user", asked: "sarah@external" } });
|
||||
});
|
||||
|
||||
it("a registered person without that authority can't settle another person's ask, pending or parked", async () => {
|
||||
register("mike", "sarah");
|
||||
const pending = await ask("sarah@external");
|
||||
for (const body of [{ verb: "approve" }, { verb: "deny", reason: "no" }, { verb: "answer", decision: "Ship it" }]) {
|
||||
const refused = await act({ qitemId: pending.qitemId, actorSession: "mike@external", ...body });
|
||||
expect(refused).toMatchObject({ status: 400, body: { error: "answer_not_recorded" } });
|
||||
}
|
||||
expect(repo.getById(pending.qitemId)?.state).toBe("pending");
|
||||
|
||||
const work = await repo.create({ sourceSession: ASKER, destinationSession: "designer@rig", body: "Layout", summary: "Layout", nudge: false });
|
||||
await repo.update({ qitemId: work.qitemId, actorSession: "designer@rig", state: "blocked", blockedOn: "human-sarah@host" } as never);
|
||||
expect(await act({ verb: "resolve", qitemId: work.qitemId, actorSession: "mike@external", decision: "Wide" })).toMatchObject({ status: 400, body: { error: "answer_not_recorded" } });
|
||||
expect(repo.getById(work.qitemId)?.state).toBe("blocked");
|
||||
// Sarah settles her own.
|
||||
expect((await act({ verb: "approve", qitemId: pending.qitemId, actorSession: "sarah@external" })).status).toBe(200);
|
||||
expect(audit(pending.qitemId)).toEqual({ verb: "approve", actor: "sarah@external", notes: null });
|
||||
});
|
||||
|
||||
it("a seat's own transport identity is never replaced by the authority", async () => {
|
||||
register("mike", "sarah"); expect(setDefaultPerson("mike", home).ok).toBe(true);
|
||||
const sarahs = await ask("sarah@external");
|
||||
expect((await act({ verb: "approve", qitemId: sarahs.qitemId, actorSession: "mike@external", authority: "primary-user" }, { "X-OpenRig-Session": "orch@rig" })).status).toBe(200);
|
||||
expect(audit(sarahs.qitemId)).toEqual({ verb: "approve", actor: "orch@rig", notes: null });
|
||||
});
|
||||
|
||||
it("with no one registered, acts are recorded as the claimed actor, as before", async () => {
|
||||
const legacy = await ask("human@host");
|
||||
expect((await act({ verb: "answer", qitemId: legacy.qitemId, actorSession: "human@host", authority: "primary-user", decision: "Yes" })).status).toBe(200);
|
||||
expect(audit(legacy.qitemId)).toEqual({ verb: "answer", actor: "human@host", notes: { authority: "primary-user", asked: "human@host" } });
|
||||
expect(repo.list({ limit: 50 }).find((item) => item.replyTo === legacy.qitemId)?.summary).toBe("human@host answered: Ship the draft?");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// Test-only: the real inbound router, queue ports, reply resolver and write contract over migrated SQLite, with two
|
||||
// registered people. Slack itself isn't involved; events are handed to the router as Slack would deliver them.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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 { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
|
||||
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
|
||||
import { InboundRouter } from "../src/domain/gateway/slack/inbound.js";
|
||||
import { makeQueuePorts } from "../src/domain/gateway/slack/queue-access.js";
|
||||
import { makeHumanReplyResolver } from "../src/domain/gateway/slack/slack-subsystem.js";
|
||||
import { SeenStore, DeadLetterStore } from "../src/domain/gateway/slack/state-store.js";
|
||||
import type { HumanFragment } from "../src/domain/gateway/human-registry.js";
|
||||
|
||||
// With several registered people, a person who wasn't asked can still write in the asked person's Slack thread. Their
|
||||
// words reach the agent as their own message, and never become the asked person's answer: not on a pending ask, typed
|
||||
// or clicked, and not on a question parked on someone else.
|
||||
|
||||
const person = (entityId: string, handle: string): HumanFragment => ({
|
||||
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
|
||||
connectorBindings: [{ kind: "slack", connectorRef: "primary", secretsRef: "unused", role: "primary", handle }],
|
||||
prefs: { deliveryClass: "A" },
|
||||
});
|
||||
const ANA = "human-ana@external", MIKE = "human-mike@external", ASKER = "requester@test-rig";
|
||||
const registry = { ok: true as const, entities: [person("human-ana", "UANA"), person("human-mike", "UMIKE")] };
|
||||
const sources: Record<string, string> = { UANA: ANA, UMIKE: MIKE };
|
||||
const questions = [{ id: "choice", question: "Proceed?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] }];
|
||||
|
||||
const closes: Array<() => void> = [];
|
||||
afterEach(() => { for (const close of closes.splice(0).reverse()) close(); });
|
||||
|
||||
async function fixture(kind: "pending" | "parked") {
|
||||
const home = mkdtempSync(join(tmpdir(), "wrong-answerer-"));
|
||||
const db = new Database(join(home, "queue.sqlite")); migrate(db, ALL_MIGRATIONS); const bus = new EventBus(db);
|
||||
const repo = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => registry,
|
||||
transport: { send: async () => ({ ok: true, verified: true, sent: true }) } });
|
||||
const contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
|
||||
let ask: { qitemId: string };
|
||||
if (kind === "pending") {
|
||||
// A direct ask to Ana, waiting for her answer.
|
||||
ask = await repo.create({ sourceSession: ASKER, destinationSession: ANA, body: "Synthetic ask", summary: "Proceed?",
|
||||
humanIntent: "decision", humanQuestions: questions, evidenceRef: "/fixture/decision", nudge: false });
|
||||
} else {
|
||||
// Work parked on Ana, and posted to her in Slack, as a real post records it.
|
||||
ask = await repo.create({ sourceSession: ASKER, destinationSession: "dev@test-rig", body: "work", nudge: false });
|
||||
repo.update({ qitemId: ask.qitemId, actorSession: "dev@test-rig", state: "blocked", blockedOn: "human-ana@host", summary: "Parked",
|
||||
evidenceRef: "/proof/parked.md", transitionNote: "parked on Ana" });
|
||||
const episode = repo.listTransitions(ask.qitemId).filter((t) => t.ownerNotificationKind === "human-required").at(-1)!;
|
||||
repo.update({ qitemId: ask.qitemId, actorSession: "openrig-slack",
|
||||
transitionNote: `slack-owner-notification-posted notification_key=${ask.qitemId}:${episode.transitionId} kind=human-required thread_ts=1.1 message_ts=1.5` });
|
||||
}
|
||||
const router = new InboundRouter({ queue: makeQueuePorts(repo), destination: ASKER, seen: new SeenStore(join(home, "seen.jsonl")),
|
||||
deadLetter: new DeadLetterStore(join(home, "dead.jsonl")),
|
||||
resolveSender: (user: string) => (sources[user] ? { admitted: true, source: sources[user]! } : { admitted: false, teaching: "unregistered" }),
|
||||
resolveRoute: () => ({ destination: ASKER, correlationQitemId: ask.qitemId }),
|
||||
acknowledgeAnswer: async () => {}, recordHumanAnswer: (input) => repo.recordHumanAnswer(input),
|
||||
resolveHumanReply: makeHumanReplyResolver(repo, contract) });
|
||||
closes.push(() => { db.close(); rmSync(home, { recursive: true, force: true }); });
|
||||
const typed = (user: string, text: string, ts: string) =>
|
||||
router.route({ type: "message", user, channel: "CTEST", thread_ts: "1.1", ts, text });
|
||||
const clicked = (user: string, actionTs: string) => router.routeAction({ type: "block_actions", user: { id: user }, channel: { id: "CTEST" },
|
||||
container: { type: "message", message_ts: "1.1", channel_id: "CTEST" }, message: { ts: "1.1" },
|
||||
actions: [{ type: "button", block_id: "or-q:choice", action_id: "or-opt:yes", value: "yes", action_ts: actionTs }] });
|
||||
const state = () => {
|
||||
const row = repo.getById(ask.qitemId)!;
|
||||
const replies = repo.list({ limit: 100 }).filter((item) => item.replyTo === ask.qitemId);
|
||||
return {
|
||||
state: row.state, blockedOn: row.blockedOn ?? null, answers: row.humanAnswers ?? null,
|
||||
resolvedBy: repo.listTransitions(ask.qitemId).filter((t) => t.ownerNotificationKind === "human-decision-resolved").map((t) => t.actorSession),
|
||||
replies: replies.map((item) => ({ source: item.sourceSession, destination: item.destinationSession, humanAnswer: (item.tags ?? []).includes("human-answer") })),
|
||||
};
|
||||
};
|
||||
return { typed, clicked, state };
|
||||
}
|
||||
|
||||
describe("a reply from a registered person who wasn't asked", () => {
|
||||
it("typed in a pending ask's thread: reaches the agent as their own message; the ask stays open and unanswered", async () => {
|
||||
const f = await fixture("pending");
|
||||
await f.typed("UMIKE", "I'd say yes", "3.3");
|
||||
expect(f.state()).toEqual({ state: "pending", blockedOn: null, answers: null, resolvedBy: [],
|
||||
replies: [{ source: MIKE, destination: ASKER, humanAnswer: false }] });
|
||||
});
|
||||
|
||||
it("clicked on a pending ask's button: records nothing as the asked person's answer", async () => {
|
||||
const f = await fixture("pending");
|
||||
await f.clicked("UMIKE", "2.2");
|
||||
expect(f.state()).toMatchObject({ state: "pending", answers: null, resolvedBy: [] });
|
||||
expect(f.state().replies.filter((reply) => reply.humanAnswer)).toEqual([]);
|
||||
// The same click from the person asked does record their answer, so the refusal above is about who clicked.
|
||||
await f.clicked("UANA", "2.3");
|
||||
expect(f.state()).toMatchObject({ state: "done", answers: { choice: "yes" }, resolvedBy: [ANA] });
|
||||
});
|
||||
|
||||
it("typed on a question parked on someone else: reaches the agent and doesn't resume the question", async () => {
|
||||
const f = await fixture("parked");
|
||||
await f.typed("UMIKE", "go ahead", "3.3");
|
||||
expect(f.state()).toMatchObject({ state: "blocked", blockedOn: "human-ana@host", resolvedBy: [] });
|
||||
expect(f.state().replies).toEqual([{ source: MIKE, destination: ASKER, humanAnswer: false }]);
|
||||
});
|
||||
|
||||
it("the asked person still answers, after the other person wrote, and the answer is theirs", async () => {
|
||||
const pending = await fixture("pending");
|
||||
await pending.typed("UMIKE", "I'd say yes", "3.3");
|
||||
await pending.typed("UANA", "Yes, go ahead", "4.4");
|
||||
expect(pending.state()).toMatchObject({ state: "done", resolvedBy: [ANA] });
|
||||
expect(pending.state().replies.filter((reply) => reply.humanAnswer)).toEqual([{ source: ANA, destination: ASKER, humanAnswer: true }]);
|
||||
|
||||
const parked = await fixture("parked");
|
||||
await parked.typed("UMIKE", "go ahead", "3.3");
|
||||
await parked.typed("UANA", "go ahead", "4.4");
|
||||
expect(parked.state()).toMatchObject({ state: "in-progress", resolvedBy: [ANA] });
|
||||
});
|
||||
});
|
||||
@@ -278,7 +278,8 @@ describe("an ask to a person is on Steering and the TUI; Slack gets it by their
|
||||
const read = await feed(app, row);
|
||||
const item = read.items.find((entry) => entry.id === `queue:${row.qitemId}`)!;
|
||||
expect(item.answer).toEqual({ verb: "approve", actorSession: `${HUMAN}@kernel` });
|
||||
expect(read.items.find((entry) => entry.id === `queue:${parked.qitemId}`)?.answer).toBeUndefined();
|
||||
// A row parked on the person is resolved in words, not approved.
|
||||
expect(read.items.find((entry) => entry.id === `queue:${parked.qitemId}`)?.answer).toEqual({ verb: "resolve", actorSession: `${HUMAN}@kernel` });
|
||||
const act = (body: Record<string, unknown>) => app.request("/api/mission-control/action", { method: "POST",
|
||||
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
// The TUI posts the served answer; Steering's Approve posts its own card's body for the twin.
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 { setDefaultPerson } from "../src/domain/gateway/participants.js";
|
||||
import { makeEnsureStuckExceptionItem } from "../src/domain/workflow-exception-escalation.js";
|
||||
import { workflowRoutes } from "../src/routes/workflow.js";
|
||||
|
||||
@@ -96,7 +97,10 @@ 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/);
|
||||
expect(() => resolveWorkflowHumanDestination()).toThrow(/choose a default with rig gateway default/);
|
||||
// The default person, chosen in the participant side file, is the fallback; the explicit setting still wins over it.
|
||||
expect(setDefaultPerson("owner-one")).toMatchObject({ ok: true });
|
||||
expect(resolveWorkflowHumanDestination()).toBe("owner-one@external");
|
||||
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");
|
||||
|
||||
@@ -43,7 +43,8 @@ export function composeHumanUpdates(attention: AttentionRead | null, updates: De
|
||||
|
||||
export function attentionLines(state: ViewState, snap: FleetSnapshot, width: number): ContentLine[] {
|
||||
const read = snap.attentionRead;
|
||||
const lines: ContentLine[] = [{ text: "FEED · Instance / All humans" }];
|
||||
const lines: ContentLine[] = [{ text: feedTitle(read?.perspective) }];
|
||||
if (read?.perspective?.defaultSource === "none") lines.push({ text: "No default person is chosen: rig gateway default <id> chooses one." });
|
||||
if (!read) return wrapDetailLines([...lines, { text: "Unavailable: Feed sources have not answered." }, ...snap.readErrors.map(text => ({ text }))], width);
|
||||
if (state.attentionOpen) {
|
||||
const d = read.detail;
|
||||
@@ -65,9 +66,14 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
|
||||
const qitemId = d.item.id.startsWith("queue:") ? d.item.id.slice("queue:".length) : null;
|
||||
if (d.item.answer && qitemId) {
|
||||
const { verb, actorSession } = d.item.answer;
|
||||
// Whoever's queue is shown, the act is the primary user's; it says whose ask it settles when that's someone else.
|
||||
const actor = actingAs(read, actorSession);
|
||||
const as = `as ${actor}${d.item.recipient && d.item.recipient !== actor && read.perspective ? ` on ${d.item.recipient}'s ask` : ""}`;
|
||||
const open = (d.item.humanQuestions ?? []).filter(q => answerOf(d.item, q.id) == null);
|
||||
if (verb === "approve") {
|
||||
lines.push(listItem(`Approve as ${actorSession} (closes this request, as Steering's Approve does)`, { type: "act", act: "answer", qitemId, verb, actorSession }));
|
||||
if (verb === "resolve") {
|
||||
lines.push({ text: `Resolve it in your own words: reply <your decision>, ${as} (it unparks the row for its owner).` });
|
||||
} else if (verb === "approve") {
|
||||
lines.push(listItem(`Approve ${as} (closes this request, as Steering's Approve does)`, { type: "act", act: "answer", qitemId, verb, actorSession }));
|
||||
} else if (!open.length) {
|
||||
lines.push(listItem(`Send the recorded answers back to ${d.item.asker ?? "the asker"} and close this request`, { type: "act", act: "answer", qitemId, verb, actorSession }));
|
||||
} else {
|
||||
@@ -75,8 +81,8 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
|
||||
for (const o of q.options) lines.push(listItem(`Answer "${q.question}": ${o.label}${o.recommended ? " (recommended)" : ""}`,
|
||||
{ type: "act", act: "answer", qitemId, verb, actorSession, questionId: q.id, optionId: o.id }));
|
||||
}
|
||||
if (verb === "approve" || open.length) {
|
||||
lines.push({ text: `Or answer in your own words: reply <text>, as ${actorSession}${verb === "answer" ? " (it fills the first unanswered question and closes the request)" : ""}.` });
|
||||
if (verb !== "resolve" && (verb === "approve" || open.length)) {
|
||||
lines.push({ text: `Or answer in your own words: reply <text>, ${as}${verb === "answer" ? " (it fills the first unanswered question and closes the request)" : ""}.` });
|
||||
}
|
||||
}
|
||||
// Chat, as on the Feed card: open the asker's terminal (its pod's view) to answer in your own words.
|
||||
@@ -110,13 +116,39 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
|
||||
return wrapDetailLines(lines, width);
|
||||
}
|
||||
|
||||
/** The mission-control write for an answer act. Terminal acts carry the primary user's authority; the daemon records who
|
||||
* that is and whose ask it was. */
|
||||
export function answerRequest(action: Extract<Action, { type: "act"; act: "answer" }>) {
|
||||
const { act: _act, type: _type, ...input } = action;
|
||||
return { ...input, authority: "primary-user" as const };
|
||||
}
|
||||
|
||||
/** Who an act on the Feed is recorded as: the primary user once anyone is registered, else the served actor, as before. */
|
||||
export function actingAs(read: AttentionRead | null | undefined, served: string): string {
|
||||
return read?.perspective ? read.perspective.actingAs.person ?? "the primary user" : served;
|
||||
}
|
||||
|
||||
/** The TUI opens on the default person's Feed only when the participants file chose one, and only while the person hasn't
|
||||
* moved off the opening view. With none chosen it stays on the topology, as before. */
|
||||
export function opensOnDefaultFeed(read: AttentionRead | null | undefined, state: ViewState, untouched: boolean): boolean {
|
||||
return untouched && state.section === "topology" && read?.perspective?.defaultSource === "chosen";
|
||||
}
|
||||
|
||||
/** Whose queue the Feed shows, and who acts on it: the primary user, whichever queue is shown. */
|
||||
function feedTitle(p: AttentionRead["perspective"]): string {
|
||||
if (!p) return "FEED · Instance / All humans";
|
||||
const viewing = p.basis === "unknown" ? `no registered person "${p.requested}"` : p.viewing ?? "everyone";
|
||||
return `FEED · Viewing ${viewing} · you act as ${p.actingAs.person ?? "the primary user"}`;
|
||||
}
|
||||
|
||||
/** The `reply` command on the open request: the person's own words, answered on that row as the person the Feed served. */
|
||||
export function replyAct(read: AttentionRead | null | undefined, openId: string | null | undefined, text: string): Action {
|
||||
const item = read?.detail?.item;
|
||||
if (!item?.answer || item.id !== openId || !item.id.startsWith("queue:") || !text.trim()) {
|
||||
return { type: "notice", message: "reply answers the open request addressed to a person: open it in the Feed, then reply <your words>." };
|
||||
}
|
||||
return { type: "act", act: "answer", qitemId: item.id.slice("queue:".length), verb: "answer", actorSession: item.answer.actorSession, decision: text.trim() };
|
||||
return { type: "act", act: "answer", qitemId: item.id.slice("queue:".length), verb: item.answer.verb === "resolve" ? "resolve" : "answer",
|
||||
actorSession: item.answer.actorSession, decision: text.trim() };
|
||||
}
|
||||
|
||||
/** A question's recorded answer, by own key only (a question id like "constructor" isn't an answer). */
|
||||
|
||||
@@ -96,6 +96,9 @@ export const COMMAND_REGISTRY: readonly CommandEntry[] = [
|
||||
build: view => view ? ({ type: "act", act: "open-terminal", view }) : ({ type: "error", message: "terminal needs a view, for example rig:<name>" }) },
|
||||
{ name: "reply", aliases: [], args: "<your words>", description: "answer the open Feed request in your own words, as the person it's addressed to; it goes back to the asker", context: "standard", sample: "reply Ship it on Friday",
|
||||
build: text => text.trim() ? ({ type: "attention-reply", text }) : ({ type: "error", message: "reply needs your answer, for example reply Ship it on Friday" }) },
|
||||
{ name: "whose", aliases: [], args: "<person|all|default>", description: "choose whose queue the Feed shows: one person's, everyone's, or the default person's; you still act as the primary user", context: "standard", sample: "whose default",
|
||||
complete: () => ["default", "all"],
|
||||
build: view => view ? ({ type: "attention-view", view }) : ({ type: "error", message: 'whose needs a person, "all" or "default"' }) },
|
||||
{ name: "attention", aliases: ["needs", "feed"], args: "", description: "inspect human requests and outcome/health updates", context: "standard", sample: "attention", build: () => ({ type: "jump", section: "needs" }) },
|
||||
{ name: "read", aliases: [], args: "<root>/<path>[#heading]", description: "read a current file within an explicitly configured root", context: "standard", sample: "read workspace/README.md", complete: ({ snapshot }) => (snapshot.fileRoots ?? []).map((root) => `${root.name}/`), build: (value) => {
|
||||
const slash = value.indexOf("/");
|
||||
|
||||
@@ -389,8 +389,9 @@ export class DaemonClient {
|
||||
* returns the QueueItem or 404). Used for the BLOCKED ON AGENTS bounded per-row
|
||||
* 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)}` : ""}`);
|
||||
humanAttention(item?: string | null, view?: string | null) {
|
||||
const query = [item ? `item=${encodeURIComponent(item)}` : null, view ? `view=${encodeURIComponent(view)}` : null].filter(Boolean).join("&");
|
||||
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);
|
||||
@@ -402,7 +403,7 @@ export class DaemonClient {
|
||||
return this.get(`/api/review/fleet`);
|
||||
}
|
||||
/** 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 }) {
|
||||
missionControlAction(input: { verb: "approve" | "answer" | "resolve"; qitemId: string; actorSession: string; questionId?: string; optionId?: string; decision?: string; authority?: "primary-user" }) {
|
||||
return this.post("/api/mission-control/action", input) as Promise<{ verb: string; qitemId: string; closedQitem: unknown;
|
||||
answer?: { outcome: "recorded" | "answered"; unanswered: string[]; message?: string } }>;
|
||||
}
|
||||
|
||||
@@ -367,7 +367,7 @@ function agentSpecTruth(raw?: string): { runtime?: string; skills: string[] } {
|
||||
* avoids re-reading every spec every refresh; the key rolls when the library
|
||||
* entry's updatedAt changes. Owned by the caller (instance-scoped, no module state). */
|
||||
export type SpecReviewCache = Map<string, SpecLibraryReviewRead>;
|
||||
export type HydrateViewContext = Pick<ViewState, "project" | "section" | "viewTab" | "drill" | "file" | "externalUrl" | "terminalView" | "attentionOpen" | "pendingDrill">;
|
||||
export type HydrateViewContext = Pick<ViewState, "project" | "section" | "viewTab" | "drill" | "file" | "externalUrl" | "terminalView" | "attentionOpen" | "attentionView" | "pendingDrill">;
|
||||
|
||||
export async function hydrateSnapshot(
|
||||
client: DaemonClient,
|
||||
@@ -400,7 +400,7 @@ export async function hydrateSnapshot(
|
||||
|
||||
if (viewContext?.section === "needs") {
|
||||
const [attention, updates, roots] = await Promise.all([
|
||||
safe<NonNullable<FleetSnapshot["attentionRead"]>>("Feed", () => client.humanAttention(viewContext.attentionOpen)),
|
||||
safe<NonNullable<FleetSnapshot["attentionRead"]>>("Feed", () => client.humanAttention(viewContext.attentionOpen, viewContext.attentionView ?? "default")),
|
||||
safe<DeliveredHumanUpdates>("delivered updates", () => client.humanUpdates()),
|
||||
safe<Awaited<ReturnType<DaemonClient["fileRoots"]>>>("file-roots", () => client.fileRoots()),
|
||||
]);
|
||||
|
||||
@@ -33,7 +33,8 @@ import { restoreKeyAction, type RestoreInputEvent } from "./crash-cart/restore-i
|
||||
import { evaluateOneClickGate, restoreConfirmMessage } from "./crash-cart/one-click-gate.js";
|
||||
import { daemonStartArgs } from "./crash-cart/start-daemon.js";
|
||||
import { readLocal } from "./local-reading.js";
|
||||
import { replyAct } from "./attention/attention-model.js";
|
||||
import { replyAct, opensOnDefaultFeed, actingAs, answerRequest } from "./attention/attention-model.js";
|
||||
import type { AttentionRead } from "@openrig/daemon/attention";
|
||||
import { StartupController } from "./startup.js";
|
||||
import { dirname, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -168,6 +169,19 @@ async function run(): Promise<void> {
|
||||
} });
|
||||
return true;
|
||||
}
|
||||
// Once, at startup: the chosen default person's Feed opens first (see opensOnDefaultFeed).
|
||||
let defaultFeedChecked = false;
|
||||
function openChosenDefaultFeed(): void {
|
||||
if (defaultFeedChecked || !client) return;
|
||||
defaultFeedChecked = true;
|
||||
const revision = inputRevision;
|
||||
void client.humanAttention(null, "default").then((read) => {
|
||||
if (!opensOnDefaultFeed(read as AttentionRead | null, view.get(), revision === inputRevision)) return;
|
||||
view.dispatch({ type: "jump", section: "needs" });
|
||||
draw();
|
||||
void live?.refresh();
|
||||
}, () => {});
|
||||
}
|
||||
function commandContext() {
|
||||
return currentCommandContext(startup && startup.state.connection !== "up" ? "unverified" : crashCartOpts.daemonState ?? null);
|
||||
}
|
||||
@@ -235,7 +249,7 @@ async function run(): Promise<void> {
|
||||
draw();
|
||||
// Skip during the probe must remain skipped, but can start reads when
|
||||
// the connection later answers. This never changes the chosen page.
|
||||
if (!startup?.state.open && startup?.state.connection === "up" && !liveEnabled && enableLive()) void live?.refresh();
|
||||
if (!startup?.state.open && startup?.state.connection === "up" && !liveEnabled && enableLive()) { void live?.refresh(); openChosenDefaultFeed(); }
|
||||
},
|
||||
onHelp: () => { view.dispatch({ type: "palette-open" }); draw(); },
|
||||
readLocal: (request) => readLocal(cliEntry, request),
|
||||
@@ -441,12 +455,13 @@ async function run(): Promise<void> {
|
||||
const result = await client.launchNode(action.rigId, action.agent);
|
||||
view.dispatch({ type: "notice", message: launchNodeNotice(action.agent, result) });
|
||||
} 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"
|
||||
? `Approved ${action.qitemId} as ${action.actorSession}: the request is closed on its own row.`
|
||||
const result = await client.missionControlAction(answerRequest(action));
|
||||
const actor = actingAs(snapshot.attentionRead, action.actorSession);
|
||||
view.dispatch({ type: "notice", message: action.verb === "resolve" ? `Resolved ${action.qitemId} as ${actor}: it's unparked for its owner, with your decision.`
|
||||
: action.verb === "approve"
|
||||
? `Approved ${action.qitemId} as ${actor}: the request is closed on its own row.`
|
||||
: result.answer?.message ?? (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.`) });
|
||||
: `Answered ${action.qitemId} as ${actor}: the answer went back to the asker, and the request is closed.`) });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -74,5 +74,5 @@ export class PageRead {
|
||||
export function pageReadKey(s: ViewState): string {
|
||||
return JSON.stringify([s.section, s.viewTab, s.drill, s.project, s.scopesMission,
|
||||
s.scopesSelected, s.executionOpen, s.file && [s.file.root, s.file.path],
|
||||
s.externalUrl, s.terminalView, s.attentionOpen, s.pendingDrill]);
|
||||
s.externalUrl, s.terminalView, s.attentionOpen, s.pendingDrill, s.attentionView ?? null]);
|
||||
}
|
||||
@@ -86,12 +86,13 @@ export function createViewState(options: CreateViewStateOptions): ViewStateStore
|
||||
executionOpen: null,
|
||||
healthOpen: null,
|
||||
attentionOpen: null,
|
||||
attentionView: null,
|
||||
};
|
||||
const listeners = new Set<(s: ViewState) => void>();
|
||||
|
||||
function dispatch(action: Action): ViewState {
|
||||
const previous = state;
|
||||
if (["attention-category", "attention-open", "terminal-preview", "project-select", "jump", "drill", "cross", "tab", "scopes-mission-open", "scopes-open", "health-open", "execution-open", "recent-open", "timezone", "config-category", "config-setting"].includes(action.type)) state = { ...state, file: null, externalUrl: null, recentOpen: null, timeZoneHelp: false, attentionOpen: null, specLaunch: null, ...(state.pendingDrill ? { pendingDrill: null } : {}) };
|
||||
if (["attention-category", "attention-open", "attention-view", "terminal-preview", "project-select", "jump", "drill", "cross", "tab", "scopes-mission-open", "scopes-open", "health-open", "execution-open", "recent-open", "timezone", "config-category", "config-setting"].includes(action.type)) state = { ...state, file: null, externalUrl: null, recentOpen: null, timeZoneHelp: false, attentionOpen: null, specLaunch: null, ...(state.pendingDrill ? { pendingDrill: null } : {}) };
|
||||
state = reduce(state, action, getSnapshot());
|
||||
// A pending address belongs to the Topology landing it opened; leaving it cancels the address.
|
||||
if (state.pendingDrill && (state.section !== "topology" || state.drill.length > 0)) state = { ...state, pendingDrill: null };
|
||||
@@ -142,6 +143,9 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
|
||||
return resetContent({ ...next, terminalPage: Math.max(0, Math.min(action.page, (snap.terminals?.preview?.composed.pages.length ?? 1) - 1)) });
|
||||
case "attention-category":
|
||||
return syncSelection({ ...resetContent({ ...next, section: "needs", attentionCategory: action.category, attentionOpen: null }), focusedPane: "content" }, snap);
|
||||
case "attention-view":
|
||||
// Whose queue the Feed shows; acts on it are still the primary user's.
|
||||
return syncSelection({ ...resetContent({ ...next, section: "needs", attentionView: action.view === "default" ? null : action.view, attentionCategory: null, attentionOpen: null }), focusedPane: "content" }, snap);
|
||||
case "attention-open":
|
||||
return syncSelection({ ...resetContent({ ...next, section: "needs", attentionOpen: action.id, file: null, externalUrl: null, healthOpen: null }), focusedPane: "content" }, snap);
|
||||
case "attention-source": {
|
||||
|
||||
@@ -518,6 +518,8 @@ export type Action =
|
||||
| { type: "attention-category"; category: "action" | "update" }
|
||||
| { type: "attention-open"; id: string }
|
||||
| { type: "attention-source"; path: string }
|
||||
/** Whose Feed queue to show: a person, "all" or "default". A view, never who acts. */
|
||||
| { type: "attention-view"; view: string }
|
||||
| { type: "file-open"; target: import("./reading.js").FileTarget }
|
||||
| { type: "external-open"; url: string }
|
||||
/** Leave the TUI briefly and print `value` as one unbroken line for exact copying; Enter returns. */
|
||||
@@ -574,7 +576,7 @@ export type Action =
|
||||
| { type: "act"; act: "run"; rigId: string; agent: string }
|
||||
/** Answer a request on its own row, as the person the Feed served: Steering's Approve, or a choice
|
||||
* (questionId + optionId) or their own words (decision) through mission control's answer. */
|
||||
| { type: "act"; act: "answer"; qitemId: string; verb: "approve" | "answer"; actorSession: string; questionId?: string; optionId?: string; decision?: string }
|
||||
| { type: "act"; act: "answer"; qitemId: string; verb: "approve" | "answer" | "resolve"; actorSession: string; questionId?: string; optionId?: string; decision?: string }
|
||||
/** Answer the open request in your own words (the `reply` command); main resolves it to the served answer act. */
|
||||
| { type: "attention-reply"; text: string }
|
||||
| { type: "notice"; message: string };
|
||||
@@ -594,6 +596,8 @@ export interface ViewState {
|
||||
terminalView?: string | null;
|
||||
terminalPage?: number;
|
||||
attentionOpen?: string | null;
|
||||
/** Whose Feed queue is shown: a person, or "all"; null is the default person's (or everyone's when there is none). */
|
||||
attentionView?: string | null;
|
||||
attentionCategory?: "action" | "update" | null;
|
||||
file?: import("./reading.js").FileTarget | null;
|
||||
externalUrl?: string | null;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { DaemonClient } from "../src/daemon-client.js";
|
||||
import { hydrateSnapshot } from "../src/hydrate.js";
|
||||
import { createViewState, emptySnapshot } from "../src/state.js";
|
||||
import { attentionLines, opensOnDefaultFeed, answerRequest, replyAct } from "../src/attention/attention-model.js";
|
||||
import { parseCommand } from "../src/grammar.js";
|
||||
import type { AttentionRead } from "@openrig/daemon/attention";
|
||||
import type { Action, FleetSnapshot } from "../src/types.js";
|
||||
|
||||
// Whose queue the Feed shows is a view, never who acts: the header names both, and acts stay the primary user's.
|
||||
|
||||
const perspective = (viewing: string | null, basis: NonNullable<AttentionRead["perspective"]>["basis"], defaultSource: NonNullable<AttentionRead["perspective"]>["defaultSource"] = "chosen") =>
|
||||
({ viewing, basis, defaultSource, actingAs: { person: defaultSource === "none" ? null : "mike@external", authority: "primary-user" as const } });
|
||||
const read = (p?: AttentionRead["perspective"]): AttentionRead =>
|
||||
({ scope: "instance", readAt: "2026-10-25T10:00:00Z", items: [], sources: [{ source: "queue", state: "available", detail: "fixture" }], detail: null, detailError: null, ...(p ? { perspective: p } : {}) });
|
||||
|
||||
it("the whose command chooses whose queue the Feed reads, and the Feed says who acts", async () => {
|
||||
const views: Array<string | null> = [];
|
||||
const client = new DaemonClient({ baseUrl: "http://fixture", fetchImpl: (async (url) => {
|
||||
const u = new URL(String(url));
|
||||
if (u.pathname === "/api/attention") {
|
||||
const view = u.searchParams.get("view"); views.push(view);
|
||||
return Response.json(read(view === "sarah" ? perspective("sarah@external", "chosen-here") : view === "all" ? perspective(null, "all") : perspective("mike@external", "default")));
|
||||
}
|
||||
if (u.pathname === "/api/queue/human-updates") return Response.json({ items: [], limit: 20, truncated: false });
|
||||
if (u.pathname === "/api/files/roots") return Response.json({ roots: [] });
|
||||
throw new Error(`Unexpected request: ${u}`);
|
||||
}) as typeof fetch });
|
||||
let snap: FleetSnapshot = emptySnapshot();
|
||||
const view = createViewState({ instanceId: "perspective", getSnapshot: () => snap });
|
||||
const refresh = async () => { snap = await hydrateSnapshot(client, undefined, null, null, null, view.get()); };
|
||||
const title = () => attentionLines(view.get(), snap, 120)[0]!.text;
|
||||
|
||||
view.dispatch(parseCommand("attention")); await refresh();
|
||||
expect(views.at(-1)).toBe("default");
|
||||
expect(title()).toBe("FEED · Viewing mike@external · you act as mike@external");
|
||||
|
||||
view.dispatch(parseCommand("whose sarah")); await refresh();
|
||||
expect(view.get()).toMatchObject({ section: "needs", attentionView: "sarah" });
|
||||
expect(views.at(-1)).toBe("sarah");
|
||||
// Viewing Sarah is not acting as Sarah.
|
||||
expect(title()).toBe("FEED · Viewing sarah@external · you act as mike@external");
|
||||
|
||||
// Leaving the Feed and coming back keeps the chosen view.
|
||||
view.dispatch(parseCommand(":topology")); view.dispatch(parseCommand("attention")); await refresh();
|
||||
expect(views.at(-1)).toBe("sarah");
|
||||
|
||||
view.dispatch(parseCommand("whose all")); await refresh();
|
||||
expect(views.at(-1)).toBe("all");
|
||||
expect(title()).toBe("FEED · Viewing everyone · you act as mike@external");
|
||||
|
||||
view.dispatch(parseCommand("whose default")); await refresh();
|
||||
expect(view.get().attentionView).toBeNull();
|
||||
expect(views.at(-1)).toBe("default");
|
||||
});
|
||||
|
||||
it("the header says when the view names no one, when no default is chosen, and is as before with no one registered", () => {
|
||||
const state = createViewState({ instanceId: "perspective" }).get();
|
||||
const lines = (r: AttentionRead) => attentionLines({ ...state, section: "needs" }, { ...emptySnapshot(), attentionRead: r }, 120).map(l => l.text);
|
||||
expect(lines(read({ ...perspective(null, "unknown"), requested: "ana" }))[0]).toBe('FEED · Viewing no registered person "ana" · you act as mike@external');
|
||||
expect(lines(read(perspective(null, "all", "none"))).slice(0, 2)).toEqual([
|
||||
"FEED · Viewing everyone · you act as the primary user",
|
||||
"No default person is chosen: rig gateway default <id> chooses one.",
|
||||
]);
|
||||
expect(lines(read())[0]).toBe("FEED · Instance / All humans");
|
||||
});
|
||||
|
||||
it("opens on the default person's Feed only when one was chosen, and only before the person has moved", () => {
|
||||
const opening = createViewState({ instanceId: "perspective" }).get();
|
||||
expect(opening.section).toBe("topology");
|
||||
expect(opensOnDefaultFeed(read(perspective("mike@external", "default", "chosen")), opening, true)).toBe(true);
|
||||
// One person with no chosen default, or several and none chosen, stays on the topology.
|
||||
expect(opensOnDefaultFeed(read(perspective("mike@external", "default", "only-person")), opening, true)).toBe(false);
|
||||
expect(opensOnDefaultFeed(read(perspective(null, "all", "none")), opening, true)).toBe(false);
|
||||
expect(opensOnDefaultFeed(read(), opening, true)).toBe(false);
|
||||
expect(opensOnDefaultFeed(null, opening, true)).toBe(false);
|
||||
// Once the person has typed or moved, the TUI doesn't move them.
|
||||
expect(opensOnDefaultFeed(read(perspective("mike@external", "default", "chosen")), opening, false)).toBe(false);
|
||||
expect(opensOnDefaultFeed(read(perspective("mike@external", "default", "chosen")), { ...opening, section: "specs" }, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("an act on someone else's ask says who acts and whose ask it is, and carries the primary user's authority", () => {
|
||||
const item = { id: "queue:sarahs", kind: "action" as const, summary: "Ship the draft?", unblocks: null, urgency: "routine", at: "2026-10-25T10:00:00Z",
|
||||
scope: "instance · project unknown", project: null, source: "/api/queue/sarahs", recipient: "sarah@external",
|
||||
answer: { verb: "approve" as const, actorSession: "sarah@external" } };
|
||||
const served = (p?: AttentionRead["perspective"]): FleetSnapshot => ({ ...emptySnapshot(), attentionRead: { ...read(p), items: [item], detail: { item, lines: [], files: [] } } });
|
||||
const state = { ...createViewState({ instanceId: "perspective" }).get(), section: "needs", attentionOpen: item.id };
|
||||
const approve = (snap: FleetSnapshot) => attentionLines(state, snap, 160).find(l => l.action?.type === "act")!;
|
||||
const viewingSarah = approve(served(perspective("sarah@external", "chosen-here")));
|
||||
expect(viewingSarah.text).toContain("Approve as mike@external on sarah@external's ask");
|
||||
expect(answerRequest(viewingSarah.action as Extract<Action, { type: "act"; act: "answer" }>)).toEqual(
|
||||
{ verb: "approve", qitemId: "sarahs", actorSession: "sarah@external", authority: "primary-user" });
|
||||
// With no one registered, the label is as before.
|
||||
expect(approve(served()).text).toContain("Approve as sarah@external (closes");
|
||||
});
|
||||
|
||||
it("a row parked on someone is resolved with reply, in words, as the primary user", () => {
|
||||
const item = { id: "queue:parked", kind: "action" as const, summary: "Layout", unblocks: "Layout", urgency: "routine", at: "2026-10-25T10:00:00Z",
|
||||
scope: "instance · project unknown", project: null, source: "/api/queue/parked", recipient: "sarah@external",
|
||||
answer: { verb: "resolve" as const, actorSession: "sarah@external" } };
|
||||
const snap: FleetSnapshot = { ...emptySnapshot(), attentionRead: { ...read(perspective("sarah@external", "chosen-here")), items: [item], detail: { item, lines: [], files: [] } } };
|
||||
const state = { ...createViewState({ instanceId: "perspective" }).get(), section: "needs", attentionOpen: item.id };
|
||||
const text = attentionLines(state, snap, 160).map(l => l.text).join("\n");
|
||||
expect(text).toContain("Resolve it in your own words: reply <your decision>, as mike@external on sarah@external's ask");
|
||||
const act = replyAct(snap.attentionRead, item.id, "Use the wide layout");
|
||||
expect(act).toEqual({ type: "act", act: "answer", qitemId: "parked", verb: "resolve", actorSession: "sarah@external", decision: "Use the wide layout" });
|
||||
expect(answerRequest(act as Extract<Action, { type: "act"; act: "answer" }>)).toMatchObject({ verb: "resolve", decision: "Use the wide layout", authority: "primary-user" });
|
||||
});
|
||||
@@ -2,10 +2,10 @@ import { expect, it } from "vitest";
|
||||
import { DaemonClient } from "../src/daemon-client.js";
|
||||
import { hydrateSnapshot } from "../src/hydrate.js";
|
||||
import { createViewState, emptySnapshot, computeExplorerRows } from "../src/state.js";
|
||||
import { attentionLines, replyAct } from "../src/attention/attention-model.js";
|
||||
import { attentionLines, replyAct, answerRequest } from "../src/attention/attention-model.js";
|
||||
import { parseCommand } from "../src/grammar.js";
|
||||
import type { AttentionRead } from "@openrig/daemon/attention";
|
||||
import type { FleetSnapshot } from "../src/types.js";
|
||||
import type { Action, FleetSnapshot } from "../src/types.js";
|
||||
|
||||
it.each([140, 80])("keeps readable summaries, passive exact source navigation and Back at width %i", async width => {
|
||||
const requests: string[] = [];
|
||||
@@ -85,10 +85,10 @@ it("answers an ask from its request view as Steering does: the served Approve, a
|
||||
wanted = parked;
|
||||
view.dispatch({ type: "back" }); view.dispatch({ type: "attention-open", id: parked.id }); await refresh();
|
||||
expect(attentionLines(view.get(), snap, 140).some(l => l.action?.type === "act")).toBe(false);
|
||||
// What the driver loop sends for that act: Steering's own write, nothing else.
|
||||
await client.missionControlAction({ verb: "approve", qitemId: "quiet-ask", actorSession: "human-founder@kernel" });
|
||||
// What the driver loop sends for that act: Steering's own write, with the primary user's authority, nothing else.
|
||||
await client.missionControlAction(answerRequest(approve!.action as Extract<Action, { type: "act"; act: "answer" }>));
|
||||
expect(requests.filter(r => r.method === "POST")).toEqual([
|
||||
{ method: "POST", path: "/api/mission-control/action", body: { verb: "approve", qitemId: "quiet-ask", actorSession: "human-founder@kernel" } },
|
||||
{ method: "POST", path: "/api/mission-control/action", body: { verb: "approve", qitemId: "quiet-ask", actorSession: "human-founder@kernel", authority: "primary-user" } },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"core/agent-startup-and-context-ingestion/SKILL.md": "347f28a9567b4b8d8669aff6946229f53144e0c139aca2cbe77e0eb93a87f1b1",
|
||||
"core/cross-host-rig-commands/SKILL.md": "79ee21fa7da143aa4c3defa8fbb78c0a87ae822c69f1a99cd1acf50a9931ce4d",
|
||||
"core/human-in-the-loop/SKILL.md": "14cf0c4a0dc89179121465d4b8d81d40406427351adcdc34f875097d1e6e9ab2",
|
||||
"core/messaging-the-human/SKILL.md": "e94095e61cec0d32d96fb8c60df008d0c3ac3bd2fa789a5eb625f46f35945456",
|
||||
"core/messaging-the-human/SKILL.md": "1febb284327b6f0abb33277f486cdad145d66f73be6b8aea776867174a5b96d4",
|
||||
"core/openrig-architect/SKILL.md": "51b65d2e878eeaf2af2f23974677d6a3272728024f3a6854bf3a646567fd3ae6",
|
||||
"core/openrig-cmux/SKILL.md": "73a86eac45d68b16a38fc0a4438713c634d86061073dac96a4d9e656ea5d34dc",
|
||||
"core/openrig-herdr/SKILL.md": "20eb1f0eb40bdd7b471531aacf2ab2a596963e8b5b8583c88c29bdb0258f38aa",
|
||||
@@ -70,7 +70,7 @@
|
||||
"forming-an-openrig-mental-model/SKILL.md": "cd7bfdcc5a87485140cfcac94bc2c703aec5c5b220a3397e139c4fd6db4339e6",
|
||||
"loading-addressable-markdown/SKILL.md": "104dc6a02ae6e2d0eff519387c6839cb09803945b9ee35ec3498af8feaaa8d1e",
|
||||
"loading-addressable-markdown/scripts/resolve-markdown.mjs": "61a6d52c6c94fb1878361d19bdfaeb4524062fc4c483f557ab2f140e516d15f9",
|
||||
"messaging-the-human/SKILL.md": "e94095e61cec0d32d96fb8c60df008d0c3ac3bd2fa789a5eb625f46f35945456",
|
||||
"messaging-the-human/SKILL.md": "1febb284327b6f0abb33277f486cdad145d66f73be6b8aea776867174a5b96d4",
|
||||
"mission-slice-sop/SKILL.md": "a01e72a526c975a86f0754af206505efbafcbbc3583bea1394c50759dc5a584f",
|
||||
"openrig-operating-model/SKILL.md": "8364530a6199f6e2df42b773c35434cb4ecade0ede48e439cd9fc03df4469896",
|
||||
"openrig-operating-model/scripts/compose.py": "f71065de92b1ef4ce3ca78a8bc0cfe07bcf54ed84bb8ec5359d3889674fe385d",
|
||||
@@ -105,7 +105,7 @@
|
||||
"core/agent-startup-and-context-ingestion/SKILL.md": "347f28a9567b4b8d8669aff6946229f53144e0c139aca2cbe77e0eb93a87f1b1",
|
||||
"core/cross-host-rig-commands/SKILL.md": "79ee21fa7da143aa4c3defa8fbb78c0a87ae822c69f1a99cd1acf50a9931ce4d",
|
||||
"core/human-in-the-loop/SKILL.md": "14cf0c4a0dc89179121465d4b8d81d40406427351adcdc34f875097d1e6e9ab2",
|
||||
"core/messaging-the-human/SKILL.md": "e94095e61cec0d32d96fb8c60df008d0c3ac3bd2fa789a5eb625f46f35945456",
|
||||
"core/messaging-the-human/SKILL.md": "1febb284327b6f0abb33277f486cdad145d66f73be6b8aea776867174a5b96d4",
|
||||
"core/openrig-architect/SKILL.md": "51b65d2e878eeaf2af2f23974677d6a3272728024f3a6854bf3a646567fd3ae6",
|
||||
"core/openrig-cmux/SKILL.md": "73a86eac45d68b16a38fc0a4438713c634d86061073dac96a4d9e656ea5d34dc",
|
||||
"core/openrig-herdr/SKILL.md": "20eb1f0eb40bdd7b471531aacf2ab2a596963e8b5b8583c88c29bdb0258f38aa",
|
||||
|
||||
@@ -4,6 +4,8 @@ description: "Use when project policy calls for a human decision or update, a hu
|
||||
metadata:
|
||||
cli_surfaces_referenced:
|
||||
- gateway human list
|
||||
- gateway participants
|
||||
- gateway default
|
||||
- gateway human show
|
||||
- gateway human mode room
|
||||
- gateway human mode decide
|
||||
@@ -32,13 +34,22 @@ Discover the registered participants and inspect the chosen human:
|
||||
|
||||
```bash
|
||||
rig gateway human list --json
|
||||
rig gateway participants --json
|
||||
rig gateway human show <entityId> --json
|
||||
```
|
||||
|
||||
`participants` lists people and shared channels (one vetted channel is one
|
||||
participant), each with its organization, groups, whether it's the default, and
|
||||
its readable policy: the organization's, each group's and its own text, each
|
||||
labelled with where it came from. Read the policy of whoever you contact and
|
||||
apply it; it's context for your judgment, not a permission system, and a name
|
||||
is never a reason to assume a policy.
|
||||
|
||||
Use the returned `address` (`<entityId>@external`), not a username, remembered
|
||||
seat, connector handle, or guessed kernel address. Where several humans exist,
|
||||
use the decision ownership in Project World. An absent or ambiguous registration
|
||||
needs a named registration correction, not a fallback address.
|
||||
seat, connector handle, or guessed kernel address. Where several people exist,
|
||||
use the decision ownership in Project World; the default person (`rig gateway
|
||||
default`) is only for needs that name nobody. An absent or ambiguous
|
||||
registration needs a named registration correction, not a fallback address.
|
||||
|
||||
Check readiness: configured, enabled, active, ready, reason, and next action.
|
||||
`indeterminate` is not ready. Follow the reported next inspection; do not enable
|
||||
|
||||
Reference in new issue
Block a user