mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-13 18:49:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12e72889af | ||
|
|
a00d0e3fe1 | ||
|
|
0a246012f6 |
No files matched your search
@@ -179,7 +179,8 @@ With `needs-me.read` supported:
|
||||
`revision` and `about`. `revision` is the request's opaque decision revision: the same value as that row's
|
||||
`decisionRevision`, and what an answer can name as `expectedRevision` so it applies only to the version the person
|
||||
read. `about` lists the requests it concerns by id: a row parked on the person is itself the request, and an ask is
|
||||
about the open rows blocked on it (at most 100; empty when none are recorded).
|
||||
about the open rows blocked on it (the newest 100, with `aboutTruncated: true` when there are more). An empty `about`
|
||||
isn't proof that it's about nothing: a request that names it only in its text isn't counted.
|
||||
- **A snapshot watermark:** the response's `watermark` is the newest event `seq` read before the snapshot. Follow
|
||||
`GET /api/events` from it with `Last-Event-ID: <watermark>`: every event after the snapshot is replayed, some possibly
|
||||
already reflected in it. A click that records or changes an answer emits `queue.answer_recorded` even while the set
|
||||
@@ -191,7 +192,37 @@ With `needs-me.read` supported:
|
||||
footer. `episode` names the park an answer belongs to, or is null. `recordedIdentity` is the identity provenance
|
||||
recorded when the answer row was created. An answer row's own read has `replyTo`, the request it answers. A partial
|
||||
set of choices stays on the request's `humanAnswers` until the whole set is returned. A runner's permission request
|
||||
or a host pairing request is answered in place, with no answer row.
|
||||
or a host pairing request is answered in place, with no answer row. `answersFrom: "this-daemon"` says where they were
|
||||
read: an answer another host holds isn't among them, so an empty list isn't proof that nobody answered.
|
||||
|
||||
### What a connection supports, and what happened to a delivery (`member-connection.v1`)
|
||||
|
||||
`GET /api/member-connection/capabilities` lists each delivery route this daemon declares, with what it supports in four
|
||||
groups: `observe` (`dialog`, `turnStart`), `control` (`submit`), `nativeIdentity` (`sessionId`, `resume`) and
|
||||
`continuity` (`managedCompaction`, `restorePacketConfirmation`, `fork`). The routes are `typed` (OpenRig types into the
|
||||
member's terminal; capabilities by runtime, with `*` for any other), `codex-queue` (Codex's own queue command), a hosted
|
||||
member's `hosted-trigger`, and a person's `gateway`. A route with `armed: false` is declared but not used: nothing is
|
||||
delivered on it until a setting arms it.
|
||||
|
||||
Each capability reads `supported`, `not-supported`, `unavailable` (the route has it, but this member can't use it now,
|
||||
such as a seat with no session recorded as current), `not-applicable` (it means nothing on this route) or `unknown`. A
|
||||
missing key means `unknown`. None of them means idle or success, and a declaration isn't readiness: whether a seat has a
|
||||
current session comes from what OpenRig has recorded, not from looking at its pane or process. A daemon without the route (HTTP 404) predates the contract: treat
|
||||
every capability as unknown and keep using what works today.
|
||||
|
||||
`?session=<address>` adds `member`: `{ member, route, runtime, capabilities, reason? }` for the member that address
|
||||
names: a person the human registry resolves (or the terminal person's own `human@host`), a hosted member, or a seat. An
|
||||
address shaped like a person's, such as `<id>@external`, is a person only when the registry resolves it. A person is
|
||||
resolved first, so when the registry can't be read, any address it could resolve to a person (`<id>@external`, or any
|
||||
`member@rig`, since a person's id can be spelled that way) comes back unknown, even if a seat or hosted member has that name. That
|
||||
answer, and an address none of them has, comes back with `route: null`, every capability `unknown`, and the reason.
|
||||
|
||||
Each typed send also records what happened to it, in the daemon's `transport.delivery_evidence` event, as
|
||||
`outcome: { fact, reporter, route }`. `fact` is `accepted` (typed and submitted, with `renderVerified: true` when
|
||||
the screen showed it), `held`, `not-delivered`, `failed-effect-unknown` or `unknown`. A prompt answer typed without Enter on a send that
|
||||
succeeded is `unknown`: whether the prompt took it isn't known; if the send failed after typing, it's
|
||||
`failed-effect-unknown`. `reporter` says who established it.
|
||||
None of them means the member read the work: picking it up is a claim on the row.
|
||||
|
||||
## Route details
|
||||
|
||||
|
||||
@@ -325,6 +325,7 @@ function withoutDaemonLinkedRows<T>(row: T): T {
|
||||
delete fields.successorsTruncated;
|
||||
delete fields.successorNote;
|
||||
delete fields.answers;
|
||||
delete fields.answersFrom;
|
||||
return fields as T;
|
||||
}
|
||||
|
||||
|
||||
@@ -1110,10 +1110,11 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
db.prepare("UPDATE queue_items SET destination_session = 'human-x@external' WHERE qitem_id = ?").run(own);
|
||||
await repo.createHumanAnswer({ sourceSession: "human-x@external", destinationSession: CALLER, body: "The person's private reasoning",
|
||||
inboundReplyTo: own, nudge: false } as never);
|
||||
expect(await (await fetch(`${daemonUrl}/api/queue/${own}`)).json()).toMatchObject({ answers: [{ by: "human-x@external" }] });
|
||||
expect(await (await fetch(`${daemonUrl}/api/queue/${own}`)).json()).toMatchObject({ answers: [{ by: "human-x@external" }], answersFrom: "this-daemon" });
|
||||
const shown = await asExternal("rig_queue_show", { qitemId: own });
|
||||
expect(shown.isError).toBe(false);
|
||||
expect(shown.structured.item).not.toHaveProperty("answers");
|
||||
expect(shown.structured.item).not.toHaveProperty("answersFrom");
|
||||
expect(shown.text).not.toContain("private reasoning");
|
||||
});
|
||||
|
||||
|
||||
@@ -28,8 +28,11 @@ export interface AttentionItem {
|
||||
/** A queue request's decision revision: the same opaque token its single-row read returns and a settling act can name. */
|
||||
revision?: string;
|
||||
/** The requests this is about, by ID: a parked row is itself the request; an ask is about the open rows blocked on it
|
||||
* (at most 100). Empty when no request is recorded as waiting on it. */
|
||||
* (at most 100). Empty when no request is recorded as waiting on it, which isn't proof that it's about nothing: a
|
||||
* request that names it only in its text isn't counted. */
|
||||
about?: string[];
|
||||
/** Present when more than 100 open rows are blocked on this ask: `about` is the newest 100, not all of them. */
|
||||
aboutTruncated?: true;
|
||||
}
|
||||
export interface AttentionDetail {
|
||||
item: AttentionItem;
|
||||
|
||||
@@ -186,3 +186,139 @@ export function readWakeOutcome(recorded: string | null | undefined): WakeOutcom
|
||||
if (recorded.startsWith("failed:")) return at("not-delivered", "terminal");
|
||||
return at("unknown", null);
|
||||
}
|
||||
|
||||
/** A delivery route as the member-connection contract names it. `typed` is OpenRig typing into the member's terminal
|
||||
* (what `WakeRoute` calls `terminal`); `codex-queue` is Codex's own queue command, a route that doesn't type. */
|
||||
export type ConnectionRoute = "typed" | "codex-queue" | "hosted-trigger" | "gateway";
|
||||
|
||||
/** What a connection says about one capability. `unavailable`: the connection has it, but it can't be used for this member
|
||||
* now (a terminal seat with no session recorded as current). `not-applicable`: it means nothing on this route (a dialog check on a webhook).
|
||||
* A client reads an absent key as `unknown`, never as `not-supported`, idle or success. */
|
||||
export type Declared = "supported" | "not-supported" | "unavailable" | "not-applicable" | "unknown";
|
||||
|
||||
/** A connection's declared capabilities, in the design's four groups. Each says what this build of OpenRig can do on the
|
||||
* route, not what the member's program could do. */
|
||||
export interface ConnectionCapabilities {
|
||||
observe: {
|
||||
/** A dialog or picker on the member's screen is read before typing, so a send doesn't answer it. */
|
||||
dialog: Declared;
|
||||
/** A turn's start is correlated to the delivery that caused it. */
|
||||
turnStart: Declared;
|
||||
};
|
||||
control: {
|
||||
/** OpenRig can submit input for the member (type and press Enter). */
|
||||
submit: Declared;
|
||||
};
|
||||
nativeIdentity: {
|
||||
/** The program's own session id is captured. */
|
||||
sessionId: Declared;
|
||||
/** A stopped member can be resumed into its own conversation. */
|
||||
resume: Declared;
|
||||
};
|
||||
continuity: {
|
||||
/** OpenRig runs the member's compaction when its context fills. */
|
||||
managedCompaction: Declared;
|
||||
/** A handover's restore packet is seen leaving the member's input box, not only typed and submitted. */
|
||||
restorePacketConfirmation: Declared;
|
||||
/** A handover can fork the member's conversation. */
|
||||
fork: Declared;
|
||||
};
|
||||
}
|
||||
|
||||
/** One route's declaration. A route that isn't armed is declared but not used: nothing is delivered on it until a
|
||||
* setting arms it. */
|
||||
export interface RouteDeclaration {
|
||||
route: ConnectionRoute;
|
||||
armed: boolean;
|
||||
/** Capabilities by runtime for the typed route (`*` is any other runtime); one set for the others. */
|
||||
capabilities: Record<string, ConnectionCapabilities>;
|
||||
}
|
||||
|
||||
const caps = (dialog: Declared, turnStart: Declared, submit: Declared, sessionId: Declared, resume: Declared,
|
||||
managedCompaction: Declared, restorePacketConfirmation: Declared, fork: Declared): ConnectionCapabilities =>
|
||||
({ observe: { dialog, turnStart }, control: { submit }, nativeIdentity: { sessionId, resume },
|
||||
continuity: { managedCompaction, restorePacketConfirmation, fork } });
|
||||
|
||||
/** What this build declares on each route. Turn starts aren't correlated to deliveries yet on any route. Managed compaction
|
||||
* and restore-packet confirmation read Claude Code's own screen, so other runtimes don't have them. Codex's queue command
|
||||
* is declared and not armed: nothing uses it until a route setting does, and what it would support is unknown until it's built. A hosted member's trigger and a person's
|
||||
* gateway carry no terminal, so the terminal capabilities don't apply to them. */
|
||||
export const ROUTE_DECLARATIONS: readonly RouteDeclaration[] = [
|
||||
{ route: "typed", armed: true, capabilities: {
|
||||
"claude-code": caps("supported", "not-supported", "supported", "supported", "supported", "supported", "supported", "supported"),
|
||||
codex: caps("supported", "not-supported", "supported", "supported", "supported", "not-supported", "not-supported", "supported"),
|
||||
"*": caps("not-supported", "not-supported", "supported", "unknown", "unknown", "not-supported", "not-supported", "unknown"),
|
||||
} },
|
||||
{ route: "codex-queue", armed: false, capabilities: {
|
||||
codex: caps("not-applicable", "unknown", "unknown", "unknown", "unknown", "unknown", "not-applicable", "unknown"),
|
||||
} },
|
||||
{ route: "hosted-trigger", armed: true, capabilities: {
|
||||
"*": caps("not-applicable", "unknown", "not-applicable", "unknown", "unknown", "not-applicable", "not-applicable", "not-applicable"),
|
||||
} },
|
||||
{ route: "gateway", armed: true, capabilities: {
|
||||
"*": caps("not-applicable", "not-applicable", "not-applicable", "not-applicable", "not-applicable", "not-applicable", "not-applicable", "not-applicable"),
|
||||
} },
|
||||
];
|
||||
|
||||
/** The capabilities this build declares for a route and runtime: the runtime's own entry, else the route's `*` entry,
|
||||
* else every capability unknown. */
|
||||
export function declaredCapabilities(route: ConnectionRoute, runtime: string | null | undefined): ConnectionCapabilities {
|
||||
const declared = ROUTE_DECLARATIONS.find((d) => d.route === route)?.capabilities;
|
||||
return (runtime ? declared?.[runtime] : undefined) ?? declared?.["*"] ?? UNKNOWN_CAPABILITIES;
|
||||
}
|
||||
|
||||
const UNKNOWN_CAPABILITIES = caps("unknown", "unknown", "unknown", "unknown", "unknown", "unknown", "unknown", "unknown");
|
||||
|
||||
/** One member's connection as the daemon resolves it: the route it's reached on and its runtime, with that route's
|
||||
* declaration. `unavailable` marks what the route has but this member can't use now. Null route: the address names no
|
||||
* member this daemon can reach, and every capability is unknown. */
|
||||
export interface MemberConnection {
|
||||
member: string;
|
||||
route: ConnectionRoute | null;
|
||||
runtime: string | null;
|
||||
capabilities: ConnectionCapabilities;
|
||||
/** Why the route is null, or why a capability is unavailable. */
|
||||
reason?: string;
|
||||
}
|
||||
// A declaration says what a route can do, never that the member is ready now: `live` below is what OpenRig has recorded
|
||||
// as the seat's current session, not an observed pane or process.
|
||||
|
||||
export function memberConnection(member: string, found: { kind: "person" } | { kind: "hosted" }
|
||||
| { kind: "seat"; runtime: string | null; /** A session recorded as current, not an observed pane. */ live: boolean }
|
||||
| { kind: "none"; reason: string }): MemberConnection {
|
||||
if (found.kind === "person") return { member, route: "gateway", runtime: null, capabilities: declaredCapabilities("gateway", null) };
|
||||
if (found.kind === "hosted") return { member, route: "hosted-trigger", runtime: null, capabilities: declaredCapabilities("hosted-trigger", null) };
|
||||
if (found.kind === "none") return { member, route: null, runtime: null, capabilities: UNKNOWN_CAPABILITIES, reason: found.reason };
|
||||
const declared = declaredCapabilities("typed", found.runtime);
|
||||
if (found.live) return { member, route: "typed", runtime: found.runtime, capabilities: declared };
|
||||
// A seat with no session recorded as current keeps its declaration; what needs its terminal can't be used until it has one.
|
||||
const live = (value: Declared): Declared => value === "supported" ? "unavailable" : value;
|
||||
return { member, route: "typed", runtime: found.runtime, reason: "it has no session recorded as current",
|
||||
capabilities: { ...declared, observe: { dialog: live(declared.observe.dialog), turnStart: declared.observe.turnStart },
|
||||
control: { submit: live(declared.control.submit) } } };
|
||||
}
|
||||
|
||||
/** What a typed send's input reached, as the transport reads it from the send's result (`writtenFact`). */
|
||||
export type WrittenFact = "submitted" | "submit_only" | "text_only" | "text_submit_unknown" | "uncertain" | "none" | "unknown";
|
||||
|
||||
/** What a typed send's own result says happened to it, in the shared words, read from what its input reached (`written`,
|
||||
* the transport's own reading) so the two can't disagree. Submitted is `accepted` (with `renderVerified` when the screen
|
||||
* showed it). Held by a guard is `held`. Text typed without Enter on a send that otherwise succeeded (a prompt answer
|
||||
* sent without one) is `unknown`: whether the prompt took it isn't known. Input that may have landed on a failed send is
|
||||
* `failed-effect-unknown`, and none written is `not-delivered`. Anything else is `unknown`. None of them means the
|
||||
* member read the work. */
|
||||
export function readSendOutcome(result: { ok: boolean; outcome?: string; verified?: boolean }, written: WrittenFact):
|
||||
ReportedFact<DeliveryFact> & { route: "typed"; renderVerified?: true } {
|
||||
const at = (fact: DeliveryFact, extra: { renderVerified?: true } = {}) => ({ fact, reporter: "openrig-observed" as const, route: "typed" as const, ...extra });
|
||||
if (result.outcome === "retained") return at("held");
|
||||
switch (written) {
|
||||
case "submitted":
|
||||
case "submit_only":
|
||||
return result.outcome === "delivered" && result.verified === true ? at("accepted", { renderVerified: true }) : at("accepted");
|
||||
case "text_only": return at(result.ok ? "unknown" : "failed-effect-unknown");
|
||||
case "uncertain":
|
||||
case "text_submit_unknown": return at("failed-effect-unknown");
|
||||
case "none": return at("not-delivered");
|
||||
default: return at("unknown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { memberConnection, type MemberConnection } from "./connection-contract.js";
|
||||
import { registeredPeople, resolveRegisteredHumanAddress } from "./gateway/human-registry.js";
|
||||
import type { QueueRepository } from "./queue-repository.js";
|
||||
import { resolveGuardTarget } from "./seat-delivery-guard.js";
|
||||
import { isHumanSeatSessionRef, parseSessionName } from "./session-name.js";
|
||||
import { resolveCurrentSessionAddress } from "./session-registry.js";
|
||||
|
||||
/** The connection an address is reached on, as this daemon resolves it: a person (the gateway), a hosted member (its
|
||||
* trigger), else the seat it addresses, read by seat rather than by the newest session row with that name, and live
|
||||
* when a session is recorded as its current one (a record, not an observed pane). A person is one the human registry
|
||||
* resolves, or the reserved terminal person's seat (`human@host`, `human-<x>@kernel|host`); an address shaped like a
|
||||
* person's (`<id>@external`) that the registry doesn't resolve isn't one. A person is resolved before a hosted member
|
||||
* or seat, as the queue does, so when the registry can't be read, any spelling it could resolve to a person (an
|
||||
* `@external` address or a canonical `member@rig`) reads unknown rather than as whatever else has that name. Nobody
|
||||
* registered yet is a readable registry with no one in it. An address none of them has reads every capability
|
||||
* unknown too, with its own reason. */
|
||||
export function resolveMemberConnection(db: Database.Database, queue: Pick<QueueRepository, "humanRegistry" | "isHostedMember"> | undefined,
|
||||
member: string): MemberConnection {
|
||||
const parsed = parseSessionName(member);
|
||||
const terminalPersonSeat = isHumanSeatSessionRef(member) && parsed.kind !== "external";
|
||||
const people = queue ? registeredPeople(queue.humanRegistry()) : null;
|
||||
if (terminalPersonSeat || (people && resolveRegisteredHumanAddress(member, people) !== null)) {
|
||||
return memberConnection(member, { kind: "person" });
|
||||
}
|
||||
if (!people && (parsed.kind === "external" || parsed.kind === "canonical")) {
|
||||
return memberConnection(member, { kind: "none", reason: "the person registry couldn't be read, so whether a person has this address is unknown" });
|
||||
}
|
||||
if (queue?.isHostedMember(member)) return memberConnection(member, { kind: "hosted" });
|
||||
const target = resolveGuardTarget(db, member);
|
||||
if (!target) return memberConnection(member, { kind: "none", reason: "no seat, hosted member or registered person has this address on this daemon" });
|
||||
const node = db.prepare("SELECT runtime FROM nodes WHERE id = ?").get(target.nodeId) as { runtime: string | null } | undefined;
|
||||
return memberConnection(member, { kind: "seat", runtime: node?.runtime ?? null,
|
||||
live: resolveCurrentSessionAddress(db, member)?.nodeId === target.nodeId });
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "./composer-prompts.js";
|
||||
export { inspectStartupStagedText } from "./startup-submission-evidence.js";
|
||||
import { OutboxHandler, OutboxHandlerError } from "./outbox-handler.js";
|
||||
import { readSendOutcome, type WrittenFact } from "./connection-contract.js";
|
||||
import { DraftAwareDelivery, type GuardedDeliverySummary, type QueueWakeFacts } from "./draft-aware-delivery.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
@@ -177,7 +178,7 @@ function enterWithheld(reason: string | undefined): boolean {
|
||||
* write that may have landed is uncertain. Text that was written reads text only when a pre-Enter check refused the
|
||||
* Enter, and otherwise text with the submission unknown, since Enter may have been attempted. A send that wrote
|
||||
* nothing says so. Anything else the result doesn't establish is unknown, never a guess. */
|
||||
export function writtenFact(result: SendResult): "submitted" | "submit_only" | "text_only" | "text_submit_unknown" | "uncertain" | "none" | "unknown" {
|
||||
export function writtenFact(result: SendResult): WrittenFact {
|
||||
if (result.ok && result.outcome !== "retained") {
|
||||
if (result.submitOnly) return "submit_only";
|
||||
return result.promptInteraction === "unverified" ? "text_only" : "submitted";
|
||||
@@ -2744,7 +2745,7 @@ export class SessionTransport {
|
||||
pane: seat.pane, occupantGeneration: seat.occupant, messageId: opts?.deliveryId ?? outboxIds[0] ?? attemptId, attemptId,
|
||||
admitted: admissionUnknown ? "unknown" : admitted.length > 0 ? { outboxIds: admitted } : "none", written: writtenFact(result),
|
||||
terminalObserved: { outcome: result.outcome ?? null, verified: result.verified ?? null }, inputBoundary: "unknown", source: "send_result",
|
||||
...wireFingerprint(wire),
|
||||
...wireFingerprint(wire), outcome: readSendOutcome(result, writtenFact(result)),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!this.evidenceFailureLogged) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { BUILD_INFO, type BuildInfo } from "../build-info.js";
|
||||
import { TEAM_OPERATIONS_CONTRACT, type Support } from "./connection-contract.js";
|
||||
|
||||
/** The team-operations contract an app reads before relying on a job's fields: one contract version with per-job
|
||||
* capabilities, distinct from the build label. A capability is supported, not-supported or unknown; an absent key reads
|
||||
* unknown. */
|
||||
export const TEAM_OPERATIONS_CONTRACT = "team-operations.v1";
|
||||
export { TEAM_OPERATIONS_CONTRACT };
|
||||
|
||||
export type CapabilityValue = "supported" | "not-supported" | "unknown";
|
||||
/** A team-operations capability: supported, not-supported or unknown; an absent key reads unknown. The contract an app
|
||||
* reads before relying on a job's fields is one version with per-job capabilities, distinct from the build label. */
|
||||
export type CapabilityValue = Support;
|
||||
|
||||
/** needs-me: see what needs me, answer as me, follow it through. This build reads it (what needs the person, each
|
||||
* request's revision and what it's about, the answers returned to it, and a snapshot watermark). Answering under this
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StartupSubmissionDiagnostic } from "./startup-submission-evidence.js";
|
||||
import type { readSendOutcome } from "./connection-contract.js";
|
||||
|
||||
export interface Rig {
|
||||
id: string;
|
||||
@@ -197,7 +198,9 @@ export type RigEvent =
|
||||
admitted: DeliveryEvidenceAdmitted;
|
||||
written: "submitted" | "submit_only" | "text_only" | "text_submit_unknown" | "uncertain" | "none" | "unknown";
|
||||
terminalObserved: DeliveryEvidenceObserved; inputBoundary: "unknown"; source: "send_result";
|
||||
wireSha256: string | null; wireBytes: number | null; writtenAt: string | null }
|
||||
wireSha256: string | null; wireBytes: number | null; writtenAt: string | null;
|
||||
/** The attempt's outcome in the shared words, from the same result: which fact, who established it, and the route. */
|
||||
outcome: ReturnType<typeof readSendOutcome> }
|
||||
/** A harness's own prompt hook, as it reported: a fingerprint and byte length of the prompt it received, its own session
|
||||
* and prompt or turn id, and the generation the hook ran as. A raw observation, never a message's attribution: neither
|
||||
* harness reports a prompt's origin, so which message (if any) it was isn't established, and each delivery record's
|
||||
|
||||
@@ -90,7 +90,8 @@ export function attentionRoutes(): Hono {
|
||||
// A pairing request is also decided directly on this host's settings page, opened on this request (its row).
|
||||
...(q.evidenceRef?.startsWith("pair-request:") ? { settingsLink: { category: "instance", focus: q.qitemId, label: "Open pairing settings (Approve or Deny directly)" } } : {}),
|
||||
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)}`,
|
||||
revision: queue.decisionRevision(q), about: toPerson ? deps.slice(0, 100).map(d => d.qitem_id) : [q.qitemId] };
|
||||
revision: queue.decisionRevision(q), about: toPerson ? deps.slice(0, 100).map(d => d.qitem_id) : [q.qitemId],
|
||||
...(toPerson && deps.length > 100 ? { aboutTruncated: true as const } : {}) };
|
||||
const unbound = noConnector(toPerson ? q.destinationSession : q.blockedOn);
|
||||
const notSent = unbound || quiet.has(q.qitemId);
|
||||
const route = unanswered
|
||||
|
||||
@@ -1558,8 +1558,10 @@ export function queueRoutes(): Hono {
|
||||
...item,
|
||||
// #2446: what a settling Mission Control act can be guarded with (expectedRevision).
|
||||
decisionRevision: repo.decisionRevision(item),
|
||||
// The answer rows returned to this row, read by their link rather than their text; empty when none came back.
|
||||
// The answer rows returned to this row, read by their link rather than their text; empty when none came back here.
|
||||
// Only this daemon's rows are read: an answer another host holds isn't in them, so empty isn't proof of none.
|
||||
answers: repo.answersForAsk(qitemId),
|
||||
answersFrom: "this-daemon",
|
||||
// A closed row's outcome (its note and reason) lives only in the transition log; omitted while the row is open.
|
||||
...(closing ? { closingTransition: {
|
||||
transitionId: closing.transitionId,
|
||||
|
||||
@@ -39,6 +39,8 @@ import type { SelfAttachService } from "./domain/self-attach-service.js";
|
||||
import { rigsRoutes } from "./routes/rigs.js";
|
||||
import { OPERATION_CONTRACT, OPERATION_KINDS } from "./domain/operation-correlation.js";
|
||||
import { teamOperationsCapabilities } from "./domain/team-operations.js";
|
||||
import { MEMBER_CONNECTION_CONTRACT, ROUTE_DECLARATIONS } from "./domain/connection-contract.js";
|
||||
import { resolveMemberConnection } from "./domain/member-connection.js";
|
||||
import { sessionsRoutes, nodesRoutes, sessionAdminRoutes } from "./routes/sessions.js";
|
||||
import { adaptersRoutes } from "./routes/adapters.js";
|
||||
import { eventsRoute } from "./routes/events.js";
|
||||
@@ -798,6 +800,13 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/rigs", rigsRoutes);
|
||||
app.get("/api/operations/capabilities", (c) => c.json({ contract: OPERATION_CONTRACT, kinds: OPERATION_KINDS }));
|
||||
app.get("/api/team-operations/capabilities", (c) => c.json(teamOperationsCapabilities()));
|
||||
// What each delivery route declares it supports, and with ?session=, the route and declaration of the member that
|
||||
// address names.
|
||||
app.get("/api/member-connection/capabilities", (c) => {
|
||||
const member = c.req.query("session")?.trim();
|
||||
return c.json({ contract: MEMBER_CONNECTION_CONTRACT, routes: ROUTE_DECLARATIONS,
|
||||
...(member ? { member: resolveMemberConnection(deps.rigRepo.db, deps.queueRepo, member) } : {}) });
|
||||
});
|
||||
app.route("/api/rigs/:rigId/sessions", sessionsRoutes);
|
||||
// Slice 24 — per-rig CMUX workspace launcher.
|
||||
app.route("/api/rigs/:rigId/cmux", rigCmuxRoutes);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// The member-connection.v1 declarations: what each delivery route says it supports, read from the running daemon's
|
||||
// route and from the contract module the consumers read.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { resolveMemberConnection } from "../src/domain/member-connection.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import type { LoadResult } from "../src/domain/gateway/human-registry.js";
|
||||
import { ROUTE_DECLARATIONS, declaredCapabilities, memberConnection, type ConnectionCapabilities } from "../src/domain/connection-contract.js";
|
||||
|
||||
const dbs: Database.Database[] = [];
|
||||
afterEach(() => {
|
||||
for (const db of dbs.splice(0)) db.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const flatten = (c: ConnectionCapabilities) => Object.values(c).flatMap((group) => Object.values(group));
|
||||
|
||||
describe("declared capabilities", () => {
|
||||
it("declares Codex's queue command as a route that isn't armed, and the typed route as armed", () => {
|
||||
expect(ROUTE_DECLARATIONS.map((d) => [d.route, d.armed])).toEqual([["typed", true], ["codex-queue", false], ["hosted-trigger", true], ["gateway", true]]);
|
||||
});
|
||||
|
||||
it("declares in the design's four groups, with what only Claude Code's screen gives limited to it", () => {
|
||||
const claude = declaredCapabilities("typed", "claude-code"), codex = declaredCapabilities("typed", "codex");
|
||||
expect(Object.keys(claude)).toEqual(["observe", "control", "nativeIdentity", "continuity"]);
|
||||
expect(claude.continuity.restorePacketConfirmation).toBe("supported");
|
||||
expect(codex.continuity.restorePacketConfirmation).toBe("not-supported");
|
||||
expect(codex.continuity.managedCompaction).toBe("not-supported");
|
||||
// No route correlates a turn start to a delivery yet.
|
||||
expect(ROUTE_DECLARATIONS.flatMap((d) => Object.values(d.capabilities)).map((c) => c.observe.turnStart)).not.toContain("supported");
|
||||
// Any other runtime: typing works; what it would need to read its screen isn't claimed.
|
||||
expect(declaredCapabilities("typed", "some-new-runtime")).toMatchObject({ control: { submit: "supported" }, observe: { dialog: "not-supported" } });
|
||||
});
|
||||
|
||||
it("a seat with no live terminal keeps its declaration, with what needs the terminal unavailable, never supported or idle", () => {
|
||||
const stopped = memberConnection("dev-impl@r", { kind: "seat", runtime: "codex", live: false });
|
||||
expect(stopped).toMatchObject({ route: "typed", runtime: "codex", reason: "it has no session recorded as current",
|
||||
capabilities: { control: { submit: "unavailable" }, observe: { dialog: "unavailable" }, nativeIdentity: { resume: "supported" } } });
|
||||
const nobody = memberConnection("ghost@r", { kind: "none", reason: "no one has it" });
|
||||
expect(nobody).toMatchObject({ route: null, reason: "no one has it" });
|
||||
expect(new Set(flatten(nobody.capabilities))).toEqual(new Set(["unknown"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/member-connection/capabilities", () => {
|
||||
function setup() {
|
||||
const db = createFullTestDb();
|
||||
dbs.push(db);
|
||||
// The running daemon's queue, with nobody registered yet: a readable registry with no one in it.
|
||||
const queueRepo = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => ({ ok: false, error: "none", noHumans: true }) });
|
||||
const app = createTestApp(db, { appDeps: { queueRepo } });
|
||||
const get = async (query = "") => {
|
||||
const res = await app.app.request(`/api/member-connection/capabilities${query}`);
|
||||
return { status: res.status, body: await res.json() as Record<string, any> };
|
||||
};
|
||||
return { ...app, get };
|
||||
}
|
||||
|
||||
it("answers every route's declaration, and the member an address names: its route, runtime and capabilities", async () => {
|
||||
const t = setup();
|
||||
const rig = t.rigRepo.createRig("team");
|
||||
const live = t.rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code" });
|
||||
t.sessionRegistry.updateStatus(t.sessionRegistry.registerSession(live.id, "dev-impl@team").id, "running");
|
||||
t.sessionRegistry.updateBinding(live.id, { tmuxSession: "dev-impl@team", tmuxPane: "%1" });
|
||||
const stopped = t.rigRepo.addNode(rig.id, "dev.qa", { runtime: "codex" });
|
||||
t.sessionRegistry.updateStatus(t.sessionRegistry.registerSession(stopped.id, "dev-qa@team").id, "exited");
|
||||
|
||||
const all = await t.get();
|
||||
expect(all.status).toBe(200);
|
||||
expect(all.body).toEqual({ contract: "member-connection.v1", routes: JSON.parse(JSON.stringify(ROUTE_DECLARATIONS)) });
|
||||
|
||||
expect((await t.get("?session=dev-impl@team")).body.member).toEqual({ member: "dev-impl@team", route: "typed", runtime: "claude-code",
|
||||
capabilities: declaredCapabilities("typed", "claude-code") });
|
||||
expect((await t.get("?session=dev-qa@team")).body.member).toMatchObject({ route: "typed", runtime: "codex",
|
||||
capabilities: { control: { submit: "unavailable" } }, reason: "it has no session recorded as current" });
|
||||
expect((await t.get("?session=human@host")).body.member).toMatchObject({ route: "gateway" });
|
||||
const unknown = (await t.get("?session=nobody@nowhere")).body.member;
|
||||
expect(unknown).toMatchObject({ route: null, runtime: null, reason: expect.stringContaining("no seat, hosted member or registered person") });
|
||||
expect(new Set(flatten(unknown.capabilities))).toEqual(new Set(["unknown"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMemberConnection: a person is one the registry resolves, never an address's spelling", () => {
|
||||
const ana = { entityId: "ana", class: "human" as const, displayName: "Ana", address: "ana@external", connectorBindings: [],
|
||||
prefs: { deliveryClass: "A" as const } };
|
||||
function resolver(registry: (() => LoadResult) | null) {
|
||||
const db = createFullTestDb();
|
||||
dbs.push(db);
|
||||
// A recorded seat whose name is also a person's canonical spelling, when `ana` is registered.
|
||||
const rigRepo = new RigRepository(db), sessions = new SessionRegistry(db);
|
||||
const seat = rigRepo.addNode(rigRepo.createRig("team").id, "ana", { runtime: "claude-code" });
|
||||
sessions.updateStatus(sessions.registerSession(seat.id, "ana@team").id, "running");
|
||||
sessions.updateBinding(seat.id, { tmuxSession: "ana@team", tmuxPane: "%1" });
|
||||
const queue = registry === null ? undefined
|
||||
: new QueueRepository(db, new EventBus(db), { loadHumanRegistry: registry, isHostedMember: (address) => address === "assistant@team" });
|
||||
return (member: string) => resolveMemberConnection(db, queue, member);
|
||||
}
|
||||
const unknown = (reason: string) => expect.objectContaining({ route: null, reason: expect.stringContaining(reason) });
|
||||
|
||||
it("an @external address nobody registered is unknown, not a person; a registered one is reached through the gateway", () => {
|
||||
const empty = resolver(() => ({ ok: true as const, entities: [] }));
|
||||
expect(empty("not-registered@external")).toEqual(unknown("no seat, hosted member or registered person"));
|
||||
const registered = resolver(() => ({ ok: true as const, entities: [ana] }));
|
||||
expect(registered("ana@external")).toMatchObject({ route: "gateway", capabilities: { control: { submit: "not-applicable" } } });
|
||||
expect(registered("bob@external")).toEqual(unknown("no seat, hosted member or registered person"));
|
||||
expect(registered("assistant@team")).toMatchObject({ route: "hosted-trigger",
|
||||
capabilities: { observe: { dialog: "not-applicable" }, control: { submit: "not-applicable" } } });
|
||||
});
|
||||
|
||||
it("with no registry to read, whether a person has the address is unknown; the reserved terminal person's seat is still one", () => {
|
||||
const unreadable = resolver(() => ({ ok: false, error: "registry unreadable" }) as LoadResult);
|
||||
expect(unreadable("ana@external")).toEqual(unknown("the person registry couldn't be read"));
|
||||
const noQueue = resolver(null);
|
||||
expect(noQueue("ana@external")).toEqual(unknown("the person registry couldn't be read"));
|
||||
expect(noQueue("human@host")).toMatchObject({ route: "gateway" });
|
||||
});
|
||||
|
||||
it("with no registry to read, a canonical spelling a person could have is unknown, never the seat or hosted member with that name", () => {
|
||||
for (const lookup of [resolver(() => ({ ok: false, error: "registry unreadable" }) as LoadResult), resolver(null)]) {
|
||||
expect(lookup("ana@team")).toEqual(unknown("the person registry couldn't be read"));
|
||||
expect(lookup("assistant@team")).toEqual(unknown("the person registry couldn't be read"));
|
||||
expect(lookup("nobody@team")).toEqual(unknown("the person registry couldn't be read"));
|
||||
}
|
||||
// Readable, the same names resolve: a person first, as the queue does, then the hosted member and the seat.
|
||||
const registered = resolver(() => ({ ok: true as const, entities: [ana] }));
|
||||
expect(registered("ana@team")).toMatchObject({ route: "gateway" });
|
||||
expect(registered("assistant@team")).toMatchObject({ route: "hosted-trigger" });
|
||||
const nobodyRegistered = resolver(() => ({ ok: false, error: "none", noHumans: true }) as LoadResult);
|
||||
expect(nobodyRegistered("ana@team")).toMatchObject({ route: "typed", runtime: "claude-code" });
|
||||
expect(nobodyRegistered("ana@external")).toEqual(unknown("no seat, hosted member or registered person"));
|
||||
});
|
||||
});
|
||||
@@ -192,6 +192,21 @@ describe("needs-me.read: the attention snapshot", () => {
|
||||
const item = (qitemId: string) => snapshot.items.find((i) => i.id === `queue:${qitemId}`);
|
||||
expect(item(id)).toMatchObject({ revision: (await f.read(id)).decisionRevision, about: [waiting.qitemId] });
|
||||
expect(item(park.qitemId)).toMatchObject({ revision: (await f.read(park.qitemId)).decisionRevision, about: [park.qitemId] });
|
||||
expect(item(id)).not.toHaveProperty("aboutTruncated");
|
||||
});
|
||||
|
||||
it("says when what it lists is partial: about past 100 dependents, and answers read from this daemon only", async () => {
|
||||
const f = fixture(); const id = await f.ask();
|
||||
const waiting: string[] = [];
|
||||
for (let i = 0; i < 101; i++) {
|
||||
waiting.push((await f.repo.create({ sourceSession: ASKER, destinationSession: "owner@test-rig", body: `Waits ${i}`, nudge: false })).qitemId);
|
||||
}
|
||||
f.db.prepare(`UPDATE queue_items SET state = 'blocked', blocked_on = ? WHERE qitem_id IN (${waiting.map(() => "?").join(",")})`).run(id, ...waiting);
|
||||
const item = (await f.attention()).items.find((i) => i.id === `queue:${id}`);
|
||||
expect(item?.about).toHaveLength(100);
|
||||
expect(item).toMatchObject({ aboutTruncated: true });
|
||||
// No answer here isn't proof of none: another host's answer isn't read.
|
||||
expect(await f.read(id)).toMatchObject({ answers: [], answersFrom: "this-daemon" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -84,6 +84,10 @@ describe("explicit prompt answer delivery", () => {
|
||||
expect(result.promptInteraction).toBe("unverified");
|
||||
expect(result.warning).toContain("submission unverified");
|
||||
expect(db.prepare("SELECT count(*) AS n FROM events WHERE type = 'transport.prompt_override'").get()).toEqual({ n: 1 });
|
||||
// Its delivery record: the answer typed with no Enter, so whether the prompt took it is unknown, never accepted.
|
||||
const recorded = (db.prepare("SELECT payload FROM events WHERE type = 'transport.delivery_evidence'").all() as Array<{ payload: string }>)
|
||||
.map((row) => JSON.parse(row.payload));
|
||||
expect(recorded).toEqual([expect.objectContaining({ written: "text_only", outcome: { fact: "unknown", reporter: "openrig-observed", route: "typed" } })]);
|
||||
});
|
||||
|
||||
it.each(["claude-code", "codex"] as const)("uses a pending permission to deliver an explicit still-staged text answer for %s", async runtime => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_c
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { classifyPaneActivity, classifyStyledFrame, draftDeliveryAllows, readDraftDeliverySetting, hasExpectedStagedText, inspectStartupStagedText, probeSessionActivity, promptTextIsFaint, SessionTransport, writtenFact, type TransportReadinessChecker } from "../src/domain/session-transport.js";
|
||||
import { readSendOutcome } from "../src/domain/connection-contract.js";
|
||||
import { SeatDeliveryGuard, resolveGuardTarget, type GuardTarget } from "../src/domain/seat-delivery-guard.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
|
||||
@@ -2785,6 +2786,38 @@ describe("SessionTransport", () => {
|
||||
expect(withEvidence.outcome).toBeUndefined();
|
||||
expect(fact).toMatchObject({ written: "submitted", terminalObserved: { outcome: null, verified: null } });
|
||||
expect(fact.messageId).toBe(fact.attemptId);
|
||||
// In the shared words: the typed route took it, as OpenRig saw. Never that the member read it.
|
||||
expect(fact.outcome).toEqual({ fact: "accepted", reporter: "openrig-observed", route: "typed" });
|
||||
});
|
||||
|
||||
it("records a typed wake to a Codex seat with its own labelled outcome, on the typed route", async () => {
|
||||
seedCanonicalRig("codex");
|
||||
await createTransport(typing(), { eventBus: new EventBus(db) }).send("dev-impl@my-rig", "hello");
|
||||
expect(evidence()).toEqual([expect.objectContaining({ sessionName: "dev-impl@my-rig",
|
||||
outcome: { fact: "accepted", reporter: "openrig-observed", route: "typed" } })]);
|
||||
});
|
||||
|
||||
it("reads each result shape's outcome in the shared words: unknown when the result doesn't establish one", () => {
|
||||
const s = "dev-impl@my-rig";
|
||||
expect([
|
||||
{ ok: true, sessionName: s },
|
||||
{ ok: true, sessionName: s, verified: true, outcome: "delivered" },
|
||||
{ ok: true, sessionName: s, verified: false, outcome: "rendered-unconfirmed" },
|
||||
{ ok: true, sessionName: s, outcome: "retained", sent: false, verified: false },
|
||||
{ ok: false, sessionName: s, outcome: "failed", inputPhase: "attempted" },
|
||||
{ ok: false, sessionName: s, sent: true, outcome: "failed", inputPhase: "written", reason: "submit_refused" },
|
||||
{ ok: false, sessionName: s, sent: false, inputPhase: "not_reached" },
|
||||
{ ok: false, sessionName: s, reason: "transport_unavailable" },
|
||||
// A prompt answer typed without Enter: it succeeded, but whether the prompt took it isn't known.
|
||||
{ ok: true, sessionName: s, sent: true, verified: false, outcome: "rendered-unconfirmed", promptInteraction: "unverified" },
|
||||
// Text written and Enter refused before it was pressed: the text may sit in the input box.
|
||||
{ ok: false, sessionName: s, sent: true, outcome: "failed", inputPhase: "written", reason: "draft_input_changed" },
|
||||
].map((result) => {
|
||||
const outcome = readSendOutcome(result, writtenFact(result as Parameters<typeof writtenFact>[0]));
|
||||
return outcome.fact === "accepted" && outcome.renderVerified ? "accepted, render verified" : outcome.fact;
|
||||
}))
|
||||
.toEqual(["accepted", "accepted, render verified", "accepted", "held", "failed-effect-unknown", "failed-effect-unknown",
|
||||
"not-delivered", "unknown", "unknown", "failed-effect-unknown"]);
|
||||
});
|
||||
|
||||
it("uses the caller's delivery id as the message, and gives each attempt its own id", async () => {
|
||||
|
||||
Reference in new issue
Block a user