mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(attention): the TUI shows the person at the terminal acting on a channel's row, and a channel never acts (#2068)
With a shared channel registered and no person, OpenRig records an act on the channel's ask as the person at the terminal (`human@host`). The Feed now serves that same actor on a channel's ask or a row parked on one, so the TUI's label, its success notice and the record agree. A request that names a shared channel as the actor, without the terminal's primary-user authority, is now refused for drop, hold, route, handoff and annotate, as it already was for answer, approve, deny and resolve. Nothing is recorded. With the authority, a channel claim is replaced by the primary user for every verb, as before. Agents act as their own seat, so their routing is unchanged. Left as is: if `participants.yaml` can't be read, no shared channel is known, so only asks and replies from before the file broke are exposed. The remedy is repairing the file.
This commit is contained in:
1 parent
d8eea0208f
commit
801779fbb4
5 files changed
+61
-14
No files matched your search
@@ -72,11 +72,13 @@ person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
|
||||
- **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, except on a shared channel's ask: the act is recorded as
|
||||
`human@host`, the person at the terminal, never as the channel.
|
||||
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask or a row parked on a
|
||||
channel: the TUI shows the act as `human@host`, the person at the terminal, and it's recorded that way, never as the
|
||||
channel.
|
||||
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
|
||||
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
|
||||
the primary user's authority that acts as a channel is refused (`answer_not_recorded`) and nothing changes.
|
||||
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
|
||||
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
|
||||
- **Where the authority applies:** only on a request that carries no seat identity header, which is how the TUI calls
|
||||
the daemon. Seats and the CLI send that header, so their acts stay theirs. This is the existing trusted-network
|
||||
distinction, not an authentication check: a request that leaves the header out is taken as the terminal's, as any
|
||||
@@ -136,9 +138,10 @@ the thread.
|
||||
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. On a
|
||||
shared channel's ask that actor would be the channel, so an older TUI's answer, approve, deny or resolve there is refused
|
||||
(`answer_not_recorded`) and nothing changes. Use a current TUI, or let the asking seat close it.
|
||||
A TUI older than the daemon still works: it sends the actor the daemon serves, the person asked, which is still their
|
||||
own act. On a shared channel's ask the daemon serves `human@host`. An older TUI's approve there is recorded as
|
||||
`human@host`, and its answer is refused (`answer_not_recorded`), because answering someone else's ask takes the primary
|
||||
user's authority, which only a current TUI sends.
|
||||
|
||||
**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
|
||||
|
||||
@@ -54,7 +54,7 @@ import { resolveDefaultPerson } from "../gateway/participants.js";
|
||||
|
||||
/** The primary user when several people are registered and none is the default, or when no one is registered and the
|
||||
* claimed actor is a shared channel: the generic human seat the web UI also records. */
|
||||
const UNNAMED_PRIMARY_USER = "human@host";
|
||||
export const UNNAMED_PRIMARY_USER = "human@host";
|
||||
/** Verbs that settle what a person was asked. */
|
||||
const SETTLING_VERBS = new Set<MissionControlVerb>(["approve", "deny", "resolve", "answer"]);
|
||||
|
||||
@@ -324,6 +324,9 @@ export class MissionControlWriteContract {
|
||||
);
|
||||
}
|
||||
const source = this.requireMutableQitem(input.qitemId);
|
||||
// The same actor rule as every other verb: a channel never annotates, and the terminal's authority names the primary
|
||||
// user. An annotation settles no ask.
|
||||
input = this.asActor(input, null).input;
|
||||
const snapshot = snapshotQitem(source);
|
||||
const evaluatedAt = this.now().toISOString();
|
||||
let actionEntry: ReturnType<MissionControlActionLog["record"]> | null = null;
|
||||
@@ -627,15 +630,15 @@ export class MissionControlWriteContract {
|
||||
* several people and none chosen, the generic human seat. With no one registered it's the claimed actor, as before,
|
||||
* unless that's a shared channel: then it's the generic human seat. 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. A shared channel never settles anything: its messages are
|
||||
* correspondence, so the asking seat or the person at the terminal settles a channel's ask.
|
||||
* another registered person: that's someone else's answer. A shared channel never acts on a row, for any verb: its
|
||||
* messages are correspondence, so the asking seat or the person at the terminal acts on a channel's row.
|
||||
*/
|
||||
private asActor(input: MissionControlActionInput, asked: string | null): { input: MissionControlActionInput; note: string } {
|
||||
const channel = this.queueRepo.isSharedChannel(input.actorSession);
|
||||
if (input.authority !== "primary-user") {
|
||||
if (channel && SETTLING_VERBS.has(input.verb)) {
|
||||
if (channel) {
|
||||
throw new MissionControlWriteContractError("answer_not_recorded",
|
||||
`${input.actorSession} is a shared channel, and a channel's messages never settle an ask, so nothing was recorded on qitem ${input.qitemId}. The asking seat or the person at the terminal settles it.`,
|
||||
`${input.actorSession} is a shared channel, and a channel never acts on a row: its messages are correspondence. Nothing was recorded on qitem ${input.qitemId}; the asking seat or the person at the terminal acts on it.`,
|
||||
{ qitemId: input.qitemId, channel: input.actorSession });
|
||||
}
|
||||
const someoneElse = SETTLING_VERBS.has(input.verb) ? this.queueRepo.askedSomeoneElse(input.actorSession, asked) : null;
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 { feedPerspective } from "../domain/feed-perspective.js";
|
||||
import { UNNAMED_PRIMARY_USER } from "../domain/mission-control/mission-control-write-contract.js";
|
||||
|
||||
export function attentionRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
@@ -61,6 +62,8 @@ export function attentionRoutes(): Hono {
|
||||
const deps = queue.db.prepare("SELECT summary, qitem_id FROM queue_items WHERE blocked_on = ? AND state IN ('pending','in-progress','blocked') ORDER BY ts_created DESC LIMIT 101").all(q.qitemId) as Array<{ summary: string | null; qitem_id: string }>;
|
||||
const unblocks = isHumanSeatSessionRef(q.blockedOn ?? "") ? q.summary || "The blocked task (inspect the request)" : deps.length ? deps.slice(0, 100).map(d => d.summary || "An unnamed dependent task").join("; ") + (deps.length > 100 ? "; more dependents omitted" : "") : "No dependent task recorded; inspect the request for its intended outcome.";
|
||||
const toPerson = isHumanSeatSessionRef(q.destinationSession) || asked.has(q.qitemId);
|
||||
// A shared channel never acts: on its ask or park the person at the terminal does, as the daemon records it.
|
||||
const actorFor = (address: string) => queue.isSharedChannel(address) ? UNNAMED_PRIMARY_USER : address;
|
||||
const questions = q.humanQuestions?.length ? q.humanQuestions : null;
|
||||
const unanswered = questions ? unansweredQuestions(questions, q.humanAnswers ?? {}).length : 0;
|
||||
const item: AttentionItem = { id: `queue:${q.qitemId}`, kind: "action", recipient: toPerson ? q.destinationSession : q.blockedOn,
|
||||
@@ -68,8 +71,8 @@ export function attentionRoutes(): Hono {
|
||||
// mission control's answer (a choice, their own words, or sending a complete set's recorded answers back), which
|
||||
// Approve would close without returning. They can also open the asker's terminal (Chat). A row parked on them is
|
||||
// resolved 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 && q.state === "pending" ? { answer: { verb: questions ? "answer" as const : "approve" as const, actorSession: actorFor(q.destinationSession) } } : {}),
|
||||
...(!toPerson && q.state === "blocked" && q.blockedOn && isHumanSeatSessionRef(q.blockedOn) ? { answer: { verb: "resolve" as const, actorSession: actorFor(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)}` };
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 { setDefaultPerson, setSharedChannel } from "../src/domain/gateway/participants.js";
|
||||
import { attentionRoutes } from "../src/routes/attention.js";
|
||||
import { queueRoutes } from "../src/routes/queue.js";
|
||||
import type { AttentionRead } from "../src/attention-surface.js";
|
||||
@@ -148,4 +148,18 @@ describe("the Feed shows one person's queue, or everyone's, and acts with the pr
|
||||
expect(r.perspective).toBeUndefined();
|
||||
expect(asked(r)).toEqual(["A seat's ask"]);
|
||||
});
|
||||
|
||||
it("with only a shared channel registered, the Feed serves the person at the terminal as the actor, never the channel", async () => {
|
||||
const CHANNEL = "acme-customers@external";
|
||||
expect(setSharedChannel("acme-customers", { displayName: "Acme customers", policy: "Customers of Acme.",
|
||||
channel: { connector: "slack", connectorRef: "primary", secretsRef: "vault://slack/bot", channelId: "CACME" } }, {}, home).ok).toBe(true);
|
||||
const ask = await askTo(CHANNEL, "The channel's ask");
|
||||
const work = await repo.create({ sourceSession: "orch-lead@r", destinationSession: "designer@r", body: "Plan", summary: "Parked on the channel", evidenceRef: "/fixture/plan", nudge: false });
|
||||
await repo.update({ qitemId: work.qitemId, actorSession: "designer@r", state: "blocked", blockedOn: CHANNEL } as never);
|
||||
const r = await read();
|
||||
expect(r.perspective).toBeUndefined();
|
||||
const item = (qitemId: string) => r.items.find((i) => i.id === `queue:${qitemId}`);
|
||||
expect(item(ask.qitemId)).toMatchObject({ recipient: CHANNEL, answer: { verb: "approve", actorSession: "human@host" } });
|
||||
expect(item(work.qitemId)).toMatchObject({ recipient: CHANNEL, answer: { verb: "resolve", actorSession: "human@host" } });
|
||||
});
|
||||
});
|
||||
@@ -239,6 +239,30 @@ describe("the primary user acts on anyone's ask, recorded as themselves on that
|
||||
expect(repo.getById(work.qitemId)).toMatchObject({ state: "blocked", blockedOn: CHANNEL });
|
||||
});
|
||||
|
||||
it("without the authority, a channel can't drop, hold, route or hand off a row either; with it, the person at the terminal acts", async () => {
|
||||
addChannel();
|
||||
for (const body of [{ verb: "drop", reason: "spam" }, { verb: "hold", reason: "later" }, { verb: "route", destinationSession: "support@rig" },
|
||||
{ verb: "handoff", destinationSession: "support@rig" }]) {
|
||||
const row = await ask(CHANNEL);
|
||||
expect(await act({ qitemId: row.qitemId, actorSession: CHANNEL, ...body }), body.verb)
|
||||
.toMatchObject({ status: 400, body: { error: "answer_not_recorded", channel: CHANNEL } });
|
||||
expect(repo.getById(row.qitemId)?.state, body.verb).toBe("pending");
|
||||
}
|
||||
const dropped = await ask(CHANNEL);
|
||||
expect((await act({ verb: "drop", qitemId: dropped.qitemId, actorSession: CHANNEL, authority: "primary-user", reason: "spam" })).status).toBe(200);
|
||||
expect(audit(dropped.qitemId)).toMatchObject({ verb: "drop", actor: "human@host" });
|
||||
});
|
||||
|
||||
it("annotate follows the same rule: refused as the channel without the authority, the person at the terminal with it", async () => {
|
||||
addChannel();
|
||||
const row = await ask(CHANNEL);
|
||||
expect(await act({ verb: "annotate", qitemId: row.qitemId, actorSession: CHANNEL, annotation: "The customer called" }))
|
||||
.toMatchObject({ status: 400, body: { error: "answer_not_recorded", channel: CHANNEL } });
|
||||
expect(db.prepare("SELECT count(*) AS n FROM mission_control_actions WHERE qitem_id = ?").get(row.qitemId)).toEqual({ n: 0 });
|
||||
expect((await act({ verb: "annotate", qitemId: row.qitemId, actorSession: CHANNEL, authority: "primary-user", annotation: "The customer called" })).status).toBe(200);
|
||||
expect(audit(row.qitemId)).toEqual({ verb: "annotate", actor: "human@host", notes: { authority: "primary-user", asked: null } });
|
||||
});
|
||||
|
||||
it("leaves the legacy human seat as it was when a channel is registered", async () => {
|
||||
addChannel();
|
||||
const legacy = await ask("human@host");
|
||||
|
||||
Reference in new issue
Block a user