diff --git a/.env.example b/.env.example index 98d68aab9..1d0b0af3c 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,13 @@ E2B_API_KEY=e2b_... # PORT=8080 # PUBLIC_BASE_URL=https://switchboard.example.com +# Linear-only or combined startup (see docs/how-to/connect-linear.md). +# Only the shared bridge token belongs in the bot process; OAuth secrets stay at the edge. +# LINEAR_BRIDGE_TOKEN= +# Optional separate edge origin, e.g. local OAuth/intake on :8080 and bot run pages on :8082. +# Defaults to PUBLIC_BASE_URL. Use HTTPS, or HTTP only on loopback. +# LINEAR_BRIDGE_URL=http://localhost:8080 + # GitHub identity for the coding/review agents — pick ONE: # (a) GitHub App (idiomatic: org-owned, no user, 1h tokens minted on demand). # Org settings -> Developer settings -> GitHub Apps -> New GitHub App; diff --git a/.gitignore b/.gitignore index fc733af00..9039e869d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ data/ workspaces/ .env .agent-env/ +.wrangler/ +.dev.vars +.dev.vars.* config/config.yaml node_modules build.json diff --git a/config/config.example.yaml b/config/config.example.yaml index 9668545b3..dde5e99d1 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -202,6 +202,8 @@ defaults: # slack:U0456DEV: # actions: [agent:run:coding, repo:write, friction:write] # repos: [acme/api] # the repos this user may use +# linear:*: +# actions: [work-items:write] # issue edits still check each person's live Linear access # access:svc:ops-bot: # an Access service token (its common_name) # actions: [runs:read, runs:write, friction:read] # channels: all # sees runs from every channel diff --git a/deploy/cloudflare-memory/runs.test.ts b/deploy/cloudflare-memory/runs.test.ts index 4fea8e926..43e3a27f6 100644 --- a/deploy/cloudflare-memory/runs.test.ts +++ b/deploy/cloudflare-memory/runs.test.ts @@ -218,6 +218,31 @@ describe("run usage", () => { }); describe("run history routes", () => { + it("persists a waiting Stop and fences late history writes and stale stops", async () => { + const key = storeKey(); + const now = Date.now(); + const question = record("question", now, { awaitingInput: true }); + const stop = { at: now + 1, mode: "hard", by: { kind: "chat", id: "linear:org:alice" } }; + await post("/runs/put", { storeKey: key, record: question }); + expect( + (await post("/runs/stop-waiting", { storeKey: key, id: "question", stop: { ...stop, at: now - 1 } })).data, + ).toEqual({ result: "conflict" }); + expect((await post("/runs/stop-waiting", { storeKey: key, id: "question", stop })).data).toEqual({ + result: "stopped", + }); + await post("/runs/put", { storeKey: key, record: question }); + const read = await post("/runs/get", { storeKey: key, id: "question" }); + expect(read.data.record).toMatchObject({ status: "stopped_hard", inputStop: stop }); + expect((read.data.record as RunRecord).awaitingInput).toBeUndefined(); + expect((await post("/runs/stop-waiting", { storeKey: key, id: "question", stop })).data).toEqual({ + result: "stopped", + }); + expect( + (await post("/runs/stop-waiting", { storeKey: key, id: "question", stop: { ...stop, by: {} } })).status, + ).toBe(400); + expect((await post("/runs/stop-waiting", { storeKey: key, id: "question", stop }, {})).status).toBe(401); + }); + it("put → get round-trips the record with events in seq order; unknown id → {record: null} 200", async () => { const key = storeKey(); const now = Date.now(); diff --git a/deploy/cloudflare-memory/worker.ts b/deploy/cloudflare-memory/worker.ts index d1d4c8fe0..aa33edada 100644 --- a/deploy/cloudflare-memory/worker.ts +++ b/deploy/cloudflare-memory/worker.ts @@ -19,6 +19,10 @@ import { type DeliverySnapshotPatch, } from "../../src/core/deliverySnapshotStore.ts"; import { + type InputStop, + isInputStop, + preserveInputStop, + stopWaitingRecord, applyRetention, clampRetentionPolicy, isRunRecord, @@ -2308,6 +2312,14 @@ export class RunHistoryDO extends DurableObject { { const now = systemClock(); const policy = proposal ? this.applyProposal(proposal, now).policy : this.policyState().policy; + const existing = this.sql + .exec( + `SELECT run_id, agent, channel_id, finished_at, bytes, event_count, summary_json FROM runs WHERE run_id = ?`, + record.id, + ) + .toArray()[0]; + const previous = existing ? parseSummary(existing) : undefined; + record = preserveInputStop(record, previous?.inputStop); const finishedAt = Math.min(record.finishedAt, now + RUN_MAX_FUTURE_MS); // The tracing stamps get the same skew clamp (docs/reference/specs/tracing.md). const stored: RunRecord = { @@ -2320,12 +2332,6 @@ export class RunHistoryDO extends DurableObject { }; const { events, ...summary } = stored; const bytes = utf8ByteLength(JSON.stringify(stored)); - const existing = this.sql - .exec<{ event_count: number; finished_at: number; bytes: number }>( - `SELECT event_count, finished_at, bytes FROM runs WHERE run_id = ?`, - record.id, - ) - .toArray()[0]; const unchanged = existing !== undefined && sameStoredVersion( @@ -2409,6 +2415,33 @@ export class RunHistoryDO extends DurableObject { } } + /** Persist cancellation before a channel closes the waiting conversation. */ + async stopWaiting(id: string, stop: InputStop): Promise<"stopped" | "conflict" | "not_found"> { + let result: "stopped" | "conflict" | "not_found" = "not_found"; + let changed: RunRecord | undefined; + this.ctx.storage.transactionSync(() => { + const row = this.sql + .exec( + `SELECT run_id, agent, channel_id, finished_at, bytes, event_count, summary_json FROM runs WHERE run_id = ?`, + id, + ) + .toArray()[0]; + if (!row || !this.isKept(row, this.policyState().policy, systemClock())) return; + const summary = parseSummary(row); + if (!summary) return; + const record = { ...summary, events: parseEventRows(this.eventRows(id, 0, Number.MAX_SAFE_INTEGER)) }; + changed = stopWaitingRecord(record, stop); + if (!changed) { + result = "conflict"; + return; + } + this.upsertInTransaction(changed); + result = "stopped"; + }); + if (changed) await sendRunFinished(this.env.SHIP_COORDINATOR, changed); + return result; + } + /** Remove a run and its events. Returns whether a run row existed. */ async delete(id: string): Promise { let deleted = false; @@ -4241,6 +4274,13 @@ async function handleRuns(pathname: string, body: unknown, env: Env): Promise).stop; + if (!isInputStop(stop)) return json({ error: "invalid input stop" }, 400); + return json({ result: await stub(parsed.value.storeKey).stopWaiting(parsed.value.id, stop) }); + } if (pathname === "/runs/get") { const parsed = parseRunTarget(body); if (!parsed.ok) return json({ error: parsed.error }, 400); @@ -4301,6 +4341,7 @@ const ROUTES = new Set([ "/schedules/record", "/schedules/latest", "/runs/put", + "/runs/stop-waiting", "/runs/get", "/runs/summary", "/runs/list", diff --git a/deploy/cloudflare/linear.local.jsonc b/deploy/cloudflare/linear.local.jsonc new file mode 100644 index 000000000..4de45ad87 --- /dev/null +++ b/deploy/cloudflare/linear.local.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "switchboard-linear-local", + "main": "linear.local.ts", + "compatibility_date": "2026-08-01", + "compatibility_flags": ["enable_request_signal"], + "workers_dev": false, + "vars": { "PUBLIC_BASE_URL": "http://localhost:8080" }, + "durable_objects": { + "bindings": [{ "name": "LINEAR_STATE", "class_name": "LinearState" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["LinearState"] }] +} diff --git a/deploy/cloudflare/linear.local.ts b/deploy/cloudflare/linear.local.ts new file mode 100644 index 000000000..2a44f5f37 --- /dev/null +++ b/deploy/cloudflare/linear.local.ts @@ -0,0 +1,10 @@ +// Local OAuth and webhook testing needs no container, Docker image or cloud account. +import { handleLinearEdge, linearRoute, type LinearEnv } from "./linear"; +export { LinearState } from "./linear"; + +export default { + fetch(request: Request, env: LinearEnv): Promise | Response { + if (!linearRoute(new URL(request.url).pathname)) return new Response("Not found", { status: 404 }); + return handleLinearEdge(request, env); + }, +}; diff --git a/deploy/cloudflare/linear.ts b/deploy/cloudflare/linear.ts new file mode 100644 index 000000000..14a50b36d --- /dev/null +++ b/deploy/cloudflare/linear.ts @@ -0,0 +1,209 @@ +import { DurableObject } from "cloudflare:workers"; +import { + LinearOAuth, + LinearTokenProvider, + LINEAR_AUTHORIZE_PATH, + LINEAR_CALLBACK_PATH, +} from "../../src/channels/linear/oauth.js"; +import { DirectLinearApi } from "../../src/channels/linear/api.js"; +import { handleLinearBridge, LINEAR_BRIDGE_PATH } from "../../src/channels/linear/bridge.js"; +import { StoredLinearStore, type LinearOAuthState } from "../../src/channels/linear/store.js"; +import { StoredLinearChildStore } from "../../src/channels/linear/children.js"; +import { SqlLinearInbox } from "../../src/channels/linear/inbox.js"; +import { LinearAcknowledgements } from "../../src/channels/linear/acknowledgement.js"; +import { revokeLinearInstallation } from "../../src/channels/linear/lifecycle.js"; +import { boundedBody, handleLinearWebhook, LINEAR_WEBHOOK_PATH } from "../../src/channels/linear/webhook.js"; +import { LINEAR_TIMING } from "../../src/core/budgets.js"; +import { systemClock } from "../../src/core/trace/clock.js"; +import type { Env } from "./worker"; + +export type LinearEnv = Pick< + Env, + | "LINEAR_STATE" + | "LINEAR_CLIENT_ID" + | "LINEAR_CLIENT_SECRET" + | "LINEAR_APPLICATION_ID" + | "LINEAR_WEBHOOK_SECRET" + | "LINEAR_ORGANIZATION_ID" + | "LINEAR_BRIDGE_TOKEN" + | "PUBLIC_BASE_URL" + | "ARTIFACTS" +>; + +/** Public OAuth and signed webhook routes never wake the bot's container. + * An installation without the optional credentials stays explicitly disabled. */ +export function linearRoute(pathname: string): boolean { + return ( + pathname === LINEAR_AUTHORIZE_PATH || + pathname === LINEAR_CALLBACK_PATH || + pathname === LINEAR_WEBHOOK_PATH || + pathname === LINEAR_BRIDGE_PATH + ); +} + +export async function handleLinearEdge(request: Request, env: LinearEnv): Promise { + if ( + !env.LINEAR_CLIENT_ID || + !env.LINEAR_CLIENT_SECRET || + !env.LINEAR_APPLICATION_ID || + !env.LINEAR_WEBHOOK_SECRET || + !env.PUBLIC_BASE_URL + ) { + return Response.json({ error: "linear_disabled" }, { status: 503, headers: { "cache-control": "no-store" } }); + } + // Buffer only bounded raw bytes before crossing the Durable Object boundary. + // An early rejection there must not leave a streaming subrequest pumping + // from the outer request after its response has already been sent. + if (request.body) { + const bytes = await boundedBody(request); + if (!bytes) return Response.json({ error: "too_large" }, { status: 413 }); + request = new Request(request, { body: bytes as Uint8Array }); + } + return env.LINEAR_STATE.get(env.LINEAR_STATE.idFromName("installation")).fetch(request, { + signal: request.signal, + }); +} + +/** One durable host for the installation's OAuth state, credentials and inbox. + * No credential route exists: tokens never leave this object's storage through + * an HTTP response. The queue is retained independently of container lifetimes. */ +export class LinearState extends DurableObject { + private readonly store: StoredLinearStore; + private readonly inbox: SqlLinearInbox; + private readonly tokens: LinearTokenProvider; + private readonly acknowledgements: LinearAcknowledgements; + + constructor(ctx: DurableObjectState, env: LinearEnv) { + super(ctx, env); + this.store = new StoredLinearStore(ctx.storage); + this.inbox = new SqlLinearInbox(ctx.storage.sql); + this.tokens = new LinearTokenProvider({ + clientId: env.LINEAR_CLIENT_ID ?? "", + clientSecret: env.LINEAR_CLIENT_SECRET ?? "", + store: this.store, + fetch: (input, init) => fetch(input, init), + clock: systemClock, + }); + this.acknowledgements = new LinearAcknowledgements({ + inbox: this.inbox, + api: (organizationId) => this.api(organizationId), + clock: systemClock, + warn: (message) => console.warn(message), + }); + } + + private async api(organizationId: string): Promise { + if (this.env.LINEAR_ORGANIZATION_ID && organizationId !== this.env.LINEAR_ORGANIZATION_ID) + throw new Error("linear_wrong_installation"); + const installation = await this.store.getInstallation(organizationId); + if (!installation) throw new Error("linear_not_installed"); + return new DirectLinearApi({ + organizationId, + appUserId: installation.appUserId, + children: new StoredLinearChildStore(this.ctx.storage), + ...(this.env.ARTIFACTS + ? { + copy: { + put: (key: string, stream: ReadableStream, type: string) => + this.env.ARTIFACTS!.put(key, stream, { httpMetadata: { contentType: type } }), + lengthPipe: (size: number) => new FixedLengthStream(size), + }, + } + : {}), + token: () => this.tokens.accessToken(organizationId), + fetch: (input, init) => fetch(input, init), + }); + } + + private async armAlarm(delay: number): Promise { + const next = systemClock() + delay; + const current = await this.ctx.storage.getAlarm(); + if (current === null || current > next) await this.ctx.storage.setAlarm(next); + } + + async fetch(request: Request): Promise { + const env = this.env; + if ( + !env.LINEAR_CLIENT_ID || + !env.LINEAR_CLIENT_SECRET || + !env.LINEAR_APPLICATION_ID || + !env.LINEAR_WEBHOOK_SECRET || + !env.PUBLIC_BASE_URL + ) { + return Response.json({ error: "linear_disabled" }, { status: 503 }); + } + try { + if ((await this.ctx.storage.getAlarm()) === null) + await this.ctx.storage.setAlarm(systemClock() + LINEAR_TIMING.oauthStateMs); + const path = new URL(request.url).pathname; + if (path === LINEAR_BRIDGE_PATH) + return await handleLinearBridge(request, { + token: env.LINEAR_BRIDGE_TOKEN, + inbox: this.inbox, + clock: systemClock, + api: (organizationId) => this.api(organizationId), + }); + if (path === LINEAR_WEBHOOK_PATH) + return await handleLinearWebhook(request, { + secret: env.LINEAR_WEBHOOK_SECRET, + applicationId: env.LINEAR_APPLICATION_ID, + organizationId: env.LINEAR_ORGANIZATION_ID, + clock: systemClock, + accept: async (event) => { + if (event.payload.type === "OAuthApp" && event.payload.action === "revoked") { + if (!(await revokeLinearInstallation(this.store, event))) return false; + await this.inbox.cancelOrganization(event.payload.organizationId, event.receivedAt); + } + const acknowledge = event.payload.type === "AgentSessionEvent" && event.payload.action === "created"; + const accepted = await this.inbox.accept(event, { acknowledge }); + if (acknowledge) { + // The alarm is durable before HTTP 200; network work runs outside + // the response lifetime and never waits for the bot container. + await this.armAlarm(LINEAR_TIMING.progressMs); + this.ctx.waitUntil( + this.acknowledgements.flush().catch(() => { + console.warn("[linear] acknowledgement sweep unavailable; alarm will retry"); + }), + ); + } + return accepted; + }, + }); + if (path === LINEAR_AUTHORIZE_PATH) { + await this.pruneStates(); + const pending = await this.ctx.storage.list({ prefix: "oauth:", limit: 256 }); + if (pending.size >= 256) return Response.json({ error: "too_many_pending_installations" }, { status: 429 }); + } + const oauth = new LinearOAuth({ + clientId: env.LINEAR_CLIENT_ID, + clientSecret: env.LINEAR_CLIENT_SECRET, + baseUrl: env.PUBLIC_BASE_URL, + organizationId: env.LINEAR_ORGANIZATION_ID, + store: this.store, + fetch: (input, init) => fetch(input, init), + clock: systemClock, + }); + return await oauth.handle(request); + } catch { + return Response.json({ error: "linear_unavailable" }, { status: 503 }); + } + } + + private async pruneStates(): Promise { + const now = systemClock(); + const states = await this.ctx.storage.list({ prefix: "oauth:" }); + const expired = [...states].filter(([, value]) => value.expiresAt <= now).map(([key]) => key); + for (let offset = 0; offset < expired.length; offset += 128) + await this.ctx.storage.delete(expired.slice(offset, offset + 128)); + } + + async alarm(): Promise { + try { + await this.acknowledgements.flush(); + await this.pruneStates(); + await this.inbox.prune(systemClock() - LINEAR_TIMING.deliveryRetentionMs); + } finally { + await this.armAlarm((await this.inbox.hasPendingAcks()) ? LINEAR_TIMING.progressMs : LINEAR_TIMING.oauthStateMs); + } + } +} diff --git a/deploy/cloudflare/tsconfig.json b/deploy/cloudflare/tsconfig.json index 319d1d1ec..5758c7a60 100644 --- a/deploy/cloudflare/tsconfig.json +++ b/deploy/cloudflare/tsconfig.json @@ -14,5 +14,5 @@ "skipLibCheck": true, "allowImportingTsExtensions": true }, - "include": ["worker.ts"] + "include": ["worker.ts", "linear.local.ts"] } diff --git a/deploy/cloudflare/worker.ts b/deploy/cloudflare/worker.ts index bb8f31e8c..d99720e75 100644 --- a/deploy/cloudflare/worker.ts +++ b/deploy/cloudflare/worker.ts @@ -53,6 +53,8 @@ import { COPY_PATH, handleArtifactsCopy } from "./artifactsCopy.ts"; import { withKnownLength } from "./knownLength.ts"; import type { ShipCoordinatorParams } from "./coordinator"; import { INSTANCE, INTERNAL } from "./shared"; +import { handleLinearEdge, linearRoute, type LinearState } from "./linear"; +export { LinearState } from "./linear"; /** The ship coordinator's Workflow entrypoint is declared in coordinator.ts; * the Workflows binding resolves its `class_name` against this module @@ -67,6 +69,14 @@ const tracer = createTracer({ clock: systemClock }); const traceSinks = [workerLogSink((line) => console.log(line))]; export interface Env { + LINEAR_STATE: DurableObjectNamespace; + // Linear credentials stay on the edge: none are forwarded to the container. + LINEAR_CLIENT_ID?: string; + LINEAR_CLIENT_SECRET?: string; + LINEAR_APPLICATION_ID?: string; + LINEAR_WEBHOOK_SECRET?: string; + LINEAR_ORGANIZATION_ID?: string; + LINEAR_BRIDGE_TOKEN?: string; SWITCHBOARD: DurableObjectNamespace; /** The ship coordinator (coordinator.ts): `POST /admin/coordinator/instances` * creates its instances; the state Worker's finish sends them `run-finished-`. */ @@ -113,6 +123,7 @@ export interface Env { /** Every secret/var the Worker forwards into the container. Optional entries * are forwarded only when set, so the bot sees "not configured" as absence. */ const FORWARDED_OPTIONAL = [ + "LINEAR_BRIDGE_TOKEN", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "E2B_API_KEY", @@ -483,6 +494,7 @@ const withLength = (res: Response): Response => withKnownLength(res, (size) => n export default { async fetch(request: Request, env: Env): Promise { const pathname = new URL(request.url).pathname; + if (linearRoute(pathname)) return handleLinearEdge(request, env); // The public edge (docs/reference/specs/tracing.md item 22): whatever trace context the // caller sent is stripped, and what the container sees carries this // Worker's own root. A static asset or the live view's SSE stream gets no diff --git a/deploy/cloudflare/wrangler.template.jsonc b/deploy/cloudflare/wrangler.template.jsonc index 233e04ffe..a9ef590be 100644 --- a/deploy/cloudflare/wrangler.template.jsonc +++ b/deploy/cloudflare/wrangler.template.jsonc @@ -3,6 +3,7 @@ "name": "{{script}}", "main": "worker.ts", "compatibility_date": "2026-08-01", + "compatibility_flags": ["enable_request_signal"], // The installation's Cloudflare account (deploy/profile.json `account`) — every Worker deploys to it. "account_id": "{{account}}", // Custom domain on the installation's zone (profile `zone`; the state, @@ -56,7 +57,10 @@ } ], "durable_objects": { - "bindings": [{ "name": "SWITCHBOARD", "class_name": "SwitchboardServer" }] + "bindings": [ + { "name": "SWITCHBOARD", "class_name": "SwitchboardServer" }, + { "name": "LINEAR_STATE", "class_name": "LinearState" } + ] }, // The artifacts bucket (docs/reference/specs/execution.md item 20, record // 0033): where a run's files live by reference. Rendered only when the @@ -69,7 +73,10 @@ // {{#if artifacts}} "r2_buckets": [{ "binding": "ARTIFACTS", "bucket_name": "{{artifacts.bucket}}" }], // {{/if}} - "migrations": [{ "tag": "v1", "new_sqlite_classes": ["SwitchboardServer"] }], + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["SwitchboardServer"] }, + { "tag": "v2", "new_sqlite_classes": ["LinearState"] } + ], // The ship coordinator as a Workflow (coordinator.ts, re-exported by // worker.ts; docs/reference/specs/http-ingress.md item 9): `POST // /admin/coordinator/instances` creates its instances and the state Worker's diff --git a/deploy/secrets.manifest.json b/deploy/secrets.manifest.json index 29fac867b..c915c5d8e 100644 --- a/deploy/secrets.manifest.json +++ b/deploy/secrets.manifest.json @@ -1,6 +1,12 @@ { - "$comment": "Every Cloudflare Worker secret Switchboard provisions: its name, which Worker(s) hold it, and whether a Worker may go without it (`optional: true` everywhere, or a list of the Workers it is optional on — a shared bearer is required on the Worker that serves the feature and optional on the bot until that feature is configured). This file is the contract — src/core/secretsManifest.test.ts keeps it equal to each worker.ts `Env` interface and to the bot container's forwarding list. WHERE the values live is the deployment profile's `secretsSource` (a directory of files, `~/.secrets/switchboard` by default, or a 1Password item `op://Vault/Item` with one field per name); `deploy secrets ` (`npm run secrets` in each deploy/cloudflare*/) puts them, refusing before any upload when a required value is absent. A shared bearer must carry ONE value on every Worker listed for it. Rotation: new value at the source, `deploy secrets` on EVERY listed Worker, then `deploy restart` for the bot (its running container keeps the env it started with). Public config (URLs, ids of no consequence) is a wrangler.jsonc var, not a secret.", + "$comment": "Every Cloudflare Worker secret Switchboard provisions: its name, which Worker(s) hold it, and whether it reaches the bot container (`forwardToContainer: false` keeps a credential at the edge), and whether a Worker may go without it (`optional: true` everywhere, or a list of the Workers it is optional on — a shared bearer is required on the Worker that serves the feature and optional on the bot until that feature is configured). This file is the contract — src/core/secretsManifest.test.ts keeps it equal to each worker.ts `Env` interface and to the bot container's forwarding list. WHERE the values live is the deployment profile's `secretsSource` (a directory of files, `~/.secrets/switchboard` by default, or a 1Password item `op://Vault/Item` with one field per name); `deploy secrets ` (`npm run secrets` in each deploy/cloudflare*/) puts them, refusing before any upload when a required value is absent. A shared bearer must carry ONE value on every Worker listed for it. Rotation: new value at the source, `deploy secrets` on EVERY listed Worker, then `deploy restart` for the bot (its running container keeps the env it started with). Public config (URLs, ids of no consequence) is a wrangler.jsonc var, not a secret.", "secrets": [ + { + "name": "LINEAR_BRIDGE_TOKEN", + "workers": ["bot"], + "optional": true, + "note": "Internal bearer shared by the bot and its edge Linear bridge. The bot uses fixed session and delivery operations; Linear OAuth credentials stay on the edge. Absent disables the bridge." + }, { "name": "SLACK_BOT_TOKEN", "workers": ["bot"], @@ -157,6 +163,41 @@ "workers": ["bot", "resident"], "optional": ["bot", "resident"], "note": "The App's PEM. Second credential domain: the resident holds its own copy; rotate both together (GitHub → generate new key → put on both → revoke old). Optional on both, with GITHUB_APP_ID." + }, + { + "name": "LINEAR_CLIENT_ID", + "workers": ["bot"], + "optional": true, + "forwardToContainer": false, + "note": "Public OAuth client ID for the Linear app; edge only." + }, + { + "name": "LINEAR_CLIENT_SECRET", + "workers": ["bot"], + "optional": true, + "forwardToContainer": false, + "note": "OAuth client secret; used only by the edge for installation and refresh." + }, + { + "name": "LINEAR_APPLICATION_ID", + "workers": ["bot"], + "optional": true, + "forwardToContainer": false, + "note": "Linear application UUID, distinct from the public OAuth client ID; verifies webhook ownership." + }, + { + "name": "LINEAR_WEBHOOK_SECRET", + "workers": ["bot"], + "optional": true, + "forwardToContainer": false, + "note": "Linear webhook HMAC signing secret; edge verification before durable intake." + }, + { + "name": "LINEAR_ORGANIZATION_ID", + "workers": ["bot"], + "optional": true, + "forwardToContainer": false, + "note": "Optional Linear workspace UUID allowlist for this deployment." } ] } diff --git a/docs/decisions/0061-linear-child-sessions-reconcile-a-durable-creation-intent.md b/docs/decisions/0061-linear-child-sessions-reconcile-a-durable-creation-intent.md new file mode 100644 index 000000000..3d26f9234 --- /dev/null +++ b/docs/decisions/0061-linear-child-sessions-reconcile-a-durable-creation-intent.md @@ -0,0 +1,47 @@ +--- +title: Linear child sessions reconcile a durable creation intent +status: implemented +date: 2026-09-17 +pattern: Durable intent and reconciliation before retrying an external effect +--- + +# Linear child sessions reconcile a durable creation intent + +The shared dispatcher opens a separate channel conversation for child work. +Linear can create a native agent session on a root comment, but its public +session-creation input has no caller-supplied id. Repeating that mutation after +a lost response could create another session. Its creation webhook could also +start the same child again if treated as a new human request. + +The adapter records an intent before writing the comment. The intent binds a +comment UUID to the installation, parent session, human requester, issue and +lead. The comment mutation uses that UUID. A durable atomic claim permits only +one session-creation attempt; retries read the comment's associated session. +An uncertain result stays explicit until that observation resolves it. There +is no automatic second session-creation attempt. + +A coordinator supplies a stable channel idempotency key for its thread-opening +step. Linear derives the comment UUID from that key, requester and parent; +ordinary child opens use a fresh UUID. Reusing a key with a different lead or +identity fails. Other adapters may ignore this optional channel capability. + +Session reads recognize a managed child through the durable intent and the +app-owned comment in the current installation and issue. Intake consumes that +session's creation notification without dispatching it. The original caller +alone starts the child through the shared dispatcher; later human prompts and +Stop use normal intake, fresh access checks and authorization. + +This costs one durable record per child conversation and an observation query +when creation is retried. A crash before an unkeyed caller receives the child +can leave a visible conversation without a run; it must not be silently +reexecuted. Coordinators can reconcile their keyed steps after a restart. +Record pruning must preserve live/retryable conversations and is future work. + +Rejected: treating app-created webhooks as commands under the app's authority; +that loses the human requester and creates a second orchestrator. Also rejected: +blindly repeating session creation, or trusting a marker in comment text as +proof that a session belongs to an internal child. + +Sources: [native sessions](https://linear.app/developers/agent-interaction) and +the public [GraphQL schema](https://raw.githubusercontent.com/linear/linear/refs/heads/master/packages/sdk/src/schema.graphql), +including `CommentCreateInput`, `AgentSessionCreateOnComment` and `Comment.agentSession`. diff --git a/docs/explanation/design-decisions.md b/docs/explanation/design-decisions.md index 9ca2ef9df..e74b4c981 100644 --- a/docs/explanation/design-decisions.md +++ b/docs/explanation/design-decisions.md @@ -70,6 +70,7 @@ Statuses: **proposed** (written, not yet agreed), **accepted** (agreed, being bu | 0058 | [A thread reply is read before it is answered; a cheap gate ahead of the door decides whether the bot was addressed, and that verdict survives a restart](../decisions/0058-a-thread-reply-is-read-before-it-is-answered-intake-decides-whether-the-bot-was-addressed.md) | A two-tier gate (a cheap decision ahead of the expensive door, the shouldReply pattern) with silence as a legal outcome; the verdict claimed once per process and written first-writer-wins beside the run ledger so a replay reads the decision instead of re-deciding it; the mention as the always-on override | accepted | 2026-09-18 | | 0059 | [A deploy drains the resident fleet — admission closes to new runs, the runs in flight end, the swap lands, admission reopens](../decisions/0059-a-deploy-drains-the-resident-fleet.md) | Drain-then-swap, the bot's own SIGTERM drain moved one level up to the fleet; one record with an end in the registry every attach already passes; the run in flight is told from the new one by the registration it already holds; the deployer waits on the count it already reads; every wait names its end | proposed | 2026-09-18 | | 0060 | [A ship pipeline is a live run for its whole life, so every channel that can open a thread runs it and every surface is a projection of one record](../decisions/0060-a-ship-pipeline-is-a-live-run-for-its-whole-life-and-runs-on-every-channel-that-can-open-a-thread.md) | One store, many projections (the run record is the truth; Slack's card and the web's turn render it); a hosted run with no process, kept alive by the heartbeat every run has and claimed under a key of its own so it occupies no thread; capability over name at the channel seam | proposed | 2026-09-18 | +| 0061 | [Linear child sessions reconcile a durable creation intent](../decisions/0061-linear-child-sessions-reconcile-a-durable-creation-intent.md) | Durable intent and reconciliation before retrying an external effect | implemented | 2026-09-17 | diff --git a/docs/how-to/README.md b/docs/how-to/README.md index e771f3dd9..ee0970247 100644 --- a/docs/how-to/README.md +++ b/docs/how-to/README.md @@ -12,6 +12,7 @@ - [Configure your defaults](configure-your-defaults.md): agent, model and effort, per you or per channel. - [Connect an MCP server](connect-an-mcp-server.md): external tools without a token in chat. +- [Connect Linear](connect-linear.md): register the app and configure OAuth and webhook intake; channel delivery is in development. - [Onboard a repo](onboard-a-repo.md): an always-warm environment for one repository. - [Watch a run](watch-a-run.md): live runs, stopping one, reading history. - [Check spend](check-spend.md): cost per day and group, and the JSON twin. diff --git a/docs/how-to/connect-linear.md b/docs/how-to/connect-linear.md new file mode 100644 index 000000000..9d066e800 --- /dev/null +++ b/docs/how-to/connect-linear.md @@ -0,0 +1,208 @@ +# Connect Linear + +The Linear integration is being built in stages. OAuth, durable webhook intake, +native conversations, edge acknowledgements, dispatcher consumption, outbound +files, inline incoming files, issue tools and lifecycle cancellation are wired. +Workspace file staging is wired; live deployment verification remains in progress. Do not install the app for end users until the completed channel +is deployed. See the [delivery plan](../plans/2026-09-17-001-linear-channel.md). + +Child work can open a separate native session on a new comment on the same issue. +It retains the requesting person's permissions and leaves the issue's assignee +and delegate unchanged. Coordinator thread creation uses durable keys to recover +the same conversation after retries. The creation webhook does not start a +second child run; subsequent human replies and Stop remain native session events. +Conductors report child questions as waiting for input and point to the child's +session for the answer. Coding/review coordinators also wait when a child asks a +question, withholding earlier PR or review results. The original requester's +answer resumes that unit under fresh permission checks, retaining its branch or +PR and remaining time budget. Waiting for an answer counts toward that budget; +an unanswered question ends the unit when its deadline is reached, leaving an +existing pull request awaiting review. The coordinator follows the resumed run and counts +the question turns toward its total cost. Waiting-session cancellation persists before native completion and survives +restarts and late history writes. Live acceptance still needs verification before +the integration is ready for end users. + +Project and document sessions can use their current comment origin for context. +Project access follows its teams; document access follows its project, issue or +team owner, checked against the requesting human on every access check. Their +configuration scopes are `linear::project:` and +`linear::document:`. Unknown origins receive an explicit error. +Native child sessions currently require an issue origin. Live project/document +mention and permission-removal acceptance remains unverified. + +## Register the application + +In Linear's API settings, create a private OAuth application named after your +Switchboard installation. Set its GitHub username to the bot account that +authors its pull requests. Register these redirect URIs, using the bot's +public hostname for the first: + +```text +https:///oauth/linear/callback +http://localhost:8080/oauth/linear/callback +``` + +Enable webhooks at `https:///webhooks/linear`. Subscribe to Agent +session events, Inbox notifications, Permission changes and OAuth +authorization events. Public distribution and client-credentials grants are +not required. The application UUID in the settings URL is different from its +OAuth Client ID; keep both. + +## Configure the Worker + +Store these values through the deployment's secret source and the existing +`deploy secrets bot` command: + +| Variable | Value | +|---|---| +| `LINEAR_CLIENT_ID` | OAuth Client ID from the application settings | +| `LINEAR_CLIENT_SECRET` | OAuth client secret | +| `LINEAR_APPLICATION_ID` | Application UUID from the settings URL | +| `LINEAR_WEBHOOK_SECRET` | Webhook signing secret | +| `LINEAR_ORGANIZATION_ID` | Optional workspace UUID to restrict installation and intake to one workspace | +| `LINEAR_BRIDGE_TOKEN` | Random internal bearer shared by the bot container and its edge bridge | + +`PUBLIC_BASE_URL` already comes from the deployment profile. OAuth derives +the callback from that trusted origin, so a caller cannot select a different +callback through query parameters or a forged Host header. Credentials live +in the Worker's durable storage and never appear in callback responses. +Only the bridge bearer reaches the bot container; it permits fixed delivery +and session operations, with current app ownership checked on every request. +The bridge is disabled without that bearer. + +In the bot process, `LINEAR_BRIDGE_URL` optionally selects a separate edge origin; +it defaults to `PUBLIC_BASE_URL`. Run-page links continue to use the bot's +`PUBLIC_BASE_URL`. The bridge accepts HTTPS origins and HTTP loopback origins, +without embedded credentials, a path, query or fragment. + +The bot starts its consumer when `LINEAR_BRIDGE_TOKEN` is configured. Slack +credentials are optional for a Linear-only process; if either Slack token is +present, both are required and the Slack channel starts too. It requires +the durable run-history Worker and run ledger, so a container replacement can +rebuild the conversation and reconcile admitted work. Run registration waits for the durable delivery binding before execution. A Stop +received during admission prevents that delivery from starting, and an unavailable +binding stops the local run instead of allowing uncertain ownership. +It stops intake during +drain; pending deliveries survive in the edge inbox. The edge acknowledges new sessions +with a native thought before releasing them to the consumer. An alarm retries +failed acknowledgements under the same activity id, so container startup does +not delay the first response. If a request entered the +dispatcher but its durable admission cannot be proven, Switchboard reports the +interruption instead of repeating a possibly completed command. + +Linear people have actor ids `linear::` and use the +same open-chat baseline as Slack. Grant restricted agents and repositories +through those ids or `linear:*`; do not copy a Slack administrator's privileges +based on a matching display name. Linear team channel ids are +`linear::`, and session thread ids are +`linear::`. + +The `work_item_get` and `work_items_delegated` tools read issues visible to the +requesting person. The queue uses Linear's delegate field, preserving the +human assignee. Issue edits, subissues and comments require `work-items:write`; +grant it to selected people or `linear:*` through the normal `grants` block. +Every operation refreshes the person's team membership and public-team access. +Even an administrator's Switchboard grant does not bypass Linear's private-team +boundary. Subissue creation does not automatically delegate another run. + +Before a command, queued prompt or restored run starts, Switchboard checks the +requester's current access to the session's issue, project or document origin. Removed membership or an +inactive account prevents execution. Temporary lookup failures keep queued and +restored work available for retry. Unknown origins receive an explicit unsupported +response; session context references cannot substitute for origin visibility. + +When a session already has an active run, its original requester can steer it. +Another person’s prompt waits in the durable queue and starts a new turn under +that person’s grants after the active run finishes. Stop bypasses waiting prompts. +If a run fails with an unread follow-up and its next turn cannot check access, +Switchboard reports that the follow-up has not started and asks you to resend it. + +An agent that needs missing information can call `request_input`. Switchboard +posts the question as a native elicitation, leaving the Linear session waiting +for input. Reply in that session to continue. The completed turn records that +it is awaiting input and preserves unfinished checklist items; it does not +publish an automatic PR or review verdict. Questions survive a bot restart. + +Native Stop lets a person cancel their own active work through the shared +`runs:stop` policy. Stopping another person's work requires `runs:write` and +visibility of that run. Cancellation also covers your earlier requests still waiting to enter dispatch, +including requests loading attachments. The durable queue invalidates their +leases so a late file response cannot start them. An operator's channel grant +can cancel other people's queued requests; merely using a session does not grant +that authority. If a begun request is deferred without executing, a Stop received +in the meantime prevents it from returning to the queue. This cancellation survives +a restart. Begun requests otherwise continue through active-run cancellation. Stop can also +end your current waiting question; that cancellation is durable, so a restart does not +resume the coordinator question. A denied +or stale Stop leaves the session unchanged. A Linear session does not establish team-wide membership; +configure an operator's channel grants explicitly. Revocation and access removal are +infrastructure cancellations and require no new grant from the former requester. + +Private images, PDFs and text/code files linked in the issue description, +issue comments or native prompts can reach the model. The edge verifies the +requester's current team access and finds the link in current session context +before downloading from Linear's private storage. OAuth credentials remain at +the edge, and signed URL query strings are removed before download. + +Each prompt can carry up to 10 files; restored history allows 20 distinct files, +newest first. Images are capped at 5 MiB each, documents at 10 MiB, with a 12 MiB +combined budget per prompt or history load. Repeated history links carry bytes +only on the newest user turn. Credential-shaped filenames, unsupported formats, +missing files and files over those limits are named as unread in the prompt. +With an artifact store configured on the bot and its bucket bound at the edge, +large files and binary formats are staged into the agent's `attachments/` +directory: up to 10 files per prompt, 1 GiB per file, within +`artifacts.inbound.maxBytesPerMessage` (2 GiB by default). Files without a known +size, credential-shaped names and files over budget are reported as unread. +The edge rechecks access and session context at copy time and streams directly +into storage; the executor receives only a temporary artifact download URL. +Agents without a workspace report that they cannot stage the file. Temporary +failures downloading a new prompt's inline files retry before dispatch starts. +Hard Stop aborts an admitted run's file copy and workspace pull before another +model turn. The edge requires the `enable_request_signal` compatibility flag +and forwards cancellation to its Durable Object. Wrangler's local development +proxy currently drops client disconnects: the bot stops, but an edge copy can +finish storing an unused file. Local transfer cancellation remains under test. + +Coding runs can return files through `attach_file`. With an artifact store, +the executor streams the file to a private Linear upload using a short-lived +signed ticket; the bot never holds its bytes or gives the executor a Linear +token. Without an artifact store, the existing bounded byte-upload path is +available. Images render inline in native progress and other files appear as +links; the run's final answer still determines completion. + +The edge's `LINEAR_STATE` binding is independent of the bot container. Deploy +the Worker migration before using the OAuth endpoints. Once the full channel +is deployed, a workspace admin opens `/oauth/linear/authorize` +to install. The grant requests app identity, read/write access, mentions and +delegation. Local OAuth must start on the local host too, so the browser-bound +cookie returns to the same origin; merely registering a localhost callback +does not forward production webhooks to a laptop. + +## Test the edge locally + +From a checkout, put the Linear variables in the gitignored +`deploy/cloudflare/.dev.vars` file, then run: + +```bash +npm run dev -w deploy/cloudflare -- --config linear.local.jsonc --local --port 8080 +``` + +This runs only OAuth and webhook intake in the local Workers runtime, with +durable local storage. It needs neither a Docker build nor a Cloudflare +account. Open `http://localhost:8080/oauth/linear/authorize` to test the local +callback. Live webhook testing also requires a reachable development endpoint; +the production app's webhook URL still points at production. + +For a separate local bot on port 8082, configure its environment with +`PORT=8082`, `PUBLIC_BASE_URL=http://localhost:8082` and +`LINEAR_BRIDGE_URL=http://localhost:8080`. Keep the edge's `PUBLIC_BASE_URL` +on port 8080 so the registered OAuth callback stays correct. Both processes +need the same `LINEAR_BRIDGE_TOKEN`; the bot also needs its model provider and +durable history/ledger configuration. Start the bot with `npm run dev`. + +Use a development tunnel for `/webhooks/linear` and set the app's webhook URL +to that public HTTPS endpoint. Do not expose the Workers development inspector. +Local run-page links open on the machine running the bot; other workspace +members need a reachable HTTPS run-page origin. Verify the links in a real +Linear session along with a mention, delegation, follow-up, clarification and Stop. diff --git a/docs/plans/2026-09-17-001-linear-channel.md b/docs/plans/2026-09-17-001-linear-channel.md new file mode 100644 index 000000000..9118d98d3 --- /dev/null +++ b/docs/plans/2026-09-17-001-linear-channel.md @@ -0,0 +1,73 @@ +--- +title: Native Linear channel +type: feat +date: 2026-09-17 +status: proposed +--- + +# Native Linear channel + +## Outcome + +An installed Switchboard app accepts delegated issues and mentions in Linear, +runs the existing dispatcher with the requesting person's authority, and keeps +the conversation, progress, clarification, stop controls and deliverables in +Linear. Slack and Linear use the same agents, providers, executors and policy. +Registration alone is not completion. + +## Delivery sequence + +1. OAuth installation with browser-bound, expiring, single-use state and PKCE; + durable per-workspace credentials; refresh and revocation. Register the + production callback `/oauth/linear/callback` and the same path on + `http://localhost:8080` for local development. +2. Signed `/webhooks/linear` intake at the Worker, before container startup. + Commit events before acknowledgement, deduplicate deliveries, acknowledge + sessions within ten seconds and retain failed deliveries for retry. +3. A Linear `ChannelIO`: session-scoped history and namespaced identity, + dispatch, status activities, final responses, errors, questions and stops. + Keep the requesting user distinct from the OAuth app's API identity. +4. Channel-aware startup, directory composition and restart recovery. A Linear + session remains addressable after a container roll. Follow-ups use the + durable inbox; unrelated sessions on an issue do not share a conversation. +5. Issue actions, delegated-work listing, repository selection, attachments, + subissues and linked pull requests. Preserve the human assignee. A finished + session is distinct from an issue being Done; a PR awaiting review is not + completed implementation. Existing command confirmations remain authorized + and bound to their requesting person when rendered in Linear. +6. Deploy and install in the target workspace, then collect live receipts for + each requirement below. Keep the goal open until those receipts exist. + +## Acceptance ledger + +Every row requires live proof before this integration is complete. Implementation +progress and unit proofs are recorded in `docs/reference/specs/linear-channel.md`. + +| Requirement | Required evidence | +|---|---| +| OAuth and local callback | Successful production and localhost installation; mismatched, expired and replayed state rejected; credentials absent from URLs/logs/browser responses | +| Delegation and mentions | A delegated issue and an issue comment mention each produce one authorized run and a response in the correct Linear session | +| Context and routing | Description, instructions, relevant history and attached files reach the selected agent; configured repository and model layers still apply | +| Progress and deliverables | Timely acknowledgement, progress, PR and run-page links, and final outcome visible inside Linear | +| Follow-up and clarification | Mid-run prompt reaches the existing run; answer to a question resumes the conversation; post-completion prompt starts the next turn | +| Stop and removal | Linear Stop aborts work; removing delegation or team access prevents further unauthorized work | +| Issue actions and queue | Read/update issues, create subissues, return files, and list issues delegated to the app without confusing delegate with assignee | +| Recovery | Duplicate webhook delivery causes no duplicate task; a container roll preserves prompts, context and the output destination; transient API failure retries | +| Authorization and privacy | An unauthorized requester/repository is refused; private team context and run history stay private; revoked installation cannot refresh or run | +| Additional mention surfaces | Document/project mentions tested explicitly; unsupported event shapes receive an honest response rather than silent success | +| Slack compatibility | Relevant existing Slack, dispatcher, authorization and recovery tests pass, followed by a live Slack smoke test | +| Operations | CI green, reviewed changes merged, deployed build identified, installation and smoke-test receipts recorded | + +## Boundaries + +Linear-specific API shapes stay under the channel adapter. Durable stores have +in-memory test and Worker implementations. Credentials remain outside agent +executors. The edge handles OAuth and incoming event durability; the existing +dispatcher remains the only place that starts an agent. Any extensions needed +by typed questions, session metadata or recovery are channel-neutral seams. + +Sources: [Linear agent setup](https://linear.app/developers/agents), +[session protocol](https://linear.app/developers/agent-interaction), +[interaction guidance](https://linear.app/developers/agent-best-practices), +[signals](https://linear.app/developers/agent-signals), +[OAuth](https://linear.app/developers/oauth-2-0-authentication). diff --git a/docs/reference/authorization.md b/docs/reference/authorization.md index e50b26d90..44f86febd 100644 --- a/docs/reference/authorization.md +++ b/docs/reference/authorization.md @@ -1,6 +1,6 @@ # Reference: authorization -Two blocks in `config.yaml` decide who may do what. `grants` says what each actor **holds**; `restrict` says what is **closed unless granted**. Everything else is open to whoever can reach the bot. One policy table (`authorize(actor, action, resource)`) reads the grants on every surface — Slack, CLI, HTTP, MCP, schedules — so nothing here can be bypassed by choosing a different way to ask. Enforcement is at run time against the *resolved* agent or repo, after directives, thread stickiness, and every config layer. +Two blocks in `config.yaml` decide who may do what. `grants` says what each actor **holds**; `restrict` says what is **closed unless granted**. Everything else is open to whoever can reach the bot. One policy table (`authorize(actor, action, resource)`) reads the grants on every surface — Slack, Linear, CLI, HTTP, MCP, schedules — so nothing here can be bypassed by choosing a different way to ask. Enforcement is at run time against the *resolved* agent or repo, after directives, thread stickiness, and every config layer. ## `grants` @@ -37,6 +37,7 @@ Keyed by platform-namespaced actor id. Three axes, each a list of names or the e | Actor id | Baseline | A `grants` entry … | |---|---|---| | `slack:U…` (a Slack user) | the open chat commands (`help`/`config`/`repo`/`friction`/`memory`/`mcp`/`schedule` reads, `memory:write`, `mcp:write`) plus `agent:run:` for every agent not under `restrict.agents` | **adds** to the baseline | +| `linear::` (a Linear person) | the same open-chat baseline as Slack | **adds** to the baseline | | `access:` (an Access browser session) | every group's `read`, plus `memory:write` and `mcp:write` for its own tier (the web chat makes a session a chat user) | **adds** to the baseline | | `access:svc:` (an Access service token) | nothing | is **exactly** what it holds | | `http:` / `mcp:` (an ingress token) | nothing | is **exactly** what it holds | @@ -55,13 +56,13 @@ grants: actions: [runs:read] ``` -A key `slack:*`, `http:*`, `mcp:*` or `access:*` is a **surface entry**: the same three axes, held by every actor that authenticated on that surface. Who may authenticate there is decided elsewhere (Access admits the org, Slack the workspace, the token maps the credentials), so the set is one an operator already trusts. An actor's grants are the **union** of its own entry (or its baseline) and its surface entry — a person listed for extra rights keeps what everyone holds, and a personal entry never narrows the surface entry. `access:*` is browser sessions only: an `access:svc:` service token is a named credential and holds exactly its own entry. A surface entry is not an actor — `adminsHint` names people, never `slack:*`. +A key `slack:*`, `linear:*`, `http:*`, `mcp:*` or `access:*` is a **surface entry**: the same three axes, held by every actor that authenticated on that surface. Who may authenticate there is decided elsewhere (Access admits the org, Slack the workspace, the token maps the credentials), so the set is one an operator already trusts. An actor's grants are the **union** of its own entry (or its baseline) and its surface entry — a person listed for extra rights keeps what everyone holds, and a personal entry never narrows the surface entry. `access:*` is browser sessions only: an `access:svc:` service token is a named credential and holds exactly its own entry. A surface entry is not an actor — `adminsHint` names people, never `slack:*`. Never a baseline, held only by a grant (or `all`): `config:write` (`config set/clear/instructions channel`, channel-tier MCP servers), `repo:write` (`repo onboard/offboard/reconfigure/rebuild`, `friction propose`, forgetting shared memories, org-tier MCP servers), every `runs:*` action, every `*:exec`, `dispatch`, `deploy:write` (the restart, the crash injection, and a probe bearer for the model proxy, `POST /admin/model-proxy/bearer`), `trace:read` (the bot's span log, `GET /admin/trace/log`). **No entry with `actions: all` means nobody is an admin** — the fail-closed default; `adminsHint` (the "ask …" in a 🚫 reply) names whoever holds it. ### Validation -The load fails, naming the entry and field, on an unknown id prefix (`slack:`, `http:`, `mcp:`, `access:`, `schedule:` are the vocabulary), a misspelled `all`, an unknown axis, or a block that is not a mapping. `*` is only ever a whole surface: a partial subject (`slack:U*`) and `schedule:*`, `access:svc:*`, `agent:*`, `cli:*` are refused by name — schedules and service tokens are individually named identities, an agent derives its grants from its principal, the CLI holds everything. Nothing is ever widened to recover from a typo. `grants` and `restrict` are the only authorization keys `config.yaml` has: any other top-level key — `permissions`, a misspelling — is unknown and fails the load by name. +The load fails, naming the entry and field, on an unknown id prefix (`slack:`, `linear:`, `http:`, `mcp:`, `access:`, `schedule:` are the vocabulary), a misspelled `all`, an unknown axis, or a block that is not a mapping. `*` is only ever a whole surface: a partial subject (`slack:U*`) and `schedule:*`, `access:svc:*`, `agent:*`, `cli:*` are refused by name — schedules and service tokens are individually named identities, an agent derives its grants from its principal, the CLI holds everything. Nothing is ever widened to recover from a typo. `grants` and `restrict` are the only authorization keys `config.yaml` has: any other top-level key — `permissions`, a misspelling — is unknown and fails the load by name. ## `restrict` diff --git a/docs/reference/code-map.md b/docs/reference/code-map.md index 7399b4fe5..a19f985af 100644 --- a/docs/reference/code-map.md +++ b/docs/reference/code-map.md @@ -13,7 +13,7 @@ The map at a glance: each area of the product, where it lives, and the spec that | Commands once, every surface (chat, CLI, HTTP, MCP) | `commands`, `cli`, `init`, `setup` | `src/core/commandRegistry.ts`, `commands/`, `commandSurface.ts` | [`command-registry.md`](specs/command-registry.md) | | Authorization: actors, grants, the policy table, predicates | `authz` | `src/core/authz/` | [`authorization.md`](specs/authorization.md) | | Runs: live registry, history, tracing, the run page | `runs`, `tracing`, `costs`, `delivery` | `src/core/runRegistry.ts`, `runStore.ts`, `runsService.ts`, `trace/`, `src/channels/liveView.ts` | [`run-history.md`](specs/run-history.md), [`live-view.md`](specs/live-view.md), [`tracing.md`](specs/tracing.md) | -| Channels: Slack (transport only), HTTP, MCP ingress | `slack`, `http`, `mcp` | `src/channels/` | [`slack-channel.md`](specs/slack-channel.md), [`http-ingress.md`](specs/http-ingress.md), [`mcp-ingress.md`](specs/mcp-ingress.md) | +| Channels: Slack (transport only), HTTP, MCP ingress, Linear native sessions | `slack`, `http`, `mcp`, `linear` | `src/channels/` | [`slack-channel.md`](specs/slack-channel.md), [`http-ingress.md`](specs/http-ingress.md), [`mcp-ingress.md`](specs/mcp-ingress.md), [`linear-channel.md`](specs/linear-channel.md) | | Agents (data), the model layer, executors | `agents`, `review`, `coding`, `ship`, `research`, `explore`, `conductor`, `general`, `providers`, `resident`, `sandbox` | `src/agents/`, `src/core/provider.ts`, `src/core/harness/piAi.ts`, `src/channels/modelProxy.ts`, `src/execution/` | [`agent-*.md`](specs/README.md), [`execution.md`](specs/execution.md), [`resident-repos.md`](specs/resident-repos.md) | | The harness contract — the six clauses any process that runs a model loop for a run is held to, the `Harness` object the run loop drives every run through, the facts a row carries — and the pi harness, its one implementation: pi in the run's container, the bridge onto the run's events, the extension and the gate, the mirror; and pi's model library as the bot's provider layer for the calls made outside a run (the router, reflection) | `harness` | `src/core/harness/`, `src/channels/harnessRoutes.ts` | [`harness.md`](specs/harness.md), [`harness-pi.md`](specs/harness-pi.md) | | Memory, skills, MCP tools, GitHub tools, the toolset table | `memory`, `skills`, `tools` | `src/core/memory/`, `src/skills/`, `src/mcp/`, `src/tools/` (`toolsets.ts`: the tools the bot relays to a run's pi, by preset; pi's own workspace tools follow the identity, never this table) | [`memory.md`](specs/memory.md), [`skills.md`](specs/skills.md), [`mcp-tools.md`](specs/mcp-tools.md), [`github-tools.md`](specs/github-tools.md), [`harness-pi.md`](specs/harness-pi.md) item 7 | diff --git a/docs/reference/specs/README.md b/docs/reference/specs/README.md index 08c625853..9d1e43164 100644 --- a/docs/reference/specs/README.md +++ b/docs/reference/specs/README.md @@ -36,6 +36,7 @@ Rename a test and the build is red until the spec changes with it. Adopting the |---|---| | [routing-and-config.md](routing-and-config.md) | Directives, config layers, thread stickiness, permission gates, config commands, config awareness, custom instructions, durable runtime overrides (the `OverridesBacking` seam → the state Worker's `ConfigDO` in prod) | | [slack-channel.md](slack-channel.md) | Triggers (mention/DM/follow-up), ack reaction, status cards, formatting, attachments | +| [linear-channel.md](linear-channel.md) | Linear OAuth installation, credential lifecycle and signed webhook intake; native channel delivery tracked in its delivery plan | | [llm-output.md](llm-output.md) | Typed LLM output contract: per-datatype request/response modules (`OutputType` seam), markdown canonicalization at the answer boundary, raw+canonical in the run record, deterministic retry loop | | [run-visibility.md](run-visibility.md) | Typed run-event stream (tool calls + redacted result summaries); live in-channel status card | | [live-view.md](live-view.md) | External run page: per-run capability token + SSE stream while a run is live (in-memory registry), the same page served tokenless from run history once it has finished; Access-gated `/runs` index with an active-only default and `?all=1`; the `/runs` "Scheduled" panel — schedule registry, next fire, last firing + run link | diff --git a/docs/reference/specs/agent-coding.md b/docs/reference/specs/agent-coding.md index cbc5bfca5..0a74d6bb7 100644 --- a/docs/reference/specs/agent-coding.md +++ b/docs/reference/specs/agent-coding.md @@ -19,7 +19,7 @@ Takes a task from Slack, scopes fast, implements the change in its sandbox, push 8. **The unit contract** ([agent-ship.md](agent-ship.md) item 13): a coding run started for a plan unit — a plan runner's child, or the by-hand receipt rendered with `contract render` and pasted into the request — finds a `## Contract` block in its first user turn, rendered by OpenSwitchboard from the plan itself and never assembled by the agent (an agent that assembles its own brief chooses what to leave out), under fixed sub-headings in a fixed order: `### First instruction` (the rebase of the unit's branch onto the merged parent; a conflict ends the unit), `### Unit` (the unit's own plan section verbatim), `### Spec rows` (the spec items the unit names with their current proof bindings), `### Agent rules` (the repository's `AGENTS.md`, else `CLAUDE.md`, else none) and `### Guards` (`specs:check`, `specs:coverage --test-guard`, `hygiene:check`, `decisions:check`, one line each on what they refuse). Both prompts (`UNIT_CONTRACT`, one text shared by `CODING_SYSTEM` and `CODING_SYSTEM_RESIDENT`, so the sandbox and resident children read the same rule) name the block and its sub-headings in that order, make it outrank the free-text task beside it, put the first instruction first, require every listed test scenario added as a test, every named spec row updated so its proof binding resolves, the agent rules followed and no named guard weakened, and state the rule the review holds the diff to: a test scenario the unit listed and the diff did not add is a finding at minor severity — the same severity as a spec contradiction ([agent-review.md](agent-review.md) item 17). The agent never edits the plan record; a unit it finds wrong, or a criterion it could not prove, is said in the handoff (item 9) and in the final message (until decided, a child reports a follow-up, never proposes a unit). A run with no such block — every task-string run — is unchanged: the paragraph describes a block that is not there. 9. **The unit handoff** ([agent-ship.md](agent-ship.md) item 14): the contract's return edge, as data. A coding run that carries a `## Contract` block submits, through `submit_handoff` — the same tool path as the description, in the `full` toolset right after `submit_pr_description` and never side-effect-free — one typed `Handoff` `{ deviations: [{ from, to, why }], followUps: [{ what, where }], unproven: [{ criterion, why }], landed?: [{ what, where }] }`: where it departed from the unit as written and why, what it found and did not do and where it belongs, which of the unit's test scenarios or criteria it could not prove, and — optionally — what of the unit was already on the base when it began and the pull request or commit that carries it (the fact that ends a unit with nothing left to push `already_landed`, [agent-ship.md](agent-ship.md) item 12: the run pushes nothing of its own and opens no pull request). The tool validates the object (`parseHandoff`, [`src/core/ship/handoff.ts`](../../../src/core/ship/handoff.ts): the three lists present, at most `HANDOFF_MAX_ITEMS` entries each, every field a non-empty string of at most `HANDOFF_MAX_FIELD_CHARS` once trimmed, unknown keys dropped) and answers a bad one with a string error naming the path, never a throw; a valid one reaches the dispatcher's sink — the last valid call wins, an invalid call leaves the valid one standing, and a resumed run restores the one its earlier generation submitted, as it restores the description — and is acknowledged with its counts; with no sink listening the tool says so rather than acknowledging a recording that never happened. Both prompts (`UNIT_HANDOFF`, one text shared by `CODING_SYSTEM` and `CODING_SYSTEM_RESIDENT`, right after the contract paragraph) tie the call to the block, place it once after `submit_pr_description` and before the final message, name the three lists with their fields and the optional `landed` list with its (what, where) — the way a unit with nothing left to push ends done: push nothing of your own and open no pull request — say that the run records it and the parent posts it to the unit's board issue where a person decides each row's disposition and that the agent never edits the plan's ledger, require an empty handoff submitted as three empty lists — never skipped, because a missing handoff reads as an unfinished run, not as nothing to say — and forbid the call without a block. Where it lands: on the run's record ([run-history.md](run-history.md) item 2, redacted like every stored string) for every coding run that submitted one — a plain coding run with the block pasted into its request (the by-hand receipt) posts nowhere, having no unit issue — and, for a ship coding round whose contract names the unit's board issue, on that issue as a comment the parent posts ([agent-ship.md](agent-ship.md) item 14). -10. **Files the person should see reach the conversation** (`src/tools/attach.ts`): a coding run that renders a screenshot (`playwright screenshot`), a PDF or a recording posts it with `attach_file { path, comment? }` — the workspace file, whole, into the requesting thread, where an image renders inline; a link to a file on GitHub is not a picture. The tool is `full`-only (the read-only and workspace-less agents never post files) and never side-effect-free. It touches neither host nor platform: the bytes come off the Executor seam's `readBytes` ([execution.md](execution.md) item 19, capped at 10 MiB) and go out through the channel's `attachFile` (Slack: [slack-channel.md](slack-channel.md) item 10; the CLI harness writes the file to a temp dir the reply names), which the dispatcher binds into the tool context exactly when the requesting channel has one — a child run's opened thread forwards it by method. Each missing half is named, never papered over: a channel without uploads ("this conversation's channel takes no file uploads — link to the file instead"), an executor without a byte read, a missing or empty or over-cap file (nothing posted), a refused upload with the platform's message. The lead is the comment or, absent, the file's own name; the upload is titled by the last path segment. *With an artifact store* ([execution.md](execution.md) item 20, [record 0033](../../decisions/0033-artifacts-move-by-reference-through-r2.md)) the same tool moves the file BY REFERENCE and the bot process never holds the bytes: the dispatcher binds `ToolContext.artifacts` (the store, the run's id, a per-run sequence, the run page's link and the channel's `reply`) whenever `artifacts:` is configured, and the channel's `uploadTicket` (`ChannelIO.uploadTicket({ name, size }) → { url, complete(lead) }`, Slack's `files.getUploadURLExternal` / `files.completeUploadExternal`) when the channel has one. The chain, each step naming its own failure and stopping the rest: `stat -c %s` in the workspace measures the file (stat's words on failure; empty refused; over 1 GiB — Slack's own ceiling, `MAX_ARTIFACT_BYTES` — refused by name before anything is minted); the budget is checked before any mint — each transfer is one command under the 20-minute bash cap clipped to the run's remaining clock like any command, and a file that cannot move inside that at 1 MiB/s plus ten seconds of setup (`transferBudgetMs`) is refused naming the bytes, the time and the budget (a 1 GB file with three minutes left is refused; a 3 MiB screenshot goes, clipped); the container `curl -fsS -T`s the file to a presigned PUT with the derived `Content-Type` (signed into the URL) under the key `runs//out/-`; the bot `HEAD`s the object and refuses a missing one or a size other than the one measured (nothing posted, no event, no ticket); the `artifact` event (`direction: "out"`, naming the call that posted it — `callId`, from `ToolContext.callId`, [live-view.md](live-view.md) item 26) is published once the store holds the verified object; then the channel: with a ticket the container streams the same file from disk to the ticket's URL (`curl -fsS --upload-file … -X POST`, never a buffered `--data-binary`, which reads a 1 GiB file into memory and fails) and the bot completes the share with the lead (a failed POST or a refused `complete` carries the platform's words and says the file is kept on the run page — the event stands); without one (the CLI harness, HTTP, MCP) the tool takes the store-only path: it posts the lead and the file's own link — the run page's artifact proxy `/runs//artifacts/`, carrying the run's live token while the run is live, or the key alone when the deployment has no public URL — through `reply`, and its result names the key and says no channel upload happened. The command strings carry the presigned query (the access key id, the signature) and never the secret access key, the Slack token or the copy bearer. With a store the inline `readBytes` path is not taken; without one it runs exactly as before. *Inbound* ([execution.md](execution.md) item 20): a file the person dropped on the thread that the inline path could not carry is already in `./attachments/-` when a coding run's turn names it (`Attached files are in ./attachments/: 1-clip.mp4 (312 MB, video/mp4)`), staged by the dispatcher before the model's first read and by the runner before a steered follow-up is read; both coding prompts say so in the shared paragraph — read it from there, never ask for a re-upload — and a workspace-less agent's turn names the file and points at `agent:coding`. Both coding prompts carry one shared paragraph naming the tool, the screenshot case and the rule that text stays in the message — and the destination rule: a request for screenshots that names no destination, or a visual change, means every capture goes to BOTH the thread (`attach_file`) and the pull request (committed to an assets branch, never the PR's own diff, and referenced from the description's validation section or a PR comment so they render inline there too); only a request naming one destination narrows it, and a subset attached with the rest linked is never acceptable. +10. **Files the person should see reach the conversation** (`src/tools/attach.ts`): a coding run that renders a screenshot (`playwright screenshot`), a PDF or a recording posts it with `attach_file { path, comment? }` — the workspace file, whole, into the requesting thread, where an image renders inline; a link to a file on GitHub is not a picture. The tool is `full`-only (the read-only and workspace-less agents never post files) and never side-effect-free. It touches neither host nor platform: the bytes come off the Executor seam's `readBytes` ([execution.md](execution.md) item 19, capped at 10 MiB) and go out through the channel's `attachFile` (Slack: [slack-channel.md](slack-channel.md) item 10; the CLI harness writes the file to a temp dir the reply names), which the dispatcher binds into the tool context exactly when the requesting channel has one — a child run's opened thread forwards it by method. Each missing half is named, never papered over: a channel without uploads ("this conversation's channel takes no file uploads — link to the file instead"), an executor without a byte read, a missing or empty or over-cap file (nothing posted), a refused upload with the platform's message. The lead is the comment or, absent, the file's own name; the upload is titled by the last path segment. *With an artifact store* ([execution.md](execution.md) item 20, [record 0033](../../decisions/0033-artifacts-move-by-reference-through-r2.md)) the same tool moves the file BY REFERENCE and the bot process never holds the bytes: the dispatcher binds `ToolContext.artifacts` (the store, the run's id, a per-run sequence, the run page's link and the channel's `reply`) whenever `artifacts:` is configured, and the channel's `uploadTicket` (`ChannelIO.uploadTicket({ name, size }) → { url, method?, headers?, complete(lead) }`, Slack's `files.getUploadURLExternal` / `files.completeUploadExternal`) when the channel has one. The chain, each step naming its own failure and stopping the rest: `stat -c %s` in the workspace measures the file (stat's words on failure; empty refused; over 1 GiB — Slack's own ceiling, `MAX_ARTIFACT_BYTES` — refused by name before anything is minted); the budget is checked before any mint — each transfer is one command under the 20-minute bash cap clipped to the run's remaining clock like any command, and a file that cannot move inside that at 1 MiB/s plus ten seconds of setup (`transferBudgetMs`) is refused naming the bytes, the time and the budget (a 1 GB file with three minutes left is refused; a 3 MiB screenshot goes, clipped); the container `curl -fsS -T`s the file to a presigned PUT with the derived `Content-Type` (signed into the URL) under the key `runs//out/-`; the bot `HEAD`s the object and refuses a missing one or a size other than the one measured (nothing posted, no event, no ticket); the `artifact` event (`direction: "out"`, naming the call that posted it — `callId`, from `ToolContext.callId`, [live-view.md](live-view.md) item 26) is published once the store holds the verified object; then the channel: with a ticket the container streams the same file from disk to the ticket's URL (`curl -fsS --upload-file … -X POST` by default, or PUT with the ticket’s signed headers preserved and shell-quoted, never a buffered `--data-binary`, which reads a 1 GiB file into memory and fails) and the bot completes the share with the lead (a failed POST or a refused `complete` carries the platform's words and says the file is kept on the run page — the event stands); without one (the CLI harness, HTTP, MCP) the tool takes the store-only path: it posts the lead and the file's own link — the run page's artifact proxy `/runs//artifacts/`, carrying the run's live token while the run is live, or the key alone when the deployment has no public URL — through `reply`, and its result names the key and says no channel upload happened. The command strings carry the presigned query (the access key id, the signature) and never the secret access key, the Slack token or the copy bearer. With a store the inline `readBytes` path is not taken; without one it runs exactly as before. *Inbound* ([execution.md](execution.md) item 20): a file the person dropped on the thread that the inline path could not carry is already in `./attachments/-` when a coding run's turn names it (`Attached files are in ./attachments/: 1-clip.mp4 (312 MB, video/mp4)`), staged by the dispatcher before the model's first read and by the runner before a steered follow-up is read; both coding prompts say so in the shared paragraph — read it from there, never ask for a re-upload — and a workspace-less agent's turn names the file and points at `agent:coding`. Both coding prompts carry one shared paragraph naming the tool, the screenshot case and the rule that text stays in the message — and the destination rule: a request for screenshots that names no destination, or a visual change, means every capture goes to BOTH the thread (`attach_file`) and the pull request (committed to an assets branch, never the PR's own diff, and referenced from the description's validation section or a PR comment so they render inline there too); only a request naming one destination narrows it, and a subset attached with the rest linked is never acceptable. 11. **The harness** ([harness-pi.md](harness-pi.md)): a coding run is pi in the run's container — the prompt above as pi's system prompt, the `full` toolset relayed to the bot (`src/tools/toolsets.ts`: the recorders, `attach_file`, `diff_digest`, the skills, the GitHub reads and issue writes, `web_fetch`, the session tools) with pi's own `read`, `bash`, `edit`, `write`, `grep`, `find` and `ls` as its workspace tools (the prompt gains one note mapping the native `read_file`/`write_file` names onto them), the budgets of item 15 there, the coding preset's tool rules at the gate (item 7 there), and the PR post-step unchanged; the model turns go through the model proxy on the run's bearer. 12. **Seeded-sandbox variant** ([execution.md](execution.md) item 26): when the run lands in a sandbox seeded from the resident's snapshot, the dispatcher swaps in `CODING_SYSTEM_SEEDED` via `RunOptions.system`: the repository is ALREADY CLONED at `/workspace/checkout` on the thread's branch, dependencies installed — no cloning, no installing, no repo discovery — and, unlike the resident's, `gh` and Docker are present, so the workflow is the cold prompt's with the clone step gone: branch, implement, push with git, `diff_digest`, then `submit_pr_description` — the bot opens the PR. @@ -53,7 +53,7 @@ Takes a task from Slack, scopes fast, implements the change in its sandbox, push | `attach_file` (item 10): posts the bytes under the file's own name with the comment (or the name) as the lead; a channel without uploads is named and nothing is read; an executor without a byte read is named and nothing is posted; a missing, over-cap or empty file and an empty path are each named with nothing posted; a refused upload carries the platform's message | `[unit]` `src/tools/attach.test.ts::attach_file::*` | | The dispatcher binds the requesting channel's `attachFile` into a coding run's tool context exactly when the channel has one: the tool posts through it, or says the channel takes no files | `[unit]` `src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::a coding run's attach_file posts the workspace file through the channel's attachFile…` | | A child run's channel forwards `attachFile` to the opened thread when it has one, and offers none when it does not | `[unit]` `src/core/dispatch/spawn.test.ts::spawnChild — the one path a child run is born through::the child's channel forwards attachFile to the opened thread…` | -| Store path (item 10): stat → presigned PUT (the derived type in the header, the `runs//out/-` key) → HEAD → `artifact` event (carrying the call's `callId`) → ticket → POST → `complete`, each command under the 20-minute cap and the result naming the size; two files of one name take keys 1- and 2-; a HEAD of another size (the error also carries curl's own report of the PUT — the status answered and the bytes sent — or no such clause when the output holds none), a failed PUT, a failed POST and a refused `complete` each stop the chain where the spec says and carry the platform's words (the event stands once the store holds the file); a refused ticket, a missing file and an empty file are named with nothing minted; over 1 GiB refused by name; a 1 GB file with three minutes left refused naming the budget while a 3 MiB screenshot goes clipped, and the write-up reserve refuses everything; no ticket → the lead and the run page's link through `reply`; the inline path is not taken with a store and unchanged without; command strings carry the presigned query and never the secret key or the copy bearer | `[unit]` `src/tools/attach.test.ts::attach_file through the artifact store::*` | +| Store path (item 10): stat → presigned PUT (the derived type in the header, the `runs//out/-` key) → HEAD → `artifact` event (carrying the call's `callId`) → ticket → channel upload (POST by default, PUT with signed headers when requested) → `complete`, each command under the 20-minute cap and the result naming the size; two files of one name take keys 1- and 2-; a HEAD of another size (the error also carries curl's own report of the PUT — the status answered and the bytes sent — or no such clause when the output holds none), a failed PUT, a failed POST and a refused `complete` each stop the chain where the spec says and carry the platform's words (the event stands once the store holds the file); a refused ticket, a missing file and an empty file are named with nothing minted; over 1 GiB refused by name; a 1 GB file with three minutes left refused naming the budget while a 3 MiB screenshot goes clipped, and the write-up reserve refuses everything; no ticket → the lead and the run page's link through `reply`; the inline path is not taken with a store and unchanged without; command strings carry the presigned query and never the secret key or the copy bearer | `[unit]` `src/tools/attach.test.ts::attach_file through the artifact store::*` | | The dispatcher binds the store path (this run's keys, the channel's `uploadTicket`, its `reply`) when the deps carry a store: the executor PUTs and POSTs, the record carries the `artifact` event, the ticket is completed; without a ticket the lead goes through `reply` carrying the file's proxy link tokened with this run's live token; `readBytes` is never called | `[unit]` `src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::with an artifact store the run's attach_file moves the file by reference…` | | Store-only path (item 10, the ticketless channels): the lead carries the file's proxy link and the result names the key; without a public URL the lead names the key on the run page; the CLI harness, HTTP and MCP channels each carry `reply` and no `uploadTicket`, and through the harness the lead lands on its stream; no ticket is minted | `[unit]` `src/tools/attach.test.ts::attach_file through the artifact store::a channel without an upload ticket gets the lead and the file's proxy link…`, `…::without a public URL the lead still names the file and its key…`, `…::the three ticketless channel shapes — the CLI harness, HTTP, MCP…` | | The file link: `/runs//artifacts/` under the public base URL, each key segment encoded, `?t=` when a token is given; undefined without a public URL | `[unit]` `src/core/dispatch/reply.test.ts::artifactLink::*` | diff --git a/docs/reference/specs/agent-conductor.md b/docs/reference/specs/agent-conductor.md index 0b7a7197d..0c4b1b4e0 100644 --- a/docs/reference/specs/agent-conductor.md +++ b/docs/reference/specs/agent-conductor.md @@ -10,7 +10,7 @@ A run that starts other runs. `agent:conductor` — or the router's compound for ## Behavior 1. **A preset, chosen by name.** `agent:conductor` resolves through `parseDirectives`/`AGENTS["conductor"]` like every directive ([routing-and-config.md](routing-and-config.md) items 1 and 3). Its declared profile is machine `none`, identity `none` — nothing is provisioned and no credential is minted: the run tools call the dispatcher, the GitHub reads are REST in the bot process — and 120 minutes, long enough to outlast a coding child, every child capped by what remains of it. No built-in effort: the config layers decide, as for `coding`. Open to everyone by the open rule like every preset — a child gives the requester nothing they could not start by hand — and listed under the commented `restrict.agents` in the example config, so a deployment closes fan-out by uncommenting one line. A self-serve MCP server never reaches it (`MCP_SELF_SERVE_AGENTS`). -2. **Reach: the `conductor` toolset** — `spawn_run`, `send_to_run`, `await_runs`, `list_runs`, `get_run_status`, `web_fetch`, `update_status` and the GitHub reads (`github_repos`, `github_tree`, `github_file`, `github_search_code`, `github_issue_list`, `github_issue_get`). No shell, no files, no `web_search`, no `submit_*`, no issue writes: a conductor coordinates and never does a child's job. The five run tools are in no other toolset — the one way anything in the tree starts, steers or awaits a run is this preset's, and every existing preset is byte for byte what it was. +2. **Reach: the `conductor` toolset** — `spawn_run`, `send_to_run`, `await_runs`, `list_runs`, `get_run_status`, `web_fetch`, `update_status` and the GitHub reads (`github_repos`, `github_tree`, `github_file`, `github_search_code`, `github_issue_list`, `github_issue_get`), plus `work_item_get` and `work_items_delegated` when the channel supplies work tracking. No shell, no files, no `web_search`, no `submit_*`, no issue writes: a conductor coordinates and never does a child's job. The five run tools are in no other toolset — the one way anything in the tree starts, steers or awaits a run is this preset's, and every existing preset is byte for byte what it was. 3. **`spawn_run` is the one way a run starts another run** ([routing-and-config.md](routing-and-config.md) item 20 has the pipeline). The tool validates its input (a registered preset, a non-empty prompt, a `budget` of whole minutes of at least 2; the spawn then refuses a child the parent's remainder cannot hold above the child preset's floor — `PRESET_FLOORS` in `src/core/budgets.ts`, [decision 0046](../../decisions/0046-a-budget-is-a-lease-carved-from-its-parent-and-one-module-proves-the-leases-fit.md) — naming the floor) and calls the run's spawn capability with the moment of the call (`SpawnMoment`): the wall clock the run has left and the run's conversation so far, read off the context the runner installs (`ToolContext.conversation`, the loop's own array — this step's assistant turn included); the capability's one path is `spawnChild()` → `dispatch()`, the child an ordinary run as the requester in a thread of its own. **A child is a reader.** The stage refuses a preset whose registry `identity` is `write` by name (`spawn_identity`) before anything is opened — no thread, no dispatch — and the registry's column is the whole rule, no allowlist: `coding` and `ship` today, while `research`, `explore`, `general`, `review` (and a conductor, whose own spawn is then refused `spawn_depth`) pass; the message names the preset and points the requester at starting it by hand. The child is seeded from its parent: the stage reduces the conversation to text turns (`textTurnsOf` — each turn's text parts joined; tool calls, tool results, thinking and attachments dropped; a turn with no text dropped whole) and sets `DispatchOptions.seed`, so the child's model starts from what its parent's conversation said, then its prompt as the one new turn; a call whose context offers no conversation (a unit context, a run without a session log) seeds nothing, and the child starts from its own thread. **On the pi harness the conversation is the run's session log.** pi keeps its transcript in its own process; the bot's copy is the mirror's rows on the ledger ([session-log.md](session-log.md) item 3), so a conductor on pi ([harness-pi.md](harness-pi.md) item 12) is handed a read of its own log as `ToolContext.conversation` (`SessionCapability.readConversation`: every row from where its seed began to the tail), awaited by `spawn_run`. The relayed request can reach the bot before the poll that reads the line announcing the call, so the read waits, for a few polls at most, until the bridge has seen the call start (`LiveHarness.callSeen`) and the assistant turn that made it is in the log; the child then records `seed: parent` with the same text turns a native conductor's child gets. A log the bot cannot read costs the child its seed, never the spawn: the tool is handed no conversation, the child starts from its own thread, and a `seed` note on the parent's record says why. A `spawn_identity` refusal reaches a relayed `spawn_run` as its tool result exactly as it reaches a native one: a conductor on pi asking for `coding` reads the refusal by name and nothing opens. The result names the child — its run id, its thread key and a link — or a refusal by name: the stage's own (`spawn_depth`: a child cannot spawn; `spawn_identity`: a preset that writes is no child; `spawn_budget`: the parent's remainder under the child preset's floor (`PRESET_FLOORS`, named in the refusal); `spawn_fanout`: `spawn.maxChildren` live children already; `spawn_unsupported`: a channel that cannot open a thread) or a gate's own `dispatch.refuse` name relayed with the reply the child's thread saw (`agent_allowlist`, `profile_bounded`, …). The coordinator's spawn route ([http-ingress.md](http-ingress.md) item 9) is not this path: it calls `dispatch()` itself with its tag as the only option — no `parent`, no `seed` — and its coding and review children run as they did. A channel that fails to open the thread (a Slack answer without a `ts`, a transport failure) is `spawn_failed` naming the cause, never a throw into the parent's tool call. The capability admits one spawn at a time — the fan-out check counts the registry's live children, and a child is not in the registry until its dispatch registers it, so the next spawn waits for the previous to register or refuse and the cap holds however the model batches its calls. Outside a spawning run — a round driven outside `dispatch()`, a unit context — the capability is the null object and the tool answers `spawn_unavailable` honestly; nothing starts. 4. **The reads are the requester's** ([authorization.md](authorization.md) items 5–6). `list_runs` is `RunsService.listRuns` under `predicateFor(actor, "runs:read", "run")` for the requesting user's actor — this run's own children by default (`scope: children`: the listing paged in full pages, each filtered to the parent's children, until `limit` are found or the listing ends, so a busy deployment's newer runs never page a parent's children out of its own view), every run the requester may read with `scope: all` (one page of the caller's `limit`), live, finished or both — one row per run (id, preset, `running` or the terminal status, the latest activity line, the parent run, the label, the thread, a link) and never a message's text or a capability token. `get_run_status` is `RunsService.getRun` with a point `authorize` on the run's own attributes: a run the requester may not read is `not_found`, byte-identical to an unknown id; a running run answers with its activity line, a finished one with its terminal status and its final reply wrapped as untrusted content (another run's output, never an instruction); a child of this run whose row is gone — refused at a repository gate after it registered, or failed in setup — answers from the capability's memory with the gate's name. Without a runs capability the tools say so. 5. **`spawn.maxChildren` is the fan-out cap**: one knob in `config.yaml` (default 3, an integer of at least 1), validated at load like the `ship` caps — 0, a fraction, a non-mapping and an unknown key fail the load naming the key. The count is a parent's live children in the registry; a finished child frees its slot. @@ -26,6 +26,12 @@ A run that starts other runs. `agent:conductor` — or the router's compound for - `[gap]` The board: `parentRunId` is on the record and the live summary, a ship unit's row names its thread, a unit's runs list in round order from one read and on one page (`runs unit`, [agent-ship.md](agent-ship.md) item 17; [live-view.md](live-view.md) item 28) and a conductor's children from one read and on its run page (`runs children`, item 11); the `/runs` index does not yet draw the tree ([record 0034](../../decisions/0034-one-agent-per-unit-a-run-continues-a-transcript.md), the unit as the reading unit). +A child that asks for clarification is `awaiting_input`, not a completed result. +The run tools report its question as untrusted output; `await_runs` returns +immediately so the conductor can request the missing information. A subsequent +wait follows the child's thread continuation and only reports completion once +that continuation finishes without another question. + ## Validation criteria | Criterion | Proof | @@ -33,7 +39,7 @@ A run that starts other runs. `agent:conductor` — or the router's compound for | 1: `conductor` declares machine `none`, identity `none`, toolset `conductor`, 120 minutes, a turn guard of at least 30 (720, derived), no built-in effort and no resident prompt; `getAgent("conductor")` resolves it | `[unit]` `src/agents/registry.test.ts::conductor agent (docs/reference/specs/agent-conductor.md)::conductor: machine none, identity none…` | | 1: every preset names a valid identity; `conductor` is `none` beside `general` and `research` | `[unit]` `src/agents/registry.test.ts::agent registry matches the feature specs::identity declarations…` | | 1: open by the open rule — with no boundary set every actor kind admits `conductor` exactly as `canRunAgent` decides, and it resolves its declared profile | `[unit]` `src/config.test.ts::boundaries (Scope.boundary): a scope caps, never grants::per-actor goldens…` | -| 2: the `conductor` toolset is exactly the five run tools, `web_fetch`, `update_status` and the GitHub reads; no other toolset holds a run tool; the reads are in every toolset with a tool loop; `web_search` is not in it | `[unit]` `src/tools/github.test.ts::toolset wiring::conductor holds spawn_run…`, `::reads are in every toolset with a tool loop…`, `src/tools/web.test.ts::toolset + agent wiring::gates web_search to the research and explore toolsets…` | +| 2: the `conductor` toolset is exactly the five run tools, `web_fetch`, `update_status`, the GitHub reads and the work-tracking reads; no other toolset holds a run tool; the reads are in every toolset with a tool loop; `web_search` is not in it | `[unit]` `src/tools/github.test.ts::toolset wiring::conductor holds spawn_run…`, `::reads are in every toolset with a tool loop…`, `src/tools/web.test.ts::toolset + agent wiring::gates web_search to the research and explore toolsets…` | | 3: `spawnChild()` builds the child's message as the requesting user on the opened thread and calls `dispatch()` with `parent` set; the child's replies land in its own thread; depth, budget (the parent's remainder under the child preset's floor, named), fan-out (the default cap, a finished child, a configured cap, another parent's children) and an adapter without `openThread` refuse by name before anything is opened; a preset whose registry identity is `write` (`coding`, `ship`) is refused `spawn_identity` with no thread and no dispatch, and every preset that reads or holds no credential passes the rule; the child's seed is the parent's conversation as text turns — text parts joined, tool calls, tool results and thinking dropped, a textless turn dropped whole — handed to `dispatch()` beside `parent`, and a parent with no conversation seeds nothing; a gate's refusal is relayed by name with the child thread's reply; a failed or throwing dispatch is `spawn_failed`; the capability spawns with the moment of the call — the remaining clock and the conversation, when offered — and remembers how a registered child ended; the null capability refuses honestly | `[unit]` `src/core/dispatch/spawn.test.ts::spawnChild — the one path a child run is born through::*`, `::spawnCapabilityFor — the capability a spawning run's tools hold::*`, `::childRequestText / maxChildrenOf::*` | | 3: end to end, the factory spied — a conductor spawns a `research` child as the requester in a thread of its own: the child runs the full pipeline with its budget clipped to the parent's remaining minutes as `parent` (the factory, the runner's def, the card, the config block), its record and live summary carry `parentRunId` and its record `seed: parent` (the parent's `seed: channel`), the parent's tool result names the child, and the parent's admission slot is untouched while the child holds its own; a child for a requester without `agent:run:explore` ends at the agent gate with the allowlist refusal in the child's thread and never reaches the factory; a `read` preset in a channel bounded to `none` ends at the profile gate; a `coding` child is refused `spawn_identity` before any thread opens — nothing in the channel or the registry, the factory never asked — and the parent's tool result names the gate; a research child's model sees the parent's text turns (the request, what the conductor said before spawning, never its tool call) then its prompt as the one new turn, its record's `context` events are those turns and its `input` is the prompt; a conductor child's own `spawn_run` is refused `spawn_depth` and no grandchild exists | `[unit]` `src/core/dispatcher.test.ts::agent:conductor — a run that spawns child runs through dispatch()::*` | | 3: `spawn_run` passes the request and the moment of the call — the remaining budget, and the conversation the context offers, awaited when the read is a promise (only the budget when it offers none or the read gives none) — to the capability and reports the child's id, thread and link; relays a refusal by name with the child thread's text, `spawn_identity` like any gate; validates an unknown preset, a short budget and an empty prompt before calling anything; answers `spawn_unavailable` outside a spawning run; its description renders the child presets, the write presets and the repository presets from the registry and says the child starts from this conversation's text plus the prompt; `RUN_TOOLS` is exactly the five and only `await_runs` is side-effect free | `[unit]` `src/tools/runs.test.ts::spawn_run — the capability, called with the run's remaining wall clock::*` | @@ -85,3 +91,4 @@ A run that starts other runs. `agent:conductor` — or the router's compound for | 11: `runs children ` answers the listing behind the parent's point read — an unknown parent and a parent outside the predicate are `run not found` with the deny on the audit line — and `runs list --parent` is the same filter | `[unit]` `src/core/commands/runs.test.ts::runs unit / runs children / runs search — the unit is the reading unit::runs children lists the runs naming the parent…`, `src/core/commands/runs.test.ts::runs.list::the parent option lists the runs one run spawned…` | | 11: a conductor's run page seeds and lists its children — the history seed under the predicate with a live child's token, the live seed from the registry with each child's own token; the page draws them as fold rows in start order, a finished one opening to its timeline, a live one linking with its token | `[unit]` `src/channels/liveView.test.ts::the unit page and what a run is the parent of (item 28)::a history page seeds the runs its run spawned…`, `src/channels/liveView.test.ts::the unit page and what a run is the parent of (item 28)::a live page seeds the children the registry holds…`, `web/src/pages/runPage.test.ts::RunPage — history mode::a conductor's page lists the runs it spawned…` | | 11, live: a conductor's children from one route | `[agent]` (human-gated.) After a conductor run on the deployed bot, `runs children ` from the CLI or `GET /api/runs.children?id=…` — expect one row per child in the order they were spawned, each opening its page. | +| Child clarification stays unfinished and follows its continuation | `[unit]` `src/tools/runs.test.ts::child clarification stays unfinished::*`, `src/core/dispatch/awaitChildren.test.ts::waiting for child clarification::*` | diff --git a/docs/reference/specs/agent-explore.md b/docs/reference/specs/agent-explore.md index e00130d33..cd6bf6b73 100644 --- a/docs/reference/specs/agent-explore.md +++ b/docs/reference/specs/agent-explore.md @@ -29,7 +29,7 @@ The long, read-only investigation: `agent:explore` clones a repository into a co |---|---| | 1: `explore` declares machine `repo-cold`, identity `read`, toolset `explore`, 120 minutes, a turn guard of at least 100 (720, derived), at least 64k tokens, cache TTL `1h`, no built-in effort and no resident prompt; `getAgent("explore")` resolves it | `[unit]` `src/agents/registry.test.ts::explore agent (docs/reference/specs/agent-explore.md)::explore: repo-cold machine class, read identity, the explore toolset, 120 minutes…` | | 1: every preset names a valid identity; `explore` is `read` beside `review` | `[unit]` `src/agents/registry.test.ts::agent registry matches the feature specs::identity declarations…` | -| 2: the `explore` toolset is exactly `bash`, `read_file`, `update_status`, `web_fetch`, `web_search`, the two skill tools and the GitHub reads — no `submit_*`, no issue writes; the reads are in every toolset with a tool loop | `[unit]` `src/tools/github.test.ts::toolset wiring::explore relays update_status, web_fetch, web_search, the skill tools, the session tools and the GitHub reads — no submit_*, no issue writes, and none of pi's own workspace tools`, `::reads are in every toolset with a tool loop…` | +| 2: the `explore` toolset is exactly `bash`, `read_file`, `update_status`, `web_fetch`, `web_search`, the two skill tools and the GitHub and work-tracking reads — no `submit_*`, no issue writes; the reads are in every toolset with a tool loop | `[unit]` `src/tools/github.test.ts::toolset wiring::explore relays update_status, request_input, web_fetch, web_search, the skill tools, the session tools and the GitHub reads — no submit_*, no issue writes, and none of pi's own workspace tools`, `::reads are in every toolset with a tool loop…` | | 2: `web_search` is held by the `web` and `explore` toolsets and no other | `[unit]` `src/tools/web.test.ts::toolset + agent wiring::gates web_search to the research and explore toolsets…` | | 2: the description the router reads says the preset cannot attach or post files, the same fact the prompt tells the model, so an ask that names a posted file has a reason on the table to route elsewhere | `[unit]` `src/agents/registry.test.ts::explore agent (docs/reference/specs/agent-explore.md)::explore's prompt: the deliverable is a claim table…` | | 3: end to end on a deployment with a sandbox and a resident fleet — `agent:explore in ` reaches the factory with the profile `{ repo-cold, read, 120 }` and a sandbox backend, the runner gets the explore def, the repository is vetted with exactly one GitHub lookup and the resident Worker is never called, and the record carries the profile under the preset's name | `[unit]` `src/core/dispatcher.test.ts::agent:explore — the first repo-cold preset::agent:explore against a deployment with a resident fleet…` | @@ -38,3 +38,6 @@ The long, read-only investigation: `agent:explore` clones a repository into a co | 5: the sandbox Worker's exit-124 hint names `setsid -f` with the wrapper's stdio redirected, and never `nohup` | `[unit]` `src/execution/sandboxLifecycle.test.ts::sandbox Worker wiring (static)::the timeout hint tells the model to detach a long job with setsid -f and the wrapper's stdio redirected, and never names nohup` | | 5: a detached job outlives the command that started it and is polled to completion across tool calls (a two-hour run crosses dozens of call boundaries) | `[agent]` In Slack, in a thread: `@switchboard agent:explore in : start 'sleep 1500 && echo DONE' detached with setsid -f, redirecting to /tmp/job.log, then poll the log every few minutes and report the exact time DONE appeared and how many tool calls the wait took.` — expect the reply to quote `DONE` from the log about 25 minutes after the start, a poll count above one, and no exit 124; the run page shows the `bash` calls spanning the wait on one sandbox (no re-clone). Human-gated. | | 3, 4: the comparison run — `agent:explore` against a large onboarded monorepo lands in a cold sandbox with a read token (the card and record say `repo-cold` / `read`; the repository's resident shows no attach for the thread), runs a detached job past twenty minutes and reports a claim table with commands and numbers | `[agent]` `@switchboard agent:explore in : run the full typecheck and the test suite locally and validate the timings the CI audit claims.` — expect a claim table in the reply, the run page's first `bash` calls to be the clone and install, a `setsid -f` start and later polls of one job spanning more than twenty minutes, and `runs get ` showing `profile.machine: repo-cold`, `profile.identity: read`; `repo list` shows no thread binding for the thread on that repository's resident. Human-gated. | + +Channel-bound `work_item_get` and `work_items_delegated` are also read-only +tools; writes remain excluded. See [linear-channel.md](linear-channel.md) item 16. diff --git a/docs/reference/specs/agent-general.md b/docs/reference/specs/agent-general.md index 8ef668a1a..ecb6f4ea7 100644 --- a/docs/reference/specs/agent-general.md +++ b/docs/reference/specs/agent-general.md @@ -8,7 +8,8 @@ The default — the plain @-mention. A fast model with a small, workspace-free t ## Behavior -1. Answers directly and concisely in Slack-friendly formatting — the length is the shared brevity rule's ([routing-and-config.md](routing-and-config.md) item 26): the answer first, nothing after it is complete. Its tools are the `assistant` toolset ([github-tools.md](github-tools.md) items 4–5): the GitHub reads (`github_repos`, `github_tree`, `github_file`, `github_search_code`, `github_issue_list`, `github_issue_get`, `github_actions_run`, `github_actions_job_log`), the issue writes (`github_issue_create` / `update` / `comment` / `delete`), `web_fetch`, and `update_status` — no shell, no file writes, no `web_search`, no verdict or PR submission. +1. Answers directly and concisely in Slack-friendly formatting — the length is the shared brevity rule's ([routing-and-config.md](routing-and-config.md) item 26): the answer first, nothing after it is complete. Its tools are the `assistant` toolset ([github-tools.md](github-tools.md) items 4–5): the GitHub reads (`github_repos`, `github_tree`, `github_file`, `github_search_code`, `github_issue_list`, `github_issue_get`, `github_actions_run`, `github_actions_job_log`), the issue writes (`github_issue_create` / `update` / `comment` / `delete`), `web_fetch`, `update_status`, `request_input`, and the channel-bound work-tracking tools ([linear-channel.md](linear-channel.md) item 16) — no shell, no file writes, no `web_search`, no verdict or PR submission. + 2. Runs on a fast/cheap model by default (production: haiku) — the speed *is* the feature; five minutes is room for repos → tree → file or an issue action, not for exploration. 3. Never claims abilities it lacks and never fabricates: when a request needs a code change, a PR review, or web research it says so and points at the agent that can, **in the person's own words, never as a directive line to type** — the prompt says a plain message reaches those agents by itself ([routing-and-config.md](routing-and-config.md) item 21: a change routes to `ship`, the write door) and gives the plain-words form as the example ("in acme/api: fix the failing login test", "review ``"), and forbids handing back a command or an `agent:…` line. A general answer that ends in "run `agent:ship …`" is the router's miss turned into the person's chore, the shape the door exists to remove; the redirect names what was found and how to ask, not what to type. When it acts, it reports exactly what the tool did (issue number + URL) and never claims an action it did not perform. A loosely-named repo ("the switchboard app") is resolved with `github_repos` or the thread, not by asking. Deleting an issue happens only on an explicit delete request — closing is an update; and since GitHub grants deletion only to a repo admin's user credential, the App-backed delete reports its refusal and offers to close instead ([github-tools.md](github-tools.md) item 1). Regression pins: inventing a repo URL and telling the user to run git; bouncing "open an issue on the switchboard app" to `agent:coding` with "I have no tools for that". 4. **Runs on machine class `none`** (`AgentDef.machine`): a general ask never creates, reconnects, or touches a sandbox or workspace — even when remote execution (E2B/Cloudflare) is configured, and even when the sandbox credential is missing. The GitHub tools are REST calls in the bot process, not a workspace. See [execution.md](execution.md) behavior 7. @@ -21,7 +22,7 @@ The default — the plain @-mention. A fast model with a small, workspace-free t | Criterion | Proof | |---|---| | Toolset `assistant`, 5 min; the turn guard derived from the wall clock | `[unit]` `src/agents/registry.test.ts::general: the assistant toolset*`, `::the turn cap is a runaway guard derived from the wall clock…::*` | -| `assistant` = GitHub reads + issue writes + `web_fetch` + `update_status`, nothing else | `[unit]` `src/tools/github.test.ts::toolset wiring::assistant has no shell*` | +| `assistant` = GitHub reads + issue writes + channel-bound work tracking + `web_fetch` + `update_status`, nothing else | `[unit]` `src/tools/github.test.ts::toolset wiring::assistant has no shell*` | | Machine class `none` (coding and review declare `repo-resident`) | `[unit]` `src/agents/registry.test.ts::machine class declarations: coding and review run on repo-resident; general and research on none` | | With remote execution configured, a general ask provisions no sandbox and still answers | `[unit]` `src/core/dispatcher.test.ts::a general ask with remote execution configured provisions no sandbox and still answers` | | Prompt names its GitHub tools, forbids claiming unperformed actions, redirects coding/review/research in plain words (the example ask, "review ``", "asking for it in plain words in a new message") and never as an `agent:…` line to type, never says "NO tools" | `[unit]` `src/agents/registry.test.ts::general's prompt names its GitHub tools*` | diff --git a/docs/reference/specs/agent-review.md b/docs/reference/specs/agent-review.md index 2d5b702ff..6135de7d0 100644 --- a/docs/reference/specs/agent-review.md +++ b/docs/reference/specs/agent-review.md @@ -48,6 +48,11 @@ Reviews a PR with the full change in context and reports ranked, evidence-anchor ## Validation criteria +Final verdict and re-review turns can ask the requester for missing information +through `request_input`. A pending question becomes the channel reply, prevents +another verdict turn and skips automatic review publication until answered +([Linear channel](linear-channel.md), criterion 19). + | Criterion | Proof | |---|---| | Budgets and toolset as specified; the turn guard derived from the wall clock | `[unit]` `src/agents/registry.test.ts::review: readonly toolset, 25 min`, `::the turn cap is a runaway guard derived from the wall clock…::*`; the budget mechanics are [harness-pi.md](harness-pi.md) item 15's rows. | diff --git a/docs/reference/specs/authorization.md b/docs/reference/specs/authorization.md index 479e78a59..606cf7044 100644 --- a/docs/reference/specs/authorization.md +++ b/docs/reference/specs/authorization.md @@ -1,13 +1,13 @@ # Authorization -One decision, `authorize(actor, action, resource) → allow | deny(reason)`, over a policy table of data rows, is the only mechanism that decides what a caller may do. Identity is resolved **once per surface** into a typed `Actor`; every gate is a **policy row**; channel visibility is the **relation `member-of(actor, channel)`** and holds on every surface — Slack, HTTP ingress, MCP, the Access-gated `/api`, the CLI, the schedule shim, and memory reflection. Machines get their own gated identities, and a token must not read another team's runs (a denied read is `not_found`). The weekly self-improvement cron seeing zero runs, an Access operator reading a private-channel run, and a private-conversation fact becoming `org` memory are the three leaks the model closes — as consequences of one table, not as special cases. +One decision, `authorize(actor, action, resource) → allow | deny(reason)`, over a policy table of data rows, is the only mechanism that decides what a caller may do. Identity is resolved **once per surface** into a typed `Actor`; every gate is a **policy row**; channel visibility is the **relation `member-of(actor, channel)`** and holds on every surface — Slack, Linear, HTTP ingress, MCP, the Access-gated `/api`, the CLI, the schedule shim, and memory reflection. Machines get their own gated identities, and a token must not read another team's runs (a denied read is `not_found`). The weekly self-improvement cron seeing zero runs, an Access operator reading a private-channel run, and a private-conversation fact becoming `org` memory are the three leaks the model closes — as consequences of one table, not as special cases. - **Code**: [`src/core/authz/viewAs.ts`](../../../src/core/authz/viewAs.ts) (`holdsAll`, `isViewablePerson`, `viewingRefusal` — the words every surface of view-as agrees on, item 17), [`src/channels/viewAs.ts`](../../../src/channels/viewAs.ts) (the `sb-view-as` cookie, its two routes, `requestersOf`, `refuseWhileViewing`), [`src/core/authz/types.ts`](../../../src/core/authz/types.ts) (the shared contract: `Actor`, `ActorKind`, `Grants`/`GrantSet`/`NO_GRANTS`, `Action`, `Resource`/`ResourceType`, `ChannelVisibility`, `Condition`, `Rule`, `Decision`, `Predicate`, `ChannelDirectory`); [`src/core/authz/policy.ts`](../../../src/core/authz/policy.ts) (the table + module-load validation); [`src/core/authz/authorize.ts`](../../../src/core/authz/authorize.ts) (the one public decision); [`src/core/authz/predicate.ts`](../../../src/core/authz/predicate.ts) (`predicateFor`, store-side only); [`src/core/authz/grants.ts`](../../../src/core/authz/grants.ts) (`grantsFor`, the `grants` + `restrict` blocks, the namespace baselines, `mayRunAgent` / `mayUseRepo`); [`src/core/authz/actor.ts`](../../../src/core/authz/actor.ts) (per-surface actor resolution, `chatActorOf` — the actor every dispatch gate decides on); [`src/core/ingressTokens.ts`](../../../src/core/ingressTokens.ts) (the one token-map parser: `{ subject, channel?, email? }`); [`src/channels/requester.ts`](../../../src/channels/requester.ts) (`boundRequester`: a credential bound to a person, item 15); [`src/core/authz/channelDirectory.ts`](../../../src/core/authz/channelDirectory.ts) (the seam and its static id mapping, `StaticChannelDirectory`); [`src/channels/slackChannelDirectory.ts`](../../../src/channels/slackChannelDirectory.ts) (`SlackChannelDirectory`: `conversations.info` behind the seam, TTL cache); [`src/core/memory/reflection.ts`](../../../src/core/memory/reflection.ts) (the memory write gate, `reflectionActor`). Adapter touch points: [`src/core/commandChat.ts`](../../../src/core/commandChat.ts) (chat actor), [`src/channels/mcp.ts`](../../../src/channels/mcp.ts) (token actor), [`src/channels/commandHttp.ts`](../../../src/channels/commandHttp.ts) (`accessActor`: the Access browser / service-token actor — the one resolver for every surface the Access gate fronts), [`src/channels/liveView.ts`](../../../src/channels/liveView.ts) (the `/runs` pages decide with that actor: the index through `predicateFor`, a tokenless run read through `authorize`), [`src/index.ts`](../../../src/index.ts) (the Slack directory wired as the dispatcher's `channelDirectory` once the Slack adapter is up; the Access actor handed to the live-view handler), [`src/core/dispatch/record.ts`](../../../src/core/dispatch/record.ts) (the visibility stamp `channelVisibilityOf`, bounded by `CHANNEL_DIRECTORY_TIMEOUT_MS`; `RecordDeps` is the slice of `CoreDeps` it reads), [`src/core/schedules.ts`](../../../src/core/schedules.ts) (schedule actors declared in the registry), [`src/config.ts`](../../../src/config.ts) (`grants` schema), [`src/config/validate.ts`](../../../src/config/validate.ts) (`validateConfig` rules), [`src/core/authz/pointingActor.ts`](../../../src/core/authz/pointingActor.ts) (the pointing actor the `conversation:read` row is asked for). - **Tests**: [`src/channels/viewAs.test.ts`](../../../src/channels/viewAs.test.ts) (item 17: the cookie, the routes, the refusal), [`src/core/authz/policy.test.ts`](../../../src/core/authz/policy.test.ts), [`src/core/authz/authorize.test.ts`](../../../src/core/authz/authorize.test.ts), [`src/core/authz/predicate.test.ts`](../../../src/core/authz/predicate.test.ts), [`src/core/authz/grants.test.ts`](../../../src/core/authz/grants.test.ts), [`src/core/authz/actor.test.ts`](../../../src/core/authz/actor.test.ts), [`src/core/authz/channelDirectory.test.ts`](../../../src/core/authz/channelDirectory.test.ts), [`src/channels/slackChannelDirectory.test.ts`](../../../src/channels/slackChannelDirectory.test.ts); the adapter tests [`src/core/commandChat.test.ts`](../../../src/core/commandChat.test.ts), [`src/channels/mcp.test.ts`](../../../src/channels/mcp.test.ts), [`src/channels/commandHttp.test.ts`](../../../src/channels/commandHttp.test.ts), [`src/core/ingressTokens.test.ts`](../../../src/core/ingressTokens.test.ts), [`src/channels/requester.test.ts`](../../../src/channels/requester.test.ts), [`src/channels/liveView.test.ts`](../../../src/channels/liveView.test.ts), [`src/channels/slack.test.ts`](../../../src/channels/slack.test.ts), [`src/core/schedules.test.ts`](../../../src/core/schedules.test.ts), [`src/config.test.ts`](../../../src/config.test.ts); the store side [`src/core/commands/runs.test.ts`](../../../src/core/commands/runs.test.ts), [`src/core/commands/friction.test.ts`](../../../src/core/commands/friction.test.ts), [`src/core/frictionLedger.test.ts`](../../../src/core/frictionLedger.test.ts), [`src/core/memory/reflection.test.ts`](../../../src/core/memory/reflection.test.ts); the regression net [`src/core/commandConformance.test.ts`](../../../src/core/commandConformance.test.ts) (actor kind × action × resource enumerated from the table), [`src/core/authz/pointingActor.test.ts`](../../../src/core/authz/pointingActor.test.ts); the click (item 16) [`src/core/dispatch/confirm.test.ts`](../../../src/core/dispatch/confirm.test.ts) and the confirmation describe of [`src/core/dispatcher.test.ts`](../../../src/core/dispatcher.test.ts). ## Behavior -1. **Actors — resolved by the surface, never trusted from the request.** An `Actor` is `{ kind, id, grants, onBehalfOf?, origin?, self?, asUser? }` — `self` the ids that mean "me" (the actor's own, and the person a dashboard session is linked to, [record 0042](../../decisions/0042-a-dashboard-session-is-the-person-its-email-names-identity-not-authority.md)), read by `is-self`, `acts-as-person`, the `me` tier and the "mine" filters and never by a grant check; `asUser` that person, for display and the audit line. `kind ∈ { user, service, schedule, agent }`; `id` is platform-namespaced (invariant 4) and is the ONE identity every later decision keys on: `slack:U…` (a Slack user), `http:` (an ingress token's subject), `mcp:` (the same token over MCP), `access:` (a Cloudflare Access browser session), `access:svc:` (an Access service token), `cli:local`, `schedule:`, `agent:`. Each adapter returns exactly what it can prove and nothing more: Slack the user id of the event; HTTP/MCP the token entry the bearer matched; the Access gate the verified JWT's `sub` or `common_name`; the CLI the fixed local identity; the schedule shim the registry entry that fired. **Grants are never attached by the adapter**: one resolver, `grantsFor(actor, config)`, reads them from configuration (item 9), so two surfaces resolving the same id get the same grants. `origin` (a chat actor's `{ channelId, threadKey }`) is context for the channel-scoped commands and the channel memory scope — never authority ([command-registry.md](command-registry.md) item 21). An `agent` actor carries `onBehalfOf`, the principal whose run it is; its **effective grants are the intersection** of its own and the principal's, computed per `GrantSet` (`"all" ∩ S = S`, `S ∩ T` set-wise) — an agent never exceeds the person it acts for. An actor the adapter cannot resolve is `NO_GRANTS` on every axis. Adapters make **no authorization decision**: no scope comparison, no channel pin, no gate resolver — building the `Actor` is the whole of their part. +1. **Actors — resolved by the surface, never trusted from the request.** An `Actor` is `{ kind, id, grants, onBehalfOf?, origin?, self?, asUser? }` — `self` the ids that mean "me" (the actor's own, and the person a dashboard session is linked to, [record 0042](../../decisions/0042-a-dashboard-session-is-the-person-its-email-names-identity-not-authority.md)), read by `is-self`, `acts-as-person`, the `me` tier and the "mine" filters and never by a grant check; `asUser` that person, for display and the audit line. `kind ∈ { user, service, schedule, agent }`; `id` is platform-namespaced (invariant 4) and is the ONE identity every later decision keys on: `slack:U…` (a Slack user), `linear::` (a Linear person), `http:` (an ingress token's subject), `mcp:` (the same token over MCP), `access:` (a Cloudflare Access browser session), `access:svc:` (an Access service token), `cli:local`, `schedule:`, `agent:`. Each adapter returns exactly what it can prove and nothing more: Slack the user id of the event; Linear the signed session creator or prompt author within the installed workspace; HTTP/MCP the token entry the bearer matched; the Access gate the verified JWT's `sub` or `common_name`; the CLI the fixed local identity; the schedule shim the registry entry that fired. **Grants are never attached by the adapter**: one resolver, `grantsFor(actor, config)`, reads them from configuration (item 9), so two surfaces resolving the same id get the same grants. `origin` (a chat actor's `{ channelId, threadKey }`) is context for the channel-scoped commands and the channel memory scope — never authority ([command-registry.md](command-registry.md) item 21). An `agent` actor carries `onBehalfOf`, the principal whose run it is; its **effective grants are the intersection** of its own and the principal's, computed per `GrantSet` (`"all" ∩ S = S`, `S ∩ T` set-wise) — an agent never exceeds the person it acts for. An actor the adapter cannot resolve is `NO_GRANTS` on every axis. Adapters make **no authorization decision**: no scope comparison, no channel pin, no gate resolver — building the `Actor` is the whole of their part. 2. **Actions — today's vocabulary, no new words.** An `Action` is a command's effect class `:read | :write | :exec` (`runs:read`, `friction:write`, `repo:exec`, …), or one of the non-command actions: `agent:run:` (start the named agent — decided in `dispatch()`, invariant 3; `agent:run:*` = every agent), `repo:use` (act inside a repo's resident environment), `memory:write` (reflection writing a fact to a scope), `schedule:fire` (the schedule shim's actor firing a schedule), `coordinator:step` (a ship coordinator's step — `spawn`, `read-record`, `pr-check`, the instance creation — held by the `coordinator` ingress bearer's `service` actor and admitted to no other kind, an admin's `all` included: the steps are a program's, and the child a spawn starts is authorized as the requesting user the parent record names, never as the holder of this grant; [http-ingress.md](http-ingress.md) item 9), and `plan:merge` (the plan runner's merge of a unit's pull request — [record 0031](../../decisions/0031-the-coordinator-runs-a-plan-not-a-pull-request.md)'s merge grant, held by the same bearer by name, a `service` actor and no person, an admin's `all` included: what decides a merge is the branch and the guards, never the requester, and withdrawing the grant returns every merge to a person; the `merge` step of [http-ingress.md](http-ingress.md) item 9 decides it beside `coordinator:step`). A command declares its `action` once in `defineCommand`; `write` never implies `exec`, and no action authorizes a run. 3. **Resources — typed, with the attributes rules read.** `run { id, channelId, userId, repo?, channelVisibility? }`; `channel { id, visibility }`; `memory-scope { key, kind: org | user | repo | channel, originChannelVisibility? }`; `repo { owner, name }`; `config-scope { kind: channel, id, visibility? }` (the channel's own visibility, read by `member-of`'s public half when the scope is read from another channel; absent → never public), `config-scope { kind: user, id }` and `config-scope { kind: org }` (the three config tiers, where MCP servers live too); `agent { name }`; and `command { id }` for list-shaped actions with no single resource (`runs.list`, `friction.report`) and for every command whose definition resolves no other resource. `ChannelVisibility ∈ { public, private, dm, machine, unknown }`; `machine` is every `http:*` / `mcp:*` channel, whose members are the tokens granted it. A handler names the resource it is about to touch; nothing about a resource is inferred from the request. 4. **The policy table — data, closed vocabulary, validated at load.** A `Rule` is `{ action, resource, actorKinds?, resourceKind, originVisibility?, when: Condition[] }`. Within a row `when` is **ANDed**; rows for the same `(action, resource)` are **ORed**; **no matching row is a deny**. A row also carries one-sided **selectors** — `actorKinds` (the actor's kind), `resourceKind` (a `memory-scope`'s `org | user | repo | channel` or a `config-scope`'s `channel | user | org` — REQUIRED on a row for a kinded resource type and FORBIDDEN on every other, typed per resource as `KindOf` and enforced by the `Rule` type: a `memory-scope` row without a kind or a `run` row with one does not compile), `originVisibility` (the resource's origin channel visibility) — that pick WHICH rows apply to a request and read one side only, as distinct from the five **conditions** below, which relate actor to resource; the org-write rule (item 8) is an `originVisibility: [public]` selector on the `memory:write` × `memory-scope { kind: org }` row — a point-check-only row, never compiled into a list predicate. The condition vocabulary is CLOSED: `has-grant(g)` (`actor.grants.actions` contains `g` or is `"all"`), `member-of` — ONE definition, used by both evaluators: it holds when `actor.grants.channels` contains the resource's channel id (or is `"all"`), OR the actor's `memberOf` contains it (the channels the channel directory says the actor's PERSON is in, resolved once with the actor — a fact about the person, never a grant: item 7), OR the channel's visibility is `public` (`resource.channelVisibility`, stamped on the run at dispatch; `unknown` is never public). Channels are channels: there is no machine-channel vocabulary, no channel-prefix rule — an `http:`/`mcp:` channel is a member's channel exactly when the grant names it. `memberOf` is read from the root principal like `self` (an agent on behalf of a person reads through the person's membership and still needs its own `has-grant`), and no grant check reads it: an actor with and without `memberOf` holds the same effective grants and passes the same command rows — `is-self` (`resource.userId` equals the actor's id, or the `onBehalfOf` principal's), `owner-of` (`actor.grants.repos` contains the resource's `owner/name`, or is `"all"`), `all-channels` (`actor.grants.channels === "all"`). Every member is both evaluable against one resource and compilable to a store predicate — that is the admission test for a condition; a need for an arbitrary predicate is a plan-level stop, not a local addition. The table is validated when the module loads: a rule naming an unknown condition kind, an unknown resource type, or an action no command declares throws at import, so a malformed table can never serve a request. Every admission is a row, one vocabulary for humans and machines: every command row is ` command [has-grant()]`, and what differed by surface is now what each actor HOLDS (item 9) — `open` → the actions of those commands are the baseline every Slack user holds (`CHAT_OPEN_ACTIONS`: `help|config|repo|friction|memory|mcp|schedule:read`, `memory:write`, `mcp:write`), a browser session every `:read`, a token exactly its scopes, so a `dispatch`-only token is refused by the same row that admits a Slack user; `operator` → `has-grant(:write)` (or `:read` for the read commands) — the grant the admins' `all` translation and an operator's grants carry; `repoManager` → `has-grant(repo:write)` (and `friction:write` for `friction propose`); `channelConfig` → `has-grant(config:write)` on `config-scope { channel }`, asked by the `config.*` handlers for the `channel` scope (the `config:write` grant: never a baseline — its holders, admins, Access operators, and tokens minted with it) — membership is NOT a condition of that row: a chat user may target another channel with `--channel`, and no adapter proves channel membership until the directory (item 7's gap), so a `member-of` there would deny every non-admin `config set channel`; the command itself has two `config:write` rows on `command` — `actorKinds: [user]` with no condition (a person always has their own scope to write) and `has-grant(config:write)` (a credential needs the grant); READING another channel's scope — its instructions text included — is the table's decision too: two `config:read` rows on `config-scope { channel }`, asked by `config show --channel` and by the instructions peek (`config instructions channel` with no text) for a named target — `has-grant(config:write)` for the caller's own actor (whoever may set a channel's scope may read it; the actor's channel grants and a public channel admit through `member-of` as well) and `member-of` for the pointing actor of item 13 (one membership, the origin, no grants), so a public channel's scope is readable from anywhere, a private one only from inside it or by grant, and `unknown` (no directory, a failed or slow lookup) like a private one; the visibility is the channel directory's, bounded like the run stamp (item 7), not looked up for the caller's own channel; one refusal text for private, DM, unknown and nonexistent alike; `agentRun` → `repo test|build` resolve the resource `agent { coding }` (`CommandDef.resource`), decided by `repo:exec agent [has-grant(agent:run:{name})]` (the right to run the implicit target agent) or `repo:exec agent [has-grant(repo:exec)]` (the exec grant a token was minted with) — which agents an actor may run is an action, not a grant axis (`Grants` has exactly three: `actions`, `channels`, `repos`). The MCP tiers are `mcp:write` rows on `config-scope { channel }` (`has-grant(config:write)`, or `has-grant(mcp:write)` for a `service`) and `config-scope { org }` (`has-grant(repo:write)`, or `has-grant(mcp:write)` for a `service`); forgetting a shared memory record is the `repo:write` right. A run read is `runs:read` ∧ (`member-of` ∨ `all-channels`); a `run` resource with `is-self` is how a DM run belongs to its user. @@ -15,16 +15,16 @@ One decision, `authorize(actor, action, resource) → allow | deny(reason)`, ove 6. **List reads — the same rules, pushed into the store.** `predicateFor(actor, action, resourceType)` compiles the rows for a list-shaped action into a `Predicate` — `none`, `all`, `channels-in(ids)`, `repos-in(repos)`, `user-is(id)`, `visibility-in(visibilities)`, or an `and` / `or` of those (a row's ANDed conditions → `and`, the ORed rows → `or`) — from the SAME table `authorize` reads: `all-channels` → `all`; `member-of` → `or(channels-in(actor.grants.channels ∪ actor.memberOf), visibility-in(["public"]))` — the same definition as item 4 in the same vocabulary (one `channels-in` leaf, wider by the person's channels), so the run store filters on the stamped `channelVisibility` column and a `public` run is listed for everyone (the `visibility-in` leaf is the one addition to the contract's `Predicate`, landing with the store wiring); `owner-of` → `repos-in(actor.grants.repos)`; `is-self` → `user-is`; no matching row → `none`. It is callable only from the store adapters (`RunsService`, `RunStoreFrictionLedger`, the memory stores), which hand it to the store as its own filter: `RunListOptions.visibleTo` is the predicate's wire form (`RunVisibilityFilter` in `runRecord.ts` — the same tree with its sets as arrays), the in-memory and file stores evaluate it with `matchesVisibility` (the one truth table), the run-history DO compiles it to SQL (`channel_id IN (…)`, `channel_visibility IN (…)`, `user_id = ?`, `repo IN (…)`, each on an index ordered like the page — never a full scan), `none` never touches the store, and `all` sends no filter — so a list returns only what a point read would have allowed and **no surface loads records and filters afterwards**. A store that receives a malformed filter answers 400, never `all`. A denied list is an empty page, not an error. The invariant that makes this safe is the **`predicate ⇔ authorize` differential**: for any actor and any fixture of records, filtering by the store predicate equals filtering by point `authorize` on each record — the load-bearing test of the module. `runs list` without a store predicate (`predicateFor` returns `none` for an unknown actor) lists nothing. The "mine" filter every list surface offers (`runs list --mine`, `/runs?mine=1`) is `allOf([predicateFor(actor, …), ownedBy(actor)])` — `ownedBy` one `user-is` per id in the actor's `self` set, ORed — a narrowing of the readable predicate, never a second decision: a run the policy hides stays hidden, and an actor no run is requested as (an unlinked dashboard session, a service token) owns nothing. 7. **Channel visibility — a relation, on every surface.** `member-of(actor, channel)` and the `all-channels` grant are the two ways a run is readable; the Access API is bound like every other surface (an operator without `all-channels` gets `not_found` on a private-channel run they are not a member of), and so are the `/runs` pages: `src/index.ts` resolves the verified Access identity with the same `accessActor` the `/api` adapter uses and hands it to the live-view handler, whose index lists (the default live rows, `?all=1`, the `?stream=1` feed, the Scheduled tab's live links) through `predicateFor(actor, "runs:read", "run")` and whose tokenless finished-run routes (page, events, friction, and the 409 a tokenless stop gives a finished run) `authorize` the run's own attributes — each after the `runs:read` command admission `/api/runs.list` / `runs.get` ask first, so the same identity sees on the page exactly what the API answers, and a deny is the same 404 as an unknown id with the reason on the audit line only. The capability-token live page is untouched: the token IS the capability and the actor is not consulted on it ([live-view.md](live-view.md)). `ChannelDirectory { info(channelId) → { visibility }; isMember(actorId, channelId) → boolean | "unknown"; channelsOf(actorId) → Set | "unknown" }` is the adapter seam that supplies channel facts: it is the SOURCE of the `channelVisibility` stamp (dispatch asks it once per run) and of the actor's `memberOf` (the resolver asks `channelsOf` once per actor, for the linked person, waiting at most `CHANNEL_DIRECTORY_TIMEOUT_MS` like the stamp — past it the actor resolves without `memberOf` and the late answer fills the cache for the next request) — it is **not consulted at `authorize` time**, which reads only the stamped resource and the resolved actor; `unknown` — a channel the directory has never seen, a person it cannot list, an API failure, a timeout — is **never public and never a member**. Visibility alone gives, through item 4's `member-of`: `public` → every actor of the workspace is a member; `private` and `dm` → the run's own `userId` (`is-self`) and holders of `all-channels`; `machine` → the tokens granted that channel (`grants.channels`). **Membership** adds the third way: a dashboard session linked to its Slack person ([record 0042](../../decisions/0042-a-dashboard-session-is-the-person-its-email-names-identity-not-authority.md)) carries `memberOf` = the Slack directory's `channelsOf(person)` — every public and private channel the person is in **that the bot is also in** (`users.conversations` for the person answers within the bot token's own reach; a private channel the bot was never invited to is invisible to it and never had a run) — so a private channel's runs, its config scope and its MCP tier open to the people in it, on the runs index, the run page, the settings tabs and `/api` alike, with no handler change. Freshness is **event-driven first, TTL as the degraded path**: the bot subscribes to `member_joined_channel` / `member_left_channel` (forget that person's set — or everyone's when the member is the bot itself, since a channel the bot just joined is now visible on every member's set) and to `channel_left`, `group_left`, `channel_archive`, `group_archive`, `channel_deleted`, `group_deleted` (the bot's own reach moved: forget everyone's), and forgets everyone on every socket `connected` (Socket Mode replays nothing missed while down) — all over the existing Socket Mode connection, no inbound webhook, no new scope (`channels:read`/`groups:read` cover the events; the manifest lists them); the per-person cache (`MEMBERSHIP_TTL_MS`, ten minutes, bounded like the visibility cache, concurrent first asks sharing one paged call, a failure remembered for the window with one `[authz]` line) is what bounds a stale answer when an event never arrives. A stale-ALLOW after a member leaves therefore lasts until the leave event (milliseconds) or at most the TTL; a stale-DENY is impossible because `unknown` denies, and a new member's first read waits only for the join event or the TTL. A chat actor (`resolveChatActor`) does not carry `memberOf` yet: a Slack user's `runs list` still lists the public channels, the granted ones and their own ([gap] below). Membership is a third way in, so it changes nothing for an actor the first way already admits everywhere: in an installation whose `access:*` surface entry holds `channels: all`, every browser session reads every channel by grant and none exercises the membership half — the relation is proven there by its unit rows alone, and observed live only once a browser session without `all` exists. Every run is **stamped with `channelVisibility` at dispatch** (`dispatch()` asks `CoreDeps.channelDirectory` once per run and writes the answer into the `RunMeta` and the record), so neither read-side membership nor the memory write gate needs a Slack call at read time; a record without the stamp is `unknown` → private for writes, membership-gated for reads. Two directories implement the seam. The **static** one (`src/core/authz/channelDirectory.ts`, `visibilityOf` — the dispatcher's default, what the CLI and the tests get) is what the platform-namespaced id alone establishes: `http:*` / `mcp:*` → `machine`, `slack:D…` → `dm`, `slack:G…` → `private`, a Slack `C…` channel → `unknown`, because only `conversations.info` can tell a public `C…` from a private one. The **Slack** one (`src/channels/slackChannelDirectory.ts`, `SlackChannelDirectory`, wired by the bot in `src/index.ts` as soon as the Slack adapter is up) asks `conversations.info` for a `slack:C…` or `slack:G…` id — `is_im` or `is_mpim` → `dm` (a group DM is private to its members like a DM, and its facts belong to the person, item 8), `is_private` → `private`, else `public` — once per channel per TTL (`CHANNEL_INFO_TTL_MS`, ten minutes; a bounded FIFO cache of 1000 channels like the adapter's name caches; concurrent first lookups share one call), and answers **`unknown` on any failure** — an API error, a missing scope, a reply without a channel — remembering the failure for the same TTL so a failing channel costs one Slack call and one `[authz]` log line per window, never a call per message (a failure is grants-only visibility, never public). A `slack:D…` id is `dm` from the id alone and never reaches the API (no `im:read` needed); a non-Slack id is the static answer. The lookup needs the bot's `channels:read` (public `C…`) and `groups:read` (private `C…`/`G…`) — both already in the required set the startup scope check enforces ([slack-channel.md](slack-channel.md) item 6a). The dispatcher **bounds the wait**: a directory slower than `CHANNEL_DIRECTORY_TIMEOUT_MS` (1.5 s — a cold `conversations.info` is one round trip; every later message in the channel is a cache hit) stamps `unknown` and the run proceeds, so a Slack outage costs a reply at most that long. Consequence: a run in a **public** Slack channel is stamped `public` and readable by every actor (`member-of`'s public half) — a plain Slack user's `friction report` now counts the public channels' runs, their own, and the channels their grants name; a run in a private channel or a DM stays readable through channel grants, `all-channels`, or `is-self` only; admins and operators (`all`) are unaffected. A machine token's pinned channel is the same relation: `channels: {"http:ops"}` sees `http:ops` (plus any run stamped `public`) and nothing else; a token with NO channel grant sees only public runs and its own. 8. **Memory — reflection writes are gated.** For every candidate fact (and the summary), reflection resolves the extractor's `audience` to one of the run's scopes exactly as before ([memory.md](memory.md) item 23) and then calls `authorize(runActor, "memory:write", memory-scope { key, kind, originChannelVisibility })`, where `originChannelVisibility` is the run's stamped `channelVisibility` (item 7) and `runActor` is the run's principal (the same actor the chat commands resolve for the message's user id) **holding the run's own channel and repo as memberships** (`reflectionActor`): both are facts the dispatcher established before the run — the adapter delivered the message from that channel, the repo gate admitted that repo — stated on the membership axis so the `member-of` / `owner-of` rows recognize the run's own scopes; no action is added and `"all"` is left alone, so the one question left to the table is whether the origin may write `org`. That question: the `org` row carries the selector `originVisibility: [public, machine]`, so a fact whose run originated in a `private` or `dm` channel — or whose run is **unstamped / `unknown`** (a directory failure, a timeout, a record from before the stamp) — has **no `org` row** and is **narrowed**, never widened, never silently dropped: a `dm` origin narrows `org` → `user` (a DM is one person's conversation with the bot; its facts are that person's and travel with them), a `private` or `unknown` origin narrows `org` → `channel` (the origin's own audience — exactly its participants), and either falls to the other when the run lacks the first; `repo` is never a narrowing target, because a repo scope is read from every channel the repo is used in — wider than the origin. A narrowed correction is written **without its `supersedes`**: the record it would have retired lives in the scope the origin may not write, so that record stands. `user`, `channel`, and `repo` facts stay where the extractor put them (the audience is a hint the policy narrows, never widens); a fact from a `public` or `machine` origin keeps today's routing, org included. Any write the table denies for another reason — a candidate whose scope the actor is not a member of — is dropped, never rerouted wider. Every narrowing or drop is one `[memory]` line per reflection carrying **counts and reason tokens only** (`2× org → user (origin-visibility)`), never a fact's text. Reads are unchanged ([memory.md](memory.md) item 22): `memoryContextBlock` derives its scopes from the request and never calls `authorize`. -9. **Configuration — one `grants` shape and a `restrict` block.** `grants: { : { actions?: string[] | "all", channels?: string[] | "all", repos?: string[] | "all" } }` keyed by platform-namespaced actor id — three axes, no more (agents are actions, item 2); an absent axis is the empty set, `"all"` is explicit and never a default. `restrict: { agents?: string[], repos?: string[] }` names what is **closed unless granted**: a listed agent runs only for an actor whose grants hold `agent:run:` (or `all`), a listed repo is used only by an actor whose `repos` axis names it (case-insensitively, or `all`); everything unlisted is open to everyone who can reach the bot, and restricting one thing never takes a grant from anyone. `restrict.agents` must name registered agents and `restrict.repos` `owner/name` slugs, or the load fails naming the offender. `grantsFor(id)` is a namespace **baseline** plus the id's entry: a `slack:` id holds the open chat commands (`CHAT_OPEN_ACTIONS`) and `agent:run:` for every unrestricted agent, listed or not, and an entry ADDS to that ("exactly what it declares" is the rule for credentials); an `access:` browser session holds every registered group's read, an entry adds; `access:svc:`, `http:`, `mcp:` credentials hold exactly their entry, unlisted `NO_GRANTS`; a `schedule:` id holds what the schedule registry declares for it (`actor: { kind: "schedule", id: "schedule:", grants }`, no config list hand-names `http:cron`) unless the block names it, then config wins whole; `cli:local` everything. A key `:*` — `slack:*`, `http:*`, `mcp:*`, `access:*` — is a **surface entry**: the same three axes, held by every actor that authenticated on that surface (`access:*` is every browser session, the org Cloudflare Access admits — never an `access:svc:` token). An actor's grants are the **union** of its own entry (or its baseline), and its surface entry: a personal entry never narrows the surface entry, so a person listed for extra rights keeps what everyone holds. `*` is only ever a whole surface: a partial subject (`slack:U*`) and `schedule:*`, `access:svc:*`, `agent:*`, `cli:*` fail the load naming the key — schedules and service tokens are individually named identities, an agent derives from its principal, the CLI holds everything. A surface entry is never an actor: `adminsHint` names people, and an id no surface owns inherits no `*` entry (fail-closed, item 10). `config:write`, `repo:write`, and every `runs:*` are never a baseline. `grants` and `restrict` are the only authorization keys: any other top-level key (`permissions` among them) is unknown and fails the load by name, and an ingress token entry is exactly `{ subject, channel?, email? }` — `email` binds the credential to a person (item 15), and any other field (`scopes`, say) is ignored, so nothing in the token map can widen the grants entry. `adminsHint` / `canManageRepos` / `canEditChannelConfig` / `canRunAgent` / `canUseRepo` / `restrictedAgentsFor` all answer from the grants table. The baselines: +9. **Configuration — one `grants` shape and a `restrict` block.** `grants: { : { actions?: string[] | "all", channels?: string[] | "all", repos?: string[] | "all" } }` keyed by platform-namespaced actor id — three axes, no more (agents are actions, item 2); an absent axis is the empty set, `"all"` is explicit and never a default. `restrict: { agents?: string[], repos?: string[] }` names what is **closed unless granted**: a listed agent runs only for an actor whose grants hold `agent:run:` (or `all`), a listed repo is used only by an actor whose `repos` axis names it (case-insensitively, or `all`); everything unlisted is open to everyone who can reach the bot, and restricting one thing never takes a grant from anyone. `restrict.agents` must name registered agents and `restrict.repos` `owner/name` slugs, or the load fails naming the offender. `grantsFor(id)` is a namespace **baseline** plus the id's entry: a `slack:` or `linear:` id holds the open chat commands (`CHAT_OPEN_ACTIONS`) and `agent:run:` for every unrestricted agent, listed or not, and an entry ADDS to that ("exactly what it declares" is the rule for credentials); an `access:` browser session holds every registered group's read, an entry adds; `access:svc:`, `http:`, `mcp:` credentials hold exactly their entry, unlisted `NO_GRANTS`; a `schedule:` id holds what the schedule registry declares for it (`actor: { kind: "schedule", id: "schedule:", grants }`, no config list hand-names `http:cron`) unless the block names it, then config wins whole; `cli:local` everything. A key `:*` — `slack:*`, `linear:*`, `http:*`, `mcp:*`, `access:*` — is a **surface entry**: the same three axes, held by every actor that authenticated on that surface (`access:*` is every browser session, the org Cloudflare Access admits — never an `access:svc:` token). An actor's grants are the **union** of its own entry (or its baseline), and its surface entry: a personal entry never narrows the surface entry, so a person listed for extra rights keeps what everyone holds. `*` is only ever a whole surface: a partial subject (`slack:U*`) and `schedule:*`, `access:svc:*`, `agent:*`, `cli:*` fail the load naming the key — schedules and service tokens are individually named identities, an agent derives from its principal, the CLI holds everything. A surface entry is never an actor: `adminsHint` names people, and an id no surface owns inherits no `*` entry (fail-closed, item 10). `config:write`, `repo:write`, and every `runs:*` are never a baseline. `grants` and `restrict` are the only authorization keys: any other top-level key (`permissions` among them) is unknown and fails the load by name, and an ingress token entry is exactly `{ subject, channel?, email? }` — `email` binds the credential to a person (item 15), and any other field (`scopes`, say) is ignored, so nothing in the token map can widen the grants entry. `adminsHint` / `canManageRepos` / `canEditChannelConfig` / `canRunAgent` / `canUseRepo` / `restrictedAgentsFor` all answer from the grants table. The baselines: | Actor id | Baseline (held listed or not) | A `grants` entry … | |---|---|---| - | `slack:U…` | `{ actions: CHAT_OPEN_ACTIONS + agent:run: for every agent not under restrict.agents }` — `help:read, status:read, config:read, repo:read, friction:read, memory:read, mcp:read, schedule:read, memory:write, mcp:write` | adds to it | + | `slack:U…` or `linear::` | `{ actions: CHAT_OPEN_ACTIONS + agent:run: for every agent not under restrict.agents }` — `help:read, status:read, config:read, repo:read, friction:read, memory:read, mcp:read, schedule:read, memory:write, mcp:write, work-items:read, runs:stop:self` | adds to it | | `access:` | `{ actions: every :read + memory:write, mcp:write }` (the groups the store is handed at startup; the two personal chat writes since the web chat, [record 0043](../../decisions/0043-the-home-page-is-a-chat-the-browser-is-a-channel-and-a-turn-is-a-run.md) — the tier rows still decide the target) | adds to it | | `access:svc:`, `http:`, `mcp:` | nothing (`NO_GRANTS`) | is exactly what it holds — `dispatch` included | | `schedule:` | the registry's declared grants (`self-improvement`: `{ actions: {friction:read, friction:write}, channels: all }`) | replaces them whole | | `cli:local` | `{ actions: "all", channels: "all", repos: "all" }` — the local operator | — | - | `slack:*`, `http:*`, `mcp:*`, `access:*` (a surface entry, not an actor) | — | is unioned into every actor of that surface, on top of its own entry and baseline; `access:*` never reaches `access:svc:` | + | `slack:*`, `linear:*`, `http:*`, `mcp:*`, `access:*` (a surface entry, not an actor) | — | is unioned into every actor of that surface, on top of its own entry and baseline; `access:*` never reaches `access:svc:` | 10. **Fail-closed defaults — all preserved, enumerated.** An unresolvable actor has `NO_GRANTS`. A missing grant denies. No `(action, resource)` row denies. No admins configured means no operators (the shared 🚫 text names nobody to ask). `unknown` membership or visibility is not a member. A `channelVisibility`-less record is `unknown`. A schedule whose actor has no token sends nothing and records `misconfigured`. A malformed `scopes` or `grants` entry skips the identity rather than widening it. A `ChannelDirectory` failure yields `unknown`, never `allow`. An `agent` actor without `onBehalfOf` has its own grants only; with one, never more than the principal. 11. **Resolved questions, stated as behavior.** Admins hold `all-channels`; an operator is membership-bound unless its `channels` is `"all"` — the fleet view is one grant away, never implicit. `friction report` computes over the runs the actor can see: `RunStoreFrictionLedger.recent()` takes the actor's predicate, so a Slack user's report reflects the public channels plus their own runs and the channels their grants name, a pinned token's its channel, an `all-channels` holder's the fleet; the aggregate is per-actor by construction and a fleet view is the `all-channels` grant. Membership (item 7): the Slack directory supplies each channel's visibility from `conversations.info`, so public channels are everyone's, and each linked person's channels from `users.conversations`, so a private channel is its members' too — for the dashboard and `/api`; for a chat actor a private channel is still its user's and its grantees' until `resolveChatActor` carries `memberOf` (the remaining `[gap]`). Channels are channels: an ingress token's `channel` is where its dispatches are recorded, never a grant; a token reads no run (public runs and its own aside) until its `grants` entry names channels or `all`; the per-request pin ("the channel it speaks in") is gone. This is a **deliberate change**: it narrows tokens to what config says — a deployment grants its fleet's tokens (the cron token, an ops service token) natively, and every other token must be granted. The `self-improvement` schedule actor is declared in the registry (`scheduleActor("self-improvement", …)`) with `{ actions: {friction:read, friction:write}, channels: "all" }` — the floor `grantsFor` serves for `schedule:self-improvement` (a `grants` entry naming it replaces) — so the weekly pass analyzes every channel's runs. Until the shim hands firings to that actor, the dispatcher still sees `http:cron`, whose `grants` entry must carry the same reach. Everyone in the org: `access:*` gives every Access browser session what the entry says — Cloudflare Access already decides who may log in, so the org is granted once, never enumerated; `slack:*` does the same for every workspace member the bot hears. A restricted agent runs for an actor whose effective (unioned) grants hold `agent:run:` — a surface entry can open it to everyone on that surface, and a denial still names the actor, never the surface key. @@ -119,3 +119,25 @@ One decision, `authorize(actor, action, resource) → allow | deny(reason)`, ove | Agent actors gate tool-level actions: a coding agent's `repo:use` / push target decided by `authorize(agent actor, "repo:use", repo)` instead of the resident's compound gate | `[gap]` the `agent` kind and `onBehalfOf` exist (item 1); the resident gating has not moved | | 12: a root that ended 401/403 is never written; an internal Worker's root exists only after auth | `[unit]` `src/core/trace/workerTrace.test.ts::refusalFilter / workerLogSink::*`; the Workers' wiring is receipted live (tracing.md item 22) | | 12: the span log route opens only to an ingress bearer holding `trace:read`: 401 without a bearer, 403 with another grant, 503 without the token map ([tracing.md](tracing.md) item 26) | `[unit]` `src/channels/adminTraceLog.test.ts::GET /admin/trace/log::no bearer → 401, a bearer without trace:read → 403…` | + +## Work tracking + +`work-items:read` and `work-items:write` require the corresponding action grant +and membership in the issue’s channel. Linear resolves current team membership +and public-team access per request; the adapter supplies those facts to the +same policy table, with no configured channel override across Linear privacy +boundaries. The dispatcher binds the resolved actor outside model input. +Reads join the chat baseline; writes require an explicit grant. + +Proof: `src/channels/linear/workItems.test.ts::*`, and each policy row’s allow +and deny cases in `src/core/authz/policy.test.ts`. + +## Native cancellation + +`runs:stop` on a run permits its owner with `runs:stop:self` (the chat +baseline), or an operator holding `runs:write` with visibility of the run. +The self-stop grant does not admit run-management commands or permit stopping +another person’s run. Linear additionally scopes the control to the signed +session and to work started before the control event arrived. + +Proof: `src/channels/linear/control.test.ts::*` and the policy row coverage. diff --git a/docs/reference/specs/execution.md b/docs/reference/specs/execution.md index 9c3213289..b2e5d5852 100644 --- a/docs/reference/specs/execution.md +++ b/docs/reference/specs/execution.md @@ -39,10 +39,23 @@ Tools never touch the bot host: every `bash`/`read_file`/`write_file` runs throu 27. **A restoring resident is a short wait, never a cold fall-through (`/await-restore`).** A resident deploy restores every resident from its snapshot, and every dispatch that probed one mid-restore used to fall to the per-thread fleet at once — under load that burst exhausted `max_instances` and the runs died on capacity while a warm resident was seconds away (the leaked-slot half of the same incident was fixed separately). Event-driven, with **no polling and no retry timer on either side**: (a) the resident Worker gains `POST /await-restore` (operator scope, body `{resource}`; a not-onboarded resource is 404) whose Durable Object registers a waiter on an in-memory ledger (`RestoreWaiters`, `src/execution/restoreWaiters.ts`) BEFORE it reads the state — the read awaits storage, and a transition landing in that gap would otherwise publish to a ledger the request is not on yet and strand it — then answers a non-`restoring` state at once, withdrawing the waiter, and otherwise holds the ONE request until `setResidentState` transitions out of `restoring` — the transition itself publishes the restore event to every held waiter with the state landed on. The answer streams like `/attach` (heartbeat whitespace then one JSON document over HTTP 200) so a restore lasting minutes cannot lose the connection; a DO restart drops the held RPCs with the ledger and the bot's deadline names the fallback. (b) The bot's factory: a `/status` probe that finds the resident `restoring` opens that one held request (`ResidentExecutor.awaitRestore`, bounded by `AWAIT_RESTORE_TIMEOUT_MS` = 10 min — the outer safety net, not a retry timer) instead of falling cold; when the answer is a serviceable state it attaches as any serviceable probe does and the card names the wait (`· after waiting for the resident's restore`); a non-serviceable landing, an attach that fails after the wait, a failed wait, or an older Worker's 404 (a bundle that predates the route) takes item 26's fallback — a sandbox seeded from the probe's handle when it carried one, a fresh one otherwise — with the wait named on the note, never silently. The resumed-run re-attach path is unchanged: a resume never waits, its refusal is typed. Deploy order: the resident Worker before the bot, as always — an old Worker's 404 is the named cold fallback, today's behaviour. 28. **A container that did not accept the connection is a wait, never a dead sandbox (`runtime-busy`).** The container's runtime accepts one control connection per SDK call, and every `/exec`, `/read` and `/write` opens one. When the platform's fetch to the container's port is not accepted inside the platform's own allowance — a few seconds, well under the SDK's 30 s connect timeout — it throws a plain `Error`: `Container is taking too long to accept the connection; the application could be overwhelmed with load`. Nothing ran: the SDK was still connecting, before any process was started. The platform's words name a cause it never measured, and the token names the refusal, never its cause: live, the harness's one-second poll of its transcript log met it three minutes into a whole repository's `verify` saturating every core of the item-16 instance, AND on an idle container — a review thread whose every command was a `git clone`, two `git diff`s, a `grep` and a `sed`, each completing in under 31 ms, the runtime accepting a connection 0.9 s before the refused one and 0.8 s after it, the fleet at 7 of 25 and no deploy rolling. Both times the failure reached the bot as an ordinary in-body error (`answered`, item 9), the harness read it as the run's failure, killed the process and tore the workspace down — and the container accepted the kill a second later. The contract: (a) the **Worker** names the refusal only where no process was started — the spawn (`spawnFailure`) and the gate's warm-up — with the token `runtime-busy` in the item-3 dual shape (`runtimeBusyExecAnswer`); the file routes throw the typed `SandboxRuntimeBusyError` and the fetch handler answers HTTP 503 with the token; the recognizer is the platform's wording alone (`isRuntimeBusySignal`, `RUNTIME_BUSY_WORDING`, `src/execution/sandboxErrors.ts`), since the platform gives no type; a failure of a running command's output is never named so — the process exists, and a re-send would run it twice. (b) The **executor** re-sends the identical request on the token exactly as on `fleet-busy` (item 14) and `sandbox-starting` (item 23), after 3 s, 5 s, then 10 s, inside the operation's own budget capped at `RUNTIME_BUSY_WAIT_MAX_MS` (5 min) — when a command of the thread's own holds the container, it is bounded like every command — and throws `ExecCapacityError` naming the refusal when the wait is spent, never `ExecInfraError`; neither the token's explanation nor that message asserts what held the container or tells the reader to wait for a command that may not be running. (c) An older Worker's bare platform text stays an ordinary in-body error after one send: the token decides, never the words. The instance size is not the fix: an idle container has met the refusal, so no size rules it out, and a poll must survive it whatever the size; the SDK's connect timeout is not the knob either, since the platform's allowance runs out first and the SDK exposes none. +### Incoming file copies across channels + +A channel may provide `ChannelIO.copyAttachment(file, key)` to move its private +attachment into the configured artifact store at its credential-holding edge. +Shared staging still chooses the inbound key, publishes the receipt after a +successful copy and pulls the presigned object into the executor. Without this +capability, staging uses `ArtifactStore.copyFromUrl` as before. The method is +forwarded through child and coordinator channel wrappers and used for both the +initial request and follow-ups. Inbound message identifiers are normalized to +one key segment, including namespaced Linear event identifiers. + ## Validation criteria | Criterion | Proof | |---|---| +| Channel copy capabilities retain shared workspace pulls and receipts | `[unit]` `src/core/dispatch/staging.test.ts::staging — copy then pull::uses a channel's private copy while keeping shared workspace pulls and artifact receipts` | +| Namespaced inbound message identifiers remain a single key segment | `[unit]` `src/artifacts/keys.test.ts::key builders (item 20)::normalizes namespaced message ids into one storage segment` | | 18: `blank` → the per-thread sandbox with no repository and no credential, no resident probe though a resident is configured and a repository is in the context; on local execution, an empty per-thread workspace directory | `[unit]` `src/execution/factory.test.ts::makeExecutor machine classes::blank → a per-thread sandbox with NO repository and NO credential, no resident probe, even with a repo in the context`, `::blank (local) → a per-thread workspace directory, empty` | | 18: `repo-cold` → the per-thread sandbox with the checkout and the run's credential (write for a `full` toolset, read for `readonly`), no note, no resident probe though one is configured; without a resolved repository, an empty workspace and no note | `[unit]` `src/execution/factory.test.ts::makeExecutor machine classes::repo-cold → a per-thread sandbox WITH the checkout and the run's credential; the resident is never probed though one is configured`, `::repo-cold without a resolved repository → the per-thread sandbox with an empty workspace, no note` | | 18: the null executor's tool error names the class and the classes that provision a workspace | `[unit]` `src/execution/factory.test.ts::makeExecutor machine classes::the null executor's tool error names the class and the classes that provision a workspace` | diff --git a/docs/reference/specs/harness-pi.md b/docs/reference/specs/harness-pi.md index 97169f93c..77474171c 100644 --- a/docs/reference/specs/harness-pi.md +++ b/docs/reference/specs/harness-pi.md @@ -187,3 +187,8 @@ | 16, `[gap]` a pid-reuse guard on the re-attach — comparing the process's start time or command line to the row's facts — is not built: the container seam's `alive` is a bare `kill -0 `, so it exposes no process facts to compare, and the pid alone answers the ask-2 probe; a driver that gains process facts closes this | `[gap]` the container seam exposes no process start time or command line | | 16, live: a control reset over a live pi across a resident WORKER-only deploy | `[agent]` Start a coding run of a few minutes on a resident, then deploy the resident Worker code alone (no image change) so its Durable Object resets under the live pi → the run keeps its card and finishes on the SAME pi; its run page shows one `resumed` note reading `the resident's control plane reset under the run …`, no `sandbox_restarted` note, the row's facts read `relaunches: 0`, and no second pi is orphaned in the container (`ps` on the resident shows one pi for the run); owed after the next release | | 13, live: a plain message routed and a reflection written on a deployment whose router and extractor run on pi's library | `[agent]` On a deployment at this release with `routing.auto` on and `memory.enabled: true`: a bare mention with a review-shaped ask → the card's first line reads ``*review* on `` · routed: `` and the bot's log carries `[route] routed to review on /`; a tool-using request in a thread → `wrangler tail switchboard-memory` shows the `/write` and the bot's log carries no `[memory] … reflection failed` line; the log never carries a key. | + +The channel-bound work-tracking tools follow the same relay contract: +reads join tool-loop presets; writes join only `assistant` and `full`. +The dispatcher binds the resolved actor before the model can call them +([linear-channel.md](linear-channel.md) item 16). diff --git a/docs/reference/specs/linear-channel.md b/docs/reference/specs/linear-channel.md new file mode 100644 index 000000000..1701da5f9 --- /dev/null +++ b/docs/reference/specs/linear-channel.md @@ -0,0 +1,315 @@ +# Linear channel + +The native Linear channel's installation, intake and dispatcher adapter. +Its OAuth app is the API identity; the dispatch actor is the authenticated person initiating +the session. Native delegation names the app in `Issue.delegate` and preserves +the human assignee. + +- **Code**: `src/channels/linear/surfaces.ts`, `src/core/dispatch/runStart.ts`, `src/core/dispatch/provision.ts`, `src/core/dispatch/commandRun.ts`, `src/core/dispatch/ship.ts`, `src/artifacts/keys.ts`, `src/core/dispatch/staging.ts`, `src/core/runRecord.ts`, `src/core/runStore.ts`, `src/core/runStoreWorker.ts`, `src/core/runsService.ts`, `deploy/cloudflare-memory/worker.ts`, `src/core/coordinator/clarification.ts`, `src/core/dispatcher.ts`, `src/core/coordinator/driver.ts`, `src/core/ship/coordinator.ts`, `src/core/dispatch/lineage.ts`, `src/channels/linear/children.ts`, `src/channels/adminCoordinator.ts`, `src/core/types.ts`, `src/core/dispatch/spawn.ts`, `src/channels/startup.ts`, `src/core/dispatch/reply.ts`, `src/channels/linear/files.ts`, `src/channels/attachmentTypes.ts`, `src/channels/linear/oauth.ts`, `src/channels/linear/store.ts`, `src/channels/linear/webhook.ts`, `src/channels/linear/inbox.ts`, `src/channels/linear/api.ts`, `src/channels/linear/session.ts`, `src/channels/linear/io.ts`, `src/channels/linear/bridge.ts`, `src/channels/linear/consumer.ts`, `src/channels/linear/acknowledgement.ts`, `src/channels/linear/control.ts`, `src/channels/linear/recovery.ts`, `src/channels/linear/lifecycle.ts`, `src/channels/linear/workItems.ts`, `src/channels/linear/access.ts`, `src/core/dispatch/channelAccess.ts`, `src/core/question.ts`, `src/tools/question.ts`, `src/core/workItems.ts`, `src/tools/workItems.ts`, `src/tools/toolsets.ts`, `src/tools/runnableTool.ts`, `src/core/dispatch/runLoop.ts`, `src/core/authz/policy.ts`, `src/index.ts`, `src/core/authz/actor.ts`, `src/core/authz/grants.ts`, `src/core/budgets.ts`, `deploy/cloudflare/linear.ts`, `deploy/cloudflare/worker.ts`, `deploy/cloudflare/wrangler.template.jsonc`. +- **Tests**: `src/channels/linear/api.surfaces.test.ts`, `src/core/dispatch/commandRun.test.ts`, `src/core/dispatch/ship.test.ts`, `src/artifacts/keys.test.ts`, `src/core/dispatch/staging.test.ts`, `src/channels/linear/api.staging.test.ts`, `src/core/runStore.test.ts`, `src/core/runStoreWorker.test.ts`, `src/core/runsService.test.ts`, `deploy/cloudflare-memory/runs.test.ts`, `src/core/coordinator/clarification.test.ts`, `src/core/dispatcher.test.ts`, `src/core/coordinator/driver.test.ts`, `src/core/ship/coordinator.test.ts`, `src/core/dispatch/lineage.test.ts`, `src/channels/linear/children.test.ts`, `src/channels/linear/api.children.test.ts`, `src/channels/adminCoordinator.test.ts`, `src/channels/startup.test.ts`, `src/core/dispatch/reply.test.ts`, `src/channels/linear/files.test.ts`, `src/channels/linear/oauth.test.ts`, `src/channels/linear/store.test.ts`, `src/channels/linear/webhook.test.ts`, `src/channels/linear/inbox.test.ts`, `src/channels/linear/api.test.ts`, `src/channels/linear/session.test.ts`, `src/channels/linear/io.test.ts`, `src/channels/linear/bridge.test.ts`, `src/channels/linear/consumer.test.ts`, `src/channels/linear/acknowledgement.test.ts`, `src/channels/linear/control.test.ts`, `src/channels/linear/recovery.test.ts`, `src/channels/linear/lifecycle.test.ts`, `src/channels/linear/workItems.test.ts`, `src/tools/workItems.test.ts`, `src/tools/question.test.ts`, `src/core/dispatch/runLoop.test.ts`, `src/core/authz/actor.test.ts`. +- **Docs**: [Delivery plan](../../plans/2026-09-17-001-linear-channel.md). + +## Behavior + +1. OAuth uses `actor=app` with read, write, assignable and mentionable scopes. + Its callback is derived only from the operator's configured origin, never + a request's Host or return URL. HTTPS is required except localhost and + loopback development origins. Authorization uses PKCE and expiring, + browser-bound, single-use state. Invalid state makes no token request. +2. Installation persists the organization, app user id, access token, refresh + token and expiry before reporting success. Browser responses disclose no + credentials. OAuth failures return stable errors without upstream bodies. +3. Token refresh replaces both tokens atomically against the installation + version it read. Concurrent reads on one provider share a refresh. A + revoked or reinstalled app is not resurrected by an in-flight refresh. +4. In-memory and durable storage implementations agree on state consumption + and installation compare-and-swap. Durable operations serialize through a + transaction supplied by the storage implementation. +5. Webhook intake verifies HMAC-SHA256 over the exact received bytes, caps + streamed bodies at 1 MiB, and rejects signed timestamps outside a minute + of the adapter's clock. The configured OAuth client and optional workspace + must match the signed payload. Session creation and prompts deduplicate by + signed session/activity ids, never the unsigned delivery header or the + subscription's webhook id. Persistence precedes acknowledgement; a storage + failure is retryable and never exposes the payload in the response. Both + acceptance and duplicate acknowledgement use Linear's required HTTP 200. +6. The event inbox is SQLite-backed in the Worker and in-memory in tests. + Atomic claims return the oldest available event and a consumer lease. + Expired claims are redelivered with their run binding intact. A stale + consumer cannot renew, requeue, rebind or finish a newer consumer's claim. + Completion drops the event payload but keeps a deduplication tombstone; + pruning removes only completed tombstones, never pending work. +7. The bot Worker routes OAuth and Linear webhooks to a separate Durable + Object without starting its container. Missing Linear credentials return + 503. Tokens have no HTTP read route and are not forwarded to the container. + OAuth state expires after ten minutes and pending installations are bounded; + an alarm prunes expired state and completed-delivery tombstones. +8. Session context resolves the authenticated human from the signed creation + or prompt event, never from issue text or the assignee. Organization and app + ownership must agree with the installation and the freshly fetched session. + Follow-ups keep the same namespaced thread and carry their own message id; + a stop signal is a control input, never an agent prompt. +9. Native session history uses immutable agent activities in chronological + order, excludes progress noise and the triggering turn, and never includes + a prompt that arrived after that turn. Progress is coalesced, replies use + response/error activities, and run links are added without replacing PR links. + A steered follow-up is acknowledged with a thought, so receipt does not + falsely mark the ongoing session complete. +10. Linear human actors use `linear::`. They inherit the same + open-chat baseline as Slack, plus explicit `linear:*` and personal grants. + A matching display name or bare user id on another platform or workspace + never lends the actor that identity's privileges. Team visibility remains + unknown until proven; it is never treated as public by inference. +11. The internal edge bridge authenticates before parsing a body or claiming + work. It exposes a fixed delivery/session vocabulary, never arbitrary + GraphQL or token reads. Each session operation rechecks current access and + app ownership. Bridge failures return stable errors without upstream text. +12. A Linear-only bot starts without Slack credentials or a Slack socket. Partial + Slack configuration still fails by the missing credential name. Combined + installations start both channels. The consumer durably records entry into dispatch before invoking it. It + renews each delivery lease while work runs, consumes unrelated sessions + concurrently, and stops intake during drain. A replay reconciles the + recorded run or reports an interrupted request; it never blindly repeats + a command whose effects may already have happened. Pre-dispatch transport + failures retain their event for retry. + Turns in one session wait for the prior turn's admission, not its full + execution. Stop resolves the human actor and uses the shared `runs:stop` + policy: people can cancel their own work, while another person's work + needs the operator's write grant and visibility. It never stops a later + run created after the control event arrived. + Before looking up active runs, Stop cancels authorized requests still waiting + for dispatch admission, including a claimed request hydrating its files. + The durable queue invalidates their leases before acknowledging cancellation; + a late file response cannot pass `begin`. Only earlier requests in the same + session are affected. Self-stop covers the signed requester; stopping other + queued people requires the shared run-stop policy for that channel. + A Stop that races an unbound dispatch records its cancellation decision on + the delivery. If dispatch then returns a no-effects deferral, the queue + completes the cancelled delivery instead of making it runnable again. + Run registration awaits durable channel admission before file copies, workspace + attach, commands or coordinator creation. A stopped unbound delivery refuses + its run binding; an unconfirmed binding stops the local run. Once that + dispatch settles, completion uses the original lease, so a stopped delivery + does not replay as uncertain and cannot complete a replacement owner's claim. + The decision survives restart and cannot be cleared by lease renewal or + a retry; stale consumers still cannot defer another consumer's lease. + Only the original requester may steer an active run. Another person's + prompt stays in the durable queue until it can run under their own identity; + unknown ownership fails closed across host generations. A proven admission + deferral may clear the begun marker only while its lease owns an unbound + delivery. Later prompts retain arrival order; stop controls bypass waiting. + A permanently invalid signed request closes with an honest native error. +13. Signed revocation removes the matching installation before intake returns; + a delayed revocation cannot remove a newer installation. It cancels pending + session deliveries without discarding the control event that stops live work. + Lifecycle events + stop affected live work without invoking an agent: revocation is workspace + scoped, removed teams are team scoped, and removal from an issue checks + its current delegate. A permission contraction rechecks current session + access. Notification echoes never create a second dispatch. +14. Created sessions enter an acknowledgement phase in the durable inbox. + The edge sends a native thought without waiting for container startup, + then releases the event for dispatch. The webhook itself waits only for + persistence and alarm scheduling. Failed acknowledgements retry from an + alarm with a stable activity id; a lost mutation response is reconciled + against that activity's app, session and content. Dispatch cannot overtake + its acknowledgement or an earlier, not-yet-admitted turn in the session. +15. Outbound files use private Linear uploads scoped to a freshly checked + session. Only a single-file signed URL and its required headers cross to + the executor; the installation token stays at the edge. Upload completion + posts a native activity with an inline image or file link. A failed upload + never posts a success link or closes the active run. +16. Issue tools bind the resolved requesting person, never a model-selected + actor. Each operation resolves their current workspace membership and + public-team access, then asks the shared policy table. A guest cannot + borrow the app's public-team access; an operator grant cannot bypass + Linear's private-team boundary. Reads and writes use separate actions. + Restricted child teams inherit their enclosing private team's membership; + a private child still needs its own membership. + The paginated queue filters `delegate`, not human `assignee`. Updates name + only requested fields; subissues inherit their parent's team without + silently assigning a person or starting a second delegated run. + +17. Before queued prompts, commands or restored runs read history or start model + work, fresh active-human and origin-team facts cap the requester's access. + A definite denial closes a restored row as interrupted; transient lookup + failures leave queued and reclaimed work available for retry. Sessions + without a supported issue, project or document origin are explicitly refused. +18. An unread follow-up handed on after its run fails receives an explicit + not-started/resend notice if its access lookup fails; it is never silently lost. +19. `request_input` records a bounded question for the end of the turn, surviving + a restart in the run ledger. Delivery uses the channel's question method and + a waiting indicator with unfinished checklist items; Linear emits elicitation. + Questions from final verdict, description, and re-review turns have the same + behavior: no further model turn is requested while an answer is needed. + Automatic PR/review publication and memory reflection are skipped, while the + model process is still shut down. The turn record marks that it awaits input. + An operator stop or failure takes precedence; a new in-turn prompt clears the + earlier question. The next native reply sees the question in its history, + including when delegation created no opening user activity. + A coordinator answer keeps its coding or review preset, unit branch or PR, + round key and base. It must match the stored requester, credential, relaying + app and unit thread; ended units and exhausted time budgets refuse the reply. + Fresh agent and repository permission checks precede contract reconstruction. + The coordinator follows the continuation run and uses its result in later + briefs. Settled round cost includes earlier question turns; an unpriced turn + or incomplete listing makes that total unknown. An unavailable coordinator + context defers the answer before admission; an unavailable continuation + history retries the coordinator read rather than settling from partial facts. + +20. A Stop with no authorized active run does not post a completion or progress + activity that could change another person's session. It can close the newest + waiting question only through the shared stop policy and only if the question + predates the Stop. An older question cannot hide a newer invisible run. A + missing history snapshot retries rather than guessing whether a question waits. + Closing a waiting question persists a stop marker before delivering the native + response. A retry keeps the original marker, including after a restart or a + late history write. Coordinator reads observe the stop without reviving earlier + PR or review artifacts; a stale Stop cannot cancel a question finished later. + +21. Private file references in issue and session text can supply inline images, PDFs + and text documents. The edge rechecks the human and session, accepts only links + found in current authorized context, authenticates only to uploads.linear.app + without redirects, and bounds count and streamed bytes. Credential-shaped + files are never read. Permanent omissions are named in the prompt; transient + download failures before dispatch leave the delivery retryable. History restores + attachments with a shared budget that favors recent turns. When a mention has + no initial user activity, history restores its initiating comment alongside the + issue, including the comment's files. The bridge allows + four minutes for the batch; each individual upstream request keeps its ten-second + deadline, and the consumer renews the delivery lease while it waits. + +Local development may give the bot a `LINEAR_BRIDGE_URL` distinct from its +`PUBLIC_BASE_URL`, which continues to name the run pages. The bridge defaults +to the public origin in combined deployments, fails fast on invalid origins, +and permits HTTPS or HTTP loopback only. Session run-page links follow the same +transport restriction and never carry URL credentials. + +Child work opens its own root comment and native session on the parent's issue. +The adapter rechecks the human requester, while the shared dispatcher remains +responsible for child-agent authorization and execution. A durable creation intent +binds the comment UUID to the installation, parent, requester and lead. Only one +session-creation mutation may be attempted per intent; an uncertain response is +reconciled from the comment's session instead of creating another session. The +app-created session's creation webhook is consumed without redispatching the child; +human prompts and Stop continue through normal intake. Rebuilt channel handles +retain this behavior after a restart. A child's reply notification cannot steer +a parent belonging to another requester or whose requester is unknown. + +Coordinator record reads retain the `awaitingInput` marker and withhold earlier +PR or review artifacts while the completed turn is a question. Coding and review +rounds enter a persisted input-wait phase, re-read after durable 30-second sleeps, +and do not spawn, check PRs or merge from that question. Time awaiting a person +is reported as waiting and counts toward the unit's wall-clock budget. The last +sleep is shortened to the remaining budget; an unanswered question at or past +the deadline ends at the wall-clock cap (or review pending for an existing PR), +including after a restart. Stops and failures take precedence over a question marker. +Human-reply continuation into the coordinator's original unit is wired; live +end-to-end verification remains open. + +When the bot configures an artifact store and the edge binds its bucket, incoming +files above the inline limit and binary formats are retained as references, up +to 10 per prompt and 1 GiB per file, within `artifacts.inbound.maxBytesPerMessage` +(default 2 GiB). A known positive Content-Length is required. Credential filenames +remain excluded. The channel's `copyAttachment` capability rechecks current human +access and session context at copy time, binds the destination to that session's +inbound key, and streams exactly the declared bytes into storage at the edge. +A changed filename or size, a short stream or failed storage write cannot produce +a successful copy receipt. The shared staging code then pulls into the workspace +and records the artifact; follow-ups and child channels use the same capability. +The OAuth credential and file bytes never enter the bot. Without a workspace or +configured storage the file is named as unavailable rather than silently omitted. +Hard Stop carries the run's abort signal through file copies and workspace pulls. +A cancelled copy publishes no artifact receipt and cannot start a model turn. +The edge enables incoming request cancellation and explicitly forwards the signal +to the installation Durable Object, whose download and storage stream share it. +Local development proxies must preserve that cancellation: the Wrangler proxy +currently drops a client disconnect even though direct workerd requests cancel +the copy. Proving or replacing that local transport remains an acceptance gap. + +## Proof + +| Criterion | Proof | +|---|---| +| Stop racing an unbound dispatch is retained through deferral, renewal, retry and restart without affecting another requester or a newer lease | `[unit]` `src/channels/linear/inbox.test.ts::Linear event inbox — memory::retains Stop across an in-flight no-effects deferral without cancelling another requester`, `src/channels/linear/inbox.test.ts::Linear event inbox — sqlite::retains Stop across an in-flight no-effects deferral without cancelling another requester`, `src/channels/linear/inbox.test.ts::durable Linear event recovery::preserves the queued Stop decision across a retry, a new lease, and host replacement` | +| An authorized Stop prevents a later no-effects deferral from dispatching again, while a new request still runs | `[unit]` `src/channels/linear/consumer.test.ts::Linear event consumer::does not replay a no-effects deferral that finishes after an authorized Stop` | +| Existing queue rows remain usable when deferred-stop tracking is added | `[unit]` `src/channels/linear/inbox.test.ts::durable Linear event recovery::adds the deferred-stop field to an existing queue without cancelling its live delivery` | +| Stop invalidates queued leases only within the authorized session, requester and arrival cutoff, leaving begun work and control events intact | `[unit]` `src/channels/linear/inbox.test.ts::Linear event inbox — memory::cancels only older unbegun requests in the authorized session and invalidates their leases`, `src/channels/linear/inbox.test.ts::Linear event inbox — sqlite::cancels only older unbegun requests in the authorized session and invalidates their leases` | +| Cancelled preparation survives restart and cannot be revived by an acknowledgement | `[unit]` `src/channels/linear/inbox.test.ts::durable Linear event recovery::retains a cancelled preparation across restart and invalidates a pending acknowledgement` | +| A late file response after Stop cannot start the cancelled request, while a new request still runs | `[unit]` `src/channels/linear/consumer.test.ts::Linear event consumer::does not dispatch a file-hydrating request cancelled durably by a later Stop` | +| Queued cancellation uses shared self and channel-wide policy and fails closed on unavailable storage | `[unit]` `src/channels/linear/control.test.ts::Linear stop authorization::authorizes durable queued cancellation through the same self and channel-wide stop rules`, `src/channels/linear/control.test.ts::Linear stop authorization::retries unavailable queued cancellation before acknowledging or stopping active work` | +| The queued-cancellation bridge requires authentication and a bounded, elapsed selection | `[unit]` `src/channels/linear/bridge.test.ts::Linear edge bridge::relays scoped queued cancellation only over the authenticated bridge with an elapsed cutoff` | +| Hard Stop reaches incoming file copies and prevents cancelled file receipts and pulls | `[unit]` `src/core/dispatch/staging.test.ts::staging — copy then pull::passes hard cancellation into a channel copy and never publishes or pulls the cancelled file` | +| A stopped initial copy cannot start the model or pull a workspace file | `[unit]` `src/core/dispatcher.test.ts::inbound staging (record 0033)::a hard stop cancels an admitted file copy before any model turn or workspace pull` | +| The bridge forwards cancellation into the authenticated copy | `[unit]` `src/channels/linear/bridge.test.ts::Linear edge bridge::carries caller cancellation through the bridge request into the active file copy` | +| Local development cancellation crosses the public development proxy and leaves no completed object | `[gap]` Wrangler's development proxy currently drops the caller's disconnect; direct workerd cancellation is verified separately. | +| Staged file copies refresh access, bind session keys, and reject changed or incomplete streams | `[unit]` `src/channels/linear/api.staging.test.ts::Linear workspace file copy::*` | +| The bridge and channel bind file copies to the checked human | `[unit]` `src/channels/linear/bridge.test.ts::Linear edge bridge::relays an attachment copy with the session and human bound separately from file metadata`, `src/channels/linear/io.test.ts::Linear channel output::binds attachment copies to the checked requester and verifies the completed copy` | +| Shared staging retains workspace pulls and receipts for channel copies | `[unit]` `src/core/dispatch/staging.test.ts::staging — copy then pull::uses a channel's private copy while keeping shared workspace pulls and artifact receipts` | +| Incoming staged files retain their message identity and configured budget | `[unit]` `src/channels/linear/consumer.test.ts::Linear event consumer::passes staged metadata and the configured byte budget into dispatch under the original message id`, `src/artifacts/keys.test.ts::key builders (item 20)::normalizes namespaced message ids into one storage segment` | +| Large and binary attachments use bounded references before workspace staging | `[unit]` `src/channels/linear/files.test.ts::Linear private file ingestion::keeps oversized and binary files as bounded staging references without reading their bodies` | +| Cached finished children cannot hide a failed durable question or stop read; retry recovers the question | `[unit]` `src/channels/adminCoordinator.test.ts::coordinator question records::retries a failed durable point read %s while the finished child is still cached` | +| Unanswered coordinator questions respect the unit deadline after restart | `[unit]` `src/core/ship/coordinator.test.ts::coordinator child clarification::ends an unanswered %s question at the unit deadline after restart`, `src/core/ship/coordinator.test.ts::coordinator child clarification::ends a question first observed after the deadline without another sleep` | +| The workflow reports a question deadline and finishes without spawning or merging | `[unit]` `src/core/coordinator/driver.test.ts::coordinator driver clarification::reports an unanswered question's deadline and finishes without another child or merge` | +| A coordinator answer retains the stored task and requester | `[unit]` `src/core/coordinator/clarification.test.ts::coordinator clarification context::*` | +| A coordinator answer rechecks the original preset before starting | `[unit]` `src/core/dispatcher.test.ts::coordinator clarification replies::*` | +| Coordinator question turns do not advance PR or review work | `[unit]` `src/channels/adminCoordinator.test.ts::coordinator question records::*`, `src/core/ship/coordinator.test.ts::coordinator child clarification::*`, `src/core/coordinator/driver.test.ts::coordinator driver clarification::*` | +| Native child creation durability | `[unit]` `src/channels/linear/children.test.ts::*`, `src/channels/linear/api.children.test.ts::*` | +| Child run tools retain clarification as unfinished | `[unit]` `src/tools/runs.test.ts::child clarification stays unfinished::*` | +| Child replies preserve requester isolation | `[unit]` `src/core/dispatch/lineage.test.ts::*` | +| Native child wiring and coordinator access | `[unit]` `src/channels/linear/io.test.ts::Linear channel output::opens an isolated native child with the checked requester and a stable coordinator key`, `src/channels/linear/consumer.test.ts::Linear event consumer::consumes a managed child's creation without a second dispatch but accepts human follow-ups`, `src/channels/linear/bridge.test.ts::Linear edge bridge::relays native child creation with a fixed identity and creation id`, `src/channels/adminCoordinator.test.ts::the plan runner's steps — plan, unit-start, branch, round, unit-end, finish (item 9)::checks the requester before opening coordinator threads and gives each retry a stable channel key` | +| Local origins and run links | `[unit]` `src/channels/startup.test.ts::channel startup::routes Linear intake to a separate local edge while keeping the bot's public origin`, `src/channels/startup.test.ts::channel startup::rejects missing or unsafe Linear bridge configuration before starting the consumer`, `src/channels/linear/bridge.test.ts::Linear edge bridge::accepts local run-page links while rejecting remote plaintext and credential-bearing links` | +| 1–3: OAuth and token lifecycle | `[unit]` `src/channels/linear/oauth.test.ts::*` | +| 4: storage semantics | `[unit]` `src/channels/linear/store.test.ts::*` | +| 5: signature, replay, identity, size and durable-accept boundary | `[unit]` `src/channels/linear/webhook.test.ts::*` | +| 6: real SQLite and in-memory delivery lifecycle, fencing, retry and recovery | `[unit]` `src/channels/linear/inbox.test.ts::*` | +| 7: deployed edge routing | `[agent]` With Linear credentials absent, GET `/oauth/linear/authorize` and POST `/webhooks/linear` return 503. With credentials configured, installation redirects to Linear and callback persists the installation. Send a signed session event while the bot container is stopped; receive 200 and verify the queued delivery after restarting the consumer. The production callback is HTTPS; local testing uses the same path on `http://localhost:8080`. | +| 8: session and human identity | `[unit]` `src/channels/linear/session.test.ts::*` | +| 9: native conversation, progress and replies | `[unit]` `src/channels/linear/io.test.ts::*`, `src/channels/linear/api.test.ts::*` | +| 9: ongoing-work acknowledgement survives a host-generation boundary | `[unit]` `src/core/dispatch/admission.test.ts::admit — the thread admission claim::uses a channel's nonterminal acknowledgement for a follow-up here or on another generation` | +| 10: resolved Linear actor grants | `[unit]` `src/core/authz/actor.test.ts::Linear actor authorization::*` | +| 11: fixed authenticated bridge and durable delivery | `[unit]` `src/channels/linear/bridge.test.ts::*` | +| 12: dispatch consumption, control and recovery | `[unit]` `src/channels/linear/consumer.test.ts::*`, `src/channels/linear/control.test.ts::*`, `src/channels/linear/recovery.test.ts::*` | +| 13: revocation and lifecycle cancellation | `[unit]` `src/channels/linear/lifecycle.test.ts::*` | +| 14: durable acknowledgement and activity reconciliation | `[unit]` `src/channels/linear/acknowledgement.test.ts::*`, `src/channels/linear/inbox.test.ts::*`, `src/channels/linear/api.test.ts::*` | +| 15: private file tickets, session checks and native sharing | `[unit]` `src/channels/linear/api.test.ts::*`, `src/channels/linear/bridge.test.ts::*`, `src/channels/linear/io.test.ts::*` | +| 16: issue actions and delegated queue privacy | `[unit]` `src/channels/linear/workItems.test.ts::*` | +| Work-item tool exposure and dispatcher binding | `[unit]` `src/tools/workItems.test.ts::*`, `src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::binds work tracking to the resolved requester before a model can call an issue tool` | +| Live large-file staging and deployed installation | `[gap]` Delivery plan acceptance ledger; unit and fixture Worker proofs do not establish a live Linear installation | +| 12: independent channel startup | `[unit]` `src/channels/startup.test.ts::*` | +| Requester isolation before follow-up effects | `[unit]` `src/core/dispatch/admission.test.ts::admit — the thread admission claim::defers another requester without writing either inbox, locally or across generations` | +| Requester-bound execution after deferral | `[unit]` `src/core/dispatcher.test.ts::thread admission (docs/reference/specs/thread-admission.md)::an isolated channel defers another person and later binds a fresh run to that person` | +| 17: current requester access | `[unit]` `src/channels/linear/api.test.ts::Linear API boundary::rechecks the requesting human and current team access before a session can run`, `src/core/dispatcher.test.ts::current channel access before dispatch::*`, `src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::rechecks restored channel access: denials close rows and outages leave them reclaimable` | +| 18: unread prompt feedback | `[unit]` `src/core/dispatcher.test.ts::thread admission (docs/reference/specs/thread-admission.md)::reports an unconsumed follow-up that cannot restart while its access lookup is unavailable` | +| 19: typed question and native delivery | `[unit]` `src/tools/question.test.ts::*`, `src/core/dispatcher.test.ts::clarification through dispatch::*`, `src/channels/linear/io.test.ts::*` | +| 19: final-turn clarification | `[unit]` `src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::delivers a question from the %s turn without another model turn or automatic publication` | +| 19: stop during final-turn clarification | `[unit]` `src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::a hard stop during a final question turn clears waiting state without posting a review` | +| 19: question outcome and cleanup | `[unit]` `src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::keeps a typed question in the turn outcome, receipt and durable run record`, `src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::ends the model process when a question skips the publishing steps`, `src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::clears a pending question when a follow-up arrives before the turn finishes` | +| 19: restored questions and stop precedence | `[unit]` `src/core/dispatch/runLoop.test.ts::a resume with the answer in hand (the \`finish\` plan)::restores a pending question without more model calls or automatic PR or review publication`, `src/core/dispatch/runLoop.test.ts::a resume with the answer in hand (the \`finish\` plan)::an operator stop takes precedence over a restored question` | +| 20: durable stop withholds stale coordinator artifacts | `[unit]` `src/channels/adminCoordinator.test.ts::coordinator question records::reports a cancelled question as stopped and withholds its earlier review and PR artifacts` | +| 20: stop while awaiting input | `[unit]` `src/channels/linear/control.test.ts::*` | +| 21: private file ingestion | `[unit]` `src/channels/linear/files.test.ts::*` | +| 21: file identity, transport and turn binding | `[unit]` `src/channels/linear/api.test.ts::*`, `src/channels/linear/bridge.test.ts::*`, `src/channels/linear/io.test.ts::*`, `src/channels/linear/consumer.test.ts::*` | +| 19: review questions survive typed verdict rendering | `[unit]` `src/core/dispatch/reply.test.ts::deliverAnswer — the answer reaches the thread::delivers a review question without formatting an earlier verdict as the answer` | +| 12: run admission before effects | `[unit]` `src/core/dispatcher.test.ts::inbound staging (record 0033)::awaits channel admission before file copies, workspace attach or model execution (%s)`, `src/core/dispatch/commandRun.test.ts::runChatCommand — the machinery moved from the fast path::awaits channel admission and records a stop before executing a command (%s)`, `src/core/dispatch/ship.test.ts::runShipBranch — the agent:ship fork hands every admitted request to the plan runner::awaits channel admission and stops before creating a coordinator (%s)` | + +## Project and document origins + +Native sessions anchored to a project, project update, project overview or a +document carry that origin's current title and content. The signed human remains +the dispatch actor; project/document channel scopes are separate from team scopes. +Every access check refreshes the human and the origin's current team membership. +A project is visible through any team the human can read, including paginated +teams; a document follows its project, issue or team owner. Guests do not inherit +public-team access unless Linear reports it. Context references alone never grant +access, and unknown origins receive an explicit unsupported response. File reads +use the same current origin check and only fetch links in its content or session +conversation. A team-permission contraction rechecks active project/document +requesters; unreadable state stops their work, while access through another team +can keep a project session valid. + +| Criterion | Proof | +|---|---| +| Current project/document origin and access | `[unit]` `src/channels/linear/api.surfaces.test.ts::*` | +| Origin scopes and restored context | `[unit]` `src/channels/linear/session.test.ts::Linear session input::scopes project and document sessions independently and restores their content` | +| Live non-issue mentions | `[gap]` Verify project and document mentions in the installed workspace, including private-team access and revocation | diff --git a/docs/reference/specs/packaging.md b/docs/reference/specs/packaging.md index f43c8a503..3f36b8a00 100644 --- a/docs/reference/specs/packaging.md +++ b/docs/reference/specs/packaging.md @@ -15,7 +15,7 @@ Related: [init.md](init.md) (the installer the package exists to run), [release- 1. **One package, the CLI, under the project's scope.** `packages/switchboard/` is a workspace whose name is `project.json`'s `npmPackage` (`@coreplane/switchboard`); `check:project-facts` holds the manifest's `name`, `description`, `license` (the root's), `homepage` (the docs URL), `repository.url` and `.directory`, and `bugs.url` to the facts. `bin` is `switchboard` → `bin/switchboard.js`, a committed entry that hands the process to `dist/cli.js`, the bundle: it sets `argv[1]` to the bundle before importing it, so the bundle is the script — its entry claim (item 8) holds and `programName` (item 4) spells `switchboard`. Committed rather than the bundle itself because npm links a bin only when its target exists at install time, and the gitignored `dist/` does not until the build runs: with the bundle as the bin, `npx ` inside the checkout — where npx prefers the workspace over the registry — found no link and died with `sh: switchboard: command not found`. Unbuilt, the bin refuses on stderr naming the missing file, `npm run build -w packages/switchboard` and the checkout's `npm run cli` (exit 1); the published package always carries the bundle, so there it is one extra module load. `files` is `bin` and `dist` (npm adds the manifest, and the README and LICENSE the build copies in — the README is the repository's own, `packageReadme` in `build.mts` making every relative link and image absolute against the repository on GitHub at `HEAD` and dropping the generated diagram regions, so the npm page reads as GitHub does and the two can never diverge); `engines.node` is `>=`; `publishConfig` is `access: public` and nothing about provenance (item 5). Its `dependencies` are exactly the npm packages the bundled CLI imports, at the root's ranges — derived by a test from esbuild's metafile, so a dependency the CLI stops or starts importing fails the suite until the manifest follows. 2. **The build is derived, never listed by hand.** `npm run build -w packages/switchboard` (`build.mts`, run with tsx) bundles `src/cli.ts` with esbuild — one ESM file, `platform: node`, target the `.nvmrc` major, the repository's own modules inlined (the bot's entry `src/index.ts` and the Slack adapter among them, reached through `start`'s import), every npm package external (`packages: "external"`), the entry's shebang kept — into `dist/cli.js`, then builds the dashboard (`npm run build -w web`, as the Dockerfile does) and copies under `dist/assets/`, at their tree paths, `ROOT_ASSETS` (`.env.example`, `config/config.example.yaml`, `project.json`, `Dockerfile`, `docker-entrypoint.sh`, `.dockerignore`, and — for the work area's `npm ci` — `package.json` and `package-lock.json`), `shippedDeployAssets(git ls-files deploy)`: every path git tracks under `deploy/` except tests (the `tests` rules of `src/deploy/affected.ts`'s `INERT_RULES`) and the agent-env tooling, and `workerSourceFiles`: the files under `src/` in the union of the Worker entries' relative-import closures (`importClosure`, the crawl `--affected` judges a Worker's inputs by), each once — an import that resolves to no file fails the build, never a package shipped without a source — and `webDistAssets`: every file of the dashboard's build under `web/dist/` (`WEB_DIST_DIR`), a listing without the Vite manifest refused by name, never a package whose `start` boots half-blind. Tracked paths only (the dashboard's build is the one generated tree, built by this build), so an operator's `deploy/profile.json` and the rendered `wrangler.jsonc` files — gitignored — can never reach a tarball. `LICENSE` is copied beside the manifest (gitignored there). 3. **One resolver finds the shipped files.** `locatePackageRoot(from)` (`src/packageRoot.ts`): `assets/` beside the calling module when it carries `project.json` — the published package, `dist/cli.js` beside `dist/assets/` — else the nearest ancestor that does: the repository root from `src/setup/` or `dist/setup/` in a checkout, `/app` in the image (the Dockerfile copies `project.json` there). Neither is an error naming the marker and the start. `PACKAGE_ROOT` is what `readTemplates`, `publishedImage`, `publishedPackage` and `isCheckoutRoot` resolve under, and the `assets` of the operator root ([release-and-deploy.md](release-and-deploy.md) item 24) the deploy commands read the templates, the example profile and the secrets manifest through; The version this CLI runs as — the number the release published its images under, so what `deploy images` copies and a `registry`-mode Worker config references ([release-and-deploy.md](release-and-deploy.md) items 25–26) — is `cliVersionOnHost` (`src/deploy/host.ts`): from the package, `source.json`'s (item 7); in a checkout or the image, `packageVersion()` — the `version` of the nearest `package.json` at or above `PACKAGE_ROOT`, the root's own; `RUNS_FROM_PUBLISHED_PACKAGE` (the `assets` kind) is how `init` knows to print `npx …` as the next commands and to write the profile where it runs ([init.md](init.md) items 4 and 8), and what puts the operator root in package mode. A **relative `secretsSource` directory** (`expandDir`, the secrets host) is under the operator root — the checkout, or the directory the package was run in, where the profile that named it lives — never inside `dist/assets/`; `~` and absolute paths resolve the same everywhere. In a checkout every path resolves exactly as before the resolver existed. -4. **The installed package works, and says nothing about where it came from.** The smoke test packs the workspace (`npm pack --workspace packages/switchboard`), installs the tarball into an empty temp directory and runs the installed bin there: the tarball is `--.tgz` and carries only `package.json`, `README.md`, `LICENSE`, `bin/switchboard.js` and `dist/` (no test, vitest config, rendered config, profile or `.env`); `npx switchboard --help` prints the catalogue under `usage: switchboard …` — `programName(argv[1])` spells the checkout's `npx tsx src/cli.ts` only when Node was started on a `.ts` file, `switchboard` otherwise (the bin, the image's entrypoint); `init --dry-run --organization acme --anthropic-key sk-test` in an empty directory plans `.env` and `config/config.yaml` from the shipped examples with the key masked, ends with the package's own `ask` and `start`, and prints no path of the package, the temp directory or the repository; `start --help` prints the process's help under the bin's own name and `start ` is the usage error (exit 2); `start` in a directory with no `.env` exits 1 with the process's own one-line refusal naming `SLACK_BOT_TOKEN`; `start` with fake tokens and `PORT` set boots ONE bot — one startup log, one `http server on :` line, the dashboard manifest found beside the bundle, never `EADDRINUSE` — and ends as the socket handshake decides: Slack's `invalid_auth` (exit 1) or, unreachable, a SIGINT drain (exit 0) (item 8); `deploy plan` in a directory with no profile reads the shipped `deploy/profile.example.json`, says so, and names that directory as its root; `init --cloudflare … --zone …` in an empty directory writes `deploy/profile.json` there and renders the Worker configs under `.switchboard/` (item 7) with no install, and `deploy plan` from that directory plans that installation naming no path of the package or the repository. Under a minute: the install prefers the local npm cache, and nothing runs `npm ci`. +4. **The installed package works, and says nothing about where it came from.** The smoke test packs the workspace (`npm pack --workspace packages/switchboard`), installs the tarball into an empty temp directory and runs the installed bin there: the tarball is `--.tgz` and carries only `package.json`, `README.md`, `LICENSE`, `bin/switchboard.js` and `dist/` (no test, vitest config, rendered config, profile or `.env`); `npx switchboard --help` prints the catalogue under `usage: switchboard …` — `programName(argv[1])` spells the checkout's `npx tsx src/cli.ts` only when Node was started on a `.ts` file, `switchboard` otherwise (the bin, the image's entrypoint); `init --dry-run --organization acme --anthropic-key sk-test` in an empty directory plans `.env` and `config/config.yaml` from the shipped examples with the key masked, ends with the package's own `ask` and `start`, and prints no path of the package, the temp directory or the repository; `start --help` prints the process's help under the bin's own name and `start ` is the usage error (exit 2); `start` in a directory with no `.env` exits 1 with the process's own one-line refusal naming the absent Slack tokens or `LINEAR_BRIDGE_TOKEN`; `start` with fake tokens and `PORT` set boots ONE bot — one startup log, one `http server on :` line, the dashboard manifest found beside the bundle, never `EADDRINUSE` — and ends as the socket handshake decides: Slack's `invalid_auth` (exit 1) or, unreachable, a SIGINT drain (exit 0) (item 8); `deploy plan` in a directory with no profile reads the shipped `deploy/profile.example.json`, says so, and names that directory as its root; `init --cloudflare … --zone …` in an empty directory writes `deploy/profile.json` there and renders the Worker configs under `.switchboard/` (item 7) with no install, and `deploy plan` from that directory plans that installation naming no path of the package or the repository. Under a minute: the install prefers the local npm cache, and nothing runs `npm ci`. 5. **The release can publish it, in lockstep, with no credential of its own — and does so only when turned on.** One release-please component (`.`) carries the version: its node strategy moves the root manifest and lockfile; `extra-files` moves `packages/switchboard/package.json` (`$.version`) and the lockfile's entry for the workspace (`$.packages['packages/switchboard'].version` — left behind, the next `npm install` rewrites it). A test holds the three copies equal. When release-please reports `release_created` **from the repository's default branch and the repository variable `SWITCHBOARD_PUBLISH_NPM` is the string `true`** ([release-and-deploy.md](release-and-deploy.md) item 23 — the variable is the per-release switch, the branch the per-line one), the `publish-npm` job in `release-please.yml` — `contents: read` and `id-token: write` alone; Node from `.nvmrc`, npm upgraded to a trusted-publishing release; `npm ci`; the package's build — runs `npm publish --workspace packages/switchboard --access public --provenance=false` — **provenance off by name**: with trusted publishing npm generates a bundle by itself, and it accepts one from GitHub-hosted runners alone and this project's CI runs on Namespace runners (the registry answered 422 `Unsupported GitHub Actions runner environment: "self-hosted"` on the first two public releases — once with `--provenance`, once with no flag at all, which is why the opt-out is explicit); the images keep their attestations because the attest action signs from any runner. The credential is the run's OIDC identity: the package's npm settings name this repository and `release-please.yml` as its **trusted publisher** ([Configure the repository](../../how-to/configure-the-repository.md#5-repository-secrets-and-variables)); no token exists, nothing expires, no workflow names `NPM_TOKEN` or `NODE_AUTH_TOKEN`; no other `run:` in any workflow starts with `npm publish`. With the variable unset the job is skipped and the release-please job's `npm publish is off` step says so in one notice line. The manifest is publishable (no `private` flag; `publishConfig.access: public`, no provenance), so the variable is the one switch — and it is off until the package exists on the registry with the trusted publisher configured, since a first publish is what creates the package record the publisher is attached to (a placeholder `0.0.0` published by an org admin from an empty directory, then the release takes over). 6. **The curl front door installs nothing but what npx fetches.** `docs/public/install.sh` (served at the docs host's `/install.sh`) is POSIX `sh`: it requires `node` and `npx` on `PATH` and a Node major at least `REQUIRED_NODE_MAJOR` (held equal to `.nvmrc` by a test), refuses otherwise naming the version found and where to get Node — it never installs Node — and then `exec`s `npx --yes @latest init "$@"`, every argument passed through, in the directory it was run from, with `npm_config_engine_strict=true` on that one call: npx does not enforce a package's `engines` by itself, so the script's check is the gate a person reads and npm's is turned on behind it. 7. **The package carries what a deploy needs to run from anywhere, and says which tree it is.** Beside the templates and manifests, `dist/assets/` holds every Worker's directory as tracked, the `src/` files their `worker.ts` import (item 2 — wrangler bundles them by relative path, so they must sit where the tree keeps them), the root `package.json` and `package-lock.json`, and `source.json`: the package's `version`, the `commit` it was built from (`-dirty` suffixed when the tree had uncommitted changes — `buildStamp` from `deploy/bin/build-stamp.mjs`, the same rule the Workers are stamped by) and `builtAt`; `parsePackageSource` (`src/packageRoot.ts`) reads it and names a missing file, non-JSON or a missing field, never guessing. From the package the deploy commands copy that tree whole into `/.switchboard/` and install each Worker they run with `npm ci --workspace deploy/` at the copy's root — the root manifest's `workspaces` and the lockfile make the copy an npm workspace root, and npm tolerates the workspaces the copy lacks (`web`, `docs`, the package itself), so the Worker's dependencies land at the release's pinned versions, hoisted or nested exactly as the checkout has them ([release-and-deploy.md](release-and-deploy.md) item 24 for the work area's stamp, reuse and refusals). The commit in `source.json` is what a package deploy stamps into every Worker and compares `/healthz` against; a release build carries the release commit. diff --git a/docs/reference/specs/pr-description.md b/docs/reference/specs/pr-description.md index 202ab8ff0..67d8efd3a 100644 --- a/docs/reference/specs/pr-description.md +++ b/docs/reference/specs/pr-description.md @@ -23,6 +23,11 @@ None open. The panel renderer is [reading-diff.md](reading-diff.md) item 12: the ## Validation criteria +The final description turn can ask the requester for missing information through +`request_input`. A pending question becomes the channel reply and skips automatic +PR creation or editing, even if that turn also submits a description +([Linear channel](linear-channel.md), criterion 19). + | Criterion | Proof | |---|---| | Schema: complete object accepted, lines trimmed; no pointers, missing fields and blank strings rejected naming the field; decisions may be empty, criteria may not | `[unit]` `src/core/prDescription.test.ts::parsePrDescription (the schema)` (7) | diff --git a/docs/reference/specs/run-history.md b/docs/reference/specs/run-history.md index 38fc05c5c..0e4a6ee98 100644 --- a/docs/reference/specs/run-history.md +++ b/docs/reference/specs/run-history.md @@ -11,7 +11,7 @@ Every run becomes a durable record — identity, timing, terminal status, the re ### The record 1. **Node-free contract.** `src/core/runRecord.ts` has no Node built-in imports and no I/O or clock: bytes are measured with `TextEncoder`, and callers pass `nowMs`. The Cloudflare state Worker imports it by relative path, the same way it imports `frictionProposals.ts`, so the bot and the Worker validate, trim and page with ONE implementation. -2. **Record shape.** `RunRecord = { id, label?, agent?, model?, channelId, userId, relayedBy?, authenticatedAs?, threadKey, channelVisibility, repo?, startedAt, finishedAt, receivedAt?, sealedAt?, replyOk?, replyNote?, stepCount?, schema?, status, eventCount, storedEventCount, truncated, events, diagnosis, handoff?, verdict?, reviewHead?, reviewPost?, dispositions?, profile?, parentRunId?, seed?, pr?, pushed?, lease?, route?, instanceId? }` (`profile` is item 45, `parentRunId` item 46, `seed` item 52, `lease` the run's lease as its harness started it — `{ startedAt, endsAt, loopEndsAt }` off the first `lease` event, folded at the one record assembly, present only on a run whose harness started a loop ([harness-pi.md](harness-pi.md) item 15, [decision 0046](../../decisions/0046-a-budget-is-a-lease-carved-from-its-parent-and-one-module-proves-the-leases-fit.md)); `pushed` the heads the run pushed — `{ ref, sha }[]`, one per branch with the last `pushed_head` event's sha winning, folded at the one record assembly; the event is published by the coding post-step when the observation proves a push of a branch that is not the base (`by: "push"`) and by the budget-end salvage (`by: "salvage"`), whether or not a pull request follows, so a renewal reads progress off the row without a model ([decision 0046](../../decisions/0046-a-budget-is-a-lease-carved-from-its-parent-and-one-module-proves-the-leases-fit.md)); `pr` the pull request the coding post-step opened or edited — `{ number, url, head? }` off the last `pr_opened` event, folded at the one record assembly, `head` the branch the PR is opened from when the event names it — the fact a follow-up in the thread continues from, [resident-repos.md](resident-repos.md) item 29, and the branch the run's release hands the resident to remember, [resident-repos.md](resident-repos.md) item 16a; `verdict` the verdict a review run submitted through `submit_verdict` — [agent-review.md](agent-review.md) — with its findings, `reviewHead` the head it reviewed and posted against after the settle, 7 to 40 lowercase hex, `reviewPost` how its post-step ended — `{ posted: true, target: { repo, number }, head, verdict? }` or `{ posted: false, reason }`, [agent-review.md](agent-review.md) item 18 — and `dispositions` the set a coding run submitted through `submit_dispositions`, as submitted, the plan runner matching it to its round's findings when it reads the record — [agent-ship.md](agent-ship.md) item 6 — each redacted at every string leaf in the one record assembly and present only on a run that produced it, so a coordinator's `read-record` answers a review child's verdict, whether it was posted, and a coding run's dispositions from the record — through `RunsService`, which supplies them from the store the moment it holds the record, a finished row still inside the registry's window included, item 21) (the five optional stamps per [tracing.md](tracing.md): `stepCount` is the registry's content-event count, span records excluded; `schema` the stream schema, 2 once spans are emitted; `handoff` the typed handoff a coding child submitted — [agent-ship.md](agent-ship.md) item 14 — redacted like every stored string and present only on a run that submitted one, an affirmed empty handoff being three empty lists, distinguishable from none) (`instanceId` the plan runner instance a ship run's hand-off created — [record 0051](../../decisions/0051-a-thread-has-one-owner-for-its-life-a-message-is-one-event-in-a-chosen-mode-and-a-pipeline-idles-instead-of-ending.md) R2 — folded at the one record assembly from the last `ship_handoff { instanceId, at }` event the ship branch publishes after a successful hand-off and never after a refusal, projected like the coordinator tag, exposed on `RunView.instanceId`, and how the thread's owner rule finds the instance, [thread-admission.md](thread-admission.md) item 9; the consuming run's `input` event may carry `mode` and `consumed` — the thread events it folded, record 0051's mode-as-receipt rule) with `status ∈ completed | stopped_soft | stopped_hard | failed | interrupted` and `channelVisibility ∈ public | private | dm | machine | unknown` — the channel's visibility as the `ChannelDirectory` reported it at dispatch ([authorization.md](authorization.md) item 7), what `member-of` reads; a record written before the stamp existed is accepted and reads as `unknown` (`normalizeStored`), never public — `interrupted` marks a run cut down before finish (container replaced or crashed; the provisional tombstone and the drain-deadline write of item 27 are its only writers). `eventCount` is what the run published; `storedEventCount` is `events.length`; `truncated` says the two differ (the registry backlog or the byte budget dropped events). Every stored event keeps the registry's `seq` (`StoredRunEvent`; `storedEventSeqs` falls back to positions only for a record whose events arrived without strictly increasing stamps). `RunListItem` is the record minus `events` plus an optional `bytes` (the stored JSON size) — `diagnosis` stays on the list item so the friction ledger can be served from a listing without loading events. How the run's preset was chosen rides the stream ([routing-and-config.md](routing-and-config.md) item 21): `run_meta.agentSource` — `directive`, `sticky`, `user`, `channel`, `default` or `route` — on every run since the router landed (absent on older records and on a command run), and a `route { preset, reason, model, parts?, collapsed?, command?, input?, receipt?, outcome?, handBackRunId? }` event right after `run_meta` on a run the router answered (head material, a side fact like `pr_opened`, never a step) — on a command run the router bound from prose ([routing-and-config.md](routing-and-config.md) item 21's command menu) `preset` is `command`, `command` the id the model called, `input` the bound `{ args, options }` with every string redacted and cut at `ROUTE_COMMAND_VALUE_CAP`, `receipt` the chat form the reply led with, redacted and cut at `ROUTE_RECEIPT_CAP`; how the invoke ended is the run's own `status` and `answer`; and, on a door decision about a state change ([record 0044](../../decisions/0044-a-routed-write-is-confirmed-in-proportion-to-its-blast-radius.md)), `outcome`: `hand_back` on the record of a routed write the door handed back — a command run that invoked nothing, `completed`, its `answer` the line to paste, written by `recordRoutedDecision` with no run signalled to the channel — `offered` on the record of the same decision on a channel that can show a confirmation ([routing-and-config.md](routing-and-config.md) item 25) — a command run that invoked nothing, `completed`, its `answer` the offer as the channel shows it (the full line, the risk line, the footer), the row minted in the config object — `confirmed` on the record of the stored input run at the click, as the requester, through the typed line's path (`reason: confirmed after offer`, `source: confirm` on the audit line; its status and `answer` the command's own, as a routed read's are), and `pasted` on the record of the typed line that followed a hand-back in the same thread with the same capped receipt, whatever the command, `handBackRunId` naming the hand-back's record ([routing-and-config.md](routing-and-config.md) item 21). A sixth outcome, `refused`, on the record of a routed decision a gate refused after the bind ([record 0054](../../decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md)): written by `recordRoutedDecision` from the confirm path when the store's refusal still names the row ([routing-and-config.md](routing-and-config.md) item 25: `expired`, `foreign`) — a command run that invoked nothing, `completed`, `reason: refused after offer`, its `answer` the refusal's named line, the event carrying the row's command, input and receipt and `refusalCode` (a code from `src/core/refusal.ts`) — and the door report counts such records per day, cause and code ([load-harness.md](load-harness.md) item 19). Every other refusal is a run record too ([record 0054](../../decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md), as amended): a gate refusal before any command is bound and before admission, a silent refusal and the catch-all's `uncaught` alike leave one record with agent `door` — written by `recordRefusal` beside the rendered sentence, the refusal's code leading the label, status `completed`, no surface told of the run and no thread claimed — whose events are the redacted request as `input` and exactly one `refusal { code, cause, text }`, the sentence redacted and capped at `ROUTE_RECEIPT_CAP`, the cause the one table's for the code; a message without a thread of its own records with the channel as its thread key, a refusal that already recorded through `recordRoutedDecision` records once, and a refused click on a question's stored proposal records the proposal as its request; a `used` click and an unreadable store name no row and leave no record — there is no message to record — and stay countable from the trace's `refusal`/`cause` root attributes ([tracing.md](tracing.md) item 3), which every refusal still carries unchanged. A run a question's Yes redispatched ([record 0054](../../decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md); [routing-and-config.md](routing-and-config.md) item 21) names the question on its record: a `run_note { kind: redispatch, summary: "confirmed after question " }` published before the first turn, `` the refusal code the stored row carried, so the Yes-run is traceable to the question whose proposal it ran. Which command runs are recorded: the inline-run commands (`isInlineRunCommand`, the ones that do work) and every routed decision whose `route` carries an `outcome`; a typed no-work command outside a paste, and a routed read of one, leave no record, as before: `parts` on a compound routed to the conductor — one `{ preset, text }` per child the brief told it to spawn, the text redacted and capped as the child's whole prompt — or `collapsed: { presets }` on the write run a compound answer collapsed onto ([routing-and-config.md](routing-and-config.md) item 21: a write ask is never a part, so the answer became one route to that preset), the preset each part named in answer order and no `parts`, and, for a compound the parse refused, the event on the run that fell to the default, `preset` being `defaults.agent` and `reason` reading `compound_rejected: ` while `agentSource` stays `default` ([routing-and-config.md](routing-and-config.md) item 21); the replay (`load route`, [load-harness.md](load-harness.md) item 17) reads both to tell a requester's own choice from a fallback and to leave the router's own answers out of its labels. The decision itself also rides the record as `route? { preset, reason, model, parts?, collapsed? }` ([routing-and-config.md](routing-and-config.md) item 21): the run's own route — folded at the one record assembly from the dispatcher's decision, or from the `route` event when `run_meta.agentSource` is `route` (`routeOfEvents`) — or, on a sticky-by-transcript follow-up that continued a routed thread, the thread's decision carried forward with no event; on the record (and its `RunListItem` and `RunView`) so the next follow-up's card can keep its `routed:` receipt without reading the events. +2. **Record shape.** `RunRecord = { id, awaitingInput?, inputStop?, label?, agent?, model?, channelId, userId, relayedBy?, authenticatedAs?, threadKey, channelVisibility, repo?, startedAt, finishedAt, receivedAt?, sealedAt?, replyOk?, replyNote?, stepCount?, schema?, status, eventCount, storedEventCount, truncated, events, diagnosis, handoff?, verdict?, reviewHead?, reviewPost?, dispositions?, profile?, parentRunId?, seed?, pr?, pushed?, lease?, route?, instanceId? }` (`profile` is item 45, `parentRunId` item 46, `seed` item 52, `lease` the run's lease as its harness started it — `{ startedAt, endsAt, loopEndsAt }` off the first `lease` event, folded at the one record assembly, present only on a run whose harness started a loop ([harness-pi.md](harness-pi.md) item 15, [decision 0046](../../decisions/0046-a-budget-is-a-lease-carved-from-its-parent-and-one-module-proves-the-leases-fit.md)); `pushed` the heads the run pushed — `{ ref, sha }[]`, one per branch with the last `pushed_head` event's sha winning, folded at the one record assembly; the event is published by the coding post-step when the observation proves a push of a branch that is not the base (`by: "push"`) and by the budget-end salvage (`by: "salvage"`), whether or not a pull request follows, so a renewal reads progress off the row without a model ([decision 0046](../../decisions/0046-a-budget-is-a-lease-carved-from-its-parent-and-one-module-proves-the-leases-fit.md)); `pr` the pull request the coding post-step opened or edited — `{ number, url, head? }` off the last `pr_opened` event, folded at the one record assembly, `head` the branch the PR is opened from when the event names it — the fact a follow-up in the thread continues from, [resident-repos.md](resident-repos.md) item 29, and the branch the run's release hands the resident to remember, [resident-repos.md](resident-repos.md) item 16a; `verdict` the verdict a review run submitted through `submit_verdict` — [agent-review.md](agent-review.md) — with its findings, `reviewHead` the head it reviewed and posted against after the settle, 7 to 40 lowercase hex, `reviewPost` how its post-step ended — `{ posted: true, target: { repo, number }, head, verdict? }` or `{ posted: false, reason }`, [agent-review.md](agent-review.md) item 18 — and `dispositions` the set a coding run submitted through `submit_dispositions`, as submitted, the plan runner matching it to its round's findings when it reads the record — [agent-ship.md](agent-ship.md) item 6 — each redacted at every string leaf in the one record assembly and present only on a run that produced it, so a coordinator's `read-record` answers a review child's verdict, whether it was posted, and a coding run's dispositions from the record — through `RunsService`, which supplies them from the store the moment it holds the record, a finished row still inside the registry's window included, item 21) (the five optional stamps per [tracing.md](tracing.md): `stepCount` is the registry's content-event count, span records excluded; `schema` the stream schema, 2 once spans are emitted; `handoff` the typed handoff a coding child submitted — [agent-ship.md](agent-ship.md) item 14 — redacted like every stored string and present only on a run that submitted one, an affirmed empty handoff being three empty lists, distinguishable from none) (`instanceId` the plan runner instance a ship run's hand-off created — [record 0051](../../decisions/0051-a-thread-has-one-owner-for-its-life-a-message-is-one-event-in-a-chosen-mode-and-a-pipeline-idles-instead-of-ending.md) R2 — folded at the one record assembly from the last `ship_handoff { instanceId, at }` event the ship branch publishes after a successful hand-off and never after a refusal, projected like the coordinator tag, exposed on `RunView.instanceId`, and how the thread's owner rule finds the instance, [thread-admission.md](thread-admission.md) item 9; the consuming run's `input` event may carry `mode` and `consumed` — the thread events it folded, record 0051's mode-as-receipt rule) with `status ∈ completed | stopped_soft | stopped_hard | failed | interrupted` and `channelVisibility ∈ public | private | dm | machine | unknown` — the channel's visibility as the `ChannelDirectory` reported it at dispatch ([authorization.md](authorization.md) item 7), what `member-of` reads; a record written before the stamp existed is accepted and reads as `unknown` (`normalizeStored`), never public — `interrupted` marks a run cut down before finish (container replaced or crashed; the provisional tombstone and the drain-deadline write of item 27 are its only writers). `eventCount` is what the run published; `storedEventCount` is `events.length`; `truncated` says the two differ (the registry backlog or the byte budget dropped events). Every stored event keeps the registry's `seq` (`StoredRunEvent`; `storedEventSeqs` falls back to positions only for a record whose events arrived without strictly increasing stamps). `RunListItem` is the record minus `events` plus an optional `bytes` (the stored JSON size) — `diagnosis` stays on the list item so the friction ledger can be served from a listing without loading events. How the run's preset was chosen rides the stream ([routing-and-config.md](routing-and-config.md) item 21): `run_meta.agentSource` — `directive`, `sticky`, `user`, `channel`, `default` or `route` — on every run since the router landed (absent on older records and on a command run), and a `route { preset, reason, model, parts?, collapsed?, command?, input?, receipt?, outcome?, handBackRunId? }` event right after `run_meta` on a run the router answered (head material, a side fact like `pr_opened`, never a step) — on a command run the router bound from prose ([routing-and-config.md](routing-and-config.md) item 21's command menu) `preset` is `command`, `command` the id the model called, `input` the bound `{ args, options }` with every string redacted and cut at `ROUTE_COMMAND_VALUE_CAP`, `receipt` the chat form the reply led with, redacted and cut at `ROUTE_RECEIPT_CAP`; how the invoke ended is the run's own `status` and `answer`; and, on a door decision about a state change ([record 0044](../../decisions/0044-a-routed-write-is-confirmed-in-proportion-to-its-blast-radius.md)), `outcome`: `hand_back` on the record of a routed write the door handed back — a command run that invoked nothing, `completed`, its `answer` the line to paste, written by `recordRoutedDecision` with no run signalled to the channel — `offered` on the record of the same decision on a channel that can show a confirmation ([routing-and-config.md](routing-and-config.md) item 25) — a command run that invoked nothing, `completed`, its `answer` the offer as the channel shows it (the full line, the risk line, the footer), the row minted in the config object — `confirmed` on the record of the stored input run at the click, as the requester, through the typed line's path (`reason: confirmed after offer`, `source: confirm` on the audit line; its status and `answer` the command's own, as a routed read's are), and `pasted` on the record of the typed line that followed a hand-back in the same thread with the same capped receipt, whatever the command, `handBackRunId` naming the hand-back's record ([routing-and-config.md](routing-and-config.md) item 21). A sixth outcome, `refused`, on the record of a routed decision a gate refused after the bind ([record 0054](../../decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md)): written by `recordRoutedDecision` from the confirm path when the store's refusal still names the row ([routing-and-config.md](routing-and-config.md) item 25: `expired`, `foreign`) — a command run that invoked nothing, `completed`, `reason: refused after offer`, its `answer` the refusal's named line, the event carrying the row's command, input and receipt and `refusalCode` (a code from `src/core/refusal.ts`) — and the door report counts such records per day, cause and code ([load-harness.md](load-harness.md) item 19). Every other refusal is a run record too ([record 0054](../../decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md), as amended): a gate refusal before any command is bound and before admission, a silent refusal and the catch-all's `uncaught` alike leave one record with agent `door` — written by `recordRefusal` beside the rendered sentence, the refusal's code leading the label, status `completed`, no surface told of the run and no thread claimed — whose events are the redacted request as `input` and exactly one `refusal { code, cause, text }`, the sentence redacted and capped at `ROUTE_RECEIPT_CAP`, the cause the one table's for the code; a message without a thread of its own records with the channel as its thread key, a refusal that already recorded through `recordRoutedDecision` records once, and a refused click on a question's stored proposal records the proposal as its request; a `used` click and an unreadable store name no row and leave no record — there is no message to record — and stay countable from the trace's `refusal`/`cause` root attributes ([tracing.md](tracing.md) item 3), which every refusal still carries unchanged. A run a question's Yes redispatched ([record 0054](../../decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md); [routing-and-config.md](routing-and-config.md) item 21) names the question on its record: a `run_note { kind: redispatch, summary: "confirmed after question " }` published before the first turn, `` the refusal code the stored row carried, so the Yes-run is traceable to the question whose proposal it ran. Which command runs are recorded: the inline-run commands (`isInlineRunCommand`, the ones that do work) and every routed decision whose `route` carries an `outcome`; a typed no-work command outside a paste, and a routed read of one, leave no record, as before: `parts` on a compound routed to the conductor — one `{ preset, text }` per child the brief told it to spawn, the text redacted and capped as the child's whole prompt — or `collapsed: { presets }` on the write run a compound answer collapsed onto ([routing-and-config.md](routing-and-config.md) item 21: a write ask is never a part, so the answer became one route to that preset), the preset each part named in answer order and no `parts`, and, for a compound the parse refused, the event on the run that fell to the default, `preset` being `defaults.agent` and `reason` reading `compound_rejected: ` while `agentSource` stays `default` ([routing-and-config.md](routing-and-config.md) item 21); the replay (`load route`, [load-harness.md](load-harness.md) item 17) reads both to tell a requester's own choice from a fallback and to leave the router's own answers out of its labels. The decision itself also rides the record as `route? { preset, reason, model, parts?, collapsed? }` ([routing-and-config.md](routing-and-config.md) item 21): the run's own route — folded at the one record assembly from the dispatcher's decision, or from the `route` event when `run_meta.agentSource` is `route` (`routeOfEvents`) — or, on a sticky-by-transcript follow-up that continued a routed thread, the thread's decision carried forward with no event; on the record (and its `RunListItem` and `RunView`) so the next follow-up's card can keep its `routed:` receipt without reading the events. 3. **Structural validator.** `isRunRecord(x)` accepts only an object whose `id` matches `RUN_ID_PATTERN` (`^[A-Za-z0-9_-]{1,64}$`), whose identity/timing/count fields have the right types, whose `status` is one of the five, whose `events` is an array of objects each with a string `type` (the union grows over time — an older reader must still accept a newer record, and the run-page fields `callId`/`exitCode`/`output`/`source` ride through verbatim), and whose `diagnosis` is structurally a diagnosis (`isStoredDiagnosis`: a `byCategory` map of `{ count, durationMs }` totals — NOT the current category list), and whose `verdict`, `reviewHead` and `dispositions`, when present, have their stored shapes (`isReviewVerdictShape`, the head pattern, `isFindingDispositionsShape` — shape only, no bounds, like the handoff), and whose `handoff`, when present, has the handoff's shape (`isHandoffShape`, [agent-ship.md](agent-ship.md) item 14: three lists of string-field entries — shape only, no bounds, because redaction may lengthen a stored string). Readers zero-fill categories a stored diagnosis lacks and drop unknown ones (`normalizeDiagnosis`), so adding a friction category never invalidates a stored record. Anything else — `null`, a bad id, an unknown status, an event without a `type` — is rejected. `isRunListItem` is the same check minus events, plus an optional numeric `bytes`. 4. **Retention policy.** `RetentionPolicy = { retentionDays, maxRuns, maxBytes }`, default `{ 30, 5000, 2 GiB }`. `clampRetentionPolicy(partial)` fills missing fields from the defaults, replaces non-finite values with the defaults, floors fractions, and clamps into `RETENTION_BOUNDS`: `retentionDays [1, 365]`, `maxRuns [1, 20000]`, `maxBytes [16 MiB, 8 GiB]`. 5. **One retention function.** `applyRetention(items, policy, nowMs)` drops items with `finishedAt < nowMs − retentionDays`, keeps the newest `maxRuns` by `finishedAt` desc (tie-break `id` desc — a total order, so bot and Worker cut identical rows), then drops the oldest while the cumulative `bytes` of the kept set exceeds `maxBytes` (missing `bytes` counts as 0). Returns the kept items newest-first and never mutates its input. @@ -42,8 +42,8 @@ Every run becomes a durable record — identity, timing, terminal status, the re 19. **One service, token-free views.** `createRunsService({ registry, store, analyze? })` is the one async service behind every `runs.*` command and the live view; `store: null` means history is off (live-only). Every result is a `Result = { ok: true, value } | { ok: false, error: "not_found" | "conflict" }` and NO output carries a run's capability token: live registry rows are projected field-by-field into `RunView` (`id, label?, agent?, model?, channelId?, userId?, threadKey?, repo?` — the `RunMeta` the dispatcher passes to `registry.create(label, meta)` — plus `startedAt, finished, eventCount, stop?, persisted?`, and for a finished registry run the `finishedAt`/`status` given to `finish()`) and a persisted `RunListItem` becomes the same `RunView` shape with `finished: true, persisted: true`, so a run in both sources projects identically except for the finish-only fields (`finishedAt`, `status`, `storedEventCount`/`truncated`/`bytes`, `diagnosis`). Callers are authorized one layer up (command scopes/chat gates + Access); the service only assumes it. 20. **Read merge.** `listRuns({ status, visibleTo, agent?, channel?, threadKey?, parentRunId?, sinceMs?, limit?, before?, beforeId? })`: `limit` default 50, cap 200 (the shared `RUN_LIST_*` limits). `visibleTo` is REQUIRED — the caller's authorization predicate (`predicateFor(actor, "runs:read", "run")`, [authorization.md](authorization.md) item 6; `{ kind: "all" }` is an explicit choice, never a default): live rows are filtered by the reference evaluator `matchesPredicate`, the store receives its wire form as `visibleTo` (omitted for `all`), and `none` returns an empty page without touching either. `active` = registry runs not finished — never touches the store. `finished` = finished registry runs ∪ `store.list`; `all` = every registry run ∪ `store.list`. Persisted rows are fetched with the same `agent`/`channel`/`sinceMs` filters and a bound of `min(200, limit + liveCount)`. `threadKey` narrows both sources to one thread's runs, newest first, ANDed with `visibleTo` — live rows by their `RunMeta`, the store by the filter it is handed — so `limit: 1` is the thread's newest run: the one read behind the dispatcher's thread read ([routing-and-config.md](routing-and-config.md) item 3), a child's thread-aware rows ([agent-conductor.md](agent-conductor.md) item 10) and stage A's paste check (item 2's `outcome`; [routing-and-config.md](routing-and-config.md) item 21); `parentRunId` is item 58's. Every view carries the run's `channelVisibility` (from the `RunMeta` live, the record persisted; absent on a hand-built live row) so the `runs.*` commands authorize a point read on the view itself. A run in both sources is ONE row. An UNFINISHED live row wins whole, and its store row is suppressed from every listing (`finished` included): that row is the run's provisional `interrupted` tombstone (item 27) — the truth only once the run is dead — so a live run always lists as live, never as interrupted. A FINISHED live row is merged `{ ...persisted, ...live }`: the live row wins every field it carries (the final `stop` state, the registry's `persisted` flag) except the finish fields — the record's `finishedAt`/`status` win (it is the source of truth: a reply that threw after the loop is `failed` there while the registry row keeps `completed`) — and the store row contributes what only the record knows (`diagnosis`, `bytes`, the typed artifacts of item 2). The record's `finishedAt` IS the registry's finish clock (`RunSnapshot.finishedAt`), so the two agree whenever both are present. Rows sort by the store's own key — unfinished rows first (newest started first), then `finishedAt` desc, then id desc — so the page is a true top-N of the union and a finished registry run sits exactly where its record will; `limit` applies after the merge. A full page ending on a persisted row returns `nextBefore: { finishedAt, id }`; passing it back as `before`/`beforeId` pages the store with the compound cursor and omits live rows (they all sort ahead of any cursor and were on the first page). Live rows carry their `RunMeta`, so `agent`/`channel` filters apply to them exactly as to persisted rows (a live row created without meta is excluded by those filters); `sinceMs` compares `finishedAt ?? startedAt`. A store that throws degrades to `{ runs: , storeUnavailable: true }` — never a whole-command failure. -21. **Single-run reads.** `getRun(id, { include? })` reads the registry first (token-free `getById`/`snapshotById`), then the store — `store.getSummary` without `include` (metadata only, the event set is never loaded), `store.get` with `include: "messages"` (events present). A FINISHED row the registry still holds (inside its 60 s TTL) is read with the store beside it: the row's identity, `stop` state, `status`/`finishedAt` and events are the registry's, and the typed artifacts of item 2 — `verdict`, `reviewHead`, `reviewPost`, `dispositions`, `handoff` — and the record's `usage` with its price (`cost`, [costs.md](costs.md) item 4c; a persisted row carries both too) are overlaid from `store.getSummary` the moment the store holds the run's record; the finish record lands in the store before the finish event that wakes a coordinator (item 47) is sent, so a reader woken by it never meets a row without them. A row whose record has not landed carries none — the start tombstone (item 27) lends nothing, not its `interrupted` — an unfinished row never asks the store, and a store that throws is one `warn` line and the registry row. `getRunEvents(id, { afterSeq?, limit? })` returns events with `seq > afterSeq` (strict), at most 500 per page and ≤ 256 KiB of event JSON (always ≥ 1 event), with `nextAfterSeq` = the last returned `seq` while more follow; live from the registry backlog, persisted via `store.events` (never a full-record fetch). `seq` is the registry's stamp on BOTH sides, so an `afterSeq` cursor taken from the live stream addresses the same events after eviction. `store.events → null` is `not_found`; an existing run with nothing past `afterSeq` — a zero-event record included — is `ok` with `{ events: [] }`. `getRunFriction(id)` → `{ id, finished, diagnosis }`: live runs are analyzed with `analyze(events, { finished, truncated })` (`truncated` = the snapshot's flag, item 5 of [run-visibility.md](run-visibility.md)), persisted runs return the stored diagnosis from `store.getSummary` — never the events. `stopRun` on a run outside the registry decides 409-vs-404 from `store.getSummary` too. A store failure inside `listRuns` degrades to live rows with `storeUnavailable: true` and ONE `warn` line carrying the error message (never a token) per failing call (`createRunsService({ …, warn? })`, default `console.warn`). Unknown, malformed, expired (retention-hidden) ids are `not_found` everywhere. -22. **Stop with actor.** `stopRun(id, mode, actor)` calls the token-free `registry.requestStopById(id, mode, actor)`; a live run → `{ id, mode, state: "stopping" }` and a `stop_requested` run_note carrying `actor: { kind: access | mcp | cli | chat, id }` with `id` stripped to `^[A-Za-z0-9:@._-]{1,128}$` (`unknown` when nothing survives); a finished-but-unevicted run and a persisted run → `conflict`; anything else → `not_found`. The token-gated `requestStop` (the HTML button) publishes no actor. +21. **Single-run reads.** `getRun(id, { include?, requireRecord? })` reads the registry first (token-free `getById`/`snapshotById`), then the store — `store.getSummary` without `include` (metadata only, the event set is never loaded), `store.get` with `include: "messages"` (events present). A FINISHED row the registry still holds (inside its 60 s TTL) is read with the store beside it: the row's identity, `stop` state, `status`/`finishedAt` and events are the registry's, and the typed artifacts of item 2 — `verdict`, `reviewHead`, `reviewPost`, `dispositions`, `handoff` — and the record's `usage` with its price (`cost`, [costs.md](costs.md) item 4c; a persisted row carries both too) are overlaid from `store.getSummary` the moment the store holds the run's record; `awaitingInput` and `inputStop` also come from that row, and an input stop overrides the cached terminal status; the finish record lands in the store before the finish event that wakes a coordinator (item 47) is sent, so a reader woken by it never meets a row without them. A row whose record has not landed carries none — the start tombstone (item 27) lends nothing, not its `interrupted` — an unfinished row never asks the store, and a store that throws is one `warn` line and the registry row. Callers making coordinator decisions pass `requireRecord: true`: a finished registry row requires a readable stored record with the same terminal status (or a durable input stop), otherwise the read throws for retry. This prevents a missing record, a start tombstone or a failed point read from hiding a question or cancellation. Live reads and ordinary dashboard reads keep their existing behavior. `getRunEvents(id, { afterSeq?, limit? })` returns events with `seq > afterSeq` (strict), at most 500 per page and ≤ 256 KiB of event JSON (always ≥ 1 event), with `nextAfterSeq` = the last returned `seq` while more follow; live from the registry backlog, persisted via `store.events` (never a full-record fetch). `seq` is the registry's stamp on BOTH sides, so an `afterSeq` cursor taken from the live stream addresses the same events after eviction. `store.events → null` is `not_found`; an existing run with nothing past `afterSeq` — a zero-event record included — is `ok` with `{ events: [] }`. `getRunFriction(id)` → `{ id, finished, diagnosis }`: live runs are analyzed with `analyze(events, { finished, truncated })` (`truncated` = the snapshot's flag, item 5 of [run-visibility.md](run-visibility.md)), persisted runs return the stored diagnosis from `store.getSummary` — never the events. `stopRun` on a run outside the registry decides 409-vs-404 from `store.getSummary` too. A store failure inside `listRuns` degrades to live rows with `storeUnavailable: true` and ONE `warn` line carrying the error message (never a token) per failing call (`createRunsService({ …, warn? })`, default `console.warn`). Unknown, malformed, expired (retention-hidden) ids are `not_found` everywhere. +22. **Stop with actor.** `stopRun(id, mode, actor)` calls the token-free `registry.requestStopById(id, mode, actor)`; a live run → `{ id, mode, state: "stopping" }` and a `stop_requested` run_note carrying `actor: { kind: access | mcp | cli | chat, id }` with `id` stripped to `^[A-Za-z0-9:@._-]{1,128}$` (`unknown` when nothing survives); a finished run without a pending question → `conflict`; anything else → `not_found`. A completed question can instead close through atomic `RunStore.stopWaiting`: persist `inputStop = { at, mode, by }`, remove `awaitingInput`, and project `stopped_soft` or `stopped_hard`, returning `{ id, mode, state: "stopped" }`. Optional `receivedAt` fences a delayed Stop against a question finished later. The identical stop is idempotent; a different stop cannot replace its marker. In-memory, file and Worker stores preserve the marker across later puts of the original turn, and the file store also preserves it when a crash left its index behind its record. Storage failure propagates before any native completion is sent. The token-gated `requestStop` (the HTML button) publishes no actor. 23. **Registry capability model.** `RunRegistry` enforces *capability OR operator*: the token-gated `has`/`subscribe`/`snapshot`/`requestStop` stay for the live HTML/SSE routes (defense in depth), and the token-free `getById`/`snapshotById`/`requestStopById` serve the service. `authorizeLive(id, token)` is synchronous and returns a `LiveRunAccess` (`subscribe`, `snapshot`, `requestStop`) bound to the registry when the token is right for a non-evicted run, else `null` — the live SSE path stays byte-identical through it. ### Consumers @@ -367,3 +367,6 @@ Every run becomes a durable record — identity, timing, terminal status, the re | 59: the alarm prunes by both arms of the bound — a 30 minute window keeps a row 24 hours, a two day window keeps it the window plus the drain deadline | `[unit]` `deploy/cloudflare-memory/runLedger.test.ts::run ledger — intake receipts (item 59)::the alarm prunes by both arms of the bound…` | | 59: the Worker client posts the key and receipt under the store key with windowMs only when configured, reads the row or none, and lists with only the filters given | `[unit]` `src/core/runLedgerWorker.test.ts::intake receipts — the routes and the retry (run-history item 59)::*` | | 59: the write-through's retry after a lost response reads the same row — this write's own landed row answers inserted, another writer's answers what it stored, none inserts once more; a missing route, a permanent refusal or a failing retry answers undefined with one warning | `[unit]` `src/core/runLedger/writeThrough.test.ts::intake receipts — the write-through's retry (run-history item 59)::*`, `src/core/runLedgerWorker.test.ts::intake receipts — the routes and the retry (run-history item 59)::the write-through's retry after a lost response reads the same row and answers the stored insert` | +| Waiting Stop persists and survives a late turn write | `[unit]` `src/core/runStore.test.ts::*::stops a waiting question durably and preserves the stop across late record writes`, `src/core/runStore.test.ts::FileRunStore::retains a question stop across reopen and an index interrupted before its replacement`, `deploy/cloudflare-memory/runs.test.ts::run history routes::persists a waiting Stop and fences late history writes and stale stops` | +| A finished registry turn exposes and stops its pending question | `[unit]` `src/core/runsService.test.ts::RunsService.stopRun::reads and stops a waiting question while its finished turn is still in the registry`, `src/core/runStoreWorker.test.ts::WorkerRunStore::sends waiting cancellation to the atomic route and rejects malformed acknowledgements` | +| Required coordinator reads retry missing, stale or unavailable finished records and recover question/stop markers | `[unit]` `src/core/runsService.test.ts::RunsService.getRun::a required finished record retries %s history and recovers its waiting marker`, `src/core/runsService.test.ts::RunsService.getRun::requires a history store only once the run has finished` | diff --git a/docs/reference/specs/slack-channel.md b/docs/reference/specs/slack-channel.md index b17f84bd6..404c75b1f 100644 --- a/docs/reference/specs/slack-channel.md +++ b/docs/reference/specs/slack-channel.md @@ -2,7 +2,7 @@ Slack is a pure transport: it turns Slack events into `IncomingMessage`s, renders replies/status back, and adds nothing else. All Slack-specific behavior — triggers, receipts, formatting, attachments — lives here. -- **Code**: `src/channels/slack.ts`, `src/channels/slack/` (the adapter's concerns that stand apart: `attachments.ts` — which files reach the model and their budgeted downloads; `requester.ts` — who asked: the sender, or the person an app relayed for (item 13); `threadTurns.ts` — the pure thread-page-to-turns mapping `history()` and the conversation reader share; `references.ts` — the conversation reader of record 0037: this workspace's permalink grammar, the closed fresh classifier, the text-only fetch, the requester's standing; `statusCard.ts` — the card's Block Kit frame and the live-card record; `dedupe.ts` — the handled-set and the redelivery guard; `lookups.ts` — the cached display-name, email and team-URL reads and the permalink), `src/core/dispatch/messages.ts` (`turnContent`: the attachment assembly of a model turn), `src/channels/slackTriggers.ts` (pure trigger gating), `src/channels/slackCatchUp.ts` (reconnect catch-up + orphaned-card sweep), `src/channels/slackCatchUpStatus.ts` (catch-up status record + bot-scope check), `src/channels/mrkdwn.ts`, `src/channels/health.ts` (`/healthz` body), `src/channels/processMetrics.ts` (`/healthz.process`), `src/core/drain.ts` (drain deadline + catch-up window minimum), `deploy/cloudflare/preflight.mjs` (bot deploy preflight), `src/deploy/restart.ts` + `deploy/cloudflare/worker.ts` (`deploy restart`: `/admin/restart` authorization + refusal decision; the Container DO `stop()`) +- **Code**: `src/channels/attachmentTypes.ts`, `src/channels/slack.ts`, `src/channels/slack/` (the adapter's concerns that stand apart: `attachments.ts` — which files reach the model and their budgeted downloads; `requester.ts` — who asked: the sender, or the person an app relayed for (item 13); `threadTurns.ts` — the pure thread-page-to-turns mapping `history()` and the conversation reader share; `references.ts` — the conversation reader of record 0037: this workspace's permalink grammar, the closed fresh classifier, the text-only fetch, the requester's standing; `statusCard.ts` — the card's Block Kit frame and the live-card record; `dedupe.ts` — the handled-set and the redelivery guard; `lookups.ts` — the cached display-name, email and team-URL reads and the permalink), `src/core/dispatch/messages.ts` (`turnContent`: the attachment assembly of a model turn), `src/channels/slackTriggers.ts` (pure trigger gating), `src/channels/slackCatchUp.ts` (reconnect catch-up + orphaned-card sweep), `src/channels/slackCatchUpStatus.ts` (catch-up status record + bot-scope check), `src/channels/mrkdwn.ts`, `src/channels/health.ts` (`/healthz` body), `src/channels/processMetrics.ts` (`/healthz.process`), `src/core/drain.ts` (drain deadline + catch-up window minimum), `deploy/cloudflare/preflight.mjs` (bot deploy preflight), `src/deploy/restart.ts` + `deploy/cloudflare/worker.ts` (`deploy restart`: `/admin/restart` authorization + refusal decision; the Container DO `stop()`) - **Docs**: [How a request flows](../../explanation/how-a-request-flows.md), [Worker topology](../../explanation/worker-topology.md), [AGENTS.md invariant 1](../../../AGENTS.md) - **Tests**: `src/channels/mrkdwn.test.ts`, `src/core/dispatch/messages.test.ts` (attachment assembly), `src/channels/slack.test.ts`, `src/channels/slack/attachments.test.ts`, `src/channels/slack/threadTurns.test.ts`, `src/channels/slack/statusCard.test.ts`, `src/channels/slack/dedupe.test.ts`, `src/channels/slack/lookups.test.ts`, `src/channels/slack/requester.test.ts`, `src/channels/slackCatchUp.test.ts`, `src/channels/health.test.ts`, `src/channels/processMetrics.test.ts`, `src/core/drain.test.ts`, `deploy/cloudflare/preflight.test.mjs`, `src/deploy/restart.test.ts`, `src/deploy/restartRun.test.ts`, `src/deploy/liveGate.test.ts`, `src/channels/slack/references.test.ts`, `src/channels/slackConfirmClick.test.ts` (item 14: the action intake's Bolt-level wiring) diff --git a/packages/switchboard/smoke.test.mts b/packages/switchboard/smoke.test.mts index aa0a690b1..3f5e8d829 100644 --- a/packages/switchboard/smoke.test.mts +++ b/packages/switchboard/smoke.test.mts @@ -191,18 +191,20 @@ describe("the installed CLI", () => { expect(extra.stderr).toContain("start takes no arguments"); }); - it("`start` from the installed package boots the bot process: in a directory with no .env it refuses at once naming the missing variable (the process's own rule), exit 1 — no stack, no Docker", () => { + it("`start` from the installed package boots the bot process: in a directory with no .env it refuses at once naming the missing channel configuration (the process's own rule), exit 1 — no stack, no Docker", () => { const work = join(tmp, "work-start"); mkdirSync(work); const env = { ...Object.fromEntries( - Object.entries(process.env).filter(([k]) => k !== "SLACK_BOT_TOKEN" && k !== "SLACK_APP_TOKEN"), + Object.entries(process.env).filter( + ([k]) => k !== "SLACK_BOT_TOKEN" && k !== "SLACK_APP_TOKEN" && k !== "LINEAR_BRIDGE_TOKEN", + ), ), SWITCHBOARD_HOME: work, }; const r = spawnSync(bin, ["start"], { cwd: work, encoding: "utf8", env, timeout: 30_000 }); expect(r.status).toBe(1); - expect(r.stderr.trim()).toBe("Missing required env var SLACK_BOT_TOKEN"); + expect(r.stderr.trim()).toBe("No channel configured: set Slack tokens or LINEAR_BRIDGE_TOKEN"); expect(r.stdout).toBe(""); }); diff --git a/scripts/public-hygiene.allow b/scripts/public-hygiene.allow index debe78bd8..e61a72b82 100644 --- a/scripts/public-hygiene.allow +++ b/scripts/public-hygiene.allow @@ -89,6 +89,11 @@ deploy/cloudflare-memory/wrangler.template.jsonc "compatibility_date": "2026-08- deploy/cloudflare-resident/wrangler.template.jsonc "compatibility_date": "2026-08-01", deploy/cloudflare-sandbox/wrangler.template.jsonc "compatibility_date": "2026-08-01", deploy/cloudflare/wrangler.template.jsonc "compatibility_date": "2026-08-01", +deploy/cloudflare/linear.local.jsonc "compatibility_date": "2026-08-01", + +# These links use the required dated plan filename, not an incident retelling. +docs/how-to/connect-linear.md is deployed. See the [delivery plan](../plans/2026-09-17-001-linear-channel.md). +docs/reference/specs/linear-channel.md - **Docs**: [Delivery plan](../../plans/2026-09-17-001-linear-channel.md). # The example profile's placeholder account id: src/deploy/profile.ts requires exactly 32 hex characters, so the placeholder keeps the shape. deploy/profile.example.json "account": "00000000000000000000000000000000", diff --git a/src/agents/registry.ts b/src/agents/registry.ts index aeb5bdf6b..a172d2b10 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -617,6 +617,8 @@ ROUTED COMPOUNDS. A request may arrive already split: the router found independe HOW TO WORK. Fan out, await, compile. Read the request and split it into children only where the parts are independent; a request one preset answers is one child. Spawn each child with a prompt that says what it should do, and the repository where the preset needs one: a child starts from this conversation's text so far — every user and assistant turn before your call, never your tool calls, their results or your thinking — and your prompt is its one new turn, so tell it what to do rather than repeat what was said. Then call \`await_runs\` once with every child's id: it returns when all of them have ended, or earlier — at the edge of your own budget, at a stop, or when a follow-up lands in this thread — and \`ended\` says which; a child still running at the cut keeps running (name it in your answer, or await again after a follow-up). Steer a child with \`send_to_run\` when the request changes or a child is heading the wrong way. A child that ended — finished, failed, interrupted by a restart — is reported as it ended and never restarted; spawn a new child if the work still matters. Then compile: one answer from the write-ups \`await_runs\` returned. Never do a child's job yourself, and never claim a child finished or found something you did not read from \`await_runs\` or \`get_run_status\`. +CHILD QUESTIONS. A child whose turn ends with a question is \`awaiting_input\`, not finished. \`await_runs\` returns immediately with its question and thread link. Use \`request_input\` to relay what is missing and point the person to that child's thread to answer it. Do not mark the child complete, invent its answer or spawn a replacement. Once the person replies there, await the same child id again to follow its continuation. + A CHILD IS ITS THREAD. People can reply in a child's thread. While the child runs, the reply steers it, exactly as a reply in your own thread steers you; after it ended, the reply starts a new run of that child in the same thread. Either way the reply reaches you as a follow-up from that child — it names the child's run, the person and their words, and the new run when one started — so a wait in flight ends \`follow_up\` and you read it at your next step. The rows \`await_runs\` and \`get_run_status\` give you for that child follow the thread's newest run: \`continuedBy\` names the run now speaking for the child, and \`status\` and \`finalReply\` are its. So when a follow-up from a child's thread arrives, call \`await_runs\` again with your children before you compile: your answer reflects what the child's thread settled on, never its first reply alone. Maintain the user-facing status card with the update_status tool: one item per child (○ pending, ✱ running, ✓ finished — only once await_runs or get_run_status said so). diff --git a/src/artifacts/keys.test.ts b/src/artifacts/keys.test.ts index 366eff9a3..847124a60 100644 --- a/src/artifacts/keys.test.ts +++ b/src/artifacts/keys.test.ts @@ -29,6 +29,11 @@ describe("safeBasename (item 20)", () => { }); describe("key builders (item 20)", () => { + it("normalizes namespaced message ids into one storage segment", () => { + expect(inboundKey("linear:org:session", "org:AgentSessionEvent:digest", 1, "data.zip")).toBe( + "threads/linear-org-session/in/org-AgentSessionEvent-digest/1-data.zip", + ); + }); it("outbound keys live under the run with a per-file sequence; inbound under the thread and message with the file's index", () => { expect(outboundKey("run-1", 1, "screenshot.png")).toBe("runs/run-1/out/1-screenshot.png"); expect(outboundKey("run-1", 2, "screenshot.png")).toBe("runs/run-1/out/2-screenshot.png"); diff --git a/src/artifacts/keys.ts b/src/artifacts/keys.ts index 58a3e5755..9850546d6 100644 --- a/src/artifacts/keys.ts +++ b/src/artifacts/keys.ts @@ -34,5 +34,5 @@ export function outboundKey(runId: string, seq: number, name: string): string { /** A received file: `threads//in//-`, `index` the file's position in the message. */ export function inboundKey(threadKey: string, messageTs: string, index: number, name: string): string { - return `threads/${threadKeySafe(threadKey)}/in/${messageTs}/${index}-${safeBasename(name)}`; + return `threads/${threadKeySafe(threadKey)}/in/${threadKeySafe(messageTs)}/${index}-${safeBasename(name)}`; } diff --git a/src/artifacts/store.ts b/src/artifacts/store.ts index ec79cb5d2..b1f509997 100644 --- a/src/artifacts/store.ts +++ b/src/artifacts/store.ts @@ -103,7 +103,7 @@ export interface ArtifactStore { * or `unsatisfiable` with the size when the range starts past the end. */ get(key: string, opts?: ArtifactGetOptions): Promise; /** Copy `size` bytes from `url` (a Slack `url_private`) into `key` without the bot holding them. */ - copyFromUrl(input: { url: string; size: number; key: string }): Promise; + copyFromUrl(input: { url: string; size: number; key: string }, signal?: AbortSignal): Promise; } export const PRESIGN_TTL_SECONDS = ARTIFACT_DEFAULTS.presignTtlSeconds; @@ -241,12 +241,14 @@ export class R2ArtifactStore implements ArtifactStore { return { size, contentType, body: res.body }; } - async copyFromUrl(input: { url: string; size: number; key: string }): Promise { + async copyFromUrl(input: { url: string; size: number; key: string }, signal?: AbortSignal): Promise { const res = await this.fetchImpl(`${this.copy.baseUrl.replace(/\/$/, "")}/artifacts/copy`, { method: "POST", headers: { authorization: `Bearer ${this.copy.token.reveal()}`, "content-type": "application/json" }, body: JSON.stringify(input), - signal: AbortSignal.timeout(this.copy.timeoutMs), + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(this.copy.timeoutMs)]) + : AbortSignal.timeout(this.copy.timeoutMs), }); const text = await res.text(); if (!res.ok) @@ -310,9 +312,10 @@ export class InMemoryArtifactStore implements ArtifactStore { return { size, contentType: o.contentType, body, ...(part ? { part } : {}) }; } - async copyFromUrl(input: { url: string; size: number; key: string }): Promise { + async copyFromUrl(input: { url: string; size: number; key: string }, signal?: AbortSignal): Promise { this.copies.push(input); - const res = await this.fetchImpl(input.url); + signal?.throwIfAborted(); + const res = await this.fetchImpl(input.url, signal ? { signal } : undefined); if (!res.ok) throw new Error(`artifact store: copy of ${input.key} from ${input.url} answered HTTP ${res.status}`); const bytes = new Uint8Array(await res.arrayBuffer()); if (bytes.byteLength !== input.size) { @@ -320,6 +323,7 @@ export class InMemoryArtifactStore implements ArtifactStore { `artifact store: copy of ${input.key} received ${bytes.byteLength} of ${input.size} bytes; nothing stored`, ); } + signal?.throwIfAborted(); this.put(input.key, bytes, res.headers.get("content-type") ?? "application/octet-stream"); return { key: input.key, size: input.size }; } diff --git a/src/channels/adminCoordinator.test.ts b/src/channels/adminCoordinator.test.ts index a5afc4706..93ec32126 100644 --- a/src/channels/adminCoordinator.test.ts +++ b/src/channels/adminCoordinator.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { IncomingMessage as HttpRequest, ServerResponse } from "node:http"; import { Secret } from "../secrets.js"; import { AGENTS } from "../agents/registry.js"; @@ -1138,6 +1138,22 @@ describe("the plan runner's steps — plan, unit-start, branch, round, unit-end, }; } + it("checks the requester before opening coordinator threads and gives each retry a stable channel key", async () => { + const io = openingIo([]); + const checkAccess = vi.fn(async () => false); + const openThread = vi.fn(io.openThread!); + const h = await planHarness({ ioFor: () => ({ ...io, checkAccess, openThread }) }); + const denied = await call(h, "unit-start", { parentInstanceId: PLAN_INSTANCE.id, unit: "U10" }); + expect(denied).toMatchObject({ status: 403, body: { error: "channel_access_denied" } }); + expect(checkAccess).toHaveBeenCalledWith(PLAN_INSTANCE.userId); + expect(openThread).not.toHaveBeenCalled(); + checkAccess.mockResolvedValue(true); + expect((await call(h, "unit-start", { parentInstanceId: PLAN_INSTANCE.id, unit: "U10" })).status).toBe(200); + expect(openThread).toHaveBeenCalledTimes(1); + for (const [lead, options] of openThread.mock.calls) + expect(options).toEqual({ idempotencyKey: `${PLAN_INSTANCE.id}:${lead}` }); + }); + it("unit-start opens a plan unit's thread through the requesting thread's channel and no review thread, finds the board issue titled by the unit id, and writes the thread on the row; a generated plan's unit runs in the requesting thread and opens nothing", async () => { const opened: string[] = []; const h = await planHarness({ @@ -2655,6 +2671,221 @@ describe("POST /admin/coordinator/merge — the runner's squash of a unit's pull }); }); +describe("coordinator question records", () => { + it.each([1, 2])( + "retries a failed durable point read %s while the finished child is still cached", + async (failedRead) => { + const h = harness(); + const child = h.registry.create("coding · question", { + ...TAG, + agent: "coding", + channelId: INSTANCE.channelId, + userId: INSTANCE.userId, + threadKey: INSTANCE.threadKey, + }); + h.registry.publish(child.id, { + type: "pr_opened", + number: 7, + url: "https://github.com/acme/api/pull/7", + created: true, + }); + h.registry.finish(child.id, "completed"); + await h.store.put(record(child.id, { ...TAG, awaitingInput: true })); + const read = h.store.getSummary.bind(h.store); + let reads = 0; + vi.spyOn(h.store, "getSummary").mockImplementation((id) => { + if (++reads === failedRead) throw new Error("history temporarily unavailable"); + return read(id); + }); + const request = post(`${COORDINATOR_ADMIN_PREFIX}read-record`, { + parentInstanceId: INSTANCE.id, + runId: child.id, + }); + await expect(handleCoordinatorRequest(request, h.deps)).rejects.toThrow(/temporarily unavailable/); + const retry = await handleCoordinatorRequest(request, h.deps); + expect(retry).toMatchObject({ status: 200, body: { run: { awaitingInput: true } } }); + expect((retry!.body as { run: Record }).run.pr).toBeUndefined(); + expect(h.merges).toHaveLength(0); + }, + ); + + it("reports a cancelled question as stopped and withholds its earlier review and PR artifacts", async () => { + const h = harness(); + await h.store.put( + record("run-question", { + ...TAG, + awaitingInput: true, + verdict: { verdict: "approve", summary: "Earlier approval", findings: [] }, + reviewHead: "a".repeat(40), + events: [{ type: "pr_opened", number: 7, url: "https://github.com/acme/api/pull/7", created: true, seq: 1 }], + }), + ); + await h.store.stopWaiting("run-question", { at: NOW, mode: "hard", by: { kind: "chat", id: INSTANCE.userId } }); + const reply = await handleCoordinatorRequest( + post(`${COORDINATOR_ADMIN_PREFIX}read-record`, { + parentInstanceId: INSTANCE.id, + runId: "run-question", + }), + h.deps, + ); + expect(reply).toMatchObject({ + status: 200, + body: { run: { status: "stopped_hard", finalReply: "Stopped waiting for input." } }, + }); + const run = (reply!.body as { run: Record }).run; + expect(run.awaitingInput).toBeUndefined(); + expect(run.verdict).toBeUndefined(); + expect(run.pr).toBeUndefined(); + }); + + it("retries unavailable continuation history rather than settling an incomplete round", async () => { + const h = harness(); + await h.store.put(record("run-question", { ...TAG, awaitingInput: true })); + vi.spyOn(h.deps.runs, "listRuns").mockResolvedValueOnce({ runs: [], storeUnavailable: true }); + await expect( + handleCoordinatorRequest( + post(`${COORDINATOR_ADMIN_PREFIX}read-record`, { + parentInstanceId: INSTANCE.id, + runId: "run-question", + }), + h.deps, + ), + ).rejects.toThrow(/temporarily unavailable/); + }); + + it.each([false, true])( + "includes every earlier question's cost in the settled round (missing price: %s)", + async (missing) => { + const h = harness({ + prices: parseModelPrices({ "anthropic/claude-fable-5": { input: 3, output: 15, cacheRead: 0, cacheWrite: 0 } }), + }); + const usage = { + turns: 1, + byModel: { + "anthropic/claude-fable-5": { + turns: 1, + inputTokens: 1_000_000, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + }, + }; + await h.store.put(record("run-question", { ...TAG, awaitingInput: true, usage: missing ? undefined : usage })); + await h.store.put( + record("run-another-question", { + ...TAG, + awaitingInput: true, + usage, + startedAt: NOW - 7_000, + finishedAt: NOW - 6_000, + }), + ); + await h.store.put(record("run-answer", { ...TAG, usage, startedAt: NOW - 5_000, finishedAt: NOW - 4_000 })); + // Both the initial question and an already-followed continuation expose the same total. + for (const runId of ["run-question", "run-answer"]) { + const reply = await handleCoordinatorRequest( + post(`${COORDINATOR_ADMIN_PREFIX}read-record`, { + parentInstanceId: INSTANCE.id, + runId, + }), + h.deps, + ); + expect(reply).toMatchObject({ status: 200, body: { run: { id: "run-answer", costUsd: missing ? null : 9 } } }); + } + }, + ); + + it("follows only a continuation with the same instance, round, thread and requester", async () => { + const h = harness(); + await h.store.put(record("run-question", { ...TAG, awaitingInput: true })); + await h.store.put( + record("run-answer", { + ...TAG, + startedAt: NOW - 5_000, + finishedAt: NOW - 4_000, + events: [{ type: "answer", text: "The requested behavior is implemented.", seq: 1 }], + }), + ); + await h.store.put( + record("run-foreign", { ...TAG, userId: "slack:UBOB", startedAt: NOW - 3_000, finishedAt: NOW - 2_000 }), + ); + await h.store.put( + record("run-other-credential", { + ...TAG, + authenticatedAs: "http:other", + startedAt: NOW - 2_000, + finishedAt: NOW - 1_000, + }), + ); + await h.store.put( + record("run-other-round", { + ...TAG, + idempotencyKey: `${INSTANCE.id}:U20/0/coding`, + startedAt: NOW - 1_000, + finishedAt: NOW, + }), + ); + const reply = await handleCoordinatorRequest( + post(`${COORDINATOR_ADMIN_PREFIX}read-record`, { + parentInstanceId: INSTANCE.id, + runId: "run-question", + }), + h.deps, + ); + expect(reply).toMatchObject({ + status: 200, + body: { + run: { + id: "run-answer", + finished: true, + status: "completed", + finalReply: "The requested behavior is implemented.", + costUsd: null, + }, + }, + }); + }); + + it("returns the question marker without exposing earlier verdict or PR artifacts", async () => { + const h = harness(); + await h.store.put( + record("run-question", { + ...TAG, + awaitingInput: true, + verdict: { verdict: "approve", summary: "Earlier approval", findings: [] }, + reviewHead: "a".repeat(40), + events: [ + { type: "pr_opened", number: 7, url: "https://github.com/acme/api/pull/7", created: true, seq: 1 }, + { type: "answer", text: "Which behavior do you want?", seq: 2 }, + ], + }), + ); + const reply = await handleCoordinatorRequest( + post(`${COORDINATOR_ADMIN_PREFIX}read-record`, { + parentInstanceId: INSTANCE.id, + runId: "run-question", + }), + h.deps, + ); + expect(reply).toMatchObject({ + status: 200, + body: { + run: { + finished: true, + status: "completed", + awaitingInput: true, + finalReply: "Which behavior do you want?", + }, + }, + }); + const run = (reply!.body as { run: Record }).run; + expect(run.verdict).toBeUndefined(); + expect(run.pr).toBeUndefined(); + expect(run.reviewPosted).toBeUndefined(); + }); +}); + // Feature: record 0051's fold rule (thread-admission items 4 and 5) — the fold: every // coding spawn carries the unit's unconsumed thread events, attributed and in // arrival order, and marks them consumed by the spawn's step; a review spawn diff --git a/src/channels/adminCoordinator.ts b/src/channels/adminCoordinator.ts index 6465404c2..83b61c60c 100644 --- a/src/channels/adminCoordinator.ts +++ b/src/channels/adminCoordinator.ts @@ -282,6 +282,7 @@ function parseBrief(v: unknown): Parsed { * but the final reply — the coordinator confirms an event and reads the * child's handoff from it. */ export interface CoordinatorRunView { + awaitingInput?: true; id: string; finished: boolean; status?: string; @@ -404,7 +405,9 @@ async function openThreadFromRequester( const parent = deps.ioFor({ threadKey: instance.threadKey, userId: instance.userId }); if (!parent?.openThread) return { ok: false, response: json(503, { ok: false, error: "no_channel", at }) }; try { - const opened = await parent.openThread(lead); + if (parent.checkAccess && !(await parent.checkAccess(instance.userId))) + return { ok: false, response: json(403, { ok: false, error: "channel_access_denied", at }) }; + const opened = await parent.openThread(lead, { idempotencyKey: `${instance.id}:${lead}` }); return { ok: true, thread: { @@ -514,15 +517,22 @@ function watched(io: ChannelIO, on: { started: (id: string) => void; replied: (t status: (initial) => io.status(initial), history: () => io.history(), runStarted: (started) => { - io.runStarted?.(started); + const pending = io.runStarted?.(started); on.started(started.id); + return pending; }, }; if (io.attach) out.attach = (file) => io.attach!(file); if (io.attachFile) out.attachFile = (file) => io.attachFile!(file); if (io.uploadTicket) out.uploadTicket = (file) => io.uploadTicket!(file); + if (io.copyAttachment) out.copyAttachment = (file, key, signal) => io.copyAttachment!(file, key, signal); + if (io.workItems) out.workItems = (actor) => io.workItems!(actor); + if (io.question) out.question = (text) => io.question!(text); + if (io.checkAccess) out.checkAccess = (userId) => io.checkAccess!(userId); + if (io.isolateFollowUps) out.isolateFollowUps = true; + if (io.acknowledge) out.acknowledge = (text) => io.acknowledge!(text); if (io.runFinished) out.runFinished = (receipt) => io.runFinished!(receipt); - if (io.openThread) out.openThread = (lead) => io.openThread!(lead); + if (io.openThread) out.openThread = (lead, options) => io.openThread!(lead, options); return out; } @@ -714,6 +724,7 @@ function coordinatorRunView( return { id: view.id, finished: view.finished, + ...(view.finished && view.status === "completed" && view.awaitingInput ? { awaitingInput: true as const } : {}), ...(view.status !== undefined ? { status: view.status } : {}), ...(view.agent !== undefined ? { agent: view.agent } : {}), startedAt: view.startedAt, @@ -812,6 +823,59 @@ function reviewPostedByRecord( return same ? { reviewPosted: true } : undefined; } +/** The round key stays fixed when a person answers a child question. Read only + * siblings with that exact identity; unrelated work in the thread cannot settle it. */ +async function coordinatorRoundRuns( + runs: RunsService, + original: RunView, +): Promise<{ current: RunView; earlierCost: number | null }> { + if (!original.threadKey || !original.idempotencyKey || !original.parentInstanceId || !original.finished) + return { current: original, earlierCost: 0 }; + const members = new Map([[original.id, original]]); + let cursor: { before: number; beforeId: string } | undefined; + for (let page = 0; page < FINISHED_LOOKBACK_PAGES; page++) { + const result = await runs.listRuns({ + status: "all", + visibleTo: EVERY_RUN, + threadKey: original.threadKey, + limit: RUN_LIST_MAX_LIMIT, + ...(cursor ?? {}), + }); + if (result.storeUnavailable) throw new Error("The coordinator continuation history is temporarily unavailable"); + for (const row of result.runs) { + if ( + row.parentInstanceId === original.parentInstanceId && + row.idempotencyKey === original.idempotencyKey && + row.userId === original.userId && + row.authenticatedAs === original.authenticatedAs && + row.channelId === original.channelId && + row.threadKey === original.threadKey + ) + members.set(row.id, row); + } + cursor = result.nextBefore ? { before: result.nextBefore.finishedAt, beforeId: result.nextBefore.id } : undefined; + if (!cursor) break; + } + const newest = original.awaitingInput + ? [...members.values()].sort((a, b) => b.startedAt - a.startedAt)[0]! + : original; + const current = newest.id === original.id ? original : await runs.getRun(newest.id, { requireRecord: true }); + if ("ok" in current && !current.ok) throw new Error("The coordinator continuation is temporarily unavailable"); + const view = "ok" in current ? current.value : current; + if (!view.finished) return { current: view, earlierCost: 0 }; + // A bounded listing or an unpriced/missing question turn is unknown cost, + // never zero: a continuation must not reset a capped grant's spend. + let earlierCost: number | null = cursor ? null : 0; + for (const row of members.values()) { + if (row.id === view.id || !row.awaitingInput || row.startedAt > view.startedAt) continue; + const prior = await runs.getRun(row.id, { requireRecord: true }); + const usd = prior.ok && prior.value.finished ? prior.value.cost?.usd : undefined; + if (usd == null) earlierCost = null; + else if (earlierCost !== null) earlierCost += usd; + } + return { current: view, earlierCost }; +} + async function readRecord(body: Record, deps: AdminCoordinatorDeps): Promise { const id = parseInstanceId(body.parentInstanceId); if (!id.ok) return json(400, { ok: false, error: id.error }); @@ -822,16 +886,30 @@ async function readRecord(body: Record, deps: AdminCoordinatorD const at = (deps.clock ?? systemClock)(); // A run outside the instance is `not_found`, byte-identical to a missing one // (authorization.md: a denied read reveals nothing). - const res = await deps.runs.getRun(body.runId); + const res = await deps.runs.getRun(body.runId, { requireRecord: true }); if (!res.ok || res.value.parentInstanceId !== id.value) return json(404, { ok: false, error: "not_found" }); - const view = res.value; + const { current: view, earlierCost } = await coordinatorRoundRuns(deps.runs, res.value); if (!view.finished) return json(200, { ok: true, run: coordinatorRunView(view, id.value, undefined), at }); // Finished: the final reply and the typed artifacts the record carries — the // coding child's pull request, the review child's verdict and whether it // stands on the pull request, the coding run's dispositions. - const full = await deps.runs.getRun(body.runId, { include: "messages" }); - const record = full.ok ? full.value : view; + const full = await deps.runs.getRun(view.id, { include: "messages", requireRecord: true }); + if (!full.ok) throw new Error("The coordinator result is temporarily unavailable"); + const record = full.value; const finalReply = finalReplyOf(record.events); + // A question is a completed turn, not a completed unit. Earlier artifacts + // are deliberately withheld until the continuation supplies its result. + if (record.inputStop) + return json(200, { + ok: true, + run: { + ...coordinatorRunView(record, id.value, "Stopped waiting for input."), + costUsd: record.cost?.usd == null || earlierCost === null ? null : record.cost.usd + earlierCost, + }, + at, + }); + if (record.status === "completed" && record.awaitingInput) + return json(200, { ok: true, run: coordinatorRunView(record, id.value, finalReply), at }); const pr = prOpenedOf(record.events); // Whether the verdict stands on the unit's pull request: the child's own // record of its post first (item 18) — it posted, or it recorded why not — @@ -869,7 +947,7 @@ async function readRecord(body: Record, deps: AdminCoordinatorD // What the child cost, as the runs service prices it (costs.md item 4c): // null when unknown — no usage on the record, or a model without a price // — so a capped grant never renews on an understated total. - costUsd: record.cost?.usd ?? null, + costUsd: record.cost?.usd == null || earlierCost === null ? null : record.cost.usd + earlierCost, ...(record.handoff !== undefined ? { handoffLists: record.handoff } : {}), }, at, diff --git a/src/channels/attachmentTypes.ts b/src/channels/attachmentTypes.ts new file mode 100644 index 000000000..d806fbfb6 --- /dev/null +++ b/src/channels/attachmentTypes.ts @@ -0,0 +1,134 @@ +// Shared inbound file classification for channel adapters. + +const PDF_TYPE = "application/pdf"; +// Text-ish mimetypes beyond the `text/*` family that Slack may report. +// `application/json` is deliberately absent: JSON is a common container for +// credentials (service-account keys, token dumps), so a file is never inlined +// just because Slack tags it application/json — the denylist below plus the +// extension allowlist decide, never the JSON mimetype on its own. +const TEXT_MIME_TYPES = new Set([ + "application/xml", + "application/yaml", + "application/x-yaml", + "application/toml", + "application/x-sh", + "application/javascript", + "application/typescript", +]); +// Mimetypes Slack assigns when it can't identify a file — fall back to the +// filename extension to decide whether it's a text/code file. +const GENERIC_MIME_TYPES = new Set(["application/octet-stream", "binary/octet-stream", ""]); +// Extensions inlined as text under the generic-mimetype fallback. JSON (`.json`, +// `.jsonl`) and config formats (`.env`, `.ini`, `.cfg`, `.conf`) are absent by +// design — the first two are frequent secret containers, the rest are covered by +// the secret-file denylist — so a generic-typed config/JSON file is not "fair +// game" for inlining just because of its extension. +const TEXT_EXTENSIONS = new Set([ + ".txt", + ".md", + ".markdown", + ".log", + ".csv", + ".tsv", + ".rst", + ".yaml", + ".yml", + ".toml", + ".xml", + ".html", + ".htm", + ".css", + ".scss", + ".less", + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".py", + ".rb", + ".go", + ".rs", + ".java", + ".kt", + ".c", + ".h", + ".cpp", + ".hpp", + ".cc", + ".cs", + ".php", + ".swift", + ".sh", + ".bash", + ".zsh", + ".sql", + ".r", + ".pl", + ".lua", + ".dart", + ".scala", + ".clj", + ".ex", + ".exs", + ".vue", + ".svelte", + ".graphql", + ".proto", + ".dockerfile", +]); +// Secret-file denylist — filename shapes whose contents are likely credentials, +// private keys, or secret config. Matching files are skipped-with-note and their +// bytes NEVER reach the model prompt. This OVERRIDES text classification +// (checked before the text-mimetype/extension allowlist), because the whole risk +// is a secret file whose mimetype/extension otherwise reads as harmless text. +// +// Matched on the filename, case-insensitive, and independent of +// `node:path.extname` — which returns "" for dotfiles like `.env` and `.npmrc`, +// so an extname-based check would miss exactly the files that matter most. +const SECRET_FILE_EXTENSIONS = [".pem", ".key", ".p12", ".pfx", ".npmrc", ".netrc", ".ini", ".cfg", ".conf"]; +const SECRET_FILE_PREFIXES = ["id_rsa"]; + +/** Does this filename look like a secret/credential/key/config file? Case- + * insensitive; conservative (a false match only skips a file, never leaks one). + * Exported for tests. */ +export function isSecretFile(name: string | undefined): boolean { + const n = (name ?? "").trim().toLowerCase(); + if (!n) return false; + // `.env` in any position: bare `.env`, dotfiles (`.env.local`, + // `.env.production`), and suffixed configs (`config.env`, `prod.env`). + if (n.includes(".env")) return true; + // SSH / private-key material by filename prefix (`id_rsa`, `id_rsa.pub`, …). + if (SECRET_FILE_PREFIXES.some((p) => n.startsWith(p))) return true; + // Credential JSON blobs — the common shapes secrets ship in. + if (n === "credentials.json") return true; + if (n.endsWith(".json") && (n.includes("service-account") || n.endsWith("-key.json"))) return true; + // Secret-ish extensions, including dotfiles `extname` can't see. + return SECRET_FILE_EXTENSIONS.some((ext) => n.endsWith(ext)); +} + +/** Classify a file for document ingestion: a PDF, an inlinable text/code file, + * or neither. A secret-file denylist match (`isSecretFile`) is classified as + * neither — before any text check — so credentials never inline. Otherwise text + * detection prefers the mimetype and falls back to the filename extension only + * when Slack reports a generic/unknown type. Exported for tests. */ +export function classifyDocument(mimetype: string | undefined, name: string | undefined): "pdf" | "text" | null { + if (mimetype === PDF_TYPE) return "pdf"; + // Secret-file denylist OVERRIDES text classification: a credentials/key/config + // file is skipped, never decoded into the prompt, even when its mimetype + // (application/json, text/plain) or extension would otherwise mark it text. + if (isSecretFile(name)) return null; + const mt = mimetype ?? ""; + if (mt.startsWith("text/") || TEXT_MIME_TYPES.has(mt)) return "text"; + // Filenames also arrive as paths. Inspect only the last segment, and do + // not treat a dotfile's entire name as an extension. No host path API is + // needed, so this classifier also runs in the channel's edge Worker. + const leaf = (name ?? "").split("/").at(-1)!; + const dot = leaf.lastIndexOf("."); + const extension = dot > 0 ? leaf.slice(dot).toLowerCase() : ""; + if (GENERIC_MIME_TYPES.has(mt) && TEXT_EXTENSIONS.has(extension)) { + return "text"; + } + return null; +} diff --git a/src/channels/linear/access.ts b/src/channels/linear/access.ts new file mode 100644 index 000000000..a6bbd21a8 --- /dev/null +++ b/src/channels/linear/access.ts @@ -0,0 +1,91 @@ +import { authorize } from "../../core/authz/authorize.js"; +import type { Actor } from "../../core/authz/types.js"; +import { object, required } from "./api.js"; + +export interface LinearPersonIdentity { + id: string; + actions: "all" | readonly string[]; +} +export interface LinearAccessDeps { + organizationId: string; + appUserId: string; + query(query: string, variables: Record): Promise>; +} +export interface LinearPerson { + actor: Actor; + prefix: string; + members: Set; + publicAccess: boolean; +} + +/** Current platform facts cap config grants; neither names nor issue text establish membership. */ +export async function linearPerson(deps: LinearAccessDeps, identity: LinearPersonIdentity): Promise { + const prefix = `linear:${deps.organizationId}:`; + const userId = + typeof identity.id === "string" && identity.id.startsWith(prefix) ? identity.id.slice(prefix.length) : ""; + if ( + !/^[A-Za-z0-9_-]{1,128}$/.test(userId) || + userId === deps.appUserId || + !( + identity.actions === "all" || + (Array.isArray(identity.actions) && identity.actions.every((a) => typeof a === "string")) + ) + ) + throw new Error("linear_human_required"); + const members = new Set(); + let after: string | undefined; + let publicAccess: boolean; + const seen = new Set(); + for (let page = 0; ; page++) { + if (page === 100) throw new Error("linear_membership_too_large"); + const data = await deps.query( + `query SwitchboardPerson($id: String!, $after: String) { + user(id: $id) { id active app canAccessAnyPublicTeam organization { id } + teams(first: 100, after: $after) { nodes { id } pageInfo { hasNextPage endCursor } } } + }`, + { id: userId, after }, + ); + const user = object(data.user), + connection = object(user.teams), + info = object(connection.pageInfo); + if ( + user.id !== userId || + user.active !== true || + user.app !== false || + object(user.organization).id !== deps.organizationId + ) + throw new Error("linear_human_required"); + publicAccess = user.canAccessAnyPublicTeam === true; + if (!Array.isArray(connection.nodes)) throw new Error("linear_invalid_response"); + for (const team of connection.nodes) members.add(required(object(team).id)); + if (info.hasNextPage === false) break; + after = required(info.endCursor); + if (seen.has(after)) throw new Error("linear_invalid_pagination"); + seen.add(after); + } + const actor: Actor = { + kind: "user", + id: identity.id, + grants: { + actions: identity.actions === "all" ? "all" : new Set(identity.actions), + channels: new Set(), + repos: new Set(), + }, + memberOf: new Set([...members].map((id) => `${prefix}${id}`)), + }; + return { actor, prefix, members, publicAccess }; +} + +export function linearTeamAllows(person: LinearPerson, action: string, team: Record): boolean { + const { actor, prefix, members, publicAccess } = person; + // Restricted children inherit their enclosing private team's membership; + // an unrelated parent never opens a private child. + const memberOf = new Set(actor.memberOf); + if (team.visibility === "restricted" && members.has(String(object(team.restrictedBy).id))) + memberOf.add(`${prefix}${required(team.id)}`); + return authorize({ ...actor, memberOf }, action, { + type: "channel", + id: `${prefix}${required(team.id)}`, + visibility: publicAccess && team.visibility === "public" ? "public" : "private", + }).allow; +} diff --git a/src/channels/linear/acknowledgement.test.ts b/src/channels/linear/acknowledgement.test.ts new file mode 100644 index 000000000..56cc059a3 --- /dev/null +++ b/src/channels/linear/acknowledgement.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { LinearAcknowledgements } from "./acknowledgement.js"; +import { InMemoryLinearInbox } from "./inbox.js"; +import type { LinearApi } from "./api.js"; +import type { LinearWebhookEvent } from "./webhook.js"; + +const event: LinearWebhookEvent = { + key: "org:s:created", + receivedAt: 100, + payload: { + type: "AgentSessionEvent", + action: "created", + organizationId: "org", + appUserId: "bot", + agentSession: { id: "s", organizationId: "org", appUserId: "bot", creatorId: "alice" }, + promptContext: "Fix it", + }, +}; +function fixture() { + let now = 100; + const inbox = new InMemoryLinearInbox(); + const api: LinearApi = { + openThread: vi.fn(), + workItems: vi.fn(), + files: vi.fn(async () => []), + canRead: vi.fn(async () => true), + upload: vi.fn(), + session: vi.fn(async (id) => ({ id, appUserId: "bot", creatorId: "alice" })), + activity: vi.fn(async () => {}), + activities: vi.fn(), + link: vi.fn(), + }; + const deps = { inbox, api: vi.fn(async () => api), clock: () => now, warn: vi.fn() }; + return { + inbox, + api, + deps, + ack: new LinearAcknowledgements(deps), + advance: () => { + now += 10_000; + }, + }; +} +describe("Linear edge acknowledgement", () => { + it("posts a native thought before releasing the delivery for dispatch", async () => { + const f = fixture(); + await f.inbox.accept(event, { acknowledge: true }); + vi.mocked(f.api.activity).mockImplementation(async () => { + expect(await f.inbox.claim(100, 100, "consumer")).toBeUndefined(); + }); + await f.ack.flush(); + expect(f.api.activity).toHaveBeenCalledWith( + "s", + { type: "thought", body: "Request received. Switchboard is preparing to work on it." }, + { id: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) }, + ); + expect((await f.inbox.claim(100, 100, "consumer"))?.event.key).toBe(event.key); + }); + it("retries a failed acknowledgement across host replacement with the same activity id", async () => { + const f = fixture(); + await f.inbox.accept(event, { acknowledge: true }); + vi.mocked(f.api.activity).mockRejectedValueOnce(new Error("unavailable")); + await f.ack.flush(); + expect(await f.inbox.hasPendingAcks()).toBe(true); + expect(await f.inbox.claim(100, 100, "consumer")).toBeUndefined(); + f.advance(); + await new LinearAcknowledgements(f.deps).flush(); + expect(vi.mocked(f.api.activity).mock.calls[0]?.[2]?.id).toBe(vi.mocked(f.api.activity).mock.calls[1]?.[2]?.id); + expect(await f.inbox.hasPendingAcks()).toBe(false); + }); + it("closes dismissed or permanently invalid sessions without starting work", async () => { + const f = fixture(); + await f.inbox.accept(event, { acknowledge: true }); + vi.mocked(f.api.session).mockResolvedValueOnce({ id: "s", appUserId: "bot", dismissedAt: "removed" }); + await f.ack.flush(); + expect(f.api.activity).not.toHaveBeenCalled(); + expect(await f.inbox.claim(100, 100, "consumer")).toBeUndefined(); + const g = fixture(); + await g.inbox.accept({ ...event, payload: { ...event.payload, agentSession: { id: "s" } } }, { acknowledge: true }); + await g.ack.flush(); + expect(g.api.activity).toHaveBeenCalledWith("s", expect.objectContaining({ type: "error" }), expect.anything()); + expect(await g.inbox.claim(100, 100, "consumer")).toBeUndefined(); + }); +}); diff --git a/src/channels/linear/acknowledgement.ts b/src/channels/linear/acknowledgement.ts new file mode 100644 index 000000000..7fa2069f0 --- /dev/null +++ b/src/channels/linear/acknowledgement.ts @@ -0,0 +1,91 @@ +import { LINEAR_TIMING } from "../../core/budgets.js"; +import type { Clock } from "../../core/trace/types.js"; +import { object, required, type LinearApi } from "./api.js"; +import type { LinearDelivery, LinearInbox } from "./inbox.js"; +import { linearMessage } from "./session.js"; + +/** A deterministic UUID in the API's accepted v4 shape, derived from the signed + * event key. It is an idempotency key, never an authentication credential. */ +async function acknowledgementId(key: string): Promise { + const bytes = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`linear-ack:${key}`)), + ).slice(0, 16); + bytes[6] = (bytes[6]! & 15) | 64; + bytes[8] = (bytes[8]! & 63) | 128; + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** Runs in the edge's waitUntil and durable alarm. The dispatch phase cannot + * begin before this finishes, so a delayed retry cannot reopen a completed + * session by sending an old thought after its final response. */ +export class LinearAcknowledgements { + private flushing?: Promise; + constructor( + private readonly deps: { + inbox: LinearInbox; + api(organizationId: string): Promise; + clock: Clock; + warn(message: string): void; + }, + ) {} + + flush(): Promise { + return (this.flushing ??= this.drain().finally(() => { + this.flushing = undefined; + })); + } + + private async drain(): Promise { + let remaining = 32; + await Promise.all( + Array.from({ length: 8 }, async () => { + while (remaining-- > 0) { + const delivery = await this.deps.inbox.claimAck( + this.deps.clock(), + LINEAR_TIMING.ackLeaseMs, + crypto.randomUUID(), + ); + if (!delivery) return; + await this.send(delivery); + } + }), + ); + } + + private async send({ event, lease }: LinearDelivery): Promise { + const { inbox, clock } = this.deps; + try { + const api = await this.deps.api(event.payload.organizationId); + const session = await api.session(required(object(event.payload.agentSession).id)); + if (session.dismissedAt) { + await inbox.complete(event.key, lease, clock()); + return; + } + const id = await acknowledgementId(event.key); + try { + linearMessage(event, session, session.appUserId); + } catch { + await api.activity( + session.id, + { + type: "error", + body: "Switchboard could not establish a supported request and its human sender. Please send a new mention or delegate this issue again.", + }, + { id }, + ); + await inbox.complete(event.key, lease, clock()); + return; + } + await api.activity( + session.id, + { type: "thought", body: "Request received. Switchboard is preparing to work on it." }, + { id }, + ); + await inbox.acknowledge(event.key, lease, clock()); + } catch { + this.deps.warn("[linear] acknowledgement unfinished; retained for retry"); + await inbox.retry(event.key, lease, clock() + LINEAR_TIMING.progressMs); + } + } +} diff --git a/src/channels/linear/api.children.test.ts b/src/channels/linear/api.children.test.ts new file mode 100644 index 000000000..b087e0129 --- /dev/null +++ b/src/channels/linear/api.children.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from "vitest"; +import { DirectLinearApi } from "./api.js"; +import { InMemoryLinearChildStore } from "./children.js"; + +const commentId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +function fixture() { + const children = new InMemoryLinearChildStore(); + let comment: Record | undefined; + let loseSessionResponse = false, + hideSession = false; + const fetch = vi.fn(async (_url, init) => { + const { query, variables } = JSON.parse(String(init?.body)); + if (query.includes("SwitchboardChildComment")) + return Response.json({ + data: { + organization: { id: "org" }, + issue: { + id: "issue", + comments: { nodes: comment ? [{ ...comment, ...(hideSession ? { agentSession: null } : {}) }] : [] }, + }, + }, + }); + if (query.includes("SwitchboardCreateChildComment")) { + comment = { id: variables.input.id, body: variables.input.body, user: { id: "bot" }, agentSession: null }; + return Response.json({ data: { commentCreate: { success: true, comment: { id: commentId } } } }); + } + if (query.includes("SwitchboardCreateChildSession")) { + const child = { + id: "child", + url: "https://linear.app/session/child", + appUser: { id: "bot" }, + comment: { id: commentId }, + issue: { id: "issue" }, + }; + comment!.agentSession = child; + if (loseSessionResponse) throw new Error("lost upstream response"); + return Response.json({ data: { agentSessionCreateOnComment: { success: true, agentSession: child } } }); + } + throw new Error("unexpected query"); + }); + const api = new DirectLinearApi({ + organizationId: "org", + appUserId: "bot", + children, + token: async () => "secret", + fetch, + }); + const access = vi.spyOn(api, "canRead").mockResolvedValue(true); + vi.spyOn(api, "session").mockResolvedValue({ + id: "parent", + appUserId: "bot", + issue: { id: "issue", identifier: "EX-1", title: "Fix it", teamId: "team" }, + }); + const open = (user = "linear:org:alice", lead = "Review this change") => + api.openThread("parent", user, { id: commentId, lead }); + const mutations = () => + fetch.mock.calls + .map(([, init]) => JSON.parse(String(init?.body)).query as string) + .filter((q) => q.startsWith("mutation")); + return { + api, + access, + children, + open, + mutations, + fetch, + lose: (hidden = false) => { + loseSessionResponse = true; + hideSession = hidden; + }, + reveal: () => { + hideSession = false; + }, + }; +} + +describe("Linear native child sessions", () => { + it("recognizes a managed session during creation only from its durable comment intent", async () => { + const children = new InMemoryLinearChildStore(); + await children.ensure({ + organizationId: "org", + appUserId: "bot", + parentSessionId: "parent", + requesterId: "linear:org:alice", + issueId: "issue", + commentId, + lead: "Review", + }); + const fetch = vi.fn(async () => + Response.json({ + data: { + organization: { id: "org" }, + agentSession: { + id: "child", + appUser: { id: "bot" }, + creator: { id: "bot" }, + comment: { id: commentId, body: "Review", user: { id: "bot" } }, + issue: { id: "issue", identifier: "EX-1", title: "Fix", team: { id: "team" } }, + }, + }, + }), + ); + const api = new DirectLinearApi({ + organizationId: "org", + appUserId: "bot", + children, + token: async () => "secret", + fetch, + }); + expect((await api.session("child")).managedChild).toBe(true); + await children.beginSession("org", commentId); + expect((await api.session("child")).managedChild).toBe(true); + await children.finish("org", commentId, { id: "different" }); + expect((await api.session("child")).managedChild).toBeUndefined(); + }); + it("allows concurrent retries to attempt at most one session creation", async () => { + const f = fixture(); + const results = await Promise.allSettled([f.open(), f.open()]); + expect(results.some((r) => r.status === "fulfilled")).toBe(true); + expect(f.mutations().filter((q) => q.includes("SwitchboardCreateChildSession"))).toHaveLength(1); + expect((await f.open()).sessionId).toBe("child"); + }); + it("creates one comment and session, binds the human, and reuses the durable result", async () => { + const f = fixture(); + expect(await f.open()).toEqual({ + organizationId: "org", + sessionId: "child", + url: "https://linear.app/session/child", + }); + await f.open(); + expect(f.access).toHaveBeenCalledTimes(2); + expect(f.mutations()).toHaveLength(2); + expect(await f.children.get("org", commentId)).toMatchObject({ + requesterId: "linear:org:alice", + parentSessionId: "parent", + session: { id: "child" }, + }); + await expect(f.open("linear:org:bob")).rejects.toThrow("linear_child_conflict"); + await expect(f.open("linear:org:alice", "Different work")).rejects.toThrow("linear_child_conflict"); + }); + it("reconciles a lost session response through the comment instead of recreating", async () => { + const f = fixture(); + f.lose(); + expect((await f.open()).sessionId).toBe("child"); + expect(f.mutations()).toHaveLength(2); + }); + it("leaves an uncertain session retryable by observation without repeating its mutation", async () => { + const f = fixture(); + f.lose(true); + await expect(f.open()).rejects.toThrow("linear_child_creation_uncertain"); + await expect(f.open()).rejects.toThrow("linear_child_creation_uncertain"); + expect(f.mutations()).toHaveLength(2); + f.reveal(); + expect((await f.open()).sessionId).toBe("child"); + expect(f.mutations()).toHaveLength(2); + }); + it("refuses revoked access and invalid creation ids before writing", async () => { + const f = fixture(); + f.access.mockResolvedValue(false); + await expect(f.open()).rejects.toThrow("linear_child_denied"); + expect(f.fetch).not.toHaveBeenCalled(); + await expect(f.api.openThread("parent", "linear:org:alice", { id: "unbound", lead: "Hi" })).rejects.toThrow( + "linear_invalid_child", + ); + expect(await f.children.get("org", commentId)).toBeUndefined(); + }); +}); diff --git a/src/channels/linear/api.staging.test.ts b/src/channels/linear/api.staging.test.ts new file mode 100644 index 000000000..db8295b3d --- /dev/null +++ b/src/channels/linear/api.staging.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from "vitest"; +import { DirectLinearApi } from "./api.js"; +import { inboundKey } from "../../artifacts/keys.js"; +import type { LinearFileCopy } from "./files.js"; + +const url = "https://uploads.linear.app/org/archive"; +const file = { url, name: "data.zip", size: 100, type: "application/zip", messageId: "org:AgentSessionEvent:event" }; +const key = inboundKey("linear:org:s", file.messageId, 1, file.name); + +function fixture() { + const stored: Uint8Array[] = []; + const put = vi.fn(async (_key, stream) => { + const bytes = new Uint8Array(await new Response(stream).arrayBuffer()); + stored.push(bytes); + }); + const lengthPipe: LinearFileCopy["lengthPipe"] = (size) => { + let count = 0; + return new TransformStream({ + transform(bytes, controller) { + count += bytes.length; + if (count > size) throw new Error("long stream"); + controller.enqueue(bytes); + }, + flush() { + if (count !== size) throw new Error("short stream"); + }, + }); + }; + const download = vi.fn( + async () => + new Response(new Uint8Array(100), { + headers: { "content-type": "application/zip", "content-length": "100" }, + }), + ); + const fetch = vi.fn(async (input) => { + if (String(input) === url) return download(); + return Response.json({ + data: { + organization: { id: "org" }, + agentSession: { + id: "s", + appUser: { id: "bot" }, + issue: { + description: `[data.zip](${url})`, + comments: { + nodes: [], + pageInfo: { hasNextPage: false }, + }, + }, + }, + }, + }); + }); + const api = new DirectLinearApi({ + organizationId: "org", + appUserId: "bot", + token: async () => "oauth-secret", + fetch, + copy: { put, lengthPipe }, + }); + const access = vi.spyOn(api, "canRead").mockResolvedValue(true); + vi.spyOn(api, "activities").mockResolvedValue([]); + return { api, access, fetch, download, put, stored }; +} + +describe("Linear workspace file copy", () => { + it("aborts an in-flight stream on cancellation and leaves no completed object", async () => { + const h = fixture(); + const stop = new AbortController(); + const cancel = vi.fn(); + h.download.mockResolvedValueOnce( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(50)); + }, + cancel, + }), + { headers: { "content-type": "application/zip", "content-length": "100" } }, + ), + ); + const copying = h.api.copyAttachment("s", "linear:org:alice", file, key, stop.signal); + const rejected = expect(copying).rejects.toThrow("linear_file_copy_failed"); + await vi.waitFor(() => expect(h.put).toHaveBeenCalledOnce()); + stop.abort(); + await rejected; + expect(h.stored).toEqual([]); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("cancels the upstream when storage refuses the stream without echoing the storage error", async () => { + const h = fixture(); + const cancel = vi.fn(); + h.download.mockResolvedValueOnce( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(100)); + }, + cancel, + }), + { headers: { "content-type": "application/zip", "content-length": "100" } }, + ), + ); + h.put.mockRejectedValueOnce(new Error("internal credential-shaped detail")); + await expect(h.api.copyAttachment("s", "linear:org:alice", file, key)).rejects.toThrow("linear_file_copy_failed"); + await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()); + }); + it("streams only a current session file into that session's inbound key and rechecks access at copy time", async () => { + const h = fixture(); + expect(await h.api.files("s", "linear:org:alice", [url], false, 1000)).toMatchObject([ + { staged: { size: 100, type: "application/zip" } }, + ]); + expect(await h.api.copyAttachment("s", "linear:org:alice", file, key)).toEqual({ key, size: 100 }); + expect(h.put).toHaveBeenCalledWith(key, expect.any(ReadableStream), "application/zip"); + expect(h.stored[0]).toHaveLength(100); + expect(h.access).toHaveBeenCalledTimes(2); + expect(h.fetch.mock.calls.find(([input]) => String(input) === url)?.[1]).toMatchObject({ + redirect: "error", + headers: { authorization: "Bearer oauth-secret" }, + }); + h.access.mockResolvedValue(false); + await expect(h.api.copyAttachment("s", "linear:org:alice", file, key)).rejects.toThrow("linear_file_denied"); + expect(h.put).toHaveBeenCalledTimes(1); + }); + + it("refuses another session's key, a file absent from context, and credential-shaped names", async () => { + const h = fixture(); + await expect( + h.api.copyAttachment("s", "linear:org:alice", file, key.replace("linear-org-s/", "linear-org-other/")), + ).rejects.toThrow("linear_invalid_files"); + expect(h.fetch).not.toHaveBeenCalled(); + await expect( + h.api.copyAttachment("s", "linear:org:alice", { ...file, url: url + "-private" }, key), + ).rejects.toThrow("linear_file_denied"); + const secret = { ...file, name: ".env" }; + await expect( + h.api.copyAttachment("s", "linear:org:alice", secret, inboundKey("linear:org:s", file.messageId, 1, secret.name)), + ).rejects.toThrow("linear_file_denied"); + expect(h.download).not.toHaveBeenCalled(); + expect(h.put).not.toHaveBeenCalled(); + }); + + it.each(["size", "name", "short stream"])( + "does not confirm an attachment changed during download: %s", + async (change) => { + const h = fixture(); + h.download.mockImplementation( + async () => + new Response(new Uint8Array(change === "short stream" ? 99 : 100), { + headers: { + "content-type": "application/zip", + "content-length": change === "size" ? "101" : "100", + ...(change === "name" ? { "content-disposition": "attachment; filename=credentials.env" } : {}), + }, + }), + ); + await expect(h.api.copyAttachment("s", "linear:org:alice", file, key)).rejects.toThrow(); + expect(h.stored).toEqual([]); + }, + ); +}); diff --git a/src/channels/linear/api.surfaces.test.ts b/src/channels/linear/api.surfaces.test.ts new file mode 100644 index 000000000..7db78c5d1 --- /dev/null +++ b/src/channels/linear/api.surfaces.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; +import { DirectLinearApi } from "./api.js"; + +function fixture(comment: Record) { + const team = { id: "team", visibility: "private", restrictedBy: null as { id: string } | null }; + const user = { + id: "alice", + active: true, + app: false, + organization: { id: "org" }, + canAccessAnyPublicTeam: false, + teams: { nodes: [{ id: "team" }], pageInfo: { hasNextPage: false } }, + }; + const session: Record = { + id: "s", + appUser: { id: "bot" }, + creator: { id: "alice" }, + comment, + issue: null, + }; + const fetch = vi.fn(async (_url, init) => { + const { query, variables } = JSON.parse(String(init?.body)); + if (query.includes("SwitchboardPerson")) return Response.json({ data: { user } }); + if (query.includes("SwitchboardProjectAccess")) + return Response.json({ + data: { + organization: { id: "org" }, + project: { + id: "project", + teams: { + nodes: variables.after ? [team] : [], + pageInfo: variables.after ? { hasNextPage: false } : { hasNextPage: true, endCursor: "next" }, + }, + }, + }, + }); + return Response.json({ data: { organization: { id: "org" }, agentSession: session } }); + }); + const api = new DirectLinearApi({ organizationId: "org", appUserId: "bot", token: async () => "secret", fetch }); + return { api, fetch, user, team, session }; +} +const project = { + id: "project", + name: "Launch", + content: "Release checklist", + url: "https://linear.app/acme/project/launch", +}; + +describe("Linear project and document sessions", () => { + it("loads project context and rechecks paginated team visibility for the requesting human", async () => { + const f = fixture({ body: "Review the plan", project }); + expect(await f.api.session("s")).toMatchObject({ + surface: { kind: "project", id: "project", title: "Launch", content: "Release checklist", url: project.url }, + }); + expect(await f.api.canRead("s", "linear:org:alice")).toBe(true); + f.user.teams.nodes = []; + expect(await f.api.canRead("s", "linear:org:alice")).toBe(false); + f.team.visibility = "public"; + expect(await f.api.canRead("s", "linear:org:alice")).toBe(false); + f.user.canAccessAnyPublicTeam = true; + expect(await f.api.canRead("s", "linear:org:alice")).toBe(true); + f.user.active = false; + expect(await f.api.canRead("s", "linear:org:alice")).toBe(false); + }); + it.each(["project", "projectUpdate", "sourceComment"])( + "resolves the current project anchor from %s", + async (kind) => { + const f = fixture( + kind === "projectUpdate" + ? { projectUpdate: { project } } + : kind === "project" + ? { documentContent: { project } } + : {}, + ); + if (kind === "sourceComment") { + f.session.comment = { isArtificialAgentSessionRoot: true }; + f.session.sourceComment = { body: "original mention", project }; + } + expect(await f.api.canRead("s", "linear:org:alice")).toBe(true); + expect(await f.api.session("s")).toMatchObject({ surface: { kind: "project", id: "project" } }); + }, + ); + it.each(["project", "issue", "team"])("loads a document and checks its current %s access", async (owner) => { + const f = fixture({}); + const document = { + id: "doc", + title: "Design", + content: "Private notes", + url: "https://linear.app/acme/document/design", + [owner]: owner === "project" ? project : owner === "issue" ? { team: f.team } : f.team, + }; + f.session.comment = { body: "Please review", documentContent: { document } }; + expect(await f.api.session("s")).toMatchObject({ + surface: { kind: "document", id: "doc", title: "Design", content: "Private notes", url: document.url }, + }); + expect(await f.api.canRead("s", "linear:org:alice")).toBe(true); + f.user.teams.nodes = []; + expect(await f.api.canRead("s", "linear:org:alice")).toBe(false); + f.team.visibility = "restricted"; + f.team.restrictedBy = { id: "parent" }; + f.user.teams.nodes = [{ id: "parent" }]; + expect(await f.api.canRead("s", "linear:org:alice")).toBe(true); + }); + it("reads only files referenced by the current document and its originating comment", async () => { + const f = fixture({}); + const documentFile = "https://uploads.linear.app/org/design.txt"; + const commentFile = "https://uploads.linear.app/org/source.txt"; + const foreignFile = "https://uploads.linear.app/org/private.txt"; + f.session.comment = { + documentContent: { + document: { id: "doc", title: "Design", content: `[design.txt](${documentFile})`, team: f.team }, + }, + }; + f.session.sourceComment = { body: `[source.txt](${commentFile})`, documentContent: { document: { id: "doc" } } }; + const graphql = f.fetch.getMockImplementation()!; + f.fetch.mockImplementation(async (url, init) => + String(url).startsWith("https://uploads.linear.app/") + ? new Response("notes", { headers: { "content-type": "text/plain" } }) + : graphql(url, init), + ); + vi.spyOn(f.api, "activities").mockResolvedValue([]); + const files = await f.api.files("s", "linear:org:alice", [documentFile, commentFile, foreignFile]); + expect(files[0]).toMatchObject({ document: { data: "notes", name: "design.txt" } }); + expect(files[1]).toMatchObject({ document: { data: "notes", name: "source.txt" } }); + expect(files[2]?.skipped).toContain("current session context"); + f.user.teams.nodes = []; + f.fetch.mockClear(); + await expect(f.api.files("s", "linear:org:alice", [documentFile])).rejects.toThrow("linear_file_denied"); + expect(f.fetch.mock.calls.some(([url]) => String(url).startsWith("https://uploads.linear.app/"))).toBe(false); + }); + it("does not borrow a source project's access for an unsupported primary comment", async () => { + const f = fixture({ id: "private-comment", isArtificialAgentSessionRoot: false, body: "private primary text" }); + f.session.sourceComment = { project, body: "public source" }; + expect(await f.api.canRead("s", "linear:org:alice")).toBe(false); + expect(await f.api.session("s")).toMatchObject({ unsupportedSurface: true }); + }); + it("does not expose a source comment from a different origin", async () => { + const f = fixture({ project }); + f.session.sourceComment = { body: "Private source text", project: { id: "other-project" } }; + const session = await f.api.session("s"); + expect(session.surface?.id).toBe("project"); + expect(session.sourceComment).toBeUndefined(); + }); + it("refuses a project from another workspace and retries malformed or cycling pagination", async () => { + const f = fixture({ project }); + const normal = f.fetch.getMockImplementation()!; + const answer: Record = { organization: { id: "other" }, project: { id: "project" } }; + f.fetch.mockImplementation(async (url, init) => + JSON.parse(String(init?.body)).query.includes("SwitchboardProjectAccess") + ? Response.json({ data: answer }) + : normal(url, init), + ); + expect(await f.api.canRead("s", "linear:org:alice")).toBe(false); + answer.organization = { id: "org" }; + answer.project = { id: "project", teams: { nodes: [], pageInfo: { hasNextPage: true, endCursor: "again" } } }; + await expect(f.api.canRead("s", "linear:org:alice")).rejects.toThrow("linear_invalid_pagination"); + }); + it("refuses unknown origins and does not use context references as access to a session", async () => { + const f = fixture({}); + f.session.context = { projectId: "project" }; + expect(await f.api.canRead("s", "linear:org:alice")).toBe(false); + expect(await f.api.session("s")).toMatchObject({ unsupportedSurface: true }); + }); +}); diff --git a/src/channels/linear/api.test.ts b/src/channels/linear/api.test.ts new file mode 100644 index 000000000..9643ec7a4 --- /dev/null +++ b/src/channels/linear/api.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it, vi } from "vitest"; +import { DirectLinearApi } from "./api.js"; + +describe("Linear API boundary", () => { + it("refuses malformed file operations and a session whose installation changes during context lookup", async () => { + const fetch = vi.fn(); + const api = new DirectLinearApi({ organizationId: "org", appUserId: "bot", token: async () => "secret", fetch }); + const canRead = vi.spyOn(api, "canRead").mockResolvedValue(true); + await expect(api.files("s", "linear:org:alice", null as unknown as string[])).rejects.toThrow( + "linear_invalid_files", + ); + expect(canRead).not.toHaveBeenCalled(); + fetch.mockResolvedValueOnce( + Response.json({ data: { organization: { id: "other" }, agentSession: { id: "s", appUser: { id: "bot" } } } }), + ); + await expect(api.files("s", "linear:org:alice", ["https://uploads.linear.app/org/file"])).rejects.toThrow( + "linear_file_denied", + ); + expect(fetch).toHaveBeenCalledOnce(); + expect(String(fetch.mock.calls[0]?.[0])).toBe("https://api.linear.app/graphql"); + }); + it("reads private files only from current session context after checking the requesting human", async () => { + const file = "https://uploads.linear.app/org/file"; + const commentFile = "https://uploads.linear.app/org/comment-file"; + const outsider = "https://uploads.linear.app/org/another-team"; + const fetch = vi.fn(async (url, init) => { + if (String(url).startsWith("https://uploads.linear.app/")) + return new Response("hello", { headers: { "content-type": "text/plain" } }); + const { query, variables } = JSON.parse(String(init?.body)); + if (!query.includes("SwitchboardFileContext")) throw new Error("unexpected query"); + return Response.json({ + data: { + organization: { id: "org" }, + agentSession: { + id: "s", + appUser: { id: "bot" }, + issue: { + description: `[notes.txt](${file})`, + comments: { + nodes: variables.after ? [{ body: `[details.txt](${commentFile})` }] : [], + pageInfo: variables.after ? { hasNextPage: false } : { hasNextPage: true, endCursor: "next" }, + }, + }, + }, + }, + }); + }); + const api = new DirectLinearApi({ organizationId: "org", appUserId: "bot", token: async () => "secret", fetch }); + vi.spyOn(api, "canRead").mockResolvedValue(true); + vi.spyOn(api, "activities").mockResolvedValue([]); + const files = await api.files("s", "linear:org:alice", [file, commentFile, outsider]); + expect(files[0]).toMatchObject({ document: { data: "hello", name: "notes.txt" } }); + expect(files[1]).toMatchObject({ document: { data: "hello", name: "details.txt" } }); + expect(files[2]?.skipped).toContain("current session context"); + expect( + fetch.mock.calls.filter(([url]) => String(url).startsWith("https://uploads.linear.app/")).map(([url]) => url), + ).toEqual([file, commentFile]); + fetch.mockClear(); + vi.mocked(api.canRead).mockResolvedValue(false); + await expect(api.files("s", "linear:org:bob", [file])).rejects.toThrow("linear_file_denied"); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("rechecks the requesting human and current team access before a session can run", async () => { + const team = { id: "private", visibility: "private", restrictedBy: null }; + const user = { + id: "alice", + active: true, + app: false, + organization: { id: "org" }, + canAccessAnyPublicTeam: false, + teams: { nodes: [{ id: "private" }], pageInfo: { hasNextPage: false } }, + }; + const session = { id: "s", appUser: { id: "bot" }, dismissedAt: null, issue: { team } }; + const fetch = vi.fn(async (_url, init) => { + const { query } = JSON.parse(String(init?.body)); + return Response.json({ + data: query.includes("SwitchboardPerson") ? { user } : { organization: { id: "org" }, agentSession: session }, + }); + }); + const api = new DirectLinearApi({ organizationId: "org", appUserId: "bot", token: async () => "secret", fetch }); + expect(await api.canRead("s", "linear:org:alice")).toBe(true); + user.teams.nodes = []; + expect(await api.canRead("s", "linear:org:alice")).toBe(false); + team.visibility = "public"; + expect(await api.canRead("s", "linear:org:alice")).toBe(false); + user.canAccessAnyPublicTeam = true; + expect(await api.canRead("s", "linear:org:alice")).toBe(true); + user.active = false; + expect(await api.canRead("s", "linear:org:alice")).toBe(false); + user.active = true; + expect(await api.canRead("s", "linear:other:alice")).toBe(false); + expect(await api.canRead("s", "linear:org:bot")).toBe(false); + session.appUser.id = "other"; + expect(await api.canRead("s", "linear:org:alice")).toBe(false); + session.appUser.id = "bot"; + Object.assign(session, { dismissedAt: "now" }); + expect(await api.canRead("s", "linear:org:alice")).toBe(false); + Object.assign(session, { dismissedAt: null, issue: null }); + expect(await api.canRead("s", "linear:org:alice")).toBe(false); + fetch.mockResolvedValueOnce(new Response(null, { status: 429 })); + await expect(api.canRead("s", "linear:org:alice")).rejects.toThrow("linear_rate_limited"); + }); + + it("mints private file uploads and preserves signed storage headers without forwarding the OAuth token", async () => { + const fetch = vi.fn(async () => + Response.json({ + data: { + fileUpload: { + success: true, + uploadFile: { + uploadUrl: "https://storage.example/file?signature=one-file", + assetUrl: "https://uploads.linear.app/file", + headers: [ + { key: "Content-Disposition", value: "attachment; filename=plot.png" }, + { key: "x-goog-content-length-range", value: "12,12" }, + ], + }, + }, + }, + }), + ); + const api = new DirectLinearApi({ organizationId: "org", appUserId: "bot", token: async () => "secret", fetch }); + const upload = await api.upload("s", { name: "plot.png", size: 12 }); + expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body)).variables).toEqual({ + contentType: "image/png", + filename: "plot.png", + size: 12, + metaData: { agentSessionId: "s" }, + }); + expect(String(fetch.mock.calls[0]?.[1]?.body)).toContain("makePublic: false"); + expect(upload.headers).toMatchObject({ + "Content-Type": "image/png", + "Content-Disposition": "attachment; filename=plot.png", + "x-goog-content-length-range": "12,12", + }); + expect(JSON.stringify(upload)).not.toContain("secret"); + }); + it("rejects invalid upload requests before minting and refuses unsafe storage responses", async () => { + const fetch = vi.fn(); + const api = new DirectLinearApi({ organizationId: "org", appUserId: "bot", token: async () => "secret", fetch }); + for (const file of [ + { name: "../file", size: 1 }, + { name: "file", size: 0 }, + { name: "file", size: 2 ** 30 + 1 }, + ]) + await expect(api.upload("s", file)).rejects.toThrow("linear_invalid_file"); + expect(fetch).not.toHaveBeenCalled(); + for (const uploadUrl of ["http://storage.example/file", "https://user:pass@storage.example/file"]) { + fetch.mockResolvedValueOnce( + Response.json({ + data: { + fileUpload: { + success: true, + uploadFile: { + uploadUrl, + assetUrl: "https://uploads.linear.app/file", + headers: [], + }, + }, + }, + }), + ); + await expect(api.upload("s", { name: "file", size: 1 })).rejects.toThrow("linear_invalid_response"); + } + }); + it("reconciles a lost activity mutation only against the same app, session and content", async () => { + for (const changed of [ + {}, + { user: { id: "other" } }, + { agentSession: { id: "other" } }, + { content: { type: "thought", body: "different" } }, + ]) { + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error("response lost")) + .mockResolvedValueOnce( + Response.json({ + data: { + agentActivity: { + id: "activity", + agentSession: { id: "s" }, + user: { id: "bot" }, + content: { type: "thought", body: "Received" }, + ...changed, + }, + }, + }), + ); + const api = new DirectLinearApi({ organizationId: "org", appUserId: "bot", token: async () => "secret", fetch }); + const result = api.activity("s", { type: "thought", body: "Received" }, { id: "activity" }); + if (Object.keys(changed).length === 0) await expect(result).resolves.toBeUndefined(); + else await expect(result).rejects.toThrow("linear_api_unavailable"); + expect(fetch).toHaveBeenCalledTimes(2); + } + }); + it("checks the current workspace and session owner without exposing credentials", async () => { + const fetch = vi.fn(async () => + Response.json({ + data: { + organization: { id: "org" }, + agentSession: { + id: "s", + appUser: { id: "bot" }, + creator: { id: "alice" }, + url: "https://linear.app/s", + comment: { body: "Inspect [log.txt](https://uploads.linear.app/org/log)" }, + issue: null, + }, + }, + }), + ); + const api = new DirectLinearApi({ organizationId: "org", appUserId: "bot", token: async () => "secret", fetch }); + expect(await api.session("s")).toMatchObject({ + id: "s", + appUserId: "bot", + creatorId: "alice", + comment: { body: "Inspect [log.txt](https://uploads.linear.app/org/log)" }, + }); + expect(fetch.mock.calls[0]?.[1]).toMatchObject({ redirect: "error", headers: { authorization: "Bearer secret" } }); + const other = new DirectLinearApi({ + organizationId: "other", + appUserId: "bot", + token: async () => "secret", + fetch, + }); + await expect(other.session("s")).rejects.toThrow("linear_wrong_installation"); + }); + it("paginates history backwards and returns chronological activities", async () => { + const fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const { variables } = JSON.parse(String(init?.body)); + return Response.json({ + data: { + agentSession: { + activities: variables.before + ? { + nodes: [ + { + id: "first", + createdAt: "2020-01-01T00:00:00Z", + user: { id: "u" }, + content: { type: "prompt", body: "first" }, + }, + ], + pageInfo: { hasPreviousPage: false, startCursor: "a" }, + } + : { + nodes: [ + { + id: "last", + createdAt: "2020-01-02T00:00:00Z", + user: { id: "bot" }, + content: { type: "response", body: "last" }, + }, + ], + pageInfo: { hasPreviousPage: true, startCursor: "b" }, + }, + }, + }, + }); + }); + const api = new DirectLinearApi({ organizationId: "org", appUserId: "bot", token: async () => "secret", fetch }); + expect((await api.activities("s")).map((a) => a.id)).toEqual(["first", "last"]); + expect(fetch).toHaveBeenCalledTimes(2); + }); + it("fails closed on upstream errors and refuses unsuccessful mutation receipts", async () => { + const fetch = vi.fn(async () => Response.json({ errors: [{ message: "token secret in upstream error" }] })); + const api = new DirectLinearApi({ organizationId: "org", appUserId: "bot", token: async () => "secret", fetch }); + await expect(api.session("s")).rejects.toThrow(/^linear_api_error$/); + fetch.mockImplementation(async () => Response.json({ data: { agentActivityCreate: { success: false } } })); + await expect(api.activity("s", { type: "response", body: "answer" })).rejects.toThrow("linear_activity_failed"); + }); +}); diff --git a/src/channels/linear/api.ts b/src/channels/linear/api.ts new file mode 100644 index 000000000..e041f3c9d --- /dev/null +++ b/src/channels/linear/api.ts @@ -0,0 +1,632 @@ +import { + SURFACE_CONTEXT_FIELDS, + SURFACE_ACCESS_FIELDS, + linearSurfaceOrigin, + linearSurfaceContext, + linearSurfaceSourceText, + linearSurfaceAllows, + type LinearSurface, +} from "./surfaces.js"; +import { + downloadLinearFiles, + fileReferences, + LINEAR_FILE_LIMITS, + type LinearFile, + type LinearFileReference, + copyLinearFile, + type LinearFileCopy, +} from "./files.js"; +import type { StagedFile } from "../../core/types.js"; +import { inboundKey } from "../../artifacts/keys.js"; +import { LINEAR_TIMING } from "../../core/budgets.js"; +import { contentTypeFor } from "../../artifacts/contentType.js"; +import type { WorkItemRequest, WorkItemResult } from "../../core/workItems.js"; +import { linearPerson, linearTeamAllows } from "./access.js"; +import { linearWorkItems, type LinearWorkItemActor } from "./workItems.js"; +import type { LinearChildStore } from "./children.js"; + +export interface LinearOpenedThread { + organizationId: string; + sessionId: string; + url?: string; +} + +export interface LinearUpload { + uploadUrl: string; + assetUrl: string; + headers: Record; +} + +export interface LinearSession { + id: string; + appUserId: string; + creatorId?: string; + url?: string; + dismissedAt?: string; + /** Created through the channel's durable child intent, never webhook text. */ + managedChild?: true; + comment?: { body: string }; + sourceComment?: { body: string }; + surface?: LinearSurface; + unsupportedSurface?: true; + issue?: { + id: string; + identifier: string; + title: string; + description?: string; + teamId: string; + delegateId?: string | null; + }; +} + +export type LinearContent = + | { type: "thought" | "response" | "error" | "elicitation"; body: string } + | { type: "action"; action: string; parameter: string; result?: string }; + +export interface LinearActivity { + id: string; + at: number; + userId: string; + type: string; + body?: string; +} + +/** Bound to one installation. Implementations keep its token at the edge. */ +export interface LinearApi { + openThread(sessionId: string, userId: string, input: { id: string; lead: string }): Promise; + files( + sessionId: string, + userId: string, + urls: string[], + history?: boolean, + maxStagedBytes?: number, + ): Promise; + copyAttachment?( + sessionId: string, + userId: string, + file: StagedFile, + key: string, + signal?: AbortSignal, + ): Promise<{ key: string; size: number }>; + canRead(sessionId: string, userId: string): Promise; + session(id: string): Promise; + activities(sessionId: string): Promise; + activity(sessionId: string, content: LinearContent, options?: { ephemeral?: boolean; id?: string }): Promise; + link(sessionId: string, link: { url: string; label: string }): Promise; + upload(sessionId: string, file: { name: string; size: number }): Promise; + workItems(sessionId: string, actor: LinearWorkItemActor, input: WorkItemRequest): Promise; +} + +export const object = (value: unknown): Record => + value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +export const string = (value: unknown): string | undefined => (typeof value === "string" && value ? value : undefined); +export function required(value: unknown): string { + const result = string(value); + if (!result) throw new Error("linear_invalid_response"); + return result; +} + +const SESSION_QUERY = `query SwitchboardSession($id: String!) { + organization { id } + agentSession(id: $id) { id url dismissedAt pullRequest { id } appUser { id } creator { id } comment { id body user { id } ${SURFACE_CONTEXT_FIELDS} } + sourceComment { body ${SURFACE_CONTEXT_FIELDS} } + issue { id identifier title description team { id } delegate { id } } } +}`; +const HISTORY_QUERY = `query SwitchboardHistory($id: String!, $before: String) { + agentSession(id: $id) { activities(last: 100, before: $before, orderBy: createdAt) { + nodes { id createdAt user { id } content { + ... on AgentActivityPromptContent { type body } + ... on AgentActivityResponseContent { type body } + ... on AgentActivityElicitationContent { type body } + ... on AgentActivityErrorContent { type body } + } } + pageInfo { hasPreviousPage startCursor } + } } +}`; + +/** The only implementation that speaks to Linear. Errors never echo an + * upstream body, query variables or the installation's credential. */ +export class DirectLinearApi implements LinearApi { + constructor( + private readonly deps: { + organizationId: string; + appUserId: string; + children?: LinearChildStore; + copy?: LinearFileCopy; + token(): Promise; + fetch: typeof fetch; + }, + ) {} + + async openThread( + parentSessionId: string, + requesterId: string, + input: { id: string; lead: string }, + ): Promise { + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.id) || + typeof input.lead !== "string" || + !input.lead.trim() || + input.lead.length > 4000 + ) + throw new Error("linear_invalid_child"); + const store = this.deps.children; + if (!store) throw new Error("linear_children_unavailable"); + if (!(await this.canRead(parentSessionId, requesterId))) throw new Error("linear_child_denied"); + const parent = await this.session(parentSessionId); + if (!parent.issue || parent.dismissedAt) throw new Error("linear_child_denied"); + const org = this.deps.organizationId, + issueId = parent.issue.id, + commentId = input.id; + await store.ensure({ + organizationId: org, + appUserId: this.deps.appUserId, + parentSessionId, + requesterId, + issueId, + commentId, + lead: input.lead, + }); + const sessionFields = "id url dismissedAt appUser { id } comment { id } issue { id }"; + const observe = async (): Promise | undefined> => { + const data = await this.query( + `query SwitchboardChildComment($issue: String!, $comment: ID!) { + organization { id } issue(id: $issue) { id comments(first: 1, filter: { id: { eq: $comment } }) { + nodes { id body user { id } agentSession { ${sessionFields} } } + } } + }`, + { issue: issueId, comment: commentId }, + ); + const issue = object(data.issue), + nodes = object(issue.comments).nodes; + if (object(data.organization).id !== org || issue.id !== issueId || !Array.isArray(nodes) || nodes.length > 1) + throw new Error("linear_child_conflict"); + if (!nodes.length) return undefined; + const comment = object(nodes[0]); + if (comment.id !== commentId || object(comment.user).id !== this.deps.appUserId) + throw new Error("linear_child_conflict"); + return comment; + }; + const accept = async (value: unknown): Promise => { + const child = object(value); + if ( + object(child.appUser).id !== this.deps.appUserId || + object(child.comment).id !== commentId || + object(child.issue).id !== issueId || + child.dismissedAt + ) + throw new Error("linear_child_conflict"); + const session = { id: required(child.id), ...(string(child.url) ? { url: required(child.url) } : {}) }; + await store.finish(org, commentId, session); + return { organizationId: org, sessionId: session.id, ...(session.url ? { url: session.url } : {}) }; + }; + let comment = await observe(); + if (!comment) { + // The caller-chosen UUID makes retrying this comment write idempotent. + // If its response is lost, read the comment before proceeding. + try { + const made = await this.query( + `mutation SwitchboardCreateChildComment($input: CommentCreateInput!) { + commentCreate(input: $input) { success comment { id } } + }`, + { input: { id: commentId, issueId, body: input.lead } }, + ); + const result = object(made.commentCreate); + if (result.success !== true || object(result.comment).id !== commentId) + throw new Error("linear_invalid_response"); + } catch { + comment = await observe(); + if (!comment) throw new Error("linear_child_creation_uncertain"); + } + // Creating the comment can itself trigger a session. Observe it before + // asking Linear to create one explicitly, and verify the comment owner. + comment ??= await observe(); + if (!comment) throw new Error("linear_child_creation_uncertain"); + } + if (comment?.agentSession) { + // A session may already exist on the comment. Record that fact without + // attempting another creation, including when the comment triggered it. + await store.beginSession(org, commentId); + return accept(comment.agentSession); + } + if (!(await store.beginSession(org, commentId))) { + const found = await observe(); + if (found?.agentSession) return accept(found.agentSession); + throw new Error("linear_child_creation_uncertain"); + } + try { + const made = await this.query( + `mutation SwitchboardCreateChildSession($input: AgentSessionCreateOnComment!) { + agentSessionCreateOnComment(input: $input) { success agentSession { ${sessionFields} } } + }`, + { input: { commentId } }, + ); + const result = object(made.agentSessionCreateOnComment); + if (result.success !== true) throw new Error("linear_invalid_response"); + return await accept(result.agentSession); + } catch { + const found = await observe(); + if (found?.agentSession) return accept(found.agentSession); + throw new Error("linear_child_creation_uncertain"); + } + } + + async canRead(sessionId: string, userId: string, signal?: AbortSignal): Promise { + const data = await this.query( + `query SwitchboardSessionAccess($id: String!) { + organization { id } + agentSession(id: $id) { id dismissedAt pullRequest { id } appUser { id } + comment { ${SURFACE_ACCESS_FIELDS} } sourceComment { ${SURFACE_ACCESS_FIELDS} } + issue { team { id visibility restrictedBy { id } } } } + }`, + { id: sessionId }, + signal, + ); + const session = object(data.agentSession); + if ( + object(data.organization).id !== this.deps.organizationId || + session.id !== sessionId || + object(session.appUser).id !== this.deps.appUserId || + session.dismissedAt + ) + return false; + const team = object(object(session.issue).team); + if (!string(team.id) && !linearSurfaceOrigin(session)) return false; + try { + const person = await linearPerson( + { + organizationId: this.deps.organizationId, + appUserId: this.deps.appUserId, + query: (query, variables) => this.query(query, variables, signal), + }, + { id: userId, actions: [] }, + ); + return string(team.id) + ? linearTeamAllows(person, "conversation:read", team) + : linearSurfaceAllows( + { + organizationId: this.deps.organizationId, + appUserId: this.deps.appUserId, + query: (query, variables) => this.query(query, variables, signal), + }, + person, + session, + ); + } catch (error) { + if (error instanceof Error && error.message === "linear_human_required") return false; + throw error; + } + } + + private async fileContext( + sessionId: string, + userId: string, + urls: string[], + signal?: AbortSignal, + ): Promise> { + if (!Array.isArray(urls) || urls.length > 1000 || urls.some((url) => typeof url !== "string" || url.length > 4096)) + throw new Error("linear_invalid_files"); + if (!urls.length) return new Map(); + if (!(await this.canRead(sessionId, userId, signal))) throw new Error("linear_file_denied"); + const requested = [...new Set(urls)]; + const wanted = new Set(requested); + const allowed = new Map(); + const collect = (value: unknown) => { + if (typeof value === "string") + for (const ref of fileReferences(value)) + if (wanted.has(ref.url) && !allowed.has(ref.url)) allowed.set(ref.url, ref); + }; + let after: string | undefined; + const cursors = new Set(); + for (let page = 0; ; page++) { + if (page >= 100) throw new Error("linear_file_context_too_large"); + const data = await this.query( + `query SwitchboardFileContext($id: String!, $after: String) { + organization { id } + agentSession(id: $id) { id dismissedAt pullRequest { id } appUser { id } comment { body ${SURFACE_CONTEXT_FIELDS} } + sourceComment { body ${SURFACE_CONTEXT_FIELDS} } + issue { description comments(first: 100, after: $after) { nodes { body } pageInfo { hasNextPage endCursor } } } } + }`, + { id: sessionId, after }, + signal, + ); + const session = object(data.agentSession); + if ( + object(data.organization).id !== this.deps.organizationId || + session.id !== sessionId || + object(session.appUser).id !== this.deps.appUserId || + session.dismissedAt + ) + throw new Error("linear_file_denied"); + const issue = object(session.issue); + collect(issue.description); + collect(object(session.comment).body); + collect(linearSurfaceSourceText(session)); + collect(linearSurfaceContext(session)?.content); + if (!session.issue) break; + const comments = object(issue.comments); + if (!Array.isArray(comments.nodes)) throw new Error("linear_invalid_response"); + for (const comment of comments.nodes) collect(object(comment).body); + const info = object(comments.pageInfo); + if (info.hasNextPage === false) break; + after = required(info.endCursor); + if (cursors.has(after)) throw new Error("linear_invalid_pagination"); + cursors.add(after); + } + for (const activity of await this.activities(sessionId, signal)) collect(activity.body); + return allowed; + } + + async files( + sessionId: string, + userId: string, + urls: string[], + history = false, + maxStagedBytes = 0, + ): Promise { + if (!Number.isSafeInteger(maxStagedBytes) || maxStagedBytes < 0) throw new Error("linear_invalid_files"); + const allowed = await this.fileContext(sessionId, userId, urls); + const requested = [...new Set(urls)]; + const downloaded = await downloadLinearFiles( + requested.flatMap((url) => (allowed.has(url) ? [allowed.get(url)!] : [])), + { ...this.deps, maxStagedBytes: this.deps.copy && !history ? maxStagedBytes : 0 }, + history ? LINEAR_FILE_LIMITS.historyCount : LINEAR_FILE_LIMITS.count, + ); + const byUrl = new Map(downloaded.map((file) => [file.url, file])); + return requested.map( + (url) => byUrl.get(url) ?? { url, name: "attachment", skipped: "not found in current session context" }, + ); + } + + async copyAttachment( + sessionId: string, + userId: string, + file: StagedFile, + key: string, + signal?: AbortSignal, + ): Promise<{ key: string; size: number }> { + signal?.throwIfAborted(); + if (!this.deps.copy) throw new Error("linear_staging_unavailable"); + const index = typeof key === "string" ? Number(/\/(\d+)-[^/]+$/.exec(key)?.[1]) : NaN; + if ( + !file || + typeof file.url !== "string" || + typeof file.name !== "string" || + !file.name || + file.name.length > 255 || + typeof file.messageId !== "string" || + !/^[A-Za-z0-9:_-]{1,512}$/.test(file.messageId) || + !Number.isSafeInteger(file.size) || + file.size <= 0 || + file.size > LINEAR_FILE_LIMITS.stagedFileBytes || + !Number.isSafeInteger(index) || + index < 1 || + key !== inboundKey(`linear:${this.deps.organizationId}:${sessionId}`, file.messageId, index, file.name) + ) + throw new Error("linear_invalid_files"); + const ref = (await this.fileContext(sessionId, userId, [file.url], signal)).get(file.url); + if (!ref) throw new Error("linear_file_denied"); + return copyLinearFile(ref, file, key, { ...this.deps, copy: this.deps.copy, signal }); + } + + workItems(_sessionId: string, actor: LinearWorkItemActor, input: WorkItemRequest): Promise { + return linearWorkItems( + { organizationId: this.deps.organizationId, appUserId: this.deps.appUserId, query: this.query.bind(this) }, + actor, + input, + ); + } + + private async query( + query: string, + variables: Record, + signal?: AbortSignal, + ): Promise> { + signal?.throwIfAborted(); + const token = await this.deps.token(); + let response: Response; + try { + response = await this.deps.fetch("https://api.linear.app/graphql", { + method: "POST", + redirect: "error", + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(LINEAR_TIMING.apiTimeoutMs)]) + : AbortSignal.timeout(LINEAR_TIMING.apiTimeoutMs), + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ query, variables }), + }); + } catch { + throw new Error("linear_api_unavailable"); + } + if (!response.ok) throw new Error(response.status === 429 ? "linear_rate_limited" : "linear_api_unavailable"); + let payload: Record; + try { + payload = object(await response.json()); + } catch { + throw new Error("linear_invalid_response"); + } + if (payload.errors) throw new Error("linear_api_error"); + if (!payload.data) throw new Error("linear_invalid_response"); + return object(payload.data); + } + + async session(id: string): Promise { + const data = await this.query(SESSION_QUERY, { id }); + const session = object(data.agentSession); + if (object(data.organization).id !== this.deps.organizationId || object(session.appUser).id !== this.deps.appUserId) + throw new Error("linear_wrong_installation"); + if (session.id !== id) throw new Error("linear_invalid_response"); + const issue = object(session.issue); + const surface = linearSurfaceContext(session); + const sourceComment = linearSurfaceSourceText(session); + const childComment = string(object(session.comment).id); + const child = childComment ? await this.deps.children?.get(this.deps.organizationId, childComment) : undefined; + const managedChild = + child && + child.appUserId === this.deps.appUserId && + object(object(session.comment).user).id === this.deps.appUserId && + child.issueId === issue.id && + (!child.session || child.session.id === id); + return { + id, + appUserId: this.deps.appUserId, + ...(surface ? { surface } : !session.issue ? { unsupportedSurface: true as const } : {}), + ...(sourceComment ? { sourceComment: { body: sourceComment } } : {}), + ...(managedChild ? { managedChild: true as const } : {}), + ...(string(object(session.creator).id) ? { creatorId: string(object(session.creator).id) } : {}), + ...(string(session.url) ? { url: string(session.url) } : {}), + ...(string(session.dismissedAt) ? { dismissedAt: string(session.dismissedAt) } : {}), + ...(string(object(session.comment).body) ? { comment: { body: required(object(session.comment).body) } } : {}), + ...(session.issue + ? { + issue: { + id: required(issue.id), + identifier: required(issue.identifier), + title: required(issue.title), + teamId: required(object(issue.team).id), + delegateId: string(object(issue.delegate).id) ?? null, + ...(string(issue.description) ? { description: string(issue.description) } : {}), + }, + } + : {}), + }; + } + + async activities(sessionId: string, signal?: AbortSignal): Promise { + const rows = new Map(); + let before: string | undefined; + const seen = new Set(); + for (let page = 0; page < 100; page++) { + const data = await this.query(HISTORY_QUERY, { id: sessionId, before }, signal); + const connection = object(object(data.agentSession).activities); + if (!Array.isArray(connection.nodes)) throw new Error("linear_invalid_response"); + for (const node of connection.nodes) { + const row = object(node), + content = object(row.content); + // Thoughts and actions have no fragment in this query: progress is not conversation. + if (!string(content.type)) continue; + const id = required(row.id), + at = Date.parse(required(row.createdAt)); + if (!Number.isFinite(at)) throw new Error("linear_invalid_response"); + rows.set(id, { + id, + at, + userId: required(object(row.user).id), + type: required(content.type), + body: string(content.body), + }); + } + const info = object(connection.pageInfo); + if (info.hasPreviousPage === false) + return [...rows.values()].sort((a, b) => a.at - b.at || a.id.localeCompare(b.id)); + before = required(info.startCursor); + if (seen.has(before)) throw new Error("linear_invalid_pagination"); + seen.add(before); + } + throw new Error("linear_history_too_large"); + } + + async activity( + sessionId: string, + content: LinearContent, + options: { ephemeral?: boolean; id?: string } = {}, + ): Promise { + try { + const data = await this.query( + `mutation SwitchboardActivity($input: AgentActivityCreateInput!) { + agentActivityCreate(input: $input) { success } + }`, + { input: { agentSessionId: sessionId, content, ...options } }, + ); + if (object(data.agentActivityCreate).success !== true) throw new Error("linear_activity_failed"); + } catch (error) { + if (!options.id) throw error; + // A create may have committed even when its response was lost. A stable + // id lets the caller retry without treating a duplicate as a new activity. + // Never accept an existing id belonging to different work or content. + try { + const data = await this.query( + `query SwitchboardActivityReceipt($id: String!) { + agentActivity(id: $id) { id agentSession { id } user { id } content { + ... on AgentActivityThoughtContent { type body } + ... on AgentActivityResponseContent { type body } + ... on AgentActivityErrorContent { type body } + ... on AgentActivityElicitationContent { type body } + ... on AgentActivityActionContent { type action parameter result } + } } + }`, + { id: options.id }, + ); + const activity = object(data.agentActivity), + saved = object(activity.content); + if ( + activity.id === options.id && + object(activity.agentSession).id === sessionId && + object(activity.user).id === this.deps.appUserId && + Object.entries(content).every(([key, value]) => saved[key] === value) + ) + return; + } catch { + /* The original operation's sanitized failure is the retry signal. */ + } + throw error; + } + } + + async link(sessionId: string, link: { url: string; label: string }): Promise { + const data = await this.query( + `mutation SwitchboardLink($id: String!, $input: AgentSessionUpdateInput!) { + agentSessionUpdate(id: $id, input: $input) { success } + }`, + { id: sessionId, input: { addedExternalUrls: [link] } }, + ); + if (object(data.agentSessionUpdate).success !== true) throw new Error("linear_session_update_failed"); + } + + async upload(sessionId: string, file: { name: string; size: number }): Promise { + if ( + !file.name || + file.name.length > 255 || + /[/\\]/.test(file.name) || + [...file.name].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127) || + !Number.isSafeInteger(file.size) || + file.size <= 0 || + file.size > 2 ** 30 + ) + throw new Error("linear_invalid_file"); + const contentType = contentTypeFor(file.name); + const data = await this.query( + `mutation SwitchboardUpload($contentType: String!, $filename: String!, $size: Int!, $metaData: JSON) { + fileUpload(contentType: $contentType, filename: $filename, size: $size, makePublic: false, metaData: $metaData) { + success uploadFile { uploadUrl assetUrl headers { key value } } + } + }`, + { contentType, filename: file.name, size: file.size, metaData: { agentSessionId: sessionId } }, + ); + const payload = object(data.fileUpload), + upload = object(payload.uploadFile); + if (payload.success !== true) throw new Error("linear_upload_failed"); + const secureUrl = (value: unknown): string => { + const url = new URL(required(value)); + if (url.protocol !== "https:" || url.username || url.password) throw new Error("linear_invalid_response"); + return url.href; + }; + const uploadUrl = secureUrl(upload.uploadUrl), + assetUrl = secureUrl(upload.assetUrl); + if (!Array.isArray(upload.headers)) throw new Error("linear_invalid_response"); + const headers: Record = Object.create(null); + const names = new Set(); + for (const entry of upload.headers) { + const row = object(entry), + key = required(row.key), + value = required(row.value); + if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(key) || /[\r\n]/.test(value) || names.has(key.toLowerCase())) + throw new Error("linear_invalid_response"); + names.add(key.toLowerCase()); + headers[key] = value; + } + if (!names.has("content-type")) headers["Content-Type"] = contentType; + if (!names.has("cache-control")) headers["Cache-Control"] = "public, max-age=31536000"; + return { uploadUrl, assetUrl, headers }; + } +} diff --git a/src/channels/linear/bridge.test.ts b/src/channels/linear/bridge.test.ts new file mode 100644 index 000000000..a2bc37002 --- /dev/null +++ b/src/channels/linear/bridge.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleLinearBridge, RemoteLinearApi, RemoteLinearInbox, LINEAR_BRIDGE_PATH } from "./bridge.js"; +import { InMemoryLinearInbox } from "./inbox.js"; +import type { LinearApi } from "./api.js"; + +function fixture() { + const inbox = new InMemoryLinearInbox(); + const api: LinearApi = { + openThread: vi.fn(), + workItems: vi.fn(), + files: vi.fn(async () => []), + canRead: vi.fn(async () => true), + upload: vi.fn(), + session: vi.fn(async (id) => ({ id, appUserId: "bot" })), + activities: vi.fn(async () => []), + activity: vi.fn(async () => {}), + link: vi.fn(async () => {}), + }; + const deps = { token: "bridge-secret", inbox, clock: () => 100, api: vi.fn(async () => api) }; + const fetch = vi.fn(async (url, init) => handleLinearBridge(new Request(url, init), deps)); + return { inbox, api, deps, fetch, transport: { baseUrl: "https://bot.example", token: "bridge-secret", fetch } }; +} + +describe("Linear edge bridge", () => { + it("relays scoped queued cancellation only over the authenticated bridge with an elapsed cutoff", async () => { + const { inbox, transport, deps } = fixture(); + const pending = { + key: "pending", + receivedAt: 50, + payload: { + type: "AgentSessionEvent", + action: "created", + organizationId: "org", + agentSession: { id: "s", creatorId: "alice" }, + }, + }; + await inbox.accept(pending); + const remote = new RemoteLinearInbox(transport); + const input = { organizationId: "org", sessionId: "s", userId: "alice", receivedAt: 100 }; + for (const bad of [ + { ...input, receivedAt: 101 }, + { ...input, userId: "" }, + { ...input, userId: "alice:other" }, + { ...input, receivedAt: -1 }, + { ...input, sessionId: "s/other" }, + ]) + await expect(remote.cancelPending(bad)).rejects.toThrow("linear_bridge_unavailable"); + expect( + ( + await handleLinearBridge( + new Request("https://bot.example/internal/linear", { + method: "POST", + body: JSON.stringify({ op: "cancelPending", ...input }), + }), + deps, + ) + ).status, + ).toBe(401); + expect(await remote.cancelPending(input)).toBe(1); + expect(await remote.claim()).toBeUndefined(); + expect(await remote.cancelPending(input)).toBe(0); + }); + it("carries caller cancellation through the bridge request into the active file copy", async () => { + const { api, transport } = fixture(); + const stop = new AbortController(); + let copySignal: AbortSignal | undefined; + api.copyAttachment = vi.fn>( + (_session, _user, _file, _key, signal) => + new Promise((_resolve, reject) => { + copySignal = signal; + signal?.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }); + }), + ); + const remote = new RemoteLinearApi(transport, "org"); + const file = { + url: "https://uploads.linear.app/org/data", + name: "data.zip", + size: 100, + type: "application/zip", + messageId: "prompt", + }; + const pending = remote.copyAttachment("s", "linear:org:alice", file, "key", stop.signal); + const rejected = expect(pending).rejects.toThrow("linear_bridge_unavailable"); + await vi.waitFor(() => expect(copySignal).toBeDefined()); + stop.abort(); + await rejected; + expect(copySignal?.aborted).toBe(true); + }); + it("relays an attachment copy with the session and human bound separately from file metadata", async () => { + const { api, transport } = fixture(); + const file = { + url: "https://uploads.linear.app/org/data", + name: "data.zip", + size: 100, + type: "application/zip", + messageId: "prompt", + }; + const key = "threads/linear-org-s/in/prompt/1-data.zip"; + api.copyAttachment = vi.fn(async () => ({ key, size: 100 })); + const remote = new RemoteLinearApi(transport, "org"); + expect(await remote.copyAttachment("s", "linear:org:alice", file, key)).toEqual({ key, size: 100 }); + expect(api.copyAttachment).toHaveBeenCalledWith("s", "linear:org:alice", file, key, expect.any(AbortSignal)); + }); + it("allows child creation to complete across several upstream requests", async () => { + vi.useFakeTimers(); + const timeout = vi.spyOn(AbortSignal, "timeout").mockImplementation((ms) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ms); + return controller.signal; + }); + try { + const child = { organizationId: "org", sessionId: "child" }; + const fetch = vi.fn( + (_url, init) => + new Promise((resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + setTimeout(() => resolve(Response.json({ result: child })), 15_000); + }), + ); + const remote = new RemoteLinearApi({ baseUrl: "https://bot.example", token: "bridge", fetch }, "org"); + const check = expect( + remote.openThread("parent", "linear:org:alice", { id: "creation", lead: "Review" }), + ).resolves.toEqual(child); + await vi.advanceTimersByTimeAsync(15_000); + await check; + } finally { + timeout.mockRestore(); + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + it("relays native child creation with a fixed identity and creation id", async () => { + const { api, transport } = fixture(); + vi.mocked(api.openThread).mockResolvedValue({ organizationId: "org", sessionId: "child" }); + const remote = new RemoteLinearApi(transport, "org"); + expect(await remote.openThread("parent", "linear:org:alice", { id: "creation", lead: "Review" })).toEqual({ + organizationId: "org", + sessionId: "child", + }); + expect(api.openThread).toHaveBeenCalledWith("parent", "linear:org:alice", { id: "creation", lead: "Review" }); + }); + it("accepts local run-page links while rejecting remote plaintext and credential-bearing links", async () => { + const { api, transport } = fixture(); + const remote = new RemoteLinearApi(transport, "org"); + for (const url of [ + "http://localhost:8082/runs/run", + "http://127.0.0.1:8082/runs/run", + "http://[::1]:8082/runs/run", + "https://bot.example/runs/run", + ]) { + await remote.link("s", { url, label: "Run" }); + expect(api.link).toHaveBeenLastCalledWith("s", { url, label: "Run" }); + } + vi.mocked(api.link).mockClear(); + for (const url of [ + "http://remote.example/run", + "http://localhost.evil.example/run", + "https://user:secret@bot.example/run", + "javascript:alert(1)", + ]) { + await expect(remote.link("s", { url, label: "Run" })).rejects.toThrow("linear_bridge_unavailable"); + } + expect(api.link).not.toHaveBeenCalled(); + }); + it("allows an authenticated file batch to finish beyond a single API call deadline", async () => { + vi.useFakeTimers(); + const timeout = vi.spyOn(AbortSignal, "timeout").mockImplementation((ms) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ms); + return controller.signal; + }); + try { + const fetch = vi.fn( + (_url, init) => + new Promise((resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + setTimeout(() => resolve(Response.json({ result: [] })), 15_000); + }), + ); + const remote = new RemoteLinearApi({ baseUrl: "https://bot.example", token: "bridge", fetch }, "org"); + const result = remote.files("s", "linear:org:alice", ["https://uploads.linear.app/org/image"]); + const check = expect(result).resolves.toEqual([]); + await vi.advanceTimersByTimeAsync(15_000); + await check; + } finally { + timeout.mockRestore(); + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it("binds file downloads to a session and requester without accepting arbitrary fetch options", async () => { + const { api, transport } = fixture(); + const remote = new RemoteLinearApi(transport, "org"); + const urls = ["https://uploads.linear.app/org/file"]; + vi.mocked(api.files).mockResolvedValue([ + { url: urls[0]!, name: "file.txt", document: { mediaType: "text/plain", data: "text" } }, + ]); + expect(await remote.files("s", "linear:org:alice", urls, true)).toHaveLength(1); + expect(api.files).toHaveBeenCalledWith("s", "linear:org:alice", urls, true, 0); + vi.mocked(api.files).mockRejectedValueOnce(new Error("linear_file_denied")); + await expect(remote.files("s", "linear:org:bob", urls)).rejects.toThrow("linear_file_denied"); + }); + it("relays a requester access verdict and preserves lookup failure as retryable", async () => { + const { api, transport } = fixture(); + const remote = new RemoteLinearApi(transport, "org"); + vi.mocked(api.canRead).mockResolvedValueOnce(false); + expect(await remote.canRead("s", "linear:org:person")).toBe(false); + expect(api.canRead).toHaveBeenCalledWith("s", "linear:org:person"); + expect(api.session).not.toHaveBeenCalled(); + vi.mocked(api.canRead).mockRejectedValueOnce(new Error("rate limited")); + await expect(remote.canRead("s", "linear:org:person")).rejects.toThrow("linear_bridge_unavailable"); + }); + it("round-trips a fenced no-effects deferral and refuses one after a run binding", async () => { + const { inbox, transport, deps } = fixture(); + await inbox.accept({ + key: "defer", + receivedAt: 1, + payload: { organizationId: "org", type: "AgentSessionEvent", action: "created" }, + }); + const remote = new RemoteLinearInbox(transport); + const first = (await remote.claim())!; + await remote.begin("defer", first.lease); + expect(await remote.defer("defer", "wrong")).toBe(false); + expect(await remote.defer("defer", first.lease)).toBe(true); + expect(await remote.claim()).toBeUndefined(); + deps.clock = () => 5100; + const next = (await remote.claim())!; + expect(next.begun).toBeUndefined(); + await remote.begin("defer", next.lease); + await remote.bind("defer", next.lease, "run"); + expect(await remote.defer("defer", next.lease)).toBe(false); + }); + it("binds work-item operations to an accessible session over the fixed bridge", async () => { + const { api, transport } = fixture(); + const remote = new RemoteLinearApi(transport, "org"); + const actor = { id: "linear:org:person", actions: ["work-items:read"] }; + vi.mocked(api.workItems).mockResolvedValue({ items: [] }); + expect(await remote.workItems("s", actor, { op: "delegated" })).toEqual({ items: [] }); + expect(api.workItems).toHaveBeenCalledWith("s", actor, { op: "delegated" }); + vi.mocked(api.session).mockRejectedValueOnce(new Error("access removed")); + await expect(remote.workItems("s", actor, { op: "delegated" })).rejects.toThrow("linear_bridge_unavailable"); + expect(api.workItems).toHaveBeenCalledTimes(1); + vi.mocked(api.workItems).mockRejectedValueOnce(new Error("linear_work_item_denied")); + await expect(remote.workItems("s", actor, { op: "delegated" })).rejects.toThrow("linear_work_item_denied"); + }); + it("mints upload tickets only after checking session ownership and current access", async () => { + const { api, transport } = fixture(); + const remote = new RemoteLinearApi(transport, "org"); + vi.mocked(api.upload).mockResolvedValue({ + uploadUrl: "https://storage.example/file", + assetUrl: "https://uploads.linear.app/file", + headers: {}, + }); + expect(await remote.upload("s", { name: "file.txt", size: 3 })).toHaveProperty("uploadUrl"); + expect(api.upload).toHaveBeenCalledWith("s", { name: "file.txt", size: 3 }); + vi.mocked(api.session).mockRejectedValueOnce(new Error("access revoked")); + await expect(remote.upload("s", { name: "file.txt", size: 3 })).rejects.toThrow("linear_bridge_unavailable"); + vi.mocked(api.session).mockResolvedValueOnce({ id: "s", appUserId: "bot", dismissedAt: new Date(0).toISOString() }); + await expect(remote.upload("s", { name: "file.txt", size: 3 })).rejects.toThrow("linear_bridge_unavailable"); + expect(api.upload).toHaveBeenCalledTimes(1); + }); + it("rejects unauthenticated requests before parsing or accessing the inbox", async () => { + const { deps } = fixture(); + expect( + ( + await handleLinearBridge( + new Request(`https://bot.example${LINEAR_BRIDGE_PATH}`, { method: "POST", body: "invalid" }), + deps, + ) + ).status, + ).toBe(401); + expect(deps.api).not.toHaveBeenCalled(); + }); + it("round-trips a durable delivery and fences a stale consumer through the remote inbox", async () => { + const { inbox, transport } = fixture(); + await inbox.accept({ + key: "k", + receivedAt: 1, + payload: { organizationId: "org", type: "AgentSessionEvent", action: "created" }, + }); + const remote = new RemoteLinearInbox(transport); + const delivery = await remote.claim(); + expect(delivery?.event.key).toBe("k"); + expect(await remote.begin("k", "wrong")).toBe(false); + expect(await remote.begin("k", delivery!.lease)).toBe(true); + expect(await remote.begin("k", delivery!.lease)).toBe(false); + expect(await remote.bind("k", "wrong", "run")).toBe(false); + expect(await remote.bind("k", delivery!.lease, "run")).toBe(true); + expect(await remote.complete("k", delivery!.lease)).toBe(true); + expect(await remote.claim()).toBeUndefined(); + }); + it("keeps API operations scoped to the owned session and never forwards an arbitrary query", async () => { + const { transport, api, fetch } = fixture(); + const remote = new RemoteLinearApi(transport, "org"); + await remote.activity("session", { type: "response", body: "Done" }); + expect(api.session).toHaveBeenCalledWith("session"); + expect(api.activity).toHaveBeenCalledWith("session", { type: "response", body: "Done" }, {}); + expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).not.toHaveProperty("query"); + const bad = await fetch(`https://bot.example${LINEAR_BRIDGE_PATH}`, { + method: "POST", + headers: { authorization: "Bearer bridge-secret" }, + body: JSON.stringify({ op: "graphql", query: "mutation { ... }" }), + }); + expect(bad.status).toBe(400); + }); + it("sanitizes API failures and refuses missing installations without falling back to another workspace", async () => { + const { deps, transport } = fixture(); + deps.api.mockRejectedValue(new Error("secret upstream body")); + await expect(new RemoteLinearApi(transport, "missing").session("s")).rejects.toThrow(/^linear_bridge_unavailable$/); + expect(deps.api).toHaveBeenCalledWith("missing"); + }); +}); diff --git a/src/channels/linear/bridge.ts b/src/channels/linear/bridge.ts new file mode 100644 index 000000000..28d1047f2 --- /dev/null +++ b/src/channels/linear/bridge.ts @@ -0,0 +1,362 @@ +import type { LinearFile } from "./files.js"; +import type { StagedFile } from "../../core/types.js"; +import { ARTIFACT_DEFAULTS } from "../../artifacts/config.js"; +import type { Clock } from "../../core/trace/types.js"; +import { LINEAR_TIMING } from "../../core/budgets.js"; +import { + object, + required, + type LinearActivity, + type LinearApi, + type LinearContent, + type LinearSession, + type LinearUpload, + type LinearOpenedThread, +} from "./api.js"; +import type { LinearDelivery, LinearInbox, LinearPendingStop } from "./inbox.js"; +import { boundedBody } from "./webhook.js"; +import type { WorkItemRequest, WorkItemResult } from "../../core/workItems.js"; +import type { LinearWorkItemActor } from "./workItems.js"; + +export const LINEAR_BRIDGE_PATH = "/internal/linear"; + +const WORK_ITEM_ERRORS: Readonly> = { + linear_child_denied: 403, + linear_invalid_child: 400, + linear_child_conflict: 409, + linear_child_creation_uncertain: 409, + linear_work_item_denied: 403, + linear_file_denied: 403, + linear_invalid_files: 400, + linear_human_required: 403, + linear_invalid_work_item_input: 400, + linear_empty_work_item_update: 400, + linear_unknown_or_ambiguous_state: 400, +}; + +export interface LinearTransport { + baseUrl: string; + token: string; + fetch: typeof fetch; +} + +async function call(transport: LinearTransport, body: Record, signal?: AbortSignal): Promise { + const url = new URL(LINEAR_BRIDGE_PATH, transport.baseUrl); + if ( + url.protocol !== "https:" && + !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) + ) + throw new Error("linear_bridge_requires_https"); + signal?.throwIfAborted(); + let response: Response; + try { + response = await transport.fetch(url, { + method: "POST", + redirect: "error", + signal: AbortSignal.any([ + ...(signal ? [signal] : []), + AbortSignal.timeout( + body.op === "copyAttachment" + ? ARTIFACT_DEFAULTS.copyTimeoutMs + : body.op === "files" + ? LINEAR_TIMING.fileBridgeTimeoutMs + : body.op === "openThread" + ? LINEAR_TIMING.childBridgeTimeoutMs + : LINEAR_TIMING.apiTimeoutMs, + ), + ]), + headers: { "content-type": "application/json", authorization: `Bearer ${transport.token}` }, + body: JSON.stringify(body), + }); + } catch { + throw new Error("linear_bridge_unavailable"); + } + if (!response.ok) { + const error = object(await response.json().catch(() => null)).error; + if (typeof error === "string" && Object.hasOwn(WORK_ITEM_ERRORS, error)) throw new Error(error); + throw new Error("linear_bridge_unavailable"); + } + return ((await response.json()) as { result: T }).result; +} + +/** The bot holds this bridge bearer, never Linear's access/refresh token. */ +export class RemoteLinearApi implements LinearApi { + constructor( + private readonly transport: LinearTransport, + private readonly organizationId: string, + ) {} + openThread(sessionId: string, userId: string, input: { id: string; lead: string }): Promise { + return call(this.transport, { op: "openThread", organizationId: this.organizationId, sessionId, userId, input }); + } + files(sessionId: string, userId: string, urls: string[], history = false, maxStagedBytes = 0): Promise { + return call(this.transport, { + op: "files", + organizationId: this.organizationId, + sessionId, + userId, + urls, + history, + maxStagedBytes, + }); + } + copyAttachment( + sessionId: string, + userId: string, + file: StagedFile, + key: string, + signal?: AbortSignal, + ): Promise<{ key: string; size: number }> { + return call( + this.transport, + { + op: "copyAttachment", + organizationId: this.organizationId, + sessionId, + userId, + file, + key, + }, + signal, + ); + } + canRead(sessionId: string, userId: string): Promise { + return call(this.transport, { op: "canRead", organizationId: this.organizationId, sessionId, userId }); + } + session(sessionId: string): Promise { + return call(this.transport, { op: "session", organizationId: this.organizationId, sessionId }); + } + activities(sessionId: string): Promise { + return call(this.transport, { op: "activities", organizationId: this.organizationId, sessionId }); + } + activity(sessionId: string, content: LinearContent, options?: { ephemeral?: boolean; id?: string }): Promise { + return call(this.transport, { op: "activity", organizationId: this.organizationId, sessionId, content, options }); + } + link(sessionId: string, link: { url: string; label: string }): Promise { + return call(this.transport, { op: "link", organizationId: this.organizationId, sessionId, link }); + } + upload(sessionId: string, file: { name: string; size: number }): Promise { + return call(this.transport, { op: "upload", organizationId: this.organizationId, sessionId, file }); + } + workItems(sessionId: string, actor: LinearWorkItemActor, input: WorkItemRequest): Promise { + return call(this.transport, { op: "workItems", organizationId: this.organizationId, sessionId, actor, input }); + } +} + +/** Delivery times and leases are chosen by the durable host, not a consumer's clock. */ +export class RemoteLinearInbox { + constructor(private readonly transport: LinearTransport) {} + async claim(): Promise { + return (await call(this.transport, { op: "claim" })) ?? undefined; + } + begin(key: string, lease: string): Promise { + return call(this.transport, { op: "begin", key, lease }); + } + bind(key: string, lease: string, runId: string): Promise { + return call(this.transport, { op: "bind", key, lease, runId }); + } + renew(key: string, lease: string): Promise { + return call(this.transport, { op: "renew", key, lease }); + } + defer(key: string, lease: string): Promise { + return call(this.transport, { op: "defer", key, lease }); + } + retry(key: string, lease: string): Promise { + return call(this.transport, { op: "retry", key, lease }); + } + complete(key: string, lease: string): Promise { + return call(this.transport, { op: "complete", key, lease }); + } + cancelPending(input: LinearPendingStop): Promise { + return call(this.transport, { op: "cancelPending", ...input }); + } +} + +const answer = (status: number, value: unknown) => + Response.json(value, { status, headers: { "cache-control": "no-store" } }); + +async function authenticates(request: Request, token: string): Promise { + const supplied = request.headers.get("authorization"); + if (!supplied || supplied.length > 1024) return false; + // Compare fixed-size digests, with no token-dependent early return. + const [left, right] = await Promise.all( + [supplied, `Bearer ${token}`].map( + async (value) => new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))), + ), + ); + let different = 0; + for (let i = 0; i < left!.length; i++) different |= left![i]! ^ right![i]!; + return different === 0; +} + +function contentOf(value: unknown): LinearContent { + const content = object(value); + if (content.type === "action") + return { + type: "action", + action: required(content.action), + parameter: required(content.parameter), + ...(typeof content.result === "string" ? { result: content.result } : {}), + }; + if ( + content.type === "thought" || + content.type === "response" || + content.type === "error" || + content.type === "elicitation" + ) + return { type: content.type, body: required(content.body) }; + throw new Error("invalid_content"); +} + +/** A fixed RPC vocabulary: no arbitrary GraphQL, URL or credential-read route. + * The bearer authenticates the bot transport; request authorization still + * belongs to dispatch's resolved human actor and policy table. */ +export async function handleLinearBridge( + request: Request, + deps: { + token?: string; + inbox: LinearInbox; + clock: Clock; + api(organizationId: string): Promise; + }, +): Promise { + if (!deps.token) return answer(503, { error: "linear_bridge_disabled" }); + if (!(await authenticates(request, deps.token))) return answer(401, { error: "unauthorized" }); + if (request.method !== "POST") return answer(405, { error: "method_not_allowed" }); + let body: Record; + try { + const bytes = await boundedBody(request); + if (!bytes) return answer(413, { error: "too_large" }); + body = object(JSON.parse(new TextDecoder().decode(bytes))); + } catch { + return answer(400, { error: "invalid_body" }); + } + const op = body.op; + if ( + ![ + "claim", + "begin", + "bind", + "renew", + "retry", + "defer", + "complete", + "cancelPending", + "session", + "canRead", + "files", + "copyAttachment", + "activities", + "activity", + "link", + "upload", + "workItems", + "openThread", + ].includes(String(op)) + ) + return answer(400, { error: "unknown_operation" }); + try { + let result: unknown; + const now = deps.clock(); + if (op === "claim") result = await deps.inbox.claim(now, LINEAR_TIMING.deliveryLeaseMs, crypto.randomUUID()); + else if (op === "begin") result = await deps.inbox.begin(required(body.key), required(body.lease)); + else if (op === "bind") + result = await deps.inbox.bind(required(body.key), required(body.lease), required(body.runId)); + else if (op === "renew") + result = await deps.inbox.renew(required(body.key), required(body.lease), now + LINEAR_TIMING.deliveryLeaseMs); + else if (op === "defer") + result = await deps.inbox.defer(required(body.key), required(body.lease), now + LINEAR_TIMING.progressMs); + else if (op === "retry") + result = await deps.inbox.retry(required(body.key), required(body.lease), now + LINEAR_TIMING.progressMs); + else if (op === "complete") result = await deps.inbox.complete(required(body.key), required(body.lease), now); + else if (op === "cancelPending") { + const organizationId = required(body.organizationId), + sessionId = required(body.sessionId); + if ( + ![organizationId, sessionId, ...(body.userId === undefined ? [] : [body.userId])].every( + (id) => typeof id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(id), + ) || + typeof body.receivedAt !== "number" || + !Number.isSafeInteger(body.receivedAt) || + body.receivedAt < 0 || + body.receivedAt > now + ) + return answer(400, { error: "invalid_pending_stop" }); + result = await deps.inbox.cancelPending({ + organizationId, + sessionId, + receivedAt: body.receivedAt, + ...(typeof body.userId === "string" ? { userId: body.userId } : {}), + }); + } else { + const api = await deps.api(required(body.organizationId)), + id = required(body.sessionId); + if (op === "canRead") return answer(200, { result: await api.canRead(id, required(body.userId)) }); + // Every operation proves ownership and current access again. A guessed + // session id cannot make the bridge read a different app's conversation. + const session = await api.session(id); + if (op === "session") result = session; + else if (session.dismissedAt) return answer(409, { error: "session_dismissed" }); + else if (op === "openThread") { + const input = object(body.input); + result = await api.openThread(id, required(body.userId), { + id: required(input.id), + lead: required(input.lead), + }); + } else if (op === "copyAttachment") { + if (!api.copyAttachment) throw new Error("linear_staging_unavailable"); + result = await api.copyAttachment( + id, + required(body.userId), + object(body.file) as unknown as StagedFile, + required(body.key), + request.signal, + ); + } else if (op === "files") + result = await api.files( + id, + required(body.userId), + body.urls as string[], + body.history === true, + body.maxStagedBytes as number | undefined, + ); + else if (op === "activities") result = await api.activities(id); + else if (op === "activity") { + const options = object(body.options); + result = await api.activity(id, contentOf(body.content), { + ...(typeof options.ephemeral === "boolean" ? { ephemeral: options.ephemeral } : {}), + ...(typeof options.id === "string" ? { id: options.id } : {}), + }); + } else if (op === "workItems") { + result = await api.workItems( + id, + object(body.actor) as unknown as LinearWorkItemActor, + object(body.input) as unknown as WorkItemRequest, + ); + } else if (op === "upload") { + const file = object(body.file); + if (typeof file.size !== "number") return answer(400, { error: "invalid_file" }); + result = await api.upload(id, { name: required(file.name), size: file.size }); + } else if (op === "link") { + const link = object(body.link), + url = new URL(required(link.url)); + if ( + (url.protocol !== "https:" && + !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) || + url.username || + url.password + ) + return answer(400, { error: "invalid_link" }); + result = await api.link(id, { url: url.href, label: required(link.label) }); + } + } + return answer(200, { result: result ?? null }); + } catch (error) { + if ( + (op === "workItems" || op === "files" || op === "openThread" || op === "copyAttachment") && + error instanceof Error && + Object.hasOwn(WORK_ITEM_ERRORS, error.message) + ) + return answer(WORK_ITEM_ERRORS[error.message]!, { error: error.message }); + return answer(503, { error: "linear_bridge_unavailable" }); + } +} diff --git a/src/channels/linear/children.test.ts b/src/channels/linear/children.test.ts new file mode 100644 index 000000000..50e0ad61d --- /dev/null +++ b/src/channels/linear/children.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { InMemoryLinearChildStore, StoredLinearChildStore, type LinearChildStore } from "./children.js"; +import type { LinearStorage } from "./store.js"; + +const intent = { + organizationId: "org", + appUserId: "bot", + parentSessionId: "parent", + requesterId: "linear:org:alice", + issueId: "issue", + commentId: "comment", + lead: "Review this change", +}; + +function durable() { + const rows = new Map(); + let tail: Promise = Promise.resolve(); + const values = { + async get(key: string) { + return structuredClone(rows.get(key)) as T | undefined; + }, + async put(key: string, value: T) { + rows.set(key, structuredClone(value)); + }, + async delete(key: string) { + rows.delete(key); + }, + }; + const storage: LinearStorage = { + ...values, + transaction(fn) { + const result = tail.then(() => fn(values)); + tail = result.catch(() => {}); + return result; + }, + }; + return { store: new StoredLinearChildStore(storage), reopen: () => new StoredLinearChildStore(storage) }; +} + +for (const [name, create] of [ + ["memory", () => new InMemoryLinearChildStore()], + ["durable", () => durable().store], +] as const) + describe(`Linear child creation store (${name})`, () => { + it("binds one creation to its requester and admits only one session mutation", async () => { + const store: LinearChildStore = create(); + await store.ensure(intent); + await store.ensure(intent); + await expect(store.ensure({ ...intent, requesterId: "linear:org:bob" })).rejects.toThrow("linear_child_conflict"); + const claims = await Promise.all([store.beginSession("org", "comment"), store.beginSession("org", "comment")]); + expect(claims.sort()).toEqual([false, true]); + await store.finish("org", "comment", { id: "child", url: "https://linear.app/session/child" }); + expect(await store.get("org", "comment")).toMatchObject({ + ...intent, + sessionStarted: true, + session: { id: "child" }, + }); + expect(await store.get("other", "comment")).toBeUndefined(); + await expect(store.finish("org", "comment", { id: "different" })).rejects.toThrow("linear_child_conflict"); + }); + }); + +describe("Linear child creation recovery", () => { + it("retains an uncertain session mutation across store reconstruction", async () => { + const { store, reopen } = durable(); + await store.ensure(intent); + expect(await store.beginSession("org", "comment")).toBe(true); + const restored = reopen(); + expect(await restored.beginSession("org", "comment")).toBe(false); + await restored.finish("org", "comment", { id: "child" }); + expect((await reopen().get("org", "comment"))?.session?.id).toBe("child"); + }); +}); diff --git a/src/channels/linear/children.ts b/src/channels/linear/children.ts new file mode 100644 index 000000000..312fa37df --- /dev/null +++ b/src/channels/linear/children.ts @@ -0,0 +1,98 @@ +import type { LinearStorage } from "./store.js"; + +export interface LinearChildIntent { + organizationId: string; + appUserId: string; + parentSessionId: string; + requesterId: string; + issueId: string; + commentId: string; + lead: string; +} +export interface LinearChildRecord extends LinearChildIntent { + sessionStarted?: true; + session?: { id: string; url?: string }; +} + +/** The intent precedes either external write; an uncertain session create is + * reconciled by reading the comment, never by repeating that mutation. */ +export interface LinearChildStore { + ensure(intent: LinearChildIntent): Promise; + get(organizationId: string, commentId: string): Promise; + beginSession(organizationId: string, commentId: string): Promise; + finish(organizationId: string, commentId: string, session: NonNullable): Promise; +} + +const keyOf = (org: string, comment: string) => `child:${JSON.stringify([org, comment])}`; +function matching(current: LinearChildRecord | undefined, intent: LinearChildIntent): LinearChildRecord { + if ( + current && + Object.keys(intent).some( + (key) => current[key as keyof LinearChildIntent] !== intent[key as keyof LinearChildIntent], + ) + ) + throw new Error("linear_child_conflict"); + return current ?? { ...intent }; +} +function completed( + current: LinearChildRecord | undefined, + session: NonNullable, +): LinearChildRecord { + if (!current?.sessionStarted) throw new Error("linear_child_not_started"); + if (current.session && current.session.id !== session.id) throw new Error("linear_child_conflict"); + return { ...current, session }; +} + +export class StoredLinearChildStore implements LinearChildStore { + constructor(private readonly storage: LinearStorage) {} + ensure(intent: LinearChildIntent): Promise { + return this.storage.transaction(async (tx) => { + const key = keyOf(intent.organizationId, intent.commentId); + const current = await tx.get(key); + const record = matching(current, intent); + if (!current) await tx.put(key, record); + return record; + }); + } + get(org: string, comment: string): Promise { + return this.storage.get(keyOf(org, comment)); + } + beginSession(org: string, comment: string): Promise { + return this.storage.transaction(async (tx) => { + const key = keyOf(org, comment), + current = await tx.get(key); + if (!current || current.sessionStarted) return false; + await tx.put(key, { ...current, sessionStarted: true }); + return true; + }); + } + async finish(org: string, comment: string, session: NonNullable): Promise { + await this.storage.transaction(async (tx) => { + const key = keyOf(org, comment); + await tx.put(key, completed(await tx.get(key), session)); + }); + } +} + +export class InMemoryLinearChildStore implements LinearChildStore { + private readonly records = new Map(); + async ensure(intent: LinearChildIntent): Promise { + const key = keyOf(intent.organizationId, intent.commentId); + const record = matching(this.records.get(key), intent); + this.records.set(key, structuredClone(record)); + return structuredClone(record); + } + async get(org: string, comment: string): Promise { + return structuredClone(this.records.get(keyOf(org, comment))); + } + async beginSession(org: string, comment: string): Promise { + const current = this.records.get(keyOf(org, comment)); + if (!current || current.sessionStarted) return false; + current.sessionStarted = true; + return true; + } + async finish(org: string, comment: string, session: NonNullable): Promise { + const key = keyOf(org, comment); + this.records.set(key, structuredClone(completed(this.records.get(key), session))); + } +} diff --git a/src/channels/linear/consumer.test.ts b/src/channels/linear/consumer.test.ts new file mode 100644 index 000000000..b7e879abe --- /dev/null +++ b/src/channels/linear/consumer.test.ts @@ -0,0 +1,599 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { LinearConsumer, type LinearConsumerDeps } from "./consumer.js"; +import { InMemoryLinearInbox } from "./inbox.js"; +import type { LinearApi } from "./api.js"; +import type { LinearWebhookEvent } from "./webhook.js"; +import { stopLinearSession } from "./control.js"; +import { grantsFor } from "../../core/authz/grants.js"; +import { RunRegistry } from "../../core/runRegistry.js"; +import { createRunsService } from "../../core/runsService.js"; +import { NullRunStore } from "../../core/runStore.js"; + +const event = (id = "s"): LinearWebhookEvent => ({ + key: `org:${id}:created`, + receivedAt: 100, + payload: { + type: "AgentSessionEvent", + action: "created", + organizationId: "org", + appUserId: "bot", + agentSession: { id, appUserId: "bot", organizationId: "org", creatorId: "alice" }, + promptContext: "Fix login", + }, +}); +function fixture(maxStagedBytes?: number) { + let now = 100, + serial = 0; + const store = new InMemoryLinearInbox(); + const inbox = { + claim: () => store.claim(now, 120_000, String(++serial)), + begin: vi.fn((key: string, lease: string) => store.begin(key, lease)), + bind: vi.fn((key: string, lease: string, id: string) => store.bind(key, lease, id)), + renew: vi.fn((key: string, lease: string) => store.renew(key, lease, now + 120_000)), + defer: vi.fn((key: string, lease: string) => store.defer(key, lease, now + 5000)), + retry: vi.fn((key: string, lease: string) => store.retry(key, lease, now + 5000)), + complete: vi.fn((key: string, lease: string) => store.complete(key, lease, now)), + }; + const api: LinearApi = { + openThread: vi.fn(), + workItems: vi.fn(), + files: vi.fn(async () => []), + canRead: vi.fn(async () => true), + upload: vi.fn(), + session: vi.fn(async (id) => ({ id, appUserId: "bot", creatorId: "alice" })), + activity: vi.fn(async () => {}), + activities: vi.fn(async () => []), + link: vi.fn(async () => {}), + }; + const deps = { + maxStagedBytes, + inbox, + api: () => api, + clock: () => now, + warn: vi.fn(), + dispatch: vi.fn(async () => {}), + stop: vi.fn(async () => {}), + recover: vi.fn(async (): Promise<"handled" | "unknown"> => "unknown"), + other: vi.fn(async () => {}), + leaseLost: vi.fn(), + }; + return { + store, + inbox, + api, + deps, + consumer: new LinearConsumer(deps), + advance: (ms: number) => { + now += ms; + }, + }; +} +afterEach(() => vi.useRealTimers()); + +describe("Linear event consumer", () => { + it("stops a run registered after its delivery was cancelled during dispatch setup", async () => { + const f = fixture(); + const registry = new RunRegistry({ genId: () => "late-run", genToken: () => "t" }); + f.deps.leaseLost.mockImplementation((id) => { + registry.requestStopById(id, "hard", { kind: "chat", id: "linear:lease-lost" }); + }); + let announce!: () => void; + const effects = vi.fn(); + f.deps.dispatch.mockImplementation(async (_msg, io) => { + await new Promise((resolve) => { + announce = resolve; + }); + const run = registry.create("late-run"); + await io.runStarted?.({ id: run.id }); + if (!run.control.hardSignal.aborted) effects(); + }); + await f.store.accept(event()); + await f.consumer.poll(); + await vi.waitFor(() => expect(announce).toBeDefined()); + await f.store.cancelPending({ organizationId: "org", sessionId: "s", userId: "alice", receivedAt: 150 }); + announce(); + await f.consumer.settled(); + expect(effects).not.toHaveBeenCalled(); + expect(f.deps.leaseLost).toHaveBeenCalledWith("late-run"); + expect(f.inbox.complete).toHaveBeenCalledOnce(); + f.advance(200_000); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).toHaveBeenCalledOnce(); + expect(f.deps.recover).not.toHaveBeenCalled(); + }); + it("waits for durable run binding and stops work if that binding cannot be confirmed", async () => { + const f = fixture(); + let rejectBind!: (error: Error) => void; + f.inbox.bind.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectBind = reject; + }), + ); + let admitted = false; + f.deps.dispatch.mockImplementation(async (_msg, io) => { + await io.runStarted?.({ id: "run" }); + admitted = true; + }); + await f.store.accept(event()); + await f.consumer.poll(); + await vi.waitFor(() => expect(f.inbox.bind).toHaveBeenCalledOnce()); + expect(admitted).toBe(false); + rejectBind(new Error("transport unavailable")); + await f.consumer.settled(); + expect(f.deps.leaseLost).toHaveBeenCalledWith("run"); + expect(f.inbox.complete).toHaveBeenCalledOnce(); + }); + it("cannot complete a replacement consumer's lease after run admission is fenced", async () => { + const f = fixture(); + f.inbox.bind.mockImplementationOnce(async () => { + f.advance(200_000); + expect((await f.store.claim(200_100, 100, "replacement"))?.begun).toBe(true); + return false; + }); + f.deps.dispatch.mockImplementation(async (_msg, io) => { + await io.runStarted?.({ id: "old-run" }); + }); + await f.store.accept(event()); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.leaseLost).toHaveBeenCalledWith("old-run"); + expect(f.inbox.complete).toHaveBeenCalledWith(event().key, "1"); + expect(await f.store.complete(event().key, "replacement", 200_110)).toBe(true); + }); + it("does not replay a no-effects deferral that finishes after an authorized Stop", async () => { + const f = fixture(); + let defer!: () => void; + f.deps.dispatch.mockImplementationOnce( + () => + new Promise((resolve) => { + defer = () => resolve({ deferred: true }); + }), + ); + await f.store.accept(event()); + await f.consumer.poll(); + await vi.waitFor(() => expect(f.deps.dispatch).toHaveBeenCalledOnce()); + const runs = createRunsService({ registry: new RunRegistry(), store: new NullRunStore() }); + f.deps.stop.mockImplementationOnce((input, io) => + stopLinearSession({ runs, inbox: f.store, config: { grantsFor: (id) => grantsFor(id, {}) } }, input, io), + ); + const stop = event(); + stop.key = "stop-deferred"; + stop.receivedAt = 150; + stop.payload.action = "prompted"; + stop.payload.agentActivity = { + id: "stop-deferred", + agentSessionId: "s", + userId: "alice", + signal: "stop", + content: { type: "prompt", body: "Stop" }, + }; + await f.store.accept(stop); + await f.consumer.poll(); + await vi.waitFor(() => expect(f.inbox.complete).toHaveBeenCalledWith(stop.key, expect.any(String))); + defer(); + await f.consumer.settled(); + f.advance(200_000); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).toHaveBeenCalledOnce(); + expect(f.deps.recover).not.toHaveBeenCalled(); + expect(f.deps.warn).not.toHaveBeenCalled(); + const later = event(); + later.key = "after-stop"; + later.receivedAt = 200_100; + await f.store.accept(later); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).toHaveBeenCalledTimes(2); + }); + it("does not dispatch a file-hydrating request cancelled durably by a later Stop", async () => { + const f = fixture(); + const preparing = event(); + preparing.payload.promptContext = "Read [notes.txt](https://uploads.linear.app/org/notes)"; + let finishFiles!: () => void; + vi.mocked(f.api.files).mockImplementationOnce( + () => + new Promise((resolve) => { + finishFiles = () => resolve([]); + }), + ); + await f.store.accept(preparing); + await f.consumer.poll(); + await vi.waitFor(() => expect(f.api.files).toHaveBeenCalledOnce()); + const runs = createRunsService({ registry: new RunRegistry(), store: new NullRunStore() }); + f.deps.stop.mockImplementationOnce((input, io) => + stopLinearSession({ runs, inbox: f.store, config: { grantsFor: (id) => grantsFor(id, {}) } }, input, io), + ); + const stop = event(); + stop.key = "stop"; + stop.receivedAt = 150; + stop.payload.action = "prompted"; + stop.payload.agentActivity = { + id: "stop", + agentSessionId: "s", + userId: "alice", + signal: "stop", + content: { type: "prompt", body: "Stop" }, + }; + await f.store.accept(stop); + await f.consumer.poll(); + await vi.waitFor(() => expect(f.inbox.complete).toHaveBeenCalledWith("stop", expect.any(String))); + finishFiles(); + await f.consumer.settled(); + expect(f.deps.dispatch).not.toHaveBeenCalled(); + expect(f.deps.recover).not.toHaveBeenCalled(); + expect(f.deps.warn).not.toHaveBeenCalled(); + f.advance(200_000); + const next = event(); + next.key = "new-request"; + next.receivedAt = 200_100; + await f.store.accept(next); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).toHaveBeenCalledOnce(); + }); + it("passes staged metadata and the configured byte budget into dispatch under the original message id", async () => { + const f = fixture(1000); + const ev = event(); + const url = "https://uploads.linear.app/org/archive"; + ev.payload.promptContext = `Inspect [data.zip](${url})`; + vi.mocked(f.api.files).mockResolvedValue([ + { url, name: "data.zip", staged: { size: 100, type: "application/zip" } }, + ]); + await f.store.accept(ev); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.api.files).toHaveBeenCalledWith("s", "linear:org:alice", [url], false, 1000); + expect(f.deps.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + staged: [{ url, name: "data.zip", size: 100, type: "application/zip", messageId: ev.key }], + }), + expect.objectContaining({ copyAttachment: expect.any(Function) }), + ); + }); + it("consumes a managed child's creation without a second dispatch but accepts human follow-ups", async () => { + const f = fixture(); + vi.mocked(f.api.session).mockResolvedValue({ id: "s", appUserId: "bot", creatorId: "bot", managedChild: true }); + const created = event(); + (created.payload.agentSession as Record).creatorId = "bot"; + await f.store.accept(created); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).not.toHaveBeenCalled(); + expect(f.api.activity).not.toHaveBeenCalled(); + const follow = event(); + follow.key = "follow"; + follow.payload.action = "prompted"; + follow.payload.agentActivity = { + id: "prompt", + agentSessionId: "s", + userId: "alice", + content: { type: "prompt", body: "Please continue" }, + }; + await f.store.accept(follow); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ userId: "linear:org:alice", text: "Please continue" }), + expect.anything(), + ); + }); + it("leaves access refusal to dispatch and never claims a denied file was read", async () => { + const f = fixture(); + const ev = event(); + ev.payload.promptContext = "Read [file.txt](https://uploads.linear.app/org/file)"; + await f.store.accept(ev); + vi.mocked(f.api.files).mockRejectedValue(new Error("linear_file_denied")); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining("Attachments not read: the requester could not access this session"), + }), + expect.anything(), + ); + expect(f.inbox.complete).toHaveBeenCalledOnce(); + }); + it("loads private prompt files before beginning dispatch and retries a failed download without a begun marker", async () => { + const f = fixture(); + const url = "https://uploads.linear.app/org/image"; + const ev = event(); + ev.payload.promptContext = `Describe ![screen.png](${url})`; + await f.store.accept(ev); + vi.mocked(f.api.files) + .mockRejectedValueOnce(new Error("linear_file_unavailable")) + .mockResolvedValueOnce([ + { url, name: "screen.png", image: { name: "screen.png", mediaType: "image/png", data: "cGl4ZWxz" } }, + ]); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.inbox.begin).not.toHaveBeenCalled(); + expect(f.deps.dispatch).not.toHaveBeenCalled(); + f.advance(5000); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.api.files).toHaveBeenCalledWith("s", "linear:org:alice", [url], false, undefined); + expect(f.deps.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ images: [{ name: "screen.png", mediaType: "image/png", data: "cGl4ZWxz" }] }), + expect.anything(), + ); + expect(f.inbox.complete).toHaveBeenCalledOnce(); + }); + it("retries an explicitly deferred turn as its own requester instead of treating it as interrupted", async () => { + const f = fixture(); + await f.store.accept(event()); + f.deps.dispatch.mockResolvedValueOnce({ deferred: true }); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.inbox.defer).toHaveBeenCalledOnce(); + expect(f.inbox.complete).not.toHaveBeenCalled(); + f.advance(5000); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).toHaveBeenCalledTimes(2); + expect(f.deps.recover).not.toHaveBeenCalled(); + expect(f.inbox.complete).toHaveBeenCalledOnce(); + }); + it("holds already claimed later prompts after deferral while stop bypasses the admission wait", async () => { + const f = fixture(); + let decide!: () => void; + f.deps.dispatch.mockImplementationOnce(async () => { + await new Promise((resolve) => { + decide = resolve; + }); + return { deferred: true }; + }); + const prompt = (id: string, signal?: string): LinearWebhookEvent => ({ + ...event(), + key: id, + payload: { + ...event().payload, + action: "prompted", + agentActivity: { id, agentSessionId: "s", userId: "alice", signal, content: { type: "prompt", body: id } }, + }, + }); + await f.store.accept(event()); + await f.consumer.poll(); + await vi.waitFor(() => expect(f.deps.dispatch).toHaveBeenCalledOnce()); + await f.store.accept(prompt("later")); + await f.consumer.poll(); + await f.store.accept(prompt("stop", "stop")); + await f.consumer.poll(); + await vi.waitFor(() => expect(f.deps.stop).toHaveBeenCalledOnce()); + expect(f.deps.dispatch).toHaveBeenCalledOnce(); + decide(); + await f.consumer.settled(); + expect(f.inbox.retry).toHaveBeenCalledWith("later", expect.any(String)); + expect(f.inbox.complete).toHaveBeenCalledTimes(1); + f.advance(5000); + await f.consumer.poll(); + await f.consumer.settled(); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch.mock.calls.map(([msg]) => msg.text)).toEqual(["Fix login", "Fix login", "later"]); + expect(f.deps.recover).not.toHaveBeenCalled(); + }); + it("records dispatch entry before invoking the core and completes only after its reply", async () => { + const f = fixture(); + await f.store.accept(event()); + let finish!: () => void; + f.deps.dispatch.mockImplementation(async (...args: unknown[]) => { + expect(f.inbox.begin).toHaveBeenCalled(); + const io = args[1] as { runStarted(start: { id: string }): void }; + io.runStarted({ id: "run" }); + await new Promise((resolve) => { + finish = resolve; + }); + }); + await f.consumer.poll(); + await vi.waitFor(() => expect(f.inbox.bind).toHaveBeenCalledWith(event().key, "1", "run")); + expect(f.inbox.complete).not.toHaveBeenCalled(); + finish(); + await f.consumer.settled(); + expect(f.inbox.complete).toHaveBeenCalledWith(event().key, "1"); + expect(f.deps.dispatch.mock.calls[0]?.[0]).toMatchObject({ userId: "linear:org:alice", text: "Fix login" }); + }); + it("keeps transport failures pending without recording dispatch entry", async () => { + const f = fixture(); + await f.store.accept(event()); + vi.mocked(f.api.session).mockRejectedValueOnce(new Error("transport")); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.inbox.begin).not.toHaveBeenCalled(); + expect(f.inbox.retry).toHaveBeenCalled(); + f.advance(5000); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).toHaveBeenCalledOnce(); + }); + it("lets the core replace an interrupted run without losing the delivery lease", async () => { + const f = fixture(); + await f.store.accept(event()); + f.deps.dispatch.mockImplementation(async (_msg, io) => { + io.runStarted?.({ id: "first" }); + io.runStarted?.({ id: "replacement" }); + }); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.inbox.bind).toHaveBeenCalledTimes(1); + expect(f.deps.leaseLost).not.toHaveBeenCalled(); + expect(f.inbox.complete).toHaveBeenCalledOnce(); + }); + it("reconciles a begun delivery and never dispatches an uncertain command twice", async () => { + const f = fixture(); + await f.store.accept(event()); + await f.store.claim(100, 1, "old"); + await f.store.begin(event().key, "old"); + f.advance(2); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).not.toHaveBeenCalled(); + expect(f.deps.recover).toHaveBeenCalledOnce(); + expect(f.api.activity).toHaveBeenCalledWith("s", expect.objectContaining({ type: "error" })); + expect(f.inbox.complete).toHaveBeenCalledOnce(); + }); + it("lets the durable ledger continue an admitted run without another answer", async () => { + const f = fixture(); + await f.store.accept(event()); + await f.store.claim(100, 1, "old"); + await f.store.begin(event().key, "old"); + await f.store.bind(event().key, "old", "run"); + f.advance(2); + f.deps.recover.mockResolvedValue("handled"); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).not.toHaveBeenCalled(); + expect(f.api.activity).not.toHaveBeenCalled(); + expect(f.inbox.complete).toHaveBeenCalledOnce(); + }); + it("renews slow work, admits another session and stops intake during drain", async () => { + vi.useFakeTimers(); + const f = fixture(); + let finish!: () => void; + f.deps.dispatch.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + await f.store.accept(event()); + await f.store.accept(event("s2")); + await f.consumer.poll(); + await vi.advanceTimersByTimeAsync(40_000); + expect(f.deps.dispatch).toHaveBeenCalledTimes(2); + expect(f.inbox.renew).toHaveBeenCalledWith(event().key, "1"); + f.consumer.stop(); + await f.store.accept(event("s3")); + await f.consumer.poll(); + expect(f.deps.dispatch).toHaveBeenCalledTimes(2); + finish(); + await f.consumer.settled(); + }); + it("routes stop as a control input, without interpreting its text as an agent request", async () => { + const f = fixture(), + stop = event(); + stop.payload.action = "prompted"; + stop.payload.agentActivity = { + id: "a", + agentSessionId: "s", + userId: "alice", + signal: "stop", + content: { type: "prompt", body: "stop" }, + }; + await f.store.accept(stop); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.stop).toHaveBeenCalledWith( + expect.objectContaining({ userId: "linear:org:alice", threadKey: "linear:org:s" }), + expect.anything(), + ); + expect(f.deps.dispatch).not.toHaveBeenCalled(); + }); + it("preserves session arrival order through admission without waiting for the agent to finish", async () => { + const f = fixture(); + let admitted!: () => void, finished!: () => void; + f.deps.dispatch.mockImplementationOnce(async (_msg, io) => { + await new Promise((resolve) => { + admitted = resolve; + }); + io.runStarted?.({ id: "run" }); + await new Promise((resolve) => { + finished = resolve; + }); + }); + const follow = event(); + follow.key = "follow"; + follow.payload.action = "prompted"; + follow.payload.agentActivity = { + id: "p", + agentSessionId: "s", + userId: "alice", + content: { type: "prompt", body: "Use OAuth" }, + }; + await f.store.accept(event()); + await f.store.accept(follow); + await f.consumer.poll(); + expect(f.deps.dispatch).toHaveBeenCalledTimes(1); + await f.consumer.poll(); + expect(f.deps.dispatch).toHaveBeenCalledTimes(1); + admitted(); + await vi.waitFor(() => expect(f.deps.dispatch).toHaveBeenCalledTimes(2)); + finished(); + await f.consumer.settled(); + }); + it("fails closed on stale ownership and never discards a lifecycle event on handler failure", async () => { + const f = fixture(); + const lifecycle = event(); + lifecycle.payload.type = "OAuthApp"; + await f.store.accept(lifecycle); + f.deps.other.mockRejectedValue(new Error("unavailable")); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.inbox.complete).not.toHaveBeenCalled(); + expect(f.inbox.retry).toHaveBeenCalled(); + const g = fixture(); + await g.store.accept(event()); + g.inbox.begin.mockResolvedValue(false); + await g.consumer.poll(); + await g.consumer.settled(); + expect(g.deps.dispatch).not.toHaveBeenCalled(); + expect(g.inbox.complete).not.toHaveBeenCalled(); + }); + it("still honors Stop when a running session's origin becomes unsupported", async () => { + const f = fixture(); + vi.mocked(f.api.session).mockResolvedValue({ + id: "s", + appUserId: "bot", + creatorId: "alice", + unsupportedSurface: true, + }); + const stop = event(); + stop.payload.action = "prompted"; + stop.payload.agentActivity = { + id: "stop", + agentSessionId: "s", + userId: "alice", + signal: "stop", + content: { type: "prompt", body: "Stop" }, + }; + await f.store.accept(stop); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.stop).toHaveBeenCalledOnce(); + expect(f.deps.dispatch).not.toHaveBeenCalled(); + expect(f.api.activity).not.toHaveBeenCalled(); + }); + it("answers an unsupported origin explicitly without dispatching or repeatedly retrying it", async () => { + const f = fixture(); + vi.mocked(f.api.session).mockResolvedValue({ + id: "s", + appUserId: "bot", + creatorId: "alice", + unsupportedSurface: true, + }); + await f.store.accept(event()); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).not.toHaveBeenCalled(); + expect(f.api.activity).toHaveBeenCalledWith("s", { + type: "error", + body: expect.stringContaining("no supported issue, project or document origin"), + }); + expect(f.inbox.complete).toHaveBeenCalledOnce(); + expect(f.inbox.retry).not.toHaveBeenCalled(); + }); + it("closes permanently invalid signed inputs honestly without retrying them forever", async () => { + const f = fixture(), + invalid = event(); + invalid.payload.agentSession = { id: "s", appUserId: "bot", organizationId: "org" }; + await f.store.accept(invalid); + await f.consumer.poll(); + await f.consumer.settled(); + expect(f.deps.dispatch).not.toHaveBeenCalled(); + expect(f.inbox.retry).not.toHaveBeenCalled(); + expect(f.api.activity).toHaveBeenCalledWith("s", expect.objectContaining({ type: "error" })); + expect(f.inbox.complete).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/channels/linear/consumer.ts b/src/channels/linear/consumer.ts new file mode 100644 index 000000000..ac366fc6a --- /dev/null +++ b/src/channels/linear/consumer.ts @@ -0,0 +1,258 @@ +import { applyLinearFiles, fileReferences } from "./files.js"; +import type { ChannelIO, IncomingMessage } from "../../core/types.js"; +import type { Clock } from "../../core/trace/types.js"; +import { LINEAR_TIMING } from "../../core/budgets.js"; +import { object, required, type LinearApi } from "./api.js"; +import type { LinearDelivery } from "./inbox.js"; +import { LinearChannelIO } from "./io.js"; +import { linearMessage, type LinearInput } from "./session.js"; +import type { LinearWebhookEvent } from "./webhook.js"; + +export interface LinearConsumerInbox { + claim(): Promise; + begin(key: string, lease: string): Promise; + bind(key: string, lease: string, runId: string): Promise; + renew(key: string, lease: string): Promise; + retry(key: string, lease: string): Promise; + defer(key: string, lease: string): Promise; + complete(key: string, lease: string): Promise; +} + +export interface LinearConsumerDeps { + inbox: LinearConsumerInbox; + api(organizationId: string): LinearApi; + clock: Clock; + warn(message: string): void; + maxStagedBytes?: number; + dispatch(msg: IncomingMessage, io: ChannelIO): Promise<{ deferred?: true } | void>; + stop(input: Extract, io: ChannelIO): Promise; + /** Proves admission from the ledger/history, not merely the runStarted hook. */ + recover(delivery: LinearDelivery, msg: IncomingMessage): Promise<"handled" | "unknown">; + other(event: LinearWebhookEvent): Promise; + leaseLost(runId: string): void; +} + +const INTERRUPTED = + "Switchboard restarted while accepting this request. I could not confirm its durable run, so I have not repeated it. Please check any effects and send a new request to continue."; + +/** Intake stays independent of a dispatch's long lifetime: another session, + * follow-up or stop can arrive while the first agent is working. The inbox + * marker covers even inline commands that never create a run record. */ +export class LinearConsumer { + private stopped = false; + private started = false; + private polling?: Promise; + private timer?: ReturnType; + private readonly active = new Set>(); + private readonly sessionTurns = new Map>(); + + constructor(private readonly deps: LinearConsumerDeps) {} + + start(): void { + if (this.started || this.stopped) return; + this.started = true; + const tick = async () => { + await this.poll(); + if (!this.stopped) this.timer = setTimeout(() => void tick(), LINEAR_TIMING.progressMs); + }; + void tick(); + } + + stop(): void { + this.stopped = true; + clearTimeout(this.timer); + } + + get pending(): number { + return this.active.size; + } + + async settled(): Promise { + await this.polling; + await Promise.all([...this.active]); + } + + poll(): Promise { + return (this.polling ??= this.claimAvailable().finally(() => { + this.polling = undefined; + })); + } + + private async claimAvailable(): Promise { + try { + // Bound one pass so a constantly replenished inbox yields to drain and + // the event loop. The dispatcher owns model concurrency and admission. + for (let count = 0; count < 32 && !this.stopped; count++) { + const delivery = await this.deps.inbox.claim(); + if (!delivery) break; + if (this.stopped) { + await this.deps.inbox.retry(delivery.event.key, delivery.lease); + break; + } + const work = this.consume(delivery).finally(() => this.active.delete(work)); + this.active.add(work); + } + } catch { + this.deps.warn("[linear] event intake unavailable; will retry"); + } + } + + private async consume(delivery: LinearDelivery): Promise { + const { inbox } = this.deps, + { event, lease } = delivery; + const sessionKey = + event.payload.type === "AgentSessionEvent" + ? `${event.payload.organizationId}:${String(object(event.payload.agentSession).id)}` + : undefined; + const isStop = object(event.payload.agentActivity).signal === "stop"; + const preceding = sessionKey && !isStop ? this.sessionTurns.get(sessionKey) : undefined; + let release!: (ready: boolean) => void; + let ready = false; + let dispatchSettled = false; + const admitted = new Promise((resolve) => { + release = resolve; + }); + if (sessionKey && !isStop) this.sessionTurns.set(sessionKey, admitted); + let owned = true, + runId = delivery.runId; + let renewal: Promise | undefined; + let binding: Promise = Promise.resolve(); + const lose = () => { + owned = false; + if (runId) this.deps.leaseLost(runId); + }; + const heartbeat = setInterval(() => { + if (renewal || !owned) return; + renewal = inbox + .renew(event.key, lease) + .then((ok) => { + if (!ok) lose(); + }, lose) + .finally(() => { + renewal = undefined; + }); + }, LINEAR_TIMING.deliveryLeaseMs / 3); + try { + if (event.payload.type !== "AgentSessionEvent") { + await this.deps.other(event); + } else { + // A runStarted hook follows the core's thread admission. Let the next + // turn steer it then, rather than racing setup or waiting for its end. + if (preceding && !(await preceding)) { + if (owned) await inbox.retry(event.key, lease); + return; + } + if (!owned) return; + const api = this.deps.api(required(event.payload.organizationId)); + const session = await api.session(required(object(event.payload.agentSession).id)); + if (event.payload.action === "created" && session.managedChild) { + // openThread's caller starts this child through the shared dispatcher. + // The creation webhook is notification, not a second request to run it. + if (owned) ready = await inbox.complete(event.key, lease); + return; + } + if (session.unsupportedSurface && !(isStop && event.payload.action === "prompted")) { + if (!session.dismissedAt) + await api.activity(session.id, { + type: "error", + body: "This Linear conversation has no supported issue, project or document origin. Please mention or delegate Switchboard on an issue to continue.", + }); + if (owned) ready = await inbox.complete(event.key, lease); + return; + } + let input: LinearInput; + try { + input = linearMessage(event, session, session.appUserId); + } catch { + if (!session.dismissedAt) + await api.activity(session.id, { + type: "error", + body: "Switchboard could not establish a supported request and its human sender. Please send a new mention or delegate this issue again.", + }); + if (owned) ready = await inbox.complete(event.key, lease); + return; + } + const io: ChannelIO = new LinearChannelIO({ + api, + sessionId: session.id, + appUserId: session.appUserId, + ...(input.kind === "message" ? { triggeringActivityId: input.triggeringActivityId } : {}), + initial: event.payload.action === "created", + clock: this.deps.clock, + warn: this.deps.warn, + }); + io.runStarted = async ({ id }) => { + const first = runId === undefined; + runId = id; + // Keep the first durable hint when the core replaces an interrupted + // run; recovery also searches the request id. Every replacement must + // still wait for that binding and stop if ownership was lost. + if (first) { + binding = inbox + .bind(event.key, lease, id) + .then((ok) => { + if (!ok) lose(); + }) + .catch(() => { + this.deps.warn("[linear] run binding unavailable"); + lose(); + }); + } + await binding; + if (!owned) { + this.deps.leaseLost(id); + return; + } + ready = true; + release(true); + }; + if (!owned) return; + if (input.kind === "stop") { + // Stopping is idempotent and must be retried when its acknowledgement + // is lost. Its own handler resolves the actor and checks policy. + await this.deps.stop(input, io); + } else if (delivery.begun) { + if ((await this.deps.recover(delivery, input.msg)) === "unknown") + await api.activity(session.id, { type: "error", body: INTERRUPTED }); + } else { + const urls = fileReferences(input.msg.text).map((ref) => ref.url); + if (urls.length) { + try { + input.msg = applyLinearFiles( + input.msg, + await api.files(session.id, input.msg.userId, urls, false, this.deps.maxStagedBytes), + ); + } catch (error) { + if (!(error instanceof Error) || error.message !== "linear_file_denied") throw error; + // Dispatch still checks current access and issues the refusal. If + // access returns in between, it must not claim the files were read. + input.msg.text += "\n\n[Attachments not read: the requester could not access this session.]"; + } + } + if (!(await inbox.begin(event.key, lease))) return; + if (!owned) return; + const outcome = await this.deps.dispatch(input.msg, io); + await binding; + dispatchSettled = true; + if (outcome?.deferred) { + if (runId) throw new Error("linear_deferred_after_run_started"); + if (owned) await inbox.defer(event.key, lease); + return; + } + } + } + // A dispatch that stopped after a failed binding still settled its work. + // Complete with the original lease: the inbox CAS refuses a newer owner, + // while a Stop-marked delivery we still own needs no uncertain replay. + if (owned || dispatchSettled) ready = await inbox.complete(event.key, lease); + } catch { + this.deps.warn("[linear] delivery unfinished; retained for recovery"); + if (owned) await inbox.retry(event.key, lease).catch(() => {}); + } finally { + release(ready); + if (sessionKey && this.sessionTurns.get(sessionKey) === admitted) this.sessionTurns.delete(sessionKey); + clearInterval(heartbeat); + await renewal; + } + } +} diff --git a/src/channels/linear/control.test.ts b/src/channels/linear/control.test.ts new file mode 100644 index 000000000..a50d8127f --- /dev/null +++ b/src/channels/linear/control.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it, vi } from "vitest"; +import { stopLinearSession } from "./control.js"; +import { RunRegistry } from "../../core/runRegistry.js"; +import { createRunsService } from "../../core/runsService.js"; +import { ALL_GRANTS, grantsFor } from "../../core/authz/grants.js"; +import { NO_GRANTS } from "../../core/authz/types.js"; +import { assembleRunRecord } from "../../core/dispatch/record.js"; +import { analyzeRunFriction } from "../../core/runFriction.js"; +import { InMemoryRunStore, NullRunStore } from "../../core/runStore.js"; +import { nullChannelIO } from "../../core/nullChannelIo.js"; + +describe("Linear stop authorization", () => { + it("authorizes durable queued cancellation through the same self and channel-wide stop rules", async () => { + const inbox = { cancelPending: vi.fn(async () => 1) }; + const runs = createRunsService({ registry: new RunRegistry(), store: new NullRunStore() }); + const input = { + kind: "stop" as const, + threadKey: "linear:org:s", + channelId: "linear:org:team", + userId: "linear:org:alice", + receivedAt: 100, + }; + const done = vi.fn(async () => {}); + const io = { + ...nullChannelIO("test"), + reply: vi.fn(async () => {}), + status: vi.fn(async () => ({ update: vi.fn(), done })), + }; + await stopLinearSession({ runs, inbox, config: { grantsFor: () => NO_GRANTS } }, input, io); + expect(inbox.cancelPending).not.toHaveBeenCalled(); + await stopLinearSession({ runs, inbox, config: { grantsFor: (id) => grantsFor(id, {}) } }, input, io); + expect(inbox.cancelPending).toHaveBeenLastCalledWith({ + organizationId: "org", + sessionId: "s", + userId: "alice", + receivedAt: 100, + }); + await stopLinearSession({ runs, inbox, config: { grantsFor: () => ALL_GRANTS } }, input, io); + expect(inbox.cancelPending).toHaveBeenLastCalledWith({ organizationId: "org", sessionId: "s", receivedAt: 100 }); + await stopLinearSession( + { + runs, + inbox, + config: { + grantsFor: () => ({ ...NO_GRANTS, actions: new Set(["runs:write"]), channels: new Set(["linear:org:team"]) }), + }, + }, + input, + io, + ); + expect(inbox.cancelPending).toHaveBeenLastCalledWith({ organizationId: "org", sessionId: "s", receivedAt: 100 }); + inbox.cancelPending.mockClear(); + await stopLinearSession( + { + runs, + inbox, + config: { + grantsFor: () => ({ + ...NO_GRANTS, + actions: new Set(["runs:write"]), + channels: new Set(["linear:org:other"]), + }), + }, + }, + input, + io, + ); + expect(inbox.cancelPending).not.toHaveBeenCalled(); + expect(io.reply).not.toHaveBeenCalled(); + expect(done).toHaveBeenCalledTimes(3); + }); + it("retries unavailable queued cancellation before acknowledging or stopping active work", async () => { + const inbox = { + cancelPending: vi.fn(async () => { + throw new Error("offline"); + }), + }; + const runs = createRunsService({ registry: new RunRegistry(), store: new NullRunStore() }); + const listing = vi.spyOn(runs, "listRuns"); + const io = { ...nullChannelIO("test"), reply: vi.fn(async () => {}) }; + await expect( + stopLinearSession( + { runs, inbox, config: { grantsFor: () => ALL_GRANTS } }, + { + kind: "stop", + threadKey: "linear:org:s", + channelId: "linear:org:team", + userId: "linear:org:alice", + receivedAt: 100, + }, + io, + ), + ).rejects.toThrow("offline"); + expect(listing).not.toHaveBeenCalled(); + expect(io.reply).not.toHaveBeenCalled(); + }); + it("cancels only the authorized current question and never closes a newer or another person's session", async () => { + const registry = new RunRegistry({ now: () => 150 }); + const store = new InMemoryRunStore({ now: () => 150 }); + await store.put( + assembleRunRecord({ + run: { id: "question" }, + snap: null, + msg: { channelId: "linear:org:team", userId: "linear:org:alice", threadKey: "linear:org:s" }, + channelVisibility: "private", + finishedAt: 100, + status: "completed", + awaitingInput: true, + diagnosis: analyzeRunFriction([]), + }), + ); + const runs = createRunsService({ registry, store }); + const deps = { runs, config: { grantsFor: (id: string) => grantsFor(id, {}) } }; + const input = { + kind: "stop" as const, + threadKey: "linear:org:s", + channelId: "linear:org:team", + userId: "linear:org:alice", + receivedAt: 101, + }; + const io = { ...nullChannelIO("test"), reply: vi.fn(async () => {}), status: vi.fn() }; + await stopLinearSession(deps, { ...input, userId: "linear:org:bob" }, io); + await stopLinearSession(deps, { ...input, receivedAt: 99 }, io); + expect(io.reply).not.toHaveBeenCalled(); + expect(io.status).not.toHaveBeenCalled(); + await stopLinearSession(deps, input, io); + expect(io.reply).toHaveBeenCalledExactlyOnceWith( + "Stopped waiting for input. Send a new prompt when you want to continue.", + ); + expect(await store.get("question")).toMatchObject({ status: "stopped_hard", inputStop: { at: 101 } }); + expect((await store.get("question"))?.awaitingInput).toBeUndefined(); + // Replaying the same delivery after a process restart can finish native delivery. + const restarted = createRunsService({ registry: new RunRegistry(), store }); + await stopLinearSession({ ...deps, runs: restarted }, input, io); + expect(io.reply).toHaveBeenCalledTimes(2); + io.reply.mockClear(); + registry.create(undefined, { threadKey: input.threadKey, channelId: input.channelId, userId: "linear:org:bob" }); + await stopLinearSession(deps, { ...input, receivedAt: 151 }, io); + expect(io.reply).not.toHaveBeenCalled(); + expect(io.status).not.toHaveBeenCalled(); + }); + it("retries a failed durable stop without closing the native question", async () => { + const store = new InMemoryRunStore({ now: () => 150 }); + await store.put( + assembleRunRecord({ + run: { id: "question" }, + snap: null, + msg: { channelId: "linear:org:team", userId: "linear:org:alice", threadKey: "linear:org:s" }, + channelVisibility: "private", + finishedAt: 100, + status: "completed", + awaitingInput: true, + diagnosis: analyzeRunFriction([]), + }), + ); + vi.spyOn(store, "stopWaiting").mockRejectedValueOnce(new Error("offline")); + const runs = createRunsService({ registry: new RunRegistry(), store }); + const io = { ...nullChannelIO("test"), reply: vi.fn(async () => {}) }; + await expect( + stopLinearSession( + { runs, config: { grantsFor: (id) => grantsFor(id, {}) } }, + { + kind: "stop", + userId: "linear:org:alice", + channelId: "linear:org:team", + threadKey: "linear:org:s", + receivedAt: 101, + }, + io, + ), + ).rejects.toThrow("offline"); + expect(io.reply).not.toHaveBeenCalled(); + expect((await store.get("question"))?.awaitingInput).toBe(true); + }); + it("does not emit a lifecycle-changing reply for denied or empty stops, and retries unavailable history", async () => { + const store = new InMemoryRunStore({ now: () => 150 }); + const runs = createRunsService({ registry: new RunRegistry(), store, warn: () => {} }); + const deps = { runs, config: { grantsFor: (id: string) => grantsFor(id, {}) } }; + const input = { + kind: "stop" as const, + threadKey: "linear:org:s", + channelId: "linear:org:team", + userId: "linear:org:alice", + receivedAt: 100, + }; + const io = { ...nullChannelIO("test"), reply: vi.fn(async () => {}), status: vi.fn() }; + await stopLinearSession(deps, input, io); + expect(io.reply).not.toHaveBeenCalled(); + expect(io.status).not.toHaveBeenCalled(); + vi.spyOn(store, "list").mockRejectedValueOnce(new Error("offline")); + await expect(stopLinearSession(deps, input, io)).rejects.toThrow("linear_stop_unavailable"); + expect(io.reply).not.toHaveBeenCalled(); + }); + it("lets a person stop their own session without operator or team-wide grants, but not another person's work", async () => { + const registry = new RunRegistry({ now: () => 100 }); + const own = registry.create(undefined, { + threadKey: "linear:org:s", + channelId: "linear:org:team", + userId: "linear:org:alice", + }); + const other = registry.create(undefined, { + threadKey: "linear:org:s", + channelId: "linear:org:team", + userId: "linear:org:bob", + }); + const runs = createRunsService({ registry, store: new NullRunStore() }); + await stopLinearSession( + { runs, config: { grantsFor: (id) => grantsFor(id, {}) } }, + { + kind: "stop", + threadKey: "linear:org:s", + channelId: "linear:org:team", + userId: "linear:org:alice", + receivedAt: 101, + }, + nullChannelIO("test"), + ); + expect(own.control.requested).toBe("hard"); + expect(other.control.requested).toBeUndefined(); + }); + it("requires the policy's write permission and scopes control to the signed session and arrival", async () => { + const registry = new RunRegistry({ now: () => 100 }); + const run = registry.create(undefined, { + threadKey: "linear:org:s", + channelId: "linear:org:team", + userId: "linear:org:alice", + }); + const other = registry.create(undefined, { + threadKey: "linear:org:other", + channelId: "linear:org:team", + userId: "linear:org:alice", + }); + const runs = createRunsService({ registry, store: new NullRunStore() }); + const input = { + kind: "stop" as const, + threadKey: "linear:org:s", + channelId: "linear:org:team", + userId: "linear:org:alice", + receivedAt: 101, + }; + const io = { ...nullChannelIO("test"), reply: vi.fn(async () => {}) }; + await stopLinearSession({ runs, config: { grantsFor: () => NO_GRANTS } }, input, io); + expect(run.control.requested).toBeUndefined(); + await stopLinearSession({ runs, config: { grantsFor: () => ALL_GRANTS } }, { ...input, receivedAt: 99 }, io); + expect(run.control.requested).toBeUndefined(); + await stopLinearSession({ runs, config: { grantsFor: () => ALL_GRANTS } }, input, io); + expect(run.control.requested).toBe("hard"); + expect(other.control.requested).toBeUndefined(); + }); +}); diff --git a/src/channels/linear/control.ts b/src/channels/linear/control.ts new file mode 100644 index 000000000..194a88a77 --- /dev/null +++ b/src/channels/linear/control.ts @@ -0,0 +1,104 @@ +import { chatActorOf } from "../../core/authz/actor.js"; +import { authorize } from "../../core/authz/authorize.js"; +import { predicateFor } from "../../core/authz/predicate.js"; +import { runResource, type RunsService } from "../../core/runsService.js"; +import type { ChannelIO } from "../../core/types.js"; +import { linearThread, type LinearInput } from "./session.js"; +import type { LinearInbox } from "./inbox.js"; + +/** Native cancellation asks the shared policy for the person's own run or an + * operator's visible run. Session access never implies team-wide membership. */ +export async function stopLinearSession( + deps: { config: Parameters[0]; runs: RunsService; inbox?: Pick }, + input: Extract, + io: ChannelIO, +): Promise { + const actor = chatActorOf(deps.config, input); + let pending = 0; + if (deps.inbox) { + const thread = linearThread(input.threadKey); + if (!thread || !input.userId.startsWith(`linear:${thread.organizationId}:`)) throw new Error("linear_invalid_stop"); + const resource = { + type: "run" as const, + id: input.threadKey, + channelId: input.channelId, + channelVisibility: "unknown" as const, + userId: input.userId, + }; + // With no owner match, only the policy's channel-wide operator rules can + // authorize all queued people. Session presence supplies no membership. + const all = authorize(actor, "runs:stop", { ...resource, userId: "" }).allow; + if (all || authorize(actor, "runs:stop", resource).allow) + pending = await deps.inbox.cancelPending({ + ...thread, + receivedAt: input.receivedAt, + ...(!all ? { userId: input.userId.slice(`linear:${thread.organizationId}:`.length) } : {}), + }); + } + const listing = await deps.runs.listRuns({ + status: "active", + threadKey: input.threadKey, + visibleTo: predicateFor(actor, "runs:read", "run"), + }); + let stopped = false; + for (const run of listing.runs) { + if (run.startedAt > input.receivedAt || !authorize(actor, "runs:stop", runResource(run)).allow) continue; + const result = await deps.runs.stopRun(run.id, "hard", { kind: "chat", id: actor.id }); + if (result.ok) stopped = true; + else if (result.error !== "conflict") throw new Error("linear_stop_unavailable"); + } + if (stopped) { + const status = await io.status({ + title: "Stopping", + detail: "Stop requested. Switchboard is cancelling this session's active work.", + }); + await status.done({ title: "Stopping" }); + return; + } + if (pending) { + // A terminal response here could close a different person's active session + // or a newer prompt. A thought acknowledges only the queued cancellation. + const status = await io.status({ + title: "Stopped queued work", + detail: "Cancelled pending requests before execution.", + }); + await status.done({ title: "Stopped queued work" }); + } + // Do not filter out another person's newer run: an older visible question + // must never let this Stop close their current session. Metadata stays here; + // the shared stop policy alone authorizes an effect, and nothing is disclosed. + const latest = await deps.runs.listRuns({ + status: "all", + threadKey: input.threadKey, + visibleTo: { kind: "all" }, + limit: 1, + }); + const run = latest.runs[0]; + if (latest.storeUnavailable) throw new Error("linear_stop_unavailable"); + if ( + !run || + !run.finished || + run.startedAt > input.receivedAt || + !authorize(actor, "runs:stop", runResource(run)).allow + ) + return; + // The final native activity can reach Linear just before its run record lands. + if (!run.persisted) throw new Error("linear_stop_unavailable"); + if ( + (run.awaitingInput || run.inputStop?.at === input.receivedAt) && + run.finishedAt !== undefined && + run.finishedAt <= input.receivedAt + ) { + const result = await deps.runs.stopRun( + run.id, + "hard", + { kind: "chat", id: actor.id }, + { receivedAt: input.receivedAt }, + ); + if (!result.ok) { + if (result.error === "conflict") return; + throw new Error("linear_stop_unavailable"); + } + await io.reply("Stopped waiting for input. Send a new prompt when you want to continue."); + } +} diff --git a/src/channels/linear/files.test.ts b/src/channels/linear/files.test.ts new file mode 100644 index 000000000..0b8d6e155 --- /dev/null +++ b/src/channels/linear/files.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi } from "vitest"; +import { fileReferences, downloadLinearFiles, applyLinearFiles, LINEAR_FILE_LIMITS } from "./files.js"; + +const png = "https://uploads.linear.app/org/image"; +const pdf = "https://uploads.linear.app/org/document"; +const text = "https://uploads.linear.app/org/code"; +const refs = fileReferences(`![screen.png](${png}) [guide.pdf](<${pdf}>) [example.ts](${text})`); + +describe("Linear private file ingestion", () => { + it("stages an image above the inline limit while leaving small images inline", async () => { + const size = LINEAR_FILE_LIMITS.imageBytes + 1; + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response("unused", { headers: { "content-type": "image/png", "content-length": String(size) } }), + ) + .mockResolvedValueOnce( + new Response("small", { headers: { "content-type": "image/png", "content-length": "5" } }), + ); + const files = await downloadLinearFiles([refs[0]!, { url: pdf, name: "small.png" }], { + fetch, + token: async () => "secret", + maxStagedBytes: size, + }); + expect(files[0]).toMatchObject({ staged: { size, type: "image/png" } }); + expect(files[0]?.image).toBeUndefined(); + expect(files[1]).toMatchObject({ image: { data: "c21hbGw=" } }); + }); + + it("keeps oversized and binary files as bounded staging references without reading their bodies", async () => { + const cancel = vi.fn(); + const fetch = vi.fn( + async () => + new Response(new ReadableStream({ cancel }), { + headers: { "content-type": "application/zip", "content-length": "100" }, + }), + ); + const file = { url: text, name: "source.zip" }; + const files = await downloadLinearFiles([file, { ...file, url: pdf }], { + fetch, + token: async () => "secret", + maxStagedBytes: 150, + }); + expect(files[0]).toMatchObject({ ...file, staged: { size: 100, type: "application/zip" } }); + expect(files[1]?.skipped).toContain("budget"); + expect(cancel).toHaveBeenCalledTimes(2); + expect(applyLinearFiles({ text: `[source.zip](${text})`, messageId: "prompt" }, files)).toMatchObject({ + staged: [{ ...file, size: 100, type: "application/zip", messageId: "prompt" }], + }); + }); + + it("encodes binary attachments in an edge runtime without Node Buffer", async () => { + const response = new Response(new Uint8Array([0, 1, 2, 255]), { headers: { "content-type": "image/png" } }); + vi.stubGlobal("Buffer", undefined); + try { + const files = await downloadLinearFiles([refs[0]!], { + fetch: vi.fn(async () => response), + token: async () => "secret", + }); + expect(files[0]?.image?.data).toBe("AAEC/w=="); + } finally { + vi.unstubAllGlobals(); + } + }); + it("bounds the total bytes across files and refuses declared oversize before reading", async () => { + const cancel = vi.fn(); + const payload = new Uint8Array(7 * 1024 * 1024); + const fetch = vi.fn( + async () => + new Response( + new ReadableStream({ + start(c) { + c.enqueue(payload); + }, + cancel, + }), + { headers: { "content-type": "text/plain" } }, + ), + ); + fetch.mockResolvedValueOnce(new Response(payload, { headers: { "content-type": "text/plain" } })); + const result = await downloadLinearFiles( + [ + { url: text, name: "one.txt" }, + { url: pdf, name: "two.txt" }, + ], + { fetch, token: async () => "secret" }, + ); + expect(result[0]?.document?.data.length).toBe(payload.length); + expect(result[1]?.skipped).toContain("limit"); + expect(cancel).toHaveBeenCalledOnce(); + const getReader = vi.fn(); + const response = new Response("small", { + headers: { "content-type": "image/png", "content-length": String(LINEAR_FILE_LIMITS.imageBytes + 1) }, + }); + response.body!.getReader = getReader; + fetch.mockResolvedValueOnce(response); + expect((await downloadLinearFiles([refs[0]!], { fetch, token: async () => "secret" }))[0]?.skipped).toContain( + "limit", + ); + expect(getReader).not.toHaveBeenCalled(); + }); + it.each(["", "en"])( + "rejects credential names from encoded headers (%s) and never follows redirects or authenticates another host", + async (language) => { + const fetch = vi.fn( + async () => + new Response("private", { + headers: { + "content-type": "application/pdf", + "content-disposition": `attachment; filename* = UTF-8'${language}'%2Eenv`, + }, + }), + ); + const result = await downloadLinearFiles( + [ + { url: text, name: "guide.pdf" }, + { url: "https://other.example/file", name: "file.txt" }, + ], + { fetch, token: async () => "secret" }, + ); + expect(result[0]?.skipped).toContain("credential"); + expect(result[1]?.skipped).toContain("invalid"); + expect(fetch).toHaveBeenCalledOnce(); + fetch.mockResolvedValueOnce( + new Response(null, { status: 302, headers: { location: "https://other.example/file" } }), + ); + expect((await downloadLinearFiles([refs[0]!], { fetch, token: async () => "secret" }))[0]?.skipped).toContain( + "not available", + ); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[1]?.[1]?.redirect).toBe("error"); + }, + ); + it("extracts canonical private references without accepting other origins or retaining signatures", () => { + expect( + fileReferences( + `![screen.png](${png}?signature=private) ${png} https://uploads.linear.app.evil.test/x https://uploads.linear.app@evil.test/x http://uploads.linear.app/x`, + ), + ).toEqual([{ url: png, name: "screen.png" }]); + expect(fileReferences("https://uploads.linear.app:8443/x")).toEqual([]); + }); + it("downloads images, PDFs and text through bounded authenticated requests and maps them onto the right turn", async () => { + const fetch = vi.fn( + async (url) => + new Response(url === text ? "const answer = 42;" : "bytes", { + headers: { "content-type": url === png ? "image/png" : url === pdf ? "application/pdf" : "text/plain" }, + }), + ); + const result = await downloadLinearFiles(refs, { fetch, token: async () => "edge-secret" }); + expect(fetch).toHaveBeenCalledTimes(3); + for (const [, init] of fetch.mock.calls) + expect(init).toMatchObject({ redirect: "error", headers: { authorization: "Bearer edge-secret" } }); + expect(result[0]).toMatchObject({ + url: png, + image: { name: "screen.png", mediaType: "image/png", data: "Ynl0ZXM=" }, + }); + expect(result[1]).toMatchObject({ + document: { name: "guide.pdf", mediaType: "application/pdf", data: "Ynl0ZXM=" }, + }); + expect(result[2]).toMatchObject({ document: { name: "example.ts", data: "const answer = 42;" } }); + const turn = applyLinearFiles({ text: `Read [source](${text})` }, result); + expect(turn.documents).toEqual([result[2]!.document]); + expect(turn.images).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain("edge-secret"); + }); + it("never reads credential-shaped files, unsupported bodies or over-budget streams", async () => { + const read = vi.fn(); + const fetch = vi.fn(async () => { + const response = new Response(new Uint8Array([1]), { + headers: { "content-type": "text/plain", "content-disposition": 'attachment; filename="credentials.json"' }, + }); + response.body!.getReader = read; + return response; + }); + const result = await downloadLinearFiles( + [ + { url: text, name: "innocent.txt" }, + { url: png, name: ".env" }, + ], + { fetch, token: async () => "secret" }, + ); + expect(fetch).toHaveBeenCalledTimes(1); + expect(read).not.toHaveBeenCalled(); + expect(result.every((r) => r.skipped?.includes("credential"))).toBe(true); + fetch.mockResolvedValueOnce(new Response("zip", { headers: { "content-type": "application/zip" } })); + expect( + (await downloadLinearFiles([{ url: text, name: "file.zip" }], { fetch, token: async () => "secret" }))[0] + ?.skipped, + ).toContain("unsupported"); + const cancel = vi.fn(); + fetch.mockResolvedValueOnce( + new Response( + new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array(LINEAR_FILE_LIMITS.imageBytes + 1)); + }, + cancel, + }), + { headers: { "content-type": "image/png" } }, + ), + ); + expect((await downloadLinearFiles([refs[0]!], { fetch, token: async () => "secret" }))[0]?.skipped).toContain( + "limit", + ); + expect(cancel).toHaveBeenCalled(); + }); + it("enforces count and shared byte limits, reports permanent omissions and retries transient failures", async () => { + const fetch = vi.fn( + async () => new Response("file", { headers: { "content-type": "text/plain" } }), + ); + const files = Array.from({ length: LINEAR_FILE_LIMITS.count + 1 }, (_, i) => ({ + url: `${text}${i}`, + name: `file${i}.txt`, + })); + const result = await downloadLinearFiles(files, { fetch, token: async () => "secret" }); + expect(fetch).toHaveBeenCalledTimes(LINEAR_FILE_LIMITS.count); + expect(result.at(-1)?.skipped).toContain("limit"); + fetch.mockResolvedValueOnce(new Response(null, { status: 404 })); + const missing = await downloadLinearFiles([refs[0]!], { fetch, token: async () => "secret" }); + expect(applyLinearFiles({ text: `![screen](${png})` }, missing).text).toContain("not available"); + for (const status of [429, 500]) { + fetch.mockResolvedValueOnce(new Response(null, { status })); + await expect(downloadLinearFiles([refs[0]!], { fetch, token: async () => "secret" })).rejects.toThrow( + "linear_file_unavailable", + ); + } + fetch.mockRejectedValueOnce(new Error("secret upstream URL")); + await expect(downloadLinearFiles([refs[0]!], { fetch, token: async () => "secret" })).rejects.toThrow( + "linear_file_unavailable", + ); + }); +}); diff --git a/src/channels/linear/files.ts b/src/channels/linear/files.ts new file mode 100644 index 000000000..6fd95b57b --- /dev/null +++ b/src/channels/linear/files.ts @@ -0,0 +1,314 @@ +import type { DocumentAttachment, ImageAttachment, StagedFile } from "../../core/types.js"; +import { ARTIFACT_DEFAULTS } from "../../artifacts/config.js"; +import { LINEAR_TIMING } from "../../core/budgets.js"; +import { INLINE_IMAGE_TYPES } from "../../artifacts/contentType.js"; +import { classifyDocument, isSecretFile } from "../attachmentTypes.js"; + +export const LINEAR_FILE_LIMITS = { + count: 10, + historyCount: 20, + imageBytes: 5 * 1024 * 1024, + documentBytes: 10 * 1024 * 1024, + totalBytes: 12 * 1024 * 1024, + stagedFileBytes: 1024 * 1024 * 1024, +} as const; +export interface LinearFileReference { + url: string; + name: string; +} +export interface LinearFile extends LinearFileReference { + image?: ImageAttachment; + document?: DocumentAttachment; + staged?: { size: number; type: string }; + skipped?: string; +} + +function privateUrl(value: string): string | undefined { + try { + const url = new URL(value); + if ( + url.protocol !== "https:" || + url.hostname !== "uploads.linear.app" || + url.port || + url.username || + url.password || + url.pathname === "/" + ) + return; + // Signed query strings are capabilities; use the edge credential instead. + url.search = ""; + url.hash = ""; + return url.href; + } catch { + return; + } +} +function filename(value: string): string { + return ( + [...value.split(/[\\/]/).at(-1)!] + .filter((char) => char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127) + .join("") + .slice(0, 255) || "attachment" + ); +} +function urlName(url: string): string { + try { + return filename(decodeURIComponent(new URL(url).pathname)); + } catch { + return "attachment"; + } +} + +/** Markdown links carry original filenames even when storage uses opaque ids. + * Bare URLs work too. External links never become authenticated downloads. */ +export function fileReferences(text: string): LinearFileReference[] { + const refs = new Map(); + for (const match of text.matchAll( + /!?\[([^\]\n]{0,255})\]\(\s*)]+)>?(?:\s+"[^"]*")?\s*\)/g, + )) { + const url = privateUrl(match[2]!); + if (url && !refs.has(url)) refs.set(url, { url, name: filename(match[1] || urlName(url)) }); + } + for (const match of text.matchAll(/https:\/\/uploads\.linear\.app[^\s<>"'\])]+/g)) { + const url = privateUrl(match[0].replace(/[.,;]+$/, "")); + if (url && !refs.has(url)) refs.set(url, { url, name: urlName(url) }); + } + return [...refs.values()]; +} + +function responseName(header: string | null): string | undefined { + if (!header) return; + const encoded = /filename\*\s*=\s*UTF-8'[^']*'([^;]+)/i.exec(header)?.[1]; + if (encoded) { + try { + return filename(decodeURIComponent(encoded)); + } catch { + return; + } + } + const plain = /filename\s*=\s*(?:"([^"]*)"|([^;]+))/i.exec(header); + return plain ? filename((plain[1] ?? plain[2]!).trim()) : undefined; +} + +/** Streaming limits apply even when Content-Length is absent or dishonest. */ +async function readLimited(response: Response, limit: number): Promise { + const length = Number(response.headers.get("content-length")); + if (length > limit) { + await response.body?.cancel(); + return; + } + const reader = response.body?.getReader(); + if (!reader) return new Uint8Array(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + await reader.cancel(); + return; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +/** Web APIs only: the credential-holding Worker does not enable Node globals. + * Chunk conversion also avoids spreading a multi-megabyte file onto the stack. */ +function base64(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 32 * 1024; + for (let offset = 0; offset < bytes.length; offset += chunkSize) + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + return btoa(binary); +} + +/** Called only after the edge has proved these references belong to the + * requester's current session context. The OAuth token never leaves this call. */ +export async function downloadLinearFiles( + refs: readonly LinearFileReference[], + deps: { fetch: typeof fetch; token(): Promise; maxStagedBytes?: number }, + count: number = LINEAR_FILE_LIMITS.count, +): Promise { + const files: LinearFile[] = []; + let total = 0; + let stagedBytes = 0; + const stagingBudget = Math.min(deps.maxStagedBytes ?? 0, LINEAR_FILE_LIMITS.stagedFileBytes * count); + for (const [index, ref] of refs.entries()) { + const file: LinearFile = { ...ref }; + files.push(file); + if (privateUrl(ref.url) !== ref.url) { + file.skipped = "invalid private file URL"; + continue; + } + if (isSecretFile(ref.name) || isSecretFile(urlName(ref.url))) { + file.skipped = "looks like a credential or key file"; + continue; + } + if (index >= count || (total >= LINEAR_FILE_LIMITS.totalBytes && !stagingBudget)) { + file.skipped = "attachment budget limit"; + continue; + } + try { + const response = await deps.fetch(ref.url, { + redirect: "error", + signal: AbortSignal.timeout(LINEAR_TIMING.apiTimeoutMs), + headers: { authorization: `Bearer ${await deps.token()}` }, + }); + if (response.status === 429 || response.status >= 500) { + await response.body?.cancel(); + throw new Error("retry"); + } + if (!response.ok) { + await response.body?.cancel(); + file.skipped = "not available from Linear"; + continue; + } + file.name = responseName(response.headers.get("content-disposition")) ?? ref.name; + if (isSecretFile(file.name)) { + await response.body?.cancel(); + file.skipped = "looks like a credential or key file"; + continue; + } + const mediaType = (response.headers.get("content-type") ?? "application/octet-stream") + .split(";")[0]! + .trim() + .toLowerCase(); + const image = INLINE_IMAGE_TYPES.has(mediaType); + const kind = classifyDocument(mediaType, file.name); + const declared = Number(response.headers.get("content-length")); + const max = Math.min( + image ? LINEAR_FILE_LIMITS.imageBytes : LINEAR_FILE_LIMITS.documentBytes, + LINEAR_FILE_LIMITS.totalBytes - total, + ); + if (stagingBudget && ((!image && !kind) || declared > max)) { + await response.body?.cancel(); + if (!Number.isSafeInteger(declared) || declared <= 0) file.skipped = "no size for workspace staging"; + else if (declared > LINEAR_FILE_LIMITS.stagedFileBytes || stagedBytes + declared > stagingBudget) + file.skipped = "workspace staging byte budget"; + else { + stagedBytes += declared; + file.staged = { size: declared, type: mediaType }; + } + continue; + } + if (!image && !kind) { + await response.body?.cancel(); + file.skipped = "unsupported inline file type"; + continue; + } + const bytes = await readLimited(response, max); + if (!bytes) { + file.skipped = "attachment byte limit"; + continue; + } + total += bytes.byteLength; + const data = image || kind === "pdf" ? base64(bytes) : new TextDecoder().decode(bytes); + if (image) file.image = { name: file.name, mediaType, data }; + else file.document = { name: file.name, mediaType, data }; + } catch { + throw new Error("linear_file_unavailable"); + } + } + return files; +} + +export function applyLinearFiles< + T extends { + text: string; + messageId?: string; + images?: ImageAttachment[]; + documents?: DocumentAttachment[]; + staged?: StagedFile[]; + }, +>( + turn: T, + files: readonly LinearFile[], +): T & { images?: ImageAttachment[]; documents?: DocumentAttachment[]; staged?: StagedFile[] } { + const urls = new Set(fileReferences(turn.text).map((ref) => ref.url)); + const selected = files.filter((file) => urls.has(file.url)); + const images = selected.flatMap((file) => (file.image ? [file.image] : [])); + const documents = selected.flatMap((file) => (file.document ? [file.document] : [])); + const staged = selected.flatMap((file) => + file.staged && turn.messageId + ? [{ name: file.name, url: file.url, ...file.staged, messageId: turn.messageId }] + : [], + ); + const skipped = selected.filter((file) => file.skipped).map((file) => `${file.name}: ${file.skipped}`); + return { + ...turn, + ...(images.length ? { images: [...(turn.images ?? []), ...images] } : {}), + ...(documents.length ? { documents: [...(turn.documents ?? []), ...documents] } : {}), + ...(staged.length ? { staged: [...(turn.staged ?? []), ...staged] } : {}), + ...(skipped.length ? { text: `${turn.text}\n\n[Attachments not read: ${skipped.join("; ")}]` } : {}), + }; +} + +export interface LinearFileCopy { + put(key: string, stream: ReadableStream, type: string): Promise; + lengthPipe(size: number): { readable: ReadableStream; writable: WritableStream }; +} + +/** The edge streams a freshly authorized reference into the artifact store. + * The caller binds the key to the session; no credential or bytes leave here. */ +export async function copyLinearFile( + ref: LinearFileReference, + file: StagedFile, + key: string, + deps: { fetch: typeof fetch; token(): Promise; copy: LinearFileCopy; signal?: AbortSignal }, +): Promise<{ key: string; size: number }> { + if ( + privateUrl(file.url) !== file.url || + file.url !== ref.url || + isSecretFile(ref.name) || + isSecretFile(urlName(file.url)) || + isSecretFile(file.name) + ) + throw new Error("linear_file_denied"); + deps.signal?.throwIfAborted(); + const controller = new AbortController(); + const signal = AbortSignal.any([ + controller.signal, + AbortSignal.timeout(ARTIFACT_DEFAULTS.copyTimeoutMs), + ...(deps.signal ? [deps.signal] : []), + ]); + const response = await deps.fetch(file.url, { + redirect: "error", + signal, + headers: { authorization: `Bearer ${await deps.token()}` }, + }); + const name = responseName(response.headers.get("content-disposition")) ?? ref.name; + const size = Number(response.headers.get("content-length")); + if (response.status !== 200 || !response.body || size !== file.size || name !== file.name || isSecretFile(name)) { + await response.body?.cancel(); + throw new Error("linear_file_changed_or_unavailable"); + } + const pipe = deps.copy.lengthPipe(file.size); + const pumping = response.body.pipeTo(pipe.writable, { signal }); + try { + await Promise.all([ + pumping, + deps.copy.put(key, pipe.readable, response.headers.get("content-type") ?? "application/octet-stream"), + ]); + } catch { + controller.abort(); + // A put can fail before it starts reading. Release the pipe's backpressure + // too, so its pending write cannot keep the authenticated download alive. + await pipe.readable.cancel().catch(() => {}); + await pumping.catch(() => {}); + await response.body.cancel().catch(() => {}); + throw new Error("linear_file_copy_failed"); + } + return { key, size: file.size }; +} diff --git a/src/channels/linear/inbox.test.ts b/src/channels/linear/inbox.test.ts new file mode 100644 index 000000000..d75671c4e --- /dev/null +++ b/src/channels/linear/inbox.test.ts @@ -0,0 +1,285 @@ +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, it } from "vitest"; +import { InMemoryLinearInbox, SqlLinearInbox, type LinearSql } from "./inbox.js"; +import type { LinearWebhookEvent } from "./webhook.js"; + +const event: LinearWebhookEvent = { + key: "org:session:created", + receivedAt: 100, + payload: { organizationId: "org", type: "AgentSessionEvent", action: "created", agentSession: { id: "session" } }, +}; +const databases: DatabaseSync[] = []; +afterEach(() => { + for (const db of databases.splice(0)) db.close(); +}); + +function sqlStore() { + const db = new DatabaseSync(":memory:"); + databases.push(db); + const sql: LinearSql = { + exec>(query: string, ...params: (string | number | null)[]) { + const rows = db.prepare(query).all(...params) as T[]; + return { toArray: () => rows }; + }, + }; + return { sql, inbox: new SqlLinearInbox(sql) }; +} + +for (const kind of ["memory", "sqlite"] as const) { + describe(`Linear event inbox — ${kind}`, () => { + const make = () => (kind === "memory" ? new InMemoryLinearInbox() : sqlStore().inbox); + it("refuses run admission after Stop while retaining the uncertain delivery for recovery", async () => { + const inbox = make(); + await inbox.accept({ + ...event, + payload: { ...event.payload, agentSession: { id: "session", creatorId: "alice" } }, + }); + await inbox.claim(200, 100, "first"); + await inbox.begin(event.key, "first"); + await inbox.cancelPending({ organizationId: "org", sessionId: "session", userId: "alice", receivedAt: 210 }); + expect(await inbox.bind(event.key, "first", "late-run")).toBe(false); + const replay = await inbox.claim(400, 100, "replacement"); + expect(replay).toMatchObject({ begun: true, event: { key: event.key } }); + expect(replay?.runId).toBeUndefined(); + expect(await inbox.bind(event.key, "replacement", "another-run")).toBe(false); + }); + it("retains Stop across an in-flight no-effects deferral without cancelling another requester", async () => { + const inbox = make(); + const preparing = { + ...event, + payload: { ...event.payload, agentSession: { id: "session", creatorId: "alice" } }, + }; + await inbox.accept(preparing); + await inbox.claim(200, 100, "first"); + await inbox.begin(event.key, "first"); + expect( + await inbox.cancelPending({ organizationId: "org", sessionId: "session", userId: "bob", receivedAt: 200 }), + ).toBe(0); + expect(await inbox.defer(event.key, "first", 210)).toBe(true); + expect((await inbox.claim(210, 100, "second"))?.event.key).toBe(event.key); + await inbox.begin(event.key, "second"); + // A begun turn is not yet safe to delete: it may have already acted. + expect( + await inbox.cancelPending({ organizationId: "org", sessionId: "session", userId: "alice", receivedAt: 220 }), + ).toBe(0); + expect(await inbox.renew(event.key, "second", 400)).toBe(true); + expect(await inbox.defer(event.key, "first", 230)).toBe(false); + expect(await inbox.defer(event.key, "second", 230)).toBe(true); + expect(await inbox.claim(1000, 100, "later")).toBeUndefined(); + expect(await inbox.accept(preparing)).toBe(false); + }); + it("cancels only older unbegun requests in the authorized session and invalidates their leases", async () => { + const inbox = make(); + const pending = (key: string, userId = "alice", receivedAt = 100, sessionId = "s", organizationId = "org") => ({ + key, + receivedAt, + payload: { + type: "AgentSessionEvent", + action: "prompted", + organizationId, + agentSession: { id: sessionId }, + agentActivity: { userId, content: { type: "prompt", body: "work" } }, + }, + }); + await inbox.accept(pending("copy")); + expect((await inbox.claim(200, 100, "copy-lease"))?.event.key).toBe("copy"); + await inbox.accept(pending("queued")); + await inbox.accept(pending("other-person", "bob")); + await inbox.accept(pending("later", "alice", 151)); + await inbox.accept(pending("other-session", "alice", 100, "other")); + await inbox.accept(pending("other-org", "alice", 100, "s", "other")); + const stop = pending("stop"); + Object.assign(stop.payload.agentActivity, { signal: "stop" }); + await inbox.accept(stop); + expect( + await inbox.cancelPending({ organizationId: "org", sessionId: "s", receivedAt: 150, userId: "alice" }), + ).toBe(2); + expect(await inbox.begin("copy", "copy-lease")).toBe(false); + expect(await inbox.retry("copy", "copy-lease", 200)).toBe(false); + expect(await inbox.renew("copy", "copy-lease", 500)).toBe(false); + expect(await inbox.accept(pending("copy"))).toBe(false); + expect( + await inbox.cancelPending({ organizationId: "org", sessionId: "s", receivedAt: 150, userId: "alice" }), + ).toBe(0); + expect((await inbox.claim(200, 100, "bob"))?.event.key).toBe("other-person"); + expect(await inbox.begin("other-person", "bob")).toBe(true); + expect(await inbox.cancelPending({ organizationId: "org", sessionId: "s", receivedAt: 200 })).toBe(1); + expect(await inbox.bind("other-person", "bob", "run")).toBe(false); + expect((await inbox.claim(200, 100, "next"))?.event.key).toBe("other-session"); + expect((await inbox.claim(200, 100, "next-org"))?.event.key).toBe("other-org"); + expect((await inbox.claim(200, 100, "control"))?.event.key).toBe("stop"); + }); + it("defers only an unbound claim, preserves FIFO and lets stop bypass waiting prompts", async () => { + const inbox = make(); + await inbox.accept(event); + await inbox.accept({ ...event, key: "follow" }); + await inbox.claim(200, 100, "a"); + await inbox.begin(event.key, "a"); + expect(await inbox.defer(event.key, "wrong", 400)).toBe(false); + expect(await inbox.defer(event.key, "a", 400)).toBe(true); + expect(await inbox.claim(300, 100, "b")).toBeUndefined(); + await inbox.accept({ + ...event, + key: "stop", + payload: { ...event.payload, action: "prompted", agentActivity: { signal: "stop" } }, + }); + expect((await inbox.claim(300, 100, "stop"))?.event.key).toBe("stop"); + await inbox.complete("stop", "stop", 300); + const resumed = await inbox.claim(400, 100, "c"); + expect(resumed?.event.key).toBe(event.key); + expect(resumed?.begun).toBeUndefined(); + await inbox.begin(event.key, "c"); + await inbox.bind(event.key, "c", "run"); + expect(await inbox.defer(event.key, "c", 500)).toBe(false); + }); + it("commits once, claims in arrival order and refuses concurrent consumers until lease expiry", async () => { + const inbox = make(); + expect(await inbox.accept(event)).toBe(true); + expect(await inbox.accept(event)).toBe(false); + await inbox.accept({ ...event, key: "org:session:prompted:p1", receivedAt: 101 }); + const first = await inbox.claim(200, 1000, "lease-a"); + expect(first).toMatchObject({ event, lease: "lease-a", attempts: 1 }); + await inbox.begin(event.key, "lease-a"); + expect((await inbox.claim(200, 1000, "lease-b"))?.event.key).toBe("org:session:prompted:p1"); + expect(await inbox.claim(1199, 1000, "lease-c")).toBeUndefined(); + expect(await inbox.claim(1200, 1000, "lease-c")).toMatchObject({ event, lease: "lease-c", attempts: 2 }); + expect(await inbox.complete(event.key, "lease-a", 1300)).toBe(false); + expect(await inbox.complete(event.key, "lease-c", 1300)).toBe(true); + expect(await inbox.accept(event)).toBe(false); + }); + it("persists the run binding, renews ownership and requeues transient failures", async () => { + const inbox = make(); + await inbox.accept(event); + await inbox.claim(200, 100, "a"); + expect(await inbox.begin(event.key, "wrong")).toBe(false); + expect(await inbox.begin(event.key, "a")).toBe(true); + expect(await inbox.bind(event.key, "wrong", "run")).toBe(false); + expect(await inbox.bind(event.key, "a", "run")).toBe(true); + expect(await inbox.renew(event.key, "a", 500)).toBe(true); + expect(await inbox.claim(499, 100, "b")).toBeUndefined(); + expect(await inbox.retry(event.key, "a", 600)).toBe(true); + expect(await inbox.claim(599, 100, "b")).toBeUndefined(); + expect(await inbox.claim(600, 100, "b")).toMatchObject({ runId: "run", begun: true, attempts: 2 }); + expect(await inbox.renew(event.key, "a", 900)).toBe(false); + }); + it("prunes only completed delivery tombstones, keeping pending work", async () => { + const inbox = make(); + await inbox.accept(event); + await inbox.claim(200, 100, "a"); + await inbox.complete(event.key, "a", 250); + await inbox.accept({ ...event, key: "pending" }); + await inbox.prune(251); + expect(await inbox.accept(event)).toBe(true); + expect((await inbox.claim(300, 100, "b"))?.event.key).toBe("pending"); + }); + it("cancels revoked workspace requests while preserving control events and other installations", async () => { + const inbox = make(); + await inbox.accept(event); + await inbox.claim(200, 100, "a"); + await inbox.accept({ ...event, key: "other", payload: { ...event.payload, organizationId: "other" } }); + await inbox.accept({ + ...event, + key: "revoke", + payload: { ...event.payload, type: "OAuthApp", action: "revoked" }, + }); + await inbox.cancelOrganization("org", 250); + expect(await inbox.renew(event.key, "a", 500)).toBe(false); + expect(await inbox.accept(event)).toBe(false); + expect((await inbox.claim(300, 100, "b"))?.event.key).toBe("other"); + expect((await inbox.claim(300, 100, "c"))?.event.key).toBe("revoke"); + }); + it("holds dispatch and later turns until acknowledgement succeeds, retaining failed acknowledgements", async () => { + const inbox = make(); + await inbox.accept(event, { acknowledge: true }); + await inbox.accept({ ...event, key: "follow" }); + expect(await inbox.hasPendingAcks()).toBe(true); + expect(await inbox.claim(200, 100, "consumer")).toBeUndefined(); + expect(await inbox.claimAck(200, 100, "edge")).toMatchObject({ event, lease: "edge" }); + expect(await inbox.acknowledge(event.key, "wrong", 201)).toBe(false); + expect(await inbox.retry(event.key, "edge", 250)).toBe(true); + expect(await inbox.claimAck(249, 100, "retry")).toBeUndefined(); + expect(await inbox.claimAck(250, 100, "retry")).toMatchObject({ event }); + expect(await inbox.acknowledge(event.key, "retry", 251)).toBe(true); + expect(await inbox.hasPendingAcks()).toBe(false); + expect(await inbox.claim(251, 100, "consumer")).toMatchObject({ event }); + expect(await inbox.claim(251, 100, "follow")).toBeUndefined(); + await inbox.begin(event.key, "consumer"); + expect((await inbox.claim(251, 100, "follow"))?.event.key).toBe("follow"); + }); + it("does not let a retrying pre-dispatch request be overtaken in its session", async () => { + const inbox = make(); + await inbox.accept(event); + await inbox.accept({ ...event, key: "follow" }); + await inbox.accept({ ...event, key: "other", payload: { ...event.payload, agentSession: { id: "other" } } }); + await inbox.claim(200, 100, "first"); + await inbox.retry(event.key, "first", 400); + expect((await inbox.claim(300, 100, "next"))?.event.key).toBe("other"); + expect(await inbox.claim(300, 100, "later")).toBeUndefined(); + expect((await inbox.claim(400, 100, "retry"))?.event.key).toBe(event.key); + }); + }); +} + +describe("durable Linear event recovery", () => { + it("adds the deferred-stop field to an existing queue without cancelling its live delivery", async () => { + const { sql, inbox } = sqlStore(); + await inbox.accept(event); + await inbox.claim(200, 100, "old"); + await inbox.begin(event.key, "old"); + sql.exec("ALTER TABLE linear_deliveries DROP COLUMN defer_stop_at"); + const migrated = new SqlLinearInbox(sql); + expect(await migrated.claim(300, 100, "new")).toMatchObject({ event, begun: true, attempts: 2 }); + expect(await migrated.defer(event.key, "new", 310)).toBe(true); + expect(await migrated.claim(310, 100, "retry")).toMatchObject({ event }); + }); + it("preserves the queued Stop decision across a retry, a new lease, and host replacement", async () => { + const { sql, inbox } = sqlStore(); + const preparing = { ...event, payload: { ...event.payload, agentSession: { id: "session", creatorId: "alice" } } }; + await inbox.accept(preparing); + await inbox.claim(200, 100, "old"); + await inbox.begin(event.key, "old"); + await inbox.cancelPending({ organizationId: "org", sessionId: "session", userId: "alice", receivedAt: 200 }); + await inbox.retry(event.key, "old", 210); + const restored = new SqlLinearInbox(sql); + expect(await restored.claim(210, 100, "new")).toMatchObject({ begun: true }); + expect(await restored.defer(event.key, "old", 220)).toBe(false); + expect(await restored.defer(event.key, "new", 220)).toBe(true); + expect(await new SqlLinearInbox(sql).claim(1000, 100, "replay")).toBeUndefined(); + }); + it("retains a cancelled preparation across restart and invalidates a pending acknowledgement", async () => { + const { sql, inbox } = sqlStore(); + const created = { ...event, payload: { ...event.payload, agentSession: { id: "session", creatorId: "alice" } } }; + await inbox.accept(created, { acknowledge: true }); + await inbox.claimAck(200, 100, "ack"); + expect( + await inbox.cancelPending({ organizationId: "org", sessionId: "session", userId: "alice", receivedAt: 200 }), + ).toBe(1); + const restored = new SqlLinearInbox(sql); + expect(await restored.acknowledge(created.key, "ack", 210)).toBe(false); + expect(await restored.begin(created.key, "ack")).toBe(false); + expect(await restored.hasPendingAcks()).toBe(false); + expect(await restored.claim(500, 100, "new")).toBeUndefined(); + expect(await restored.accept(created)).toBe(false); + }); + it("restores an unfinished acknowledgement before making its request dispatchable", async () => { + const { sql, inbox } = sqlStore(); + await inbox.accept(event, { acknowledge: true }); + await inbox.claimAck(200, 100, "old"); + const restored = new SqlLinearInbox(sql); + expect(await restored.claim(300, 100, "consumer")).toBeUndefined(); + expect(await restored.claimAck(300, 100, "new")).toMatchObject({ event, attempts: 2 }); + expect(await restored.acknowledge(event.key, "old", 301)).toBe(false); + expect(await restored.acknowledge(event.key, "new", 301)).toBe(true); + expect(await restored.claim(301, 100, "consumer")).toMatchObject({ event }); + }); + it("restores a leased event and its run after host replacement without truncating large context", async () => { + const { sql, inbox } = sqlStore(); + const large = { ...event, payload: { ...event.payload, promptContext: "説明".repeat(80_000) } }; + await inbox.accept(large); + await inbox.claim(200, 100, "old"); + await inbox.bind(event.key, "old", "run"); + const restored = new SqlLinearInbox(sql); + expect(await restored.claim(299, 100, "new")).toBeUndefined(); + expect(await restored.claim(300, 100, "new")).toMatchObject({ event: large, runId: "run" }); + }); +}); diff --git a/src/channels/linear/inbox.ts b/src/channels/linear/inbox.ts new file mode 100644 index 000000000..9cef3736c --- /dev/null +++ b/src/channels/linear/inbox.ts @@ -0,0 +1,474 @@ +import type { LinearWebhookEvent } from "./webhook.js"; +import { object } from "./api.js"; + +export interface LinearDelivery { + event: LinearWebhookEvent; + lease: string; + attempts: number; + /** Set before entering dispatch, including commands without a run record. */ + begun?: boolean; + /** A reconciliation hint, not proof that ledger admission succeeded. */ + runId?: string; +} + +/** Selection authorized by the bot's shared run-stop policy. An omitted user + * covers the session; an explicit user is its signed, unnamespaced sender. */ +export interface LinearPendingStop { + organizationId: string; + sessionId: string; + receivedAt: number; + userId?: string; +} + +export interface LinearInbox { + accept(event: LinearWebhookEvent, options?: { acknowledge?: boolean }): Promise; + claim(now: number, leaseMs: number, lease: string): Promise; + claimAck(now: number, leaseMs: number, lease: string): Promise; + acknowledge(key: string, lease: string, at: number): Promise; + hasPendingAcks(): Promise; + begin(key: string, lease: string): Promise; + bind(key: string, lease: string, runId: string): Promise; + renew(key: string, lease: string, until: number): Promise; + retry(key: string, lease: string, at: number): Promise; + /** Clear dispatch entry only after an explicit no-effects admission deferral. */ + defer(key: string, lease: string, at: number): Promise; + complete(key: string, lease: string, at: number): Promise; + cancelOrganization(organizationId: string, at: number): Promise; + /** Completes unbegun requests and remembers Stop for a later no-effects + * deferral. Returns only requests completed now, not uncertain dispatches. */ + cancelPending(input: LinearPendingStop): Promise; + prune(completedBefore: number): Promise; +} + +/** Cloudflare's SQLite cursor, narrowed so real SQLite can exercise the same queries locally. */ +export interface LinearSql { + exec>( + query: string, + ...params: (string | number | null)[] + ): { toArray(): T[] }; +} + +type Row = { + event_key: string; + payload: string; + received_at: number; + lease: string; + attempts: number; + run_id: string | null; + begun: number; +}; + +/** Each mutation is one atomic SQL statement. Payloads live in SQLite, not + * Durable Object KV values, whose smaller ceiling would truncate issue context. */ +export class SqlLinearInbox implements LinearInbox { + constructor(private readonly sql: LinearSql) { + sql.exec(`CREATE TABLE IF NOT EXISTS linear_deliveries ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_key TEXT NOT NULL UNIQUE, + payload TEXT, + received_at INTEGER NOT NULL, + phase TEXT NOT NULL DEFAULT 'pending', + available_at INTEGER NOT NULL DEFAULT 0, + lease TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + run_id TEXT, + begun INTEGER NOT NULL DEFAULT 0, + defer_stop_at INTEGER, + finished_at INTEGER + )`); + if ( + !sql + .exec("PRAGMA table_info(linear_deliveries)") + .toArray() + .some((column) => column.name === "begun") + ) + sql.exec("ALTER TABLE linear_deliveries ADD COLUMN begun INTEGER NOT NULL DEFAULT 0"); + if ( + !sql + .exec("PRAGMA table_info(linear_deliveries)") + .toArray() + .some((column) => column.name === "defer_stop_at") + ) + sql.exec("ALTER TABLE linear_deliveries ADD COLUMN defer_stop_at INTEGER"); + sql.exec( + "CREATE INDEX IF NOT EXISTS linear_deliveries_pending ON linear_deliveries(phase, available_at, sequence)", + ); + } + + async accept(event: LinearWebhookEvent, options: { acknowledge?: boolean } = {}): Promise { + return ( + this.sql + .exec( + "INSERT INTO linear_deliveries (event_key, payload, received_at, phase) VALUES (?, ?, ?, ?) ON CONFLICT(event_key) DO NOTHING RETURNING event_key", + event.key, + JSON.stringify(event.payload), + event.receivedAt, + options.acknowledge ? "pending_ack" : "pending", + ) + .toArray().length === 1 + ); + } + + claim(now: number, leaseMs: number, lease: string): Promise { + return this.take(now, leaseMs, lease, false); + } + claimAck(now: number, leaseMs: number, lease: string): Promise { + return this.take(now, leaseMs, lease, true); + } + private async take(now: number, leaseMs: number, lease: string, ack: boolean): Promise { + const [row] = this.sql + .exec( + `UPDATE linear_deliveries + SET phase = ?, lease = ?, available_at = ?, attempts = attempts + 1 + WHERE sequence = (SELECT candidate.sequence FROM linear_deliveries candidate + WHERE candidate.phase IN (?, ?) AND candidate.available_at <= ? + AND (? = 1 OR json_extract(candidate.payload, '$.type') != 'AgentSessionEvent' + OR json_extract(candidate.payload, '$.agentActivity.signal') = 'stop' OR NOT EXISTS ( + SELECT 1 FROM linear_deliveries prior WHERE prior.sequence < candidate.sequence + AND prior.phase != 'done' AND prior.begun = 0 + AND json_extract(prior.payload, '$.type') = 'AgentSessionEvent' + AND json_extract(prior.payload, '$.organizationId') = json_extract(candidate.payload, '$.organizationId') + AND json_extract(prior.payload, '$.agentSession.id') = json_extract(candidate.payload, '$.agentSession.id') + )) ORDER BY candidate.sequence LIMIT 1) + RETURNING event_key, payload, received_at, lease, attempts, run_id, begun`, + ack ? "processing_ack" : "processing", + lease, + now + leaseMs, + ack ? "pending_ack" : "pending", + ack ? "processing_ack" : "processing", + now, + ack ? 1 : 0, + ) + .toArray(); + if (!row) return undefined; + return { + event: { + key: row.event_key, + receivedAt: row.received_at, + payload: JSON.parse(row.payload) as LinearWebhookEvent["payload"], + }, + lease: row.lease, + attempts: row.attempts, + ...(row.begun ? { begun: true } : {}), + ...(row.run_id ? { runId: row.run_id } : {}), + }; + } + + async acknowledge(key: string, lease: string, at: number): Promise { + return ( + this.sql + .exec( + "UPDATE linear_deliveries SET phase = 'pending', lease = NULL, available_at = ? WHERE event_key = ? AND lease = ? AND phase = 'processing_ack' RETURNING event_key", + at, + key, + lease, + ) + .toArray().length === 1 + ); + } + async hasPendingAcks(): Promise { + return ( + this.sql + .exec("SELECT 1 FROM linear_deliveries WHERE phase IN ('pending_ack', 'processing_ack') LIMIT 1") + .toArray().length > 0 + ); + } + + async begin(key: string, lease: string): Promise { + return ( + this.sql + .exec( + "UPDATE linear_deliveries SET begun = 1 WHERE event_key = ? AND lease = ? AND phase = 'processing' AND begun = 0 RETURNING event_key", + key, + lease, + ) + .toArray().length === 1 + ); + } + + async bind(key: string, lease: string, runId: string): Promise { + return ( + this.sql + .exec( + "UPDATE linear_deliveries SET run_id = ? WHERE event_key = ? AND lease = ? AND phase = 'processing' AND defer_stop_at IS NULL AND (run_id IS NULL OR run_id = ?) RETURNING event_key", + runId, + key, + lease, + runId, + ) + .toArray().length === 1 + ); + } + async renew(key: string, lease: string, until: number): Promise { + return ( + this.sql + .exec( + "UPDATE linear_deliveries SET available_at = MAX(available_at, ?) WHERE event_key = ? AND lease = ? AND phase = 'processing' RETURNING event_key", + until, + key, + lease, + ) + .toArray().length === 1 + ); + } + async defer(key: string, lease: string, at: number): Promise { + return ( + this.sql + .exec( + `UPDATE linear_deliveries SET + phase = CASE WHEN defer_stop_at IS NULL THEN 'pending' ELSE 'done' END, + payload = CASE WHEN defer_stop_at IS NULL THEN payload ELSE NULL END, + finished_at = CASE WHEN defer_stop_at IS NULL THEN finished_at ELSE ? END, + begun = 0, lease = NULL, available_at = ? + WHERE event_key = ? AND lease = ? AND phase = 'processing' AND run_id IS NULL RETURNING event_key`, + at, + at, + key, + lease, + ) + .toArray().length === 1 + ); + } + async retry(key: string, lease: string, at: number): Promise { + return ( + this.sql + .exec( + "UPDATE linear_deliveries SET phase = CASE phase WHEN 'processing_ack' THEN 'pending_ack' ELSE 'pending' END, lease = NULL, available_at = ? WHERE event_key = ? AND lease = ? AND phase IN ('processing', 'processing_ack') RETURNING event_key", + at, + key, + lease, + ) + .toArray().length === 1 + ); + } + async complete(key: string, lease: string, at: number): Promise { + return ( + this.sql + .exec( + "UPDATE linear_deliveries SET phase = 'done', payload = NULL, lease = NULL, finished_at = ? WHERE event_key = ? AND lease = ? AND phase IN ('processing', 'processing_ack') RETURNING event_key", + at, + key, + lease, + ) + .toArray().length === 1 + ); + } + async prune(completedBefore: number): Promise { + this.sql.exec("DELETE FROM linear_deliveries WHERE phase = 'done' AND finished_at < ?", completedBefore); + } + async cancelOrganization(organizationId: string, at: number): Promise { + this.sql.exec( + `UPDATE linear_deliveries SET phase = 'done', payload = NULL, lease = NULL, finished_at = ? + WHERE phase != 'done' AND received_at <= ? AND json_extract(payload, '$.organizationId') = ? + AND json_extract(payload, '$.type') = 'AgentSessionEvent'`, + at, + at, + organizationId, + ); + } + async cancelPending(input: LinearPendingStop): Promise { + return this.sql + .exec( + `UPDATE linear_deliveries SET + phase = CASE WHEN begun = 0 THEN 'done' ELSE phase END, + payload = CASE WHEN begun = 0 THEN NULL ELSE payload END, + lease = CASE WHEN begun = 0 THEN NULL ELSE lease END, + finished_at = CASE WHEN begun = 0 THEN ? ELSE finished_at END, + defer_stop_at = COALESCE(defer_stop_at, ?) + WHERE phase != 'done' AND run_id IS NULL AND received_at <= ? + AND json_extract(payload, '$.type') = 'AgentSessionEvent' + AND json_extract(payload, '$.organizationId') = ? + AND json_extract(payload, '$.agentSession.id') = ? + AND COALESCE(json_extract(payload, '$.agentActivity.signal'), '') != 'stop' + AND json_extract(payload, '$.action') IN ('created', 'prompted') + AND (? IS NULL OR CASE json_extract(payload, '$.action') + WHEN 'created' THEN json_extract(payload, '$.agentSession.creatorId') + WHEN 'prompted' THEN json_extract(payload, '$.agentActivity.userId') END = ?) + RETURNING begun`, + input.receivedAt, + input.receivedAt, + input.receivedAt, + input.organizationId, + input.sessionId, + input.userId ?? null, + input.userId ?? null, + ) + .toArray() + .filter((row) => row.begun === 0).length; + } +} + +type MemoryRow = { + event?: LinearWebhookEvent; + phase: "pending" | "processing" | "pending_ack" | "processing_ack" | "done"; + availableAt: number; + lease?: string; + attempts: number; + begun?: boolean; + /** Stop cannot erase an uncertain dispatch; a later no-effects deferral can. */ + deferStopAt?: number; + runId?: string; + finishedAt?: number; +}; + +export class InMemoryLinearInbox implements LinearInbox { + private readonly rows = new Map(); + + async accept(event: LinearWebhookEvent, options: { acknowledge?: boolean } = {}): Promise { + if (this.rows.has(event.key)) return false; + this.rows.set(event.key, { + event: structuredClone(event), + phase: options.acknowledge ? "pending_ack" : "pending", + availableAt: 0, + attempts: 0, + }); + return true; + } + claim(now: number, leaseMs: number, lease: string): Promise { + return this.take(now, leaseMs, lease, false); + } + claimAck(now: number, leaseMs: number, lease: string): Promise { + return this.take(now, leaseMs, lease, true); + } + private async take(now: number, leaseMs: number, lease: string, ack: boolean): Promise { + const blocked = new Set(); + for (const row of this.rows.values()) { + if (row.phase === "done" || !row.event) continue; + const session = + row.event.payload.type === "AgentSessionEvent" + ? `${row.event.payload.organizationId}:${String(object(row.event.payload.agentSession).id)}` + : undefined; + const follows = + session !== undefined && blocked.has(session) && object(row.event.payload.agentActivity).signal !== "stop"; + if (session && !row.begun) blocked.add(session); + if ( + row.availableAt > now || + (ack + ? !["pending_ack", "processing_ack"].includes(row.phase) + : follows || !["pending", "processing"].includes(row.phase)) + ) + continue; + row.phase = ack ? "processing_ack" : "processing"; + row.lease = lease; + row.availableAt = now + leaseMs; + row.attempts++; + return { + event: structuredClone(row.event), + lease, + attempts: row.attempts, + ...(row.begun ? { begun: true } : {}), + ...(row.runId ? { runId: row.runId } : {}), + }; + } + return undefined; + } + private owned(key: string, lease: string, ack = false): MemoryRow | undefined { + const row = this.rows.get(key); + return (row?.phase === "processing" || (ack && row?.phase === "processing_ack")) && row.lease === lease + ? row + : undefined; + } + async acknowledge(key: string, lease: string, at: number): Promise { + const row = this.owned(key, lease, true); + if (row?.phase !== "processing_ack") return false; + row.phase = "pending"; + row.lease = undefined; + row.availableAt = at; + return true; + } + async hasPendingAcks(): Promise { + return [...this.rows.values()].some((row) => row.phase === "pending_ack" || row.phase === "processing_ack"); + } + async begin(key: string, lease: string): Promise { + const row = this.owned(key, lease); + if (!row || row.begun) return false; + row.begun = true; + return true; + } + async bind(key: string, lease: string, runId: string): Promise { + const row = this.owned(key, lease); + if (!row || row.deferStopAt !== undefined || (row.runId && row.runId !== runId)) return false; + row.runId = runId; + return true; + } + async renew(key: string, lease: string, until: number): Promise { + const row = this.owned(key, lease); + if (!row) return false; + row.availableAt = Math.max(row.availableAt, until); + return true; + } + async defer(key: string, lease: string, at: number): Promise { + const row = this.owned(key, lease); + if (!row || row.runId) return false; + row.begun = false; + if (row.deferStopAt !== undefined) return this.complete(key, lease, at); + row.phase = "pending"; + row.lease = undefined; + row.availableAt = at; + return true; + } + async retry(key: string, lease: string, at: number): Promise { + const row = this.owned(key, lease, true); + if (!row) return false; + row.phase = row.phase === "processing_ack" ? "pending_ack" : "pending"; + row.lease = undefined; + row.availableAt = at; + return true; + } + async complete(key: string, lease: string, at: number): Promise { + const row = this.owned(key, lease, true); + if (!row) return false; + row.phase = "done"; + row.event = undefined; + row.lease = undefined; + row.finishedAt = at; + return true; + } + async prune(completedBefore: number): Promise { + for (const [key, row] of this.rows) + if (row.phase === "done" && row.finishedAt !== undefined && row.finishedAt < completedBefore) + this.rows.delete(key); + } + async cancelOrganization(organizationId: string, at: number): Promise { + for (const row of this.rows.values()) { + if ( + row.event?.payload.organizationId !== organizationId || + row.event.payload.type !== "AgentSessionEvent" || + row.event.receivedAt > at + ) + continue; + row.phase = "done"; + row.event = undefined; + row.lease = undefined; + row.finishedAt = at; + } + } + async cancelPending(input: LinearPendingStop): Promise { + let cancelled = 0; + for (const row of this.rows.values()) { + const event = row.event; + if (!event || row.runId || row.phase === "done" || event.receivedAt > input.receivedAt) continue; + const payload = event.payload, + session = object(payload.agentSession), + activity = object(payload.agentActivity); + if ( + payload.type !== "AgentSessionEvent" || + payload.organizationId !== input.organizationId || + session.id !== input.sessionId || + activity.signal === "stop" || + !["created", "prompted"].includes(String(payload.action)) + ) + continue; + const user = payload.action === "created" ? session.creatorId : activity.userId; + if (input.userId !== undefined && input.userId !== user) continue; + row.deferStopAt ??= input.receivedAt; + if (row.begun) continue; + row.phase = "done"; + row.event = undefined; + row.lease = undefined; + row.finishedAt = input.receivedAt; + cancelled++; + } + return cancelled; + } +} diff --git a/src/channels/linear/io.test.ts b/src/channels/linear/io.test.ts new file mode 100644 index 000000000..5cc6dac2c --- /dev/null +++ b/src/channels/linear/io.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it, vi } from "vitest"; +import { LinearChannelIO } from "./io.js"; +import type { LinearApi } from "./api.js"; + +function fixture() { + let now = 100; + const api: LinearApi = { + openThread: vi.fn(), + workItems: vi.fn(), + files: vi.fn(async () => []), + canRead: vi.fn(async () => true), + upload: vi.fn(), + session: vi.fn(async () => ({ id: "s", appUserId: "bot" })), + activities: vi.fn(async () => []), + activity: vi.fn(async () => {}), + link: vi.fn(async () => {}), + }; + const io = new LinearChannelIO({ api, sessionId: "s", appUserId: "bot", clock: () => now, warn: vi.fn() }); + return { + api, + io, + tick: () => { + now += 10_000; + }, + }; +} + +describe("Linear channel output", () => { + it("binds attachment copies to the checked requester and verifies the completed copy", async () => { + const { api, io } = fixture(); + const file = { + url: "https://uploads.linear.app/org/data", + name: "data.zip", + size: 100, + type: "application/zip", + messageId: "prompt", + }; + const key = "threads/linear-org-s/in/prompt/1-data.zip"; + const copy = vi.fn(async () => ({ key, size: 100 })); + api.copyAttachment = copy; + await expect(io.copyAttachment(file, key)).rejects.toThrow("linear_staging_unavailable"); + await io.checkAccess("linear:org:alice"); + await io.copyAttachment(file, key); + expect(copy).toHaveBeenCalledWith("s", "linear:org:alice", file, key, undefined); + copy.mockResolvedValueOnce({ key, size: 99 }); + await expect(io.copyAttachment(file, key)).rejects.toThrow("linear_file_copy_incomplete"); + vi.mocked(api.canRead).mockResolvedValue(false); + await io.checkAccess("linear:org:bob"); + await expect(io.copyAttachment(file, key)).rejects.toThrow("linear_staging_unavailable"); + expect(copy).toHaveBeenCalledTimes(2); + }); + it("opens an isolated native child with the checked requester and a stable coordinator key", async () => { + const { api, io } = fixture(); + vi.mocked(api.openThread).mockResolvedValue({ + organizationId: "org", + sessionId: "child", + url: "https://linear.app/session/child", + }); + await expect(io.openThread("Review")).rejects.toThrow("linear_child_requester_required"); + expect(api.openThread).not.toHaveBeenCalled(); + await io.checkAccess("linear:org:alice"); + const child = await io.openThread("Review", { idempotencyKey: "instance/unit/review" }); + expect(child.thread).toEqual({ threadKey: "linear:org:child", sourceUrl: "https://linear.app/session/child" }); + const first = vi.mocked(api.openThread).mock.calls[0]![2].id; + await io.openThread("Review", { idempotencyKey: "instance/unit/review" }); + expect(vi.mocked(api.openThread).mock.calls[1]![2].id).toBe(first); + await io.openThread("Review", { idempotencyKey: "instance/other/review" }); + expect(vi.mocked(api.openThread).mock.calls[2]![2].id).not.toBe(first); + await child.io.reply("Child result"); + expect(api.activity).toHaveBeenLastCalledWith("child", { type: "response", body: "Child result" }, undefined); + vi.mocked(api.canRead).mockResolvedValue(false); + await io.checkAccess("linear:org:bob"); + await expect(io.openThread("Review")).rejects.toThrow("linear_child_requester_required"); + }); + it("restores an initial mention's comment attachment when no user activity was created", async () => { + const { api, io } = fixture(); + const url = "https://uploads.linear.app/org/log"; + vi.mocked(api.session).mockResolvedValue({ + id: "s", + appUserId: "bot", + issue: { id: "issue", identifier: "EX-1", title: "Fix it", teamId: "team" }, + comment: { body: `Inspect [log.txt](${url})` }, + }); + vi.mocked(api.activities).mockResolvedValue([ + { id: "a", at: 1, userId: "bot", type: "response", body: "I found the error." }, + ]); + vi.mocked(api.files).mockResolvedValue([ + { url, name: "log.txt", document: { mediaType: "text/plain", data: "error details" } }, + ]); + await io.checkAccess("linear:org:alice"); + const history = await io.history(); + expect(history[0]).toMatchObject({ + role: "user", + text: expect.stringContaining("Inspect [log.txt]"), + documents: [{ mediaType: "text/plain", data: "error details" }], + }); + }); + it("rebuilds private history files for the checked requester, newest first without duplicating bytes", async () => { + const { api, io } = fixture(); + const url = "https://uploads.linear.app/org/image"; + vi.mocked(api.activities).mockResolvedValue([ + { id: "old", at: 1, userId: "alice", type: "prompt", body: `![old](${url})` }, + { id: "answer", at: 2, userId: "bot", type: "response", body: "I see it." }, + { id: "new", at: 3, userId: "alice", type: "prompt", body: `![new](${url})` }, + ]); + vi.mocked(api.files).mockResolvedValue([ + { url, name: "screen.png", image: { mediaType: "image/png", data: "bytes" } }, + ]); + await expect(io.history()).rejects.toThrow("linear_file_requester_required"); + expect(api.files).not.toHaveBeenCalled(); + await io.checkAccess("linear:org:alice"); + const history = await io.history(); + expect(api.files).toHaveBeenCalledWith("s", "linear:org:alice", [url], true); + expect(history[2]?.images).toEqual([{ mediaType: "image/png", data: "bytes" }]); + expect(history[0]?.images).toBeUndefined(); + vi.mocked(api.canRead).mockResolvedValue(false); + await io.checkAccess("linear:org:bob"); + await expect(io.history()).rejects.toThrow("linear_file_requester_required"); + }); + it("preserves an opening question when delegation created no initial user activity", async () => { + const { api, io } = fixture(); + vi.mocked(api.session).mockResolvedValue({ + id: "s", + appUserId: "bot", + issue: { id: "issue", identifier: "EX-1", title: "Fix the build", teamId: "team" }, + }); + vi.mocked(api.activities).mockResolvedValue([ + { id: "q", at: 1, userId: "bot", type: "elicitation", body: "Which repository?" }, + ]); + expect(await io.history()).toEqual([ + { role: "user", text: "Linear issue EX-1: Fix the build\n\n" }, + { role: "assistant", text: "Which repository?", at: 1 }, + ]); + }); + it("asks for clarification as elicitation without a completion response", async () => { + const { api, io } = fixture(); + await io.question("Which repository?"); + expect(api.activity).toHaveBeenCalledExactlyOnceWith( + "s", + { type: "elicitation", body: "Which repository?" }, + undefined, + ); + }); + it("checks access for the transport requester on the bound session", async () => { + const { api, io } = fixture(); + vi.mocked(api.canRead).mockResolvedValueOnce(false); + expect(await io.checkAccess("linear:org:person")).toBe(false); + expect(api.canRead).toHaveBeenCalledWith("s", "linear:org:person"); + }); + it("acknowledges a steered follow-up without announcing that the active run has completed", async () => { + const { api, io } = fixture(); + await io.acknowledge("Your follow-up reached the active run."); + expect(api.activity).toHaveBeenCalledWith( + "s", + { type: "thought", body: "Your follow-up reached the active run." }, + undefined, + ); + }); + it("binds work-item identity and intersected grants outside the tool's input", async () => { + const { api, io } = fixture(); + const grants = { + actions: new Set(["work-items:read", "work-items:write"]), + channels: "all" as const, + repos: "all" as const, + }; + const capability = io.workItems({ + kind: "user", + id: "linear:org:person", + grants, + onBehalfOf: { + kind: "user", + id: "linear:org:other", + grants: { ...grants, actions: new Set(["work-items:read"]) }, + }, + }); + await capability.request({ op: "get", id: "ENG-1" }); + expect(api.workItems).toHaveBeenCalledWith( + "s", + { id: "linear:org:person", actions: ["work-items:read"] }, + { op: "get", id: "ENG-1" }, + ); + }); + it("shares an uploaded image as native progress without closing the session", async () => { + const { api, io } = fixture(); + vi.mocked(api.upload).mockResolvedValue({ + uploadUrl: "https://storage.example/file", + assetUrl: "https://uploads.linear.app/file", + headers: { "Content-Disposition": "attachment" }, + }); + const ticket = await io.uploadTicket({ name: "plot[1].png", size: 3 }); + expect(ticket).toMatchObject({ method: "PUT", headers: { "Content-Disposition": "attachment" } }); + expect(api.activity).not.toHaveBeenCalled(); + await ticket.complete("The plot"); + expect(api.activity).toHaveBeenCalledWith( + "s", + { type: "thought", body: "The plot\n\n![plot\\[1\\].png]()" }, + undefined, + ); + }); + it("uploads inline bytes with signed headers and never shares a failed upload", async () => { + const { api } = fixture(); + vi.mocked(api.upload).mockResolvedValue({ + uploadUrl: "https://storage.example/file", + assetUrl: "https://uploads.linear.app/file", + headers: { "Content-Type": "text/plain" }, + }); + const uploadFetch = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 403 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + const io = new LinearChannelIO({ api, sessionId: "s", clock: () => 1, warn: vi.fn(), uploadFetch }); + const file = { name: "report.txt", bytes: new Uint8Array([1, 2, 3]), lead: "Report" }; + await expect(io.attachFile(file)).rejects.toThrow("linear_upload_failed"); + expect(api.activity).not.toHaveBeenCalled(); + await io.attachFile(file); + expect(uploadFetch).toHaveBeenCalledWith( + "https://storage.example/file", + expect.objectContaining({ + method: "PUT", + redirect: "error", + headers: { "Content-Type": "text/plain" }, + body: file.bytes, + }), + ); + expect(api.activity).toHaveBeenCalledWith( + "s", + { type: "thought", body: "Report\n\n[report.txt]()" }, + undefined, + ); + }); + it("coalesces progress, adds the run link, and sends the answer as a native response", async () => { + const { io, api, tick } = fixture(); + const status = await io.status({ title: "Working", link: { url: "https://bot.example/runs/r", label: "Run" } }); + status.update({ title: "Working", activity: { kind: "line", text: "Reading" } }); + status.update({ title: "Working", activity: { kind: "line", text: "Testing" } }); + tick(); + status.update({ title: "Working", activity: { kind: "command", tool: "bash", command: "npm test" } }); + await status.done({ title: "Done" }); + await io.reply("Tests pass. [PR](https://github.com/acme/api/pull/1)"); + expect(api.link).toHaveBeenCalledWith("s", { url: "https://bot.example/runs/r", label: "Run" }); + expect(api.activity).toHaveBeenCalledWith( + "s", + { type: "action", action: "bash", parameter: "npm test" }, + { ephemeral: true }, + ); + expect(vi.mocked(api.activity).mock.calls.at(-1)?.[1]).toEqual({ + type: "response", + body: "Tests pass. [PR](https://github.com/acme/api/pull/1)", + }); + expect(api.activity).toHaveBeenCalledTimes(3); + }); + it("recovers conversation from immutable activities before the current prompt only", async () => { + const { api } = fixture(); + vi.mocked(api.activities).mockResolvedValue([ + { id: "p1", at: 1, userId: "alice", type: "prompt", body: "first" }, + { id: "noise", at: 2, userId: "bot", type: "thought", body: "thinking" }, + { id: "a1", at: 3, userId: "bot", type: "elicitation", body: "Which repo?" }, + { id: "a-tied-future", at: 4, userId: "alice", type: "prompt", body: "a simultaneous later prompt" }, + { id: "current", at: 4, userId: "bob", type: "prompt", body: "acme/api" }, + { id: "future", at: 5, userId: "alice", type: "prompt", body: "also fix logout" }, + ]); + const io = new LinearChannelIO({ + api, + sessionId: "s", + appUserId: "bot", + triggeringActivityId: "current", + clock: () => 100, + warn: vi.fn(), + }); + expect(await io.history()).toEqual([ + { role: "user", text: "first", at: 1 }, + { role: "assistant", text: "Which repo?", at: 3 }, + ]); + vi.mocked(api.activities).mockResolvedValue([]); + await expect(io.history()).rejects.toThrow("linear_prompt_not_visible"); + }); + it("posts explicit clarification and failed-run responses with their native activity types", async () => { + const { io, api } = fixture(); + await io.offer!({ + id: "confirm", + line: "repo onboard acme/api", + risk: "Changes deployment", + footer: "team", + expiresAt: 1000, + }); + expect(vi.mocked(api.activity).mock.calls[0]?.[1]).toMatchObject({ type: "elicitation" }); + io.runFinished!({ id: "r", status: "failed" }); + await io.reply("The tests failed."); + expect(vi.mocked(api.activity).mock.calls.at(-1)?.[1]).toEqual({ type: "error", body: "The tests failed." }); + }); + it("keeps a failed progress update from preventing final delivery but propagates a failed final reply", async () => { + const { io, api } = fixture(); + vi.mocked(api.activity).mockRejectedValueOnce(new Error("unavailable")); + await io.status({ title: "Working" }); + await io.reply("Done"); + vi.mocked(api.activity).mockRejectedValueOnce(new Error("unavailable")); + await expect(io.reply("Another answer")).rejects.toThrow("unavailable"); + }); +}); diff --git a/src/channels/linear/io.ts b/src/channels/linear/io.ts new file mode 100644 index 000000000..5db19fba0 --- /dev/null +++ b/src/channels/linear/io.ts @@ -0,0 +1,288 @@ +import { linearSessionContext } from "./session.js"; +import { applyLinearFiles, fileReferences } from "./files.js"; +import type { + ChannelIO, + ConfirmationOffer, + HistoryItem, + RunReceipt, + StatusHandle, + StatusUpdate, + UploadTicket, + OpenedThread, + StagedFile, +} from "../../core/types.js"; +import type { Clock } from "../../core/trace/types.js"; +import { LINEAR_TIMING } from "../../core/budgets.js"; +import type { LinearApi, LinearContent } from "./api.js"; +import { contentTypeFor, INLINE_IMAGE_TYPES } from "../../artifacts/contentType.js"; +import { effectiveGrants } from "../../core/authz/authorize.js"; +import type { Actor } from "../../core/authz/types.js"; +import type { WorkItems } from "../../core/workItems.js"; + +/** One native session is one Switchboard conversation. No Slack formatting, + * comment scraping or installation credential crosses this boundary. */ +export class LinearChannelIO implements ChannelIO { + readonly isolateFollowUps = true; + private writes: Promise = Promise.resolve(); + private receipt?: RunReceipt; + private requesterId?: string; + private lastProgress = -Infinity; + private lastContent = ""; + private linked = new Set(); + + constructor( + private readonly deps: { + api: LinearApi; + sessionId: string; + /** Reconstructed handles resolve the app user from the current installation. */ + appUserId?: string; + triggeringActivityId?: string; + /** A new session's prompt already contains its issue and thread context. */ + initial?: boolean; + clock: Clock; + warn(message: string): void; + uploadFetch?: typeof fetch; + }, + ) {} + + async checkAccess(userId: string): Promise { + this.requesterId = undefined; + const allowed = await this.deps.api.canRead(this.deps.sessionId, userId); + if (allowed) this.requesterId = userId; + return allowed; + } + + workItems(actor: Actor): WorkItems { + const actions = effectiveGrants(actor).actions; + const identity = { id: actor.id, actions: actions === "all" ? ("all" as const) : [...actions] }; + return { request: (input) => this.deps.api.workItems(this.deps.sessionId, identity, input) }; + } + + async openThread(lead: string, options?: { idempotencyKey: string }): Promise { + const requester = this.requesterId; + if (!requester) throw new Error("linear_child_requester_required"); + let id: string = crypto.randomUUID(); + if (options) { + if (!options.idempotencyKey || options.idempotencyKey.length > 8192) throw new Error("linear_invalid_child_key"); + // A coordinator can repeat the same durable step on a rebuilt handle. + // Scope its key to the requesting person and parent session. + const bytes = new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(JSON.stringify([this.deps.sessionId, requester, options.idempotencyKey])), + ), + ).slice(0, 16); + bytes[6] = (bytes[6]! & 15) | 64; + bytes[8] = (bytes[8]! & 63) | 128; + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + id = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + const child = await this.deps.api.openThread(this.deps.sessionId, requester, { id, lead }); + return { + thread: { + threadKey: `linear:${child.organizationId}:${child.sessionId}`, + ...(child.url ? { sourceUrl: child.url } : {}), + }, + io: new LinearChannelIO({ + api: this.deps.api, + sessionId: child.sessionId, + appUserId: this.deps.appUserId, + clock: this.deps.clock, + warn: this.deps.warn, + uploadFetch: this.deps.uploadFetch, + }), + }; + } + + private enqueue(work: () => Promise): Promise { + const result = this.writes.then(work); + // The caller sees a failed reply; a later reply can still recover. Progress + // errors are observed here too, never an unhandled rejection from update(). + this.writes = result.catch(() => this.deps.warn("[linear] session output failed")); + return result; + } + + private async send(content: LinearContent, ephemeral = false): Promise { + await this.deps.api.activity(this.deps.sessionId, content, ephemeral ? { ephemeral: true } : undefined); + } + + async reply(text: string): Promise { + const type = this.receipt?.status === "failed" ? "error" : "response"; + await this.enqueue(async () => { + for (const body of chunks(text)) await this.send({ type, body }); + }); + } + + async acknowledge(text: string): Promise { + await this.enqueue(() => this.send({ type: "thought", body: text })); + } + + async attach(file: { name: string; text: string; lead: string }): Promise { + await this.reply(`${file.lead}\n\n**${file.name}**\n\n${file.text}`); + } + + async copyAttachment(file: StagedFile, key: string, signal?: AbortSignal): Promise { + if (!this.requesterId || !this.deps.api.copyAttachment) throw new Error("linear_staging_unavailable"); + const copied = await this.deps.api.copyAttachment(this.deps.sessionId, this.requesterId, file, key, signal); + if (copied.key !== key || copied.size !== file.size) throw new Error("linear_file_copy_incomplete"); + } + + async uploadTicket(file: { name: string; size: number }): Promise { + const upload = await this.deps.api.upload(this.deps.sessionId, file); + // The signed storage capability is limited to one file. Linear's OAuth + // credential never reaches the host or the executor uploading the bytes. + return { + url: upload.uploadUrl, + method: "PUT", + headers: upload.headers, + complete: (lead) => + this.enqueue(() => { + const label = file.name.replace(/[\\[\]]/g, "\\$&"); + const url = upload.assetUrl.replace(/[<>\r\n]/g, (char) => encodeURIComponent(char)); + const link = `${INLINE_IMAGE_TYPES.has(contentTypeFor(file.name)) ? "!" : ""}[${label}](<${url}>)`; + return this.send({ type: "thought", body: `${lead}\n\n${link}` }); + }), + }; + } + + async attachFile(file: { name: string; bytes: Uint8Array; lead: string }): Promise { + const ticket = await this.uploadTicket({ name: file.name, size: file.bytes.byteLength }); + let response: Response; + try { + response = await (this.deps.uploadFetch ?? fetch)(ticket.url, { + method: "PUT", + redirect: "error", + headers: ticket.headers, + body: new Uint8Array(file.bytes), + signal: AbortSignal.timeout(LINEAR_TIMING.apiTimeoutMs), + }); + } catch { + throw new Error("linear_upload_failed"); + } + if (!response.ok) throw new Error("linear_upload_failed"); + await ticket.complete(file.lead); + } + + async offer(offer: ConfirmationOffer): Promise { + await this.elicit( + [offer.risk, `Reply with this command to confirm:\n\n\`${offer.line}\``, offer.footer] + .filter(Boolean) + .join("\n\n"), + ); + } + + question(body: string): Promise { + return this.elicit(body); + } + + async elicit(body: string): Promise { + await this.enqueue(() => this.send({ type: "elicitation", body })); + } + + private progress(frame: StatusUpdate): void { + if (frame.link && !this.linked.has(frame.link.url)) { + const link = frame.link; + this.linked.add(link.url); + void this.enqueue(() => this.deps.api.link(this.deps.sessionId, link)).catch(() => this.linked.delete(link.url)); + } + const activity = frame.activity; + const content: LinearContent = + activity?.kind === "command" + ? { type: "action", action: activity.tool, parameter: activity.command.slice(0, 8000) } + : { + type: "thought", + body: [frame.title, frame.detail, activity?.text].filter(Boolean).join("\n\n").slice(0, 8000), + }; + const rendered = JSON.stringify(content), + now = this.deps.clock(); + if (rendered === this.lastContent || now - this.lastProgress < LINEAR_TIMING.progressMs) return; + this.lastProgress = now; + this.lastContent = rendered; + void this.enqueue(() => this.send(content, true)).catch(() => { + this.lastContent = ""; + }); + } + + async status(initial: StatusUpdate): Promise { + this.progress(initial); + await this.writes; + return { + update: (frame) => this.progress(frame), + // The final response itself closes the session; no premature success + // activity while the reply is still being delivered. + done: async () => { + await this.writes; + }, + }; + } + + runFinished(receipt: RunReceipt): void { + this.receipt = receipt; + } + + async history(): Promise { + if (this.deps.initial) return []; + const appUserId = this.deps.appUserId ?? (await this.deps.api.session(this.deps.sessionId)).appUserId; + let activities = await this.deps.api.activities(this.deps.sessionId); + if (this.deps.triggeringActivityId) { + const at = activities.findIndex((activity) => activity.id === this.deps.triggeringActivityId); + if (at < 0) throw new Error("linear_prompt_not_visible"); + // IDs break sorting ties, not causality. Exclude simultaneous activities + // too, rather than incorporating a not-yet-dispatched prompt by UUID order. + const cutoff = activities[at]!.at; + activities = activities.filter((activity) => activity.at < cutoff); + } + const history = activities.flatMap((activity): HistoryItem[] => { + if (!activity.body) return []; + if (activity.type === "prompt" && activity.userId !== appUserId) + return [{ role: "user", text: activity.body, at: activity.at }]; + if (["response", "elicitation", "error"].includes(activity.type) && activity.userId === appUserId) + return [{ role: "assistant", text: activity.body, at: activity.at }]; + return []; + }); + if (history[0]?.role === "assistant") { + // A delegation need not create a user activity. Preserve an opening + // question even when the provider requires a user turn first. + const session = await this.deps.api.session(this.deps.sessionId); + history.unshift({ + role: "user", + text: linearSessionContext(session) ?? "Earlier assistant messages in this Linear session follow.", + }); + } + const urls = [ + ...new Set( + [...history] + .reverse() + .filter((turn) => turn.role === "user") + .flatMap((turn) => fileReferences(turn.text).map((ref) => ref.url)), + ), + ]; + if (urls.length) { + if (!this.requesterId) throw new Error("linear_file_requester_required"); + const files = await this.deps.api.files(this.deps.sessionId, this.requesterId, urls, true); + const used = new Set(); + for (let i = history.length - 1; i >= 0; i--) { + const turn = history[i]!; + if (turn.role !== "user") continue; + // Carry each file's bytes once, on its most recent user turn. + history[i] = applyLinearFiles( + turn, + files.filter((file) => !used.has(file.url)), + ); + for (const ref of fileReferences(turn.text)) used.add(ref.url); + } + } + return history; + } +} + +function chunks(text: string): string[] { + const out: string[] = []; + for (let offset = 0; offset < text.length;) { + let end = Math.min(offset + 8000, text.length); + if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]!)) end--; + out.push(text.slice(offset, end)); + offset = end; + } + return out.length ? out : ["No response text was produced."]; +} diff --git a/src/channels/linear/lifecycle.test.ts b/src/channels/linear/lifecycle.test.ts new file mode 100644 index 000000000..0ab46f649 --- /dev/null +++ b/src/channels/linear/lifecycle.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleLinearLifecycle, revokeLinearInstallation, type LinearLiveWork } from "./lifecycle.js"; +import { InMemoryLinearStore } from "./store.js"; +import type { LinearApi } from "./api.js"; +import type { LinearWebhookEvent } from "./webhook.js"; + +const event = (type: string, action: string, extra = {}): LinearWebhookEvent => ({ + key: "k", + receivedAt: 200, + payload: { type, action, organizationId: "org", createdAt: new Date(150).toISOString(), ...extra }, +}); +const installation = { + organizationId: "org", + appUserId: "bot", + accessToken: "a", + refreshToken: "r", + expiresAt: 1000, + version: "v", + installedAt: 100, +}; +describe("Linear lifecycle", () => { + it("revokes stored credentials without deleting a newer installation or another workspace", async () => { + const store = new InMemoryLinearStore(); + await store.putInstallation(installation); + await store.putInstallation({ ...installation, organizationId: "other" }); + await revokeLinearInstallation(store, event("OAuthApp", "revoked")); + expect(await store.getInstallation("org")).toBeUndefined(); + expect(await store.getInstallation("other")).toBeDefined(); + await store.putInstallation({ ...installation, installedAt: 160 }); + await revokeLinearInstallation(store, event("OAuthApp", "revoked")); + expect(await store.getInstallation("org")).toBeDefined(); + }); + const fixture = () => { + const api: LinearApi = { + openThread: vi.fn(), + workItems: vi.fn(), + files: vi.fn(async () => []), + canRead: vi.fn(async () => true), + upload: vi.fn(), + session: vi.fn(async (id) => ({ id, appUserId: "bot" })), + activities: vi.fn(), + activity: vi.fn(), + link: vi.fn(), + }; + const deps = { + api: () => api, + halt: vi.fn(async () => {}), + live: vi.fn<() => Promise>(async () => [ + { id: "a", threadKey: "linear:org:a", channelId: "linear:org:t1", startedAt: 100 }, + { id: "b", threadKey: "linear:org:b", channelId: "linear:org:t2", startedAt: 100 }, + { id: "c", threadKey: "linear:other:c", channelId: "linear:other:t1", startedAt: 100 }, + { id: "d", threadKey: "slack:org:d", channelId: "slack:org", startedAt: 100 }, + { id: "new", threadKey: "linear:org:new", channelId: "linear:org:t1", startedAt: 201 }, + ]), + }; + return { deps, api }; + }; + it("stops only the revoked workspace's pre-existing work", async () => { + const { deps } = fixture(); + await handleLinearLifecycle(deps, event("OAuthApp", "revoked")); + expect(deps.halt.mock.calls).toEqual([["a"], ["b"]]); + }); + it("stops removed teams without lending authority to notification text", async () => { + const { deps } = fixture(); + await handleLinearLifecycle( + deps, + event("PermissionChange", "teamAccessChanged", { removedTeamIds: ["t1"], canAccessAllPublicTeams: true }), + ); + expect(deps.halt.mock.calls).toEqual([["a"]]); + }); + it("rechecks access when public-team permission contracts and fails closed", async () => { + const { deps, api } = fixture(); + vi.mocked(api.session).mockRejectedValueOnce(new Error("forbidden")); + await handleLinearLifecycle( + deps, + event("PermissionChange", "teamAccessChanged", { + removedTeamIds: [], + canAccessAllPublicTeams: false, + appUserId: "bot", + }), + ); + expect(deps.halt.mock.calls).toEqual([["a"]]); + }); + it("rechecks project and document requesters when team permissions contract", async () => { + const { deps, api } = fixture(); + deps.live.mockResolvedValue([ + { + id: "a", + threadKey: "linear:org:a", + channelId: "linear:org:project:p", + startedAt: 100, + userId: "linear:org:alice", + }, + { + id: "b", + threadKey: "linear:org:b", + channelId: "linear:org:document:d", + startedAt: 100, + userId: "linear:org:bob", + }, + ]); + vi.mocked(api.canRead).mockImplementation(async (_session, user) => user === "linear:org:bob"); + await handleLinearLifecycle( + deps, + event("PermissionChange", "teamAccessChanged", { removedTeamIds: ["team"], canAccessAllPublicTeams: true }), + ); + expect(deps.halt.mock.calls).toEqual([["a"]]); + expect(api.canRead).toHaveBeenCalledWith("a", "linear:org:alice"); + deps.halt.mockClear(); + vi.mocked(api.canRead).mockRejectedValue(new Error("access unavailable")); + await handleLinearLifecycle(deps, event("PermissionChange", "teamAccessChanged", { removedTeamIds: ["team"] })); + expect(deps.halt.mock.calls).toEqual([["a"], ["b"]]); + }); + it("checks the current issue delegate before stopping an unassigned notification", async () => { + const { deps, api } = fixture(); + vi.mocked(api.session).mockImplementation(async (id) => ({ + id, + appUserId: "bot", + issue: { id: "issue", identifier: "I-1", title: "Work", teamId: "t1", delegateId: id === "a" ? null : "bot" }, + })); + await handleLinearLifecycle( + deps, + event("AppUserNotification", "issueUnassignedFromYou", { appUserId: "bot", notification: { issueId: "issue" } }), + ); + expect(deps.halt.mock.calls).toEqual([["a"]]); + deps.halt.mockClear(); + await handleLinearLifecycle(deps, event("AppUserNotification", "issueCommentMention")); + expect(deps.halt).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/linear/lifecycle.ts b/src/channels/linear/lifecycle.ts new file mode 100644 index 000000000..487520f84 --- /dev/null +++ b/src/channels/linear/lifecycle.ts @@ -0,0 +1,83 @@ +import { object, type LinearApi } from "./api.js"; +import { linearThread } from "./session.js"; +import type { LinearStore } from "./store.js"; +import type { LinearWebhookEvent } from "./webhook.js"; + +/** Called only after signature and installation validation, before acknowledging + * the delivery. CAS fences a refresh; the original installation time fences a + * delayed revocation delivered after a workspace installed the app again. */ +export async function revokeLinearInstallation(store: LinearStore, event: LinearWebhookEvent): Promise { + if (event.payload.type !== "OAuthApp" || event.payload.action !== "revoked") return false; + const createdAt = typeof event.payload.createdAt === "string" ? Date.parse(event.payload.createdAt) : NaN; + if (!Number.isFinite(createdAt)) throw new Error("linear_invalid_revocation"); + for (;;) { + const current = await store.getInstallation(event.payload.organizationId); + if (!current) return true; + if (current.installedAt !== undefined && current.installedAt > createdAt) return false; + if (await store.replaceInstallation(current.organizationId, current.version, undefined)) return true; + } +} + +export interface LinearLiveWork { + id: string; + threadKey?: string; + channelId?: string; + userId?: string; + startedAt: number; +} + +/** These are infrastructure cancellations following signed lifecycle events, + * not human stop requests. They can remove authority, never confer it. */ +export async function handleLinearLifecycle( + deps: { live(): Promise; halt(id: string): Promise; api(org: string): LinearApi }, + event: LinearWebhookEvent, +): Promise { + const p = event.payload; + const revoked = p.type === "OAuthApp" && p.action === "revoked"; + const permissions = p.type === "PermissionChange" && p.action === "teamAccessChanged"; + const unassigned = p.type === "AppUserNotification" && p.action === "issueUnassignedFromYou"; + // Session webhooks own dispatch. The inbox also echoes mentions, assignments + // and comments; routing those again would create a second task. + if (!revoked && !permissions && !unassigned) return; + const removed = new Set( + Array.isArray(p.removedTeamIds) ? p.removedTeamIds.filter((id) => typeof id === "string") : [], + ); + for (const run of await deps.live()) { + const thread = linearThread(run.threadKey ?? ""); + if (!thread || thread.organizationId !== p.organizationId || run.startedAt > event.receivedAt) continue; + let stop = + revoked || (permissions && [...removed].some((id) => run.channelId === `linear:${p.organizationId}:${id}`)); + const scopedOrigin = + run.channelId?.startsWith(`linear:${p.organizationId}:project:`) || + run.channelId?.startsWith(`linear:${p.organizationId}:document:`); + if (!stop && permissions && scopedOrigin && (removed.size > 0 || p.canAccessAllPublicTeams === false)) { + // A project may retain access through another team. Re-evaluate its + // current origin and requester rather than treating a removed team as + // either an unconditional stop or permission to keep running. + try { + stop = !run.userId || !(await deps.api(thread.organizationId).canRead(thread.sessionId, run.userId)); + } catch { + stop = true; + } + } + if (!stop && (unassigned || (permissions && p.canAccessAllPublicTeams === false && !scopedOrigin))) { + try { + const session = await deps.api(thread.organizationId).session(thread.sessionId); + if (session.appUserId !== p.appUserId) continue; + stop = + !!session.dismissedAt || + !!( + unassigned && + session.issue && + session.issue.id === object(p.notification).issueId && + session.issue.delegateId !== session.appUserId + ); + } catch { + // A permission contraction whose current scope cannot be established + // must not leave an executor running on formerly accessible context. + stop = true; + } + } + if (stop) await deps.halt(run.id); + } +} diff --git a/src/channels/linear/oauth.test.ts b/src/channels/linear/oauth.test.ts new file mode 100644 index 000000000..cf54c628a --- /dev/null +++ b/src/channels/linear/oauth.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from "vitest"; +import { LinearOAuth, LinearTokenProvider } from "./oauth.js"; +import { InMemoryLinearStore, type LinearInstallation } from "./store.js"; + +function fixture(baseUrl = "https://bot.example") { + const store = new InMemoryLinearStore(); + let now = 100_000; + const fetcher = vi.fn(); + const oauth = new LinearOAuth({ + baseUrl, + clientId: "client", + clientSecret: "secret", + store, + fetch: fetcher, + clock: () => now, + }); + return { + store, + fetcher, + oauth, + setNow: (value: number) => { + now = value; + }, + }; +} + +async function begin(f: ReturnType, baseUrl = "https://bot.example") { + const res = await f.oauth.handle(new Request(`${baseUrl}/oauth/linear/authorize`)); + const url = new URL(res.headers.get("location")!); + return { res, url, state: url.searchParams.get("state")!, cookie: res.headers.get("set-cookie")!.split(";")[0] }; +} + +const tokenResponse = () => + Response.json({ access_token: "access-private", refresh_token: "refresh-private", expires_in: 86400 }); +const identityResponse = () => Response.json({ data: { viewer: { id: "app" }, organization: { id: "org" } } }); + +describe("Linear OAuth", () => { + it("uses app scopes, a trusted callback, browser-bound state and PKCE", async () => { + const f = fixture(); + const { res, url, state } = await begin(f); + expect(res.status).toBe(302); + expect(url.origin).toBe("https://linear.app"); + expect(url.searchParams.get("actor")).toBe("app"); + expect(url.searchParams.get("scope")?.split(",")).toEqual(["read", "write", "app:assignable", "app:mentionable"]); + expect(url.searchParams.get("redirect_uri")).toBe("https://bot.example/oauth/linear/callback"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toHaveLength(43); + expect(state).toHaveLength(43); + expect(res.headers.get("set-cookie")).toContain("HttpOnly; Secure; SameSite=Lax"); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(f.fetcher).not.toHaveBeenCalled(); + }); + it("allows local HTTP but rejects insecure remote origins and URL credentials", async () => { + const local = fixture("http://localhost:8080"); + const { url, res } = await begin(local, "http://localhost:8080"); + expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:8080/oauth/linear/callback"); + expect(res.headers.get("set-cookie")).not.toContain("Secure"); + for (const origin of [ + "http://example.com", + "https://secret@bot.example", + "https://bot.example/path", + "https://bot.example?x=1", + ]) { + expect(() => fixture(origin)).toThrow("LINEAR_PUBLIC_BASE_URL"); + } + }); + it("refuses missing, mismatched, expired and replayed state before token exchange", async () => { + const f = fixture(); + const { state, cookie } = await begin(f); + const request = (suffix: string, header?: string) => + new Request(`https://bot.example/oauth/linear/callback?code=c${suffix}`, { + headers: header ? { cookie: header } : {}, + }); + expect((await f.oauth.handle(request(`&state=${state}`))).status).toBe(400); + expect((await f.oauth.handle(request("&state=wrong", cookie))).status).toBe(400); + f.setNow(1_000_000); + expect((await f.oauth.handle(request(`&state=${state}`, cookie))).status).toBe(400); + f.setNow(100_000); + expect((await f.oauth.handle(request(`&state=${state}`, cookie))).status).toBe(400); + expect(f.fetcher).not.toHaveBeenCalled(); + }); + it("persists workspace and app identity before success and never returns credentials", async () => { + const f = fixture(); + const { state, cookie } = await begin(f); + f.fetcher.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(identityResponse()); + const callback = new Request(`https://bot.example/oauth/linear/callback?code=code&state=${state}`, { + headers: { cookie }, + }); + const res = await f.oauth.handle(callback); + expect(res.status).toBe(200); + const stored = await f.store.getInstallation("org"); + expect(stored).toMatchObject({ + organizationId: "org", + appUserId: "app", + accessToken: "access-private", + refreshToken: "refresh-private", + expiresAt: 86_500_000, + }); + const body = await res.text(); + expect(body).not.toMatch(/access-private|refresh-private|secret|code=/); + expect(res.headers.get("set-cookie")).toContain("Max-Age=0"); + const exchange = new URLSearchParams(f.fetcher.mock.calls[0][1]?.body as URLSearchParams); + expect(exchange.get("code_verifier")).toHaveLength(43); + expect(exchange.get("redirect_uri")).toBe("https://bot.example/oauth/linear/callback"); + expect((await f.oauth.handle(callback)).status).toBe(400); + expect(f.fetcher).toHaveBeenCalledTimes(2); + }); + it("does not disclose upstream errors or save an incomplete installation", async () => { + const f = fixture(); + const { state, cookie } = await begin(f); + f.fetcher.mockResolvedValueOnce(new Response("secret-access-private", { status: 401 })); + const res = await f.oauth.handle( + new Request(`https://bot.example/oauth/linear/callback?code=c&state=${state}`, { headers: { cookie } }), + ); + expect(res.status).toBe(502); + expect(await res.text()).toBe("Linear installation failed. Start the installation again."); + expect(await f.store.getInstallation("org")).toBeUndefined(); + }); + it("rejects methods and unknown paths without creating state or making requests", async () => { + const f = fixture(); + expect( + (await f.oauth.handle(new Request("https://bot.example/oauth/linear/authorize", { method: "POST" }))).status, + ).toBe(405); + expect((await f.oauth.handle(new Request("https://bot.example/oauth/linear/other"))).status).toBe(404); + expect(f.fetcher).not.toHaveBeenCalled(); + }); +}); + +describe("Linear token refresh", () => { + const installation: LinearInstallation = { + organizationId: "org", + appUserId: "app", + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: 0, + version: "v1", + }; + it("shares concurrent refresh and persists both replacement tokens", async () => { + const f = fixture(); + await f.store.putInstallation(installation); + f.fetcher.mockResolvedValueOnce(tokenResponse()); + const tokens = new LinearTokenProvider({ + clientId: "client", + clientSecret: "secret", + store: f.store, + fetch: f.fetcher, + clock: () => 100_000, + }); + expect(await Promise.all([tokens.accessToken("org"), tokens.accessToken("org")])).toEqual([ + "access-private", + "access-private", + ]); + expect(f.fetcher).toHaveBeenCalledTimes(1); + expect(await f.store.getInstallation("org")).toMatchObject({ + refreshToken: "refresh-private", + expiresAt: 86_500_000, + }); + expect(await tokens.accessToken("org")).toBe("access-private"); + expect(f.fetcher).toHaveBeenCalledTimes(1); + }); + it("does not resurrect an installation revoked during refresh", async () => { + const f = fixture(); + await f.store.putInstallation(installation); + f.fetcher.mockImplementationOnce(async () => { + await f.store.replaceInstallation("org", "v1", undefined); + return tokenResponse(); + }); + const tokens = new LinearTokenProvider({ + clientId: "client", + clientSecret: "secret", + store: f.store, + fetch: f.fetcher, + clock: () => 100_000, + }); + await expect(tokens.accessToken("org")).rejects.toThrow("linear_installation_changed"); + expect(await f.store.getInstallation("org")).toBeUndefined(); + }); +}); diff --git a/src/channels/linear/oauth.ts b/src/channels/linear/oauth.ts new file mode 100644 index 000000000..0b572f5dc --- /dev/null +++ b/src/channels/linear/oauth.ts @@ -0,0 +1,236 @@ +import type { Clock } from "../../core/trace/types.js"; +import { LINEAR_TIMING } from "../../core/budgets.js"; +import type { LinearStore } from "./store.js"; + +export const LINEAR_AUTHORIZE_PATH = "/oauth/linear/authorize"; +export const LINEAR_CALLBACK_PATH = "/oauth/linear/callback"; +const STATE_TTL_MS = LINEAR_TIMING.oauthStateMs; +const REFRESH_MARGIN_MS = LINEAR_TIMING.refreshMarginMs; +const TOKEN_URL = "https://api.linear.app/oauth/token"; +export const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"; + +interface TokenDeps { + clientId: string; + clientSecret: string; + store: LinearStore; + fetch: typeof fetch; + clock: Clock; +} + +export interface LinearOAuthDeps extends TokenDeps { + /** Configured by the operator, not inferred from Host or query parameters. */ + baseUrl: string; + /** Optional installation allowlist for deployments dedicated to one workspace. */ + organizationId?: string; +} + +function base64url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +const random = () => base64url(crypto.getRandomValues(new Uint8Array(32))); +const object = (value: unknown): Record => + typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : {}; + +function configuredOrigin(raw: string): URL { + const url = new URL(raw); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if ( + (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + throw new Error("LINEAR_PUBLIC_BASE_URL must be an HTTPS origin or an HTTP loopback origin"); + } + return url; +} + +/** Never include upstream response bodies: OAuth servers can echo credentials. */ +async function exchange(deps: TokenDeps, fields: Record) { + const response = await deps.fetch(TOKEN_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ ...fields, client_id: deps.clientId, client_secret: deps.clientSecret }), + signal: AbortSignal.timeout(10_000), + redirect: "error", + }); + if (!response.ok) throw new Error(`linear_token_exchange_${response.status}`); + const token = object(await response.json()); + if ( + typeof token.access_token !== "string" || + !token.access_token || + typeof token.refresh_token !== "string" || + !token.refresh_token || + typeof token.expires_in !== "number" || + !Number.isFinite(token.expires_in) || + token.expires_in <= 0 + ) { + throw new Error("linear_token_response_invalid"); + } + return { + accessToken: token.access_token, + refreshToken: token.refresh_token, + expiresAt: deps.clock() + token.expires_in * 1000, + }; +} + +/** OAuth transport shared by the production Worker and the local development host. */ +export class LinearOAuth { + private readonly redirectUri: string; + private readonly cookieName: string; + private readonly cookieFlags: string; + + constructor(private readonly deps: LinearOAuthDeps) { + const origin = configuredOrigin(deps.baseUrl); + this.redirectUri = new URL(LINEAR_CALLBACK_PATH, origin).href; + const secure = origin.protocol === "https:"; + this.cookieName = secure ? "__Host-linear-oauth" : "linear-oauth"; + this.cookieFlags = `Path=/; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax`; + } + + private answer(status: number, body: string, clearCookie = false): Response { + return new Response(body, { + status, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + "referrer-policy": "no-referrer", + ...(clearCookie ? { "set-cookie": `${this.cookieName}=; Max-Age=0; ${this.cookieFlags}` } : {}), + }, + }); + } + + async handle(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname !== LINEAR_AUTHORIZE_PATH && url.pathname !== LINEAR_CALLBACK_PATH) + return this.answer(404, "Not found"); + if (request.method !== "GET") return this.answer(405, "Method not allowed"); + if (url.pathname === LINEAR_AUTHORIZE_PATH) return this.authorize(); + return this.callback(request, url); + } + + private async authorize(): Promise { + const state = random(); + const verifier = random(); + const challenge = base64url( + new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))), + ); + await this.deps.store.putState(state, { + verifier, + redirectUri: this.redirectUri, + expiresAt: this.deps.clock() + STATE_TTL_MS, + }); + const url = new URL("https://linear.app/oauth/authorize"); + url.search = new URLSearchParams({ + client_id: this.deps.clientId, + redirect_uri: this.redirectUri, + response_type: "code", + actor: "app", + scope: "read,write,app:assignable,app:mentionable", + state, + code_challenge: challenge, + code_challenge_method: "S256", + prompt: "consent", + }).toString(); + return new Response(null, { + status: 302, + headers: { + location: url.href, + "cache-control": "no-store", + "referrer-policy": "no-referrer", + "set-cookie": `${this.cookieName}=${state}; Max-Age=${STATE_TTL_MS / 1000}; ${this.cookieFlags}`, + }, + }); + } + + private async callback(request: Request, url: URL): Promise { + const state = url.searchParams.get("state"); + const cookie = request.headers + .get("cookie") + ?.split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(`${this.cookieName}=`)) + ?.slice(this.cookieName.length + 1); + if (!state || !/^[A-Za-z0-9_-]{43}$/.test(state) || cookie !== state) + return this.answer(400, "Invalid OAuth state."); + const pending = await this.deps.store.takeState(state); + if (!pending || pending.expiresAt <= this.deps.clock() || pending.redirectUri !== this.redirectUri) + return this.answer(400, "Invalid OAuth state.", true); + if (url.searchParams.has("error")) return this.answer(400, "Linear authorization was not completed.", true); + const code = url.searchParams.get("code"); + if (!code) return this.answer(400, "Missing authorization code.", true); + try { + const tokens = await exchange(this.deps, { + grant_type: "authorization_code", + code, + redirect_uri: this.redirectUri, + code_verifier: pending.verifier, + }); + const response = await this.deps.fetch(LINEAR_GRAPHQL_URL, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${tokens.accessToken}` }, + body: JSON.stringify({ query: "query SwitchboardInstallation { viewer { id } organization { id } }" }), + signal: AbortSignal.timeout(10_000), + redirect: "error", + }); + if (!response.ok) throw new Error("linear_identity_failed"); + const payload = object(await response.json()); + const data = object(payload.data); + const appUserId = object(data.viewer).id; + const organizationId = object(data.organization).id; + if ( + payload.errors || + typeof appUserId !== "string" || + !appUserId || + typeof organizationId !== "string" || + !organizationId + ) + throw new Error("linear_identity_invalid"); + if (this.deps.organizationId && organizationId !== this.deps.organizationId) + return this.answer(403, "This Linear workspace is not enabled for this deployment.", true); + await this.deps.store.putInstallation({ + organizationId, + appUserId, + ...tokens, + version: random(), + installedAt: this.deps.clock(), + }); + return this.answer(200, "Switchboard is connected to Linear. You can close this window.", true); + } catch { + return this.answer(502, "Linear installation failed. Start the installation again.", true); + } + } +} + +/** One provider per durable installation host; concurrent callers share a refresh. */ +export class LinearTokenProvider { + private readonly pending = new Map>(); + constructor(private readonly deps: TokenDeps) {} + + accessToken(organizationId: string): Promise { + const pending = this.pending.get(organizationId); + if (pending) return pending; + const work = this.read(organizationId).finally(() => { + this.pending.delete(organizationId); + }); + this.pending.set(organizationId, work); + return work; + } + + private async read(organizationId: string): Promise { + const current = await this.deps.store.getInstallation(organizationId); + if (!current) throw new Error("linear_not_installed"); + if (current.expiresAt - this.deps.clock() > REFRESH_MARGIN_MS) return current.accessToken; + const tokens = await exchange(this.deps, { grant_type: "refresh_token", refresh_token: current.refreshToken }); + const next = { ...current, ...tokens, version: random() }; + if (!(await this.deps.store.replaceInstallation(organizationId, current.version, next))) + throw new Error("linear_installation_changed"); + return next.accessToken; + } +} diff --git a/src/channels/linear/recovery.test.ts b/src/channels/linear/recovery.test.ts new file mode 100644 index 000000000..2170aa899 --- /dev/null +++ b/src/channels/linear/recovery.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { recoverLinearDelivery } from "./recovery.js"; +import { InMemoryRunLedger } from "../../core/runLedger/inMemory.js"; +import type { RunStore } from "../../core/runStore.js"; +import type { IncomingMessage } from "../../core/types.js"; + +const msg: IncomingMessage = { + channelId: "linear:org:team", + threadKey: "linear:org:s", + userId: "linear:org:alice", + text: "fix", + messageId: "p", +}; +const delivery = { + event: { + key: "key", + receivedAt: 100, + payload: { type: "AgentSessionEvent", action: "created", organizationId: "org" }, + }, + lease: "l", + attempts: 2, + begun: true, +}; +const fixture = async (request?: Record) => { + const ledger = new InMemoryRunLedger(() => 100); + await ledger.claim({ + runId: "run", + threadKey: msg.threadKey, + gen: "g", + leaseMs: 100, + startedAt: 100, + meta: { ...msg, request }, + card: null, + system: "", + tools: [], + }); + const store = { get: vi.fn(async () => null), list: vi.fn(async () => []) }; + return { ledger, store }; +}; +describe("Linear dispatch recovery", () => { + it("finds a durable admission even when the run binding response was lost", async () => { + expect(await recoverLinearDelivery(await fixture({ ...msg }), delivery, msg)).toBe("handled"); + }); + it("finds a durable follow-up without confusing another sender or message", async () => { + const f = await fixture(); + await f.ledger.pushInbox("run", { ...msg }); + expect(await recoverLinearDelivery(f, delivery, msg)).toBe("handled"); + expect(await recoverLinearDelivery(f, delivery, { ...msg, userId: "linear:org:bob" })).toBe("unknown"); + }); + it("does not treat a runStarted binding without durable admission as success", async () => { + expect(await recoverLinearDelivery(await fixture(), { ...delivery, runId: "run" }, msg)).toBe("unknown"); + }); + it("retains the delivery when the durable store is unavailable", async () => { + const f = await fixture(); + f.store.list.mockRejectedValue(new Error("unavailable")); + await expect(recoverLinearDelivery(f, delivery, msg)).rejects.toThrow("unavailable"); + }); +}); diff --git a/src/channels/linear/recovery.ts b/src/channels/linear/recovery.ts new file mode 100644 index 000000000..072527153 --- /dev/null +++ b/src/channels/linear/recovery.ts @@ -0,0 +1,49 @@ +import type { IncomingMessage } from "../../core/types.js"; +import type { RunLedger } from "../../core/runLedger/ledger.js"; +import type { RunStore } from "../../core/runStore.js"; +import type { LinearDelivery } from "./inbox.js"; +import { LINEAR_TIMING } from "../../core/budgets.js"; + +/** A durable request or inbox entry proves admission even when the consumer + * died before recording the run binding. These reads are internal recovery, + * never a listing returned to a Linear user. */ +export async function recoverLinearDelivery( + deps: { ledger: Pick; store: Pick }, + delivery: LinearDelivery, + msg: IncomingMessage, +): Promise<"handled" | "unknown"> { + const matches = (request: Record | undefined) => + request?.messageId === msg.messageId && request?.userId === msg.userId && request?.threadKey === msg.threadKey; + for (const row of await deps.ledger.listLive()) { + if (row.meta.threadKey !== msg.threadKey) continue; + if (matches(row.meta.request)) return "handled"; + if ((await deps.ledger.readInbox(row.runId, 0)).some((item) => matches(item.message))) return "handled"; + } + // A finish can race the live listing. Check the bound run, then the thread's + // finished records, paging across identical finish timestamps as well. + const proves = async (id: string) => { + const record = await deps.store.get(id); + return ( + record?.threadKey === msg.threadKey && + record.replyOk === true && + record.events.some((event) => event.type === "input" && event.messageId === msg.messageId) + ); + }; + if (delivery.runId && (await proves(delivery.runId))) return "handled"; + let before: number | undefined, beforeId: string | undefined; + for (;;) { + const rows = await deps.store.list({ + threadKey: msg.threadKey, + sinceMs: delivery.event.receivedAt - LINEAR_TIMING.webhookSkewMs, + limit: 100, + before, + beforeId, + }); + for (const row of rows) if (await proves(row.id)) return "handled"; + if (rows.length < 100) return "unknown"; + const last = rows[rows.length - 1]!; + if (last.finishedAt === before && last.id === beforeId) throw new Error("linear_recovery_cursor_stalled"); + before = last.finishedAt; + beforeId = last.id; + } +} diff --git a/src/channels/linear/session.test.ts b/src/channels/linear/session.test.ts new file mode 100644 index 000000000..ce29b2fc0 --- /dev/null +++ b/src/channels/linear/session.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { linearMessage, linearThread } from "./session.js"; +import type { LinearSession } from "./api.js"; +import type { LinearWebhookEvent } from "./webhook.js"; + +const session: LinearSession = { + id: "session", + appUserId: "bot", + creatorId: "alice", + url: "https://linear.app/acme/issue/ENG-1", + issue: { id: "issue", identifier: "ENG-1", title: "Fix login", description: "Login fails", teamId: "team" }, +}; +const event: LinearWebhookEvent = { + key: "org:session:created", + receivedAt: 100, + payload: { + type: "AgentSessionEvent", + action: "created", + organizationId: "org", + appUserId: "bot", + agentSession: { id: "session", appUserId: "bot", organizationId: "org", creatorId: "alice" }, + promptContext: "Fix login", + }, +}; + +describe("Linear session input", () => { + it("scopes project and document sessions independently and restores their content", () => { + for (const kind of ["project", "document"] as const) { + const current: LinearSession = { + id: "session", + appUserId: "bot", + creatorId: "alice", + surface: { + kind, + id: "origin", + title: "Design", + content: "Read the linked notes", + url: "https://linear.app/acme/origin", + }, + }; + expect( + linearMessage({ ...event, payload: { ...event.payload, promptContext: undefined } }, current, "bot"), + ).toMatchObject({ + kind: "message", + msg: { + channelId: `linear:org:${kind}:origin`, + threadKey: "linear:org:session", + userId: "linear:org:alice", + channelName: "Design", + sourceUrl: "https://linear.app/acme/origin", + text: expect.stringContaining("Read the linked notes"), + }, + }); + } + }); + it("retains the source comment and its file when promptContext is absent", () => { + const body = "Please inspect [log.txt](https://uploads.linear.app/org/log)"; + const input = linearMessage( + { ...event, payload: { ...event.payload, promptContext: undefined } }, + { ...session, comment: { body } }, + "bot", + ); + expect(input).toMatchObject({ kind: "message", msg: { text: expect.stringContaining(body) } }); + }); + it("preserves the signed responsible person, team scope and session identity", () => { + expect(linearMessage(event, session, "bot")).toMatchObject({ + kind: "message", + msg: { + userId: "linear:org:alice", + channelId: "linear:org:team", + threadKey: "linear:org:session", + messageId: event.key, + text: event.payload.promptContext, + receivedAt: 100, + }, + }); + expect(linearThread("linear:org:session")).toEqual({ organizationId: "org", sessionId: "session" }); + expect(linearThread("slack:org:session")).toBeUndefined(); + }); + it("uses a follow-up's own author and id without replacing the initial context", () => { + const follow: LinearWebhookEvent = { + ...event, + key: "prompt-key", + payload: { + ...event.payload, + action: "prompted", + agentActivity: { + id: "activity", + agentSessionId: "session", + userId: "bob", + content: { type: "prompt", body: "Use OAuth" }, + }, + }, + }; + expect(linearMessage(follow, session, "bot")).toMatchObject({ + kind: "message", + msg: { + userId: "linear:org:bob", + threadKey: "linear:org:session", + messageId: "activity", + text: "Use OAuth", + }, + }); + expect( + linearMessage( + { + ...follow, + payload: { + ...follow.payload, + agentActivity: { + ...(follow.payload.agentActivity as object), + signal: "stop", + }, + }, + }, + session, + "bot", + ), + ).toMatchObject({ kind: "stop", userId: "linear:org:bob", threadKey: "linear:org:session" }); + }); + it("refuses forged ownership, missing human identity, mismatched prompts and dismissed sessions", () => { + for (const changed of [ + { ...session, appUserId: "another-bot" }, + { ...session, creatorId: undefined }, + { ...session, id: "another-session" }, + { ...session, dismissedAt: "dismissed" }, + ]) + expect(() => linearMessage(event, changed, "bot")).toThrow(); + expect(() => + linearMessage({ ...event, payload: { ...event.payload, appUserId: "other" } }, session, "bot"), + ).toThrow(); + expect(() => + linearMessage( + { + ...event, + payload: { + ...event.payload, + action: "prompted", + agentActivity: { + id: "p", + userId: "bob", + agentSessionId: "other", + content: { type: "prompt", body: "hi" }, + }, + }, + }, + session, + "bot", + ), + ).toThrow(); + }); +}); diff --git a/src/channels/linear/session.ts b/src/channels/linear/session.ts new file mode 100644 index 000000000..cbb14482b --- /dev/null +++ b/src/channels/linear/session.ts @@ -0,0 +1,104 @@ +import type { IncomingMessage } from "../../core/types.js"; +import { object, string, type LinearSession } from "./api.js"; +import type { LinearWebhookEvent } from "./webhook.js"; + +const safeId = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); + +export function linearThread(threadKey: string): { organizationId: string; sessionId: string } | undefined { + const [platform, organizationId, sessionId, extra] = threadKey.split(":"); + return platform === "linear" && safeId(organizationId) && safeId(sessionId) && extra === undefined + ? { organizationId, sessionId } + : undefined; +} + +export type LinearInput = + | { kind: "message"; msg: IncomingMessage; triggeringActivityId?: string } + | { kind: "stop"; userId: string; channelId: string; threadKey: string; receivedAt: number }; + +/** Signed identity plus fresh access/ownership. Prompt text is never identity + * or authority, and delegation never borrows the issue assignee's grants. */ +export function linearMessage(event: LinearWebhookEvent, current: LinearSession, appUserId: string): LinearInput { + const payload = event.payload, + session = object(payload.agentSession); + const org = payload.organizationId; + if ( + payload.type !== "AgentSessionEvent" || + !safeId(org) || + !safeId(current.id) || + session.id !== current.id || + session.organizationId !== org || + payload.appUserId !== appUserId || + session.appUserId !== appUserId || + current.appUserId !== appUserId + ) + throw new Error("linear_wrong_session"); + if (current.dismissedAt) throw new Error("linear_session_dismissed"); + const channel = current.issue?.teamId ?? current.surface?.id ?? current.id; + if (!safeId(channel)) throw new Error("linear_invalid_team"); + const scope = !current.issue && current.surface ? `${current.surface.kind}:${channel}` : channel; + const channelId = `linear:${org}:${scope}`, + threadKey = `linear:${org}:${current.id}`; + let user: unknown, + text: string | undefined, + messageId = event.key, + name: string | undefined; + let triggeringActivityId: string | undefined; + if (payload.action === "created") { + user = session.creatorId; + if (user !== current.creatorId) throw new Error("linear_wrong_creator"); + name = string(object(session.creator).name); + text = string(payload.promptContext) ?? linearSessionContext(current) ?? string(object(session.comment).body); + } else if (payload.action === "prompted") { + const activity = object(payload.agentActivity), + content = object(activity.content); + if (!safeId(activity.id) || activity.agentSessionId !== current.id || content.type !== "prompt") + throw new Error("linear_invalid_prompt"); + user = activity.userId; + name = string(object(activity.user).name); + if (!safeId(user) || user === appUserId) throw new Error("linear_human_required"); + if (activity.signal === "stop") + return { kind: "stop", userId: `linear:${org}:${user}`, channelId, threadKey, receivedAt: event.receivedAt }; + messageId = activity.id; + triggeringActivityId = activity.id; + text = string(content.body); + } else throw new Error("linear_unsupported_event"); + if (!safeId(user) || user === appUserId) throw new Error("linear_human_required"); + if (!text) throw new Error("linear_empty_prompt"); + return { + kind: "message", + triggeringActivityId, + msg: { + channelId, + threadKey, + userId: `linear:${org}:${user}`, + text, + messageId, + receivedAt: event.receivedAt, + ...(name ? { userName: name } : {}), + ...(current.issue + ? { channelName: current.issue.identifier } + : current.surface + ? { channelName: current.surface.title } + : {}), + ...((current.url ?? current.surface?.url) ? { sourceUrl: current.url ?? current.surface?.url } : {}), + }, + }; +} + +/** A created session need not retain a user activity. Rebuild its issue and + * initiating comment together, so later turns retain the mention's files. */ +export function linearSessionContext(session: LinearSession): string | undefined { + const parts = [ + session.issue + ? `Linear issue ${session.issue.identifier}: ${session.issue.title}\n\n${session.issue.description ?? ""}` + : undefined, + session.surface + ? `Linear ${session.surface.kind} ${session.surface.title}:\n\n${session.surface.content ?? ""}` + : undefined, + session.sourceComment?.body && session.sourceComment.body !== session.comment?.body + ? `Source comment:\n${session.sourceComment.body}` + : undefined, + session.comment?.body ? `Comment that started this session:\n${session.comment.body}` : undefined, + ].filter((part) => part !== undefined); + return parts.length ? parts.join("\n\n") : undefined; +} diff --git a/src/channels/linear/store.test.ts b/src/channels/linear/store.test.ts new file mode 100644 index 000000000..3079ade50 --- /dev/null +++ b/src/channels/linear/store.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { InMemoryLinearStore, StoredLinearStore, type LinearStorage, type LinearInstallation } from "./store.js"; + +const installation: LinearInstallation = { + organizationId: "org", + appUserId: "app", + accessToken: "access", + refreshToken: "refresh", + expiresAt: 100, + version: "v1", +}; + +function storage(): LinearStorage { + const rows = new Map(); + const values = { + async get(key: string) { + return structuredClone(rows.get(key)) as T | undefined; + }, + async put(key: string, value: T) { + rows.set(key, structuredClone(value)); + }, + async delete(key: string) { + rows.delete(key); + }, + }; + let tail: Promise = Promise.resolve(); + return { + ...values, + transaction(fn: (tx: typeof values) => Promise) { + const next = tail.then(() => fn(values)); + tail = next.catch(() => {}); + return next; + }, + }; +} + +for (const kind of ["memory", "durable"] as const) { + describe(`Linear installation store — ${kind}`, () => { + const make = () => (kind === "memory" ? new InMemoryLinearStore() : new StoredLinearStore(storage())); + it("consumes browser state exactly once, including concurrent callbacks", async () => { + const store = make(); + const state = { verifier: "pkce", expiresAt: 1000, redirectUri: "http://localhost:8080/oauth/linear/callback" }; + await store.putState("nonce", state); + const taken = await Promise.all([store.takeState("nonce"), store.takeState("nonce")]); + expect(taken).toEqual([state, undefined]); + }); + it("isolates installations and refuses stale refresh after revocation or reinstall", async () => { + const store = make(); + await store.putInstallation(installation); + await store.putInstallation({ ...installation, organizationId: "other" }); + expect(await store.replaceInstallation("org", "stale", { ...installation, version: "v2" })).toBe(false); + expect(await store.replaceInstallation("org", "v1", undefined)).toBe(true); + expect(await store.replaceInstallation("org", "v1", { ...installation, version: "v2" })).toBe(false); + await store.putInstallation({ ...installation, version: "reinstalled" }); + expect(await store.replaceInstallation("org", "v1", { ...installation, version: "v2" })).toBe(false); + expect((await store.getInstallation("other"))?.version).toBe("v1"); + }); + }); +} + +describe("durable Linear store", () => { + it("restores credentials and pending state in a new store instance", async () => { + const backing = storage(); + const first = new StoredLinearStore(backing); + await first.putInstallation(installation); + await first.putState("nonce", { + verifier: "pkce", + expiresAt: 1000, + redirectUri: "https://bot.example/oauth/linear/callback", + }); + const restored = new StoredLinearStore(backing); + expect(await restored.getInstallation("org")).toEqual(installation); + expect((await restored.takeState("nonce"))?.verifier).toBe("pkce"); + expect(await first.takeState("nonce")).toBeUndefined(); + }); +}); diff --git a/src/channels/linear/store.ts b/src/channels/linear/store.ts new file mode 100644 index 000000000..09e1e20d9 --- /dev/null +++ b/src/channels/linear/store.ts @@ -0,0 +1,106 @@ +/** Credentials belong to the transport, never to the model or its executor. */ +export interface LinearInstallation { + organizationId: string; + appUserId: string; + accessToken: string; + refreshToken: string; + expiresAt: number; + /** Original installation time, preserved across refreshes. */ + installedAt?: number; + /** Changes on install and refresh, so an old refresh cannot undo revocation. */ + version: string; +} + +export interface LinearOAuthState { + verifier: string; + expiresAt: number; + redirectUri: string; +} + +export interface LinearStore { + putState(nonce: string, state: LinearOAuthState): Promise; + takeState(nonce: string): Promise; + getInstallation(organizationId: string): Promise; + putInstallation(installation: LinearInstallation): Promise; + replaceInstallation(organizationId: string, version: string, next: LinearInstallation | undefined): Promise; +} + +interface LinearStorageValues { + get(key: string): Promise; + put(key: string, value: T): Promise; + delete(key: string): Promise; +} + +/** DurableObject storage provides these operations, including serializable transactions. */ +export interface LinearStorage extends LinearStorageValues { + transaction(fn: (tx: LinearStorageValues) => Promise): Promise; +} + +export class StoredLinearStore implements LinearStore { + constructor(private readonly storage: LinearStorage) {} + + async putState(nonce: string, state: LinearOAuthState): Promise { + await this.storage.put(`oauth:${nonce}`, state); + } + + takeState(nonce: string): Promise { + return this.storage.transaction(async (tx) => { + const key = `oauth:${nonce}`; + const state = await tx.get(key); + if (state) await tx.delete(key); + return state; + }); + } + + getInstallation(organizationId: string): Promise { + return this.storage.get(`installation:${organizationId}`); + } + + async putInstallation(installation: LinearInstallation): Promise { + await this.storage.put(`installation:${installation.organizationId}`, installation); + } + + replaceInstallation(organizationId: string, version: string, next: LinearInstallation | undefined): Promise { + if (next && next.organizationId !== organizationId) throw new Error("linear_installation_identity_mismatch"); + return this.storage.transaction(async (tx) => { + const key = `installation:${organizationId}`; + const current = await tx.get(key); + if (current?.version !== version) return false; + if (next) await tx.put(key, next); + else await tx.delete(key); + return true; + }); + } +} + +/** Tests and explicitly ephemeral local sessions; production uses StoredLinearStore. */ +export class InMemoryLinearStore implements LinearStore { + private readonly states = new Map(); + private readonly installations = new Map(); + + async putState(nonce: string, state: LinearOAuthState): Promise { + this.states.set(nonce, structuredClone(state)); + } + async takeState(nonce: string): Promise { + const value = this.states.get(nonce); + this.states.delete(nonce); + return structuredClone(value); + } + async getInstallation(organizationId: string): Promise { + return structuredClone(this.installations.get(organizationId)); + } + async putInstallation(installation: LinearInstallation): Promise { + this.installations.set(installation.organizationId, structuredClone(installation)); + } + async replaceInstallation( + organizationId: string, + version: string, + next: LinearInstallation | undefined, + ): Promise { + if (next && next.organizationId !== organizationId) throw new Error("linear_installation_identity_mismatch"); + if (this.installations.get(organizationId)?.version !== version) return false; + if (next) this.installations.set(organizationId, structuredClone(next)); + else this.installations.delete(organizationId); + return true; + } +} diff --git a/src/channels/linear/surfaces.ts b/src/channels/linear/surfaces.ts new file mode 100644 index 000000000..a10637a1b --- /dev/null +++ b/src/channels/linear/surfaces.ts @@ -0,0 +1,121 @@ +import { object, required, string } from "./api.js"; +import { linearTeamAllows, type LinearAccessDeps, type LinearPerson } from "./access.js"; + +export interface LinearSurface { + kind: "project" | "document"; + id: string; + title: string; + content?: string; + url?: string; +} + +const projectFields = "id name content url"; +/** Only comment ownership establishes an origin. Session context can contain + * arbitrary references and is deliberately not used as an access boundary. */ +export const SURFACE_CONTEXT_FIELDS = ` + isArtificialAgentSessionRoot + project { ${projectFields} } + projectUpdate { project { ${projectFields} } } + documentContent { document { id title content url } project { ${projectFields} } } +`; +const teamFields = "id visibility restrictedBy { id }"; +export const SURFACE_ACCESS_FIELDS = ` + isArtificialAgentSessionRoot + project { id } + projectUpdate { project { id } } + documentContent { document { id project { id } issue { team { ${teamFields} } } team { ${teamFields} } } project { id } } +`; + +export function linearSurfaceOrigin( + session: Record, +): { kind: LinearSurface["kind"]; entity: Record } | undefined { + if (session.issue || session.pullRequest) return undefined; + const primary = object(session.comment); + // A real primary comment with an unknown parent must not borrow access from + // a source in some other conversation. Only an artificial root can fall back. + const origins = + !session.comment || primary.isArtificialAgentSessionRoot === true + ? [session.comment, session.sourceComment] + : [session.comment]; + for (const value of origins) { + const comment = object(value), + content = object(comment.documentContent); + if (content.document) return { kind: "document", entity: object(content.document) }; + const project = comment.project ?? object(comment.projectUpdate).project ?? content.project; + if (project) return { kind: "project", entity: object(project) }; + } + return undefined; +} + +/** A source comment can come from another conversation. Include its text only + * when its own fresh origin is the one whose access this session checks. */ +export function linearSurfaceSourceText(session: Record): string | undefined { + const origin = linearSurfaceOrigin(session); + const source = linearSurfaceOrigin({ sourceComment: session.sourceComment }); + return origin && + source && + origin.kind === source.kind && + string(origin.entity.id) && + origin.entity.id === source.entity.id + ? string(object(session.sourceComment).body) + : undefined; +} + +export function linearSurfaceContext(session: Record): LinearSurface | undefined { + const origin = linearSurfaceOrigin(session); + if (!origin) return undefined; + const { kind, entity } = origin; + return { + kind, + id: required(entity.id), + // Linear permits an empty document title. + title: + string(kind === "document" ? entity.title : entity.name) ?? + (kind === "document" ? "Untitled document" : "Untitled project"), + ...(string(entity.content) ? { content: string(entity.content) } : {}), + ...(string(entity.url) ? { url: string(entity.url) } : {}), + }; +} + +/** Projects inherit visibility from any of their teams. Documents inherit the + * visibility of their owner; an unsupported owner never implies public access. */ +export async function linearSurfaceAllows( + deps: LinearAccessDeps, + person: LinearPerson, + session: Record, +): Promise { + const origin = linearSurfaceOrigin(session); + if (!origin) return false; + let project: Record; + if (origin.kind === "document") { + const document = origin.entity; + const team = object(object(document.issue).team ?? document.team); + if (string(team.id)) return linearTeamAllows(person, "conversation:read", team); + project = object(document.project); + } else project = origin.entity; + if (!string(project.id)) return false; + let after: string | undefined; + const seen = new Set(); + for (let page = 0; ; page++) { + if (page === 100) throw new Error("linear_project_access_too_large"); + const data = await deps.query( + `query SwitchboardProjectAccess($id: String!, $after: String) { + organization { id } + project(id: $id) { id teams(first: 100, after: $after) { + nodes { ${teamFields} } pageInfo { hasNextPage endCursor } + } } + }`, + { id: project.id, after }, + ); + const current = object(data.project), + teams = object(current.teams), + info = object(teams.pageInfo); + if (object(data.organization).id !== deps.organizationId || current.id !== project.id) return false; + if (!Array.isArray(teams.nodes)) throw new Error("linear_invalid_response"); + if (teams.nodes.some((team) => linearTeamAllows(person, "conversation:read", object(team)))) return true; + if (info.hasNextPage === false) return false; + after = required(info.endCursor); + if (seen.has(after)) throw new Error("linear_invalid_pagination"); + seen.add(after); + } +} diff --git a/src/channels/linear/webhook.test.ts b/src/channels/linear/webhook.test.ts new file mode 100644 index 000000000..d391aec74 --- /dev/null +++ b/src/channels/linear/webhook.test.ts @@ -0,0 +1,107 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import { handleLinearWebhook, type LinearWebhookDeps } from "./webhook.js"; + +const now = 1_000_000; +const secret = "test-webhook-secret"; +const event = { + type: "AgentSessionEvent", + action: "created", + organizationId: "org", + oauthClientId: "client", + webhookTimestamp: now, + agentSession: { id: "session" }, +}; +function signed(value: unknown = event, delivery = "delivery") { + const body = JSON.stringify(value); + return new Request("https://bot.example/webhooks/linear", { + method: "POST", + body, + headers: { + "linear-signature": createHmac("sha256", secret).update(body).digest("hex"), + "linear-delivery": delivery, + }, + }); +} +function fixture() { + const accept = vi.fn().mockResolvedValue(true); + const deps: LinearWebhookDeps = { secret, applicationId: "client", organizationId: "org", clock: () => now, accept }; + return { deps, accept }; +} + +describe("Linear signed webhook intake", () => { + it("persists a verified event before acknowledging and records adapter arrival time", async () => { + const f = fixture(); + const res = await handleLinearWebhook(signed(), f.deps); + expect(res.status).toBe(200); + expect(f.accept).toHaveBeenCalledWith( + expect.objectContaining({ key: "org:session:created", receivedAt: now, payload: event }), + ); + }); + it("uses signed session and activity ids for deduplication, not an unsigned delivery header", async () => { + const f = fixture(); + f.accept.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + expect((await handleLinearWebhook(signed(event, "one"), f.deps)).status).toBe(200); + expect((await handleLinearWebhook(signed({ ...event, webhookTimestamp: now + 1 }, "two"), f.deps)).status).toBe( + 200, + ); + expect(f.accept.mock.calls[0][0].key).toBe(f.accept.mock.calls[1][0].key); + const prompt = { + ...event, + action: "prompted", + agentActivity: { id: "prompt-1", content: { type: "prompt", body: "continue" } }, + }; + await handleLinearWebhook(signed(prompt), f.deps); + expect(f.accept.mock.calls[2][0].key).toBe("org:session:prompted:prompt-1"); + }); + it("rejects forged signatures, modified bytes, old or future timestamps, and other installations", async () => { + const f = fixture(); + const forged = signed(); + forged.headers.set("linear-signature", "0".repeat(64)); + expect((await handleLinearWebhook(forged, f.deps)).status).toBe(401); + const altered = signed(); + expect( + ( + await handleLinearWebhook( + new Request(altered.url, { + method: "POST", + headers: altered.headers, + body: JSON.stringify({ ...event, action: "prompted" }), + }), + f.deps, + ) + ).status, + ).toBe(401); + for (const offset of [-60_001, 60_001]) + expect((await handleLinearWebhook(signed({ ...event, webhookTimestamp: now + offset }), f.deps)).status).toBe( + 401, + ); + expect((await handleLinearWebhook(signed({ ...event, organizationId: "another" }), f.deps)).status).toBe(403); + expect((await handleLinearWebhook(signed({ ...event, oauthClientId: "another" }), f.deps)).status).toBe(403); + expect(f.accept).not.toHaveBeenCalled(); + }); + it("fails closed when disabled and rejects methods, malformed events and oversized bodies", async () => { + const f = fixture(); + expect((await handleLinearWebhook(signed(), { ...f.deps, secret: undefined })).status).toBe(503); + expect((await handleLinearWebhook(new Request("https://bot.example/webhooks/linear"), f.deps)).status).toBe(405); + expect((await handleLinearWebhook(signed({ ...event, agentSession: {} }), f.deps)).status).toBe(400); + expect((await handleLinearWebhook(signed({ ...event, action: "prompted" }), f.deps)).status).toBe(400); + expect((await handleLinearWebhook(signed({ ...event, text: "x".repeat(1024 * 1024) }), f.deps)).status).toBe(413); + expect(f.accept).not.toHaveBeenCalled(); + }); + it("returns a retryable failure if persistence fails, never a success", async () => { + const f = fixture(); + f.accept.mockRejectedValueOnce(new Error("store unavailable with private details")); + const res = await handleLinearWebhook(signed(), f.deps); + expect(res.status).toBe(503); + expect(await res.text()).not.toContain("private details"); + }); + it("retains lifecycle events and ignores event types the integration did not subscribe to", async () => { + const f = fixture(); + for (const type of ["OAuthApp", "PermissionChange", "AppUserNotification"]) { + expect((await handleLinearWebhook(signed({ ...event, type, action: "revoked" }), f.deps)).status).toBe(200); + } + expect((await handleLinearWebhook(signed({ ...event, type: "Issue" }), f.deps)).status).toBe(200); + expect(f.accept).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/channels/linear/webhook.ts b/src/channels/linear/webhook.ts new file mode 100644 index 000000000..6abddbacb --- /dev/null +++ b/src/channels/linear/webhook.ts @@ -0,0 +1,130 @@ +import type { Clock } from "../../core/trace/types.js"; +import { LINEAR_TIMING } from "../../core/budgets.js"; + +export const LINEAR_WEBHOOK_PATH = "/webhooks/linear"; +const MAX_BYTES = 1024 * 1024; +const MAX_SKEW_MS = LINEAR_TIMING.webhookSkewMs; +const TYPES = new Set(["AgentSessionEvent", "OAuthApp", "PermissionChange", "AppUserNotification"]); + +export interface LinearWebhookEvent { + /** Derived from signed data; a retry cannot change this by changing a header. */ + key: string; + receivedAt: number; + payload: Record & { type: string; action: string; organizationId: string }; +} + +export interface LinearWebhookDeps { + secret?: string; + /** The application's UUID, distinct from its public OAuth client id. */ + applicationId: string; + organizationId?: string; + clock: Clock; + /** Persist-if-absent, atomically. False means the delivery is already recorded. */ + accept(event: LinearWebhookEvent): Promise; +} + +const answer = (status: number, outcome: string) => Response.json({ outcome }, { status }); +const record = (value: unknown): Record => + typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : {}; +const id = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); + +/** Cap actual streamed bytes, regardless of whether Content-Length is present or truthful. */ +export async function boundedBody(request: Request): Promise { + const reader = request.body?.getReader(); + if (!reader) return new Uint8Array(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > MAX_BYTES) { + await reader.cancel(); + return undefined; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +async function eventKey(payload: LinearWebhookEvent["payload"]): Promise { + if (payload.type === "AgentSessionEvent") { + const sessionId = record(payload.agentSession).id; + if (!id(sessionId)) return undefined; + if (payload.action === "created") return `${payload.organizationId}:${sessionId}:created`; + const activityId = record(payload.agentActivity).id; + if (payload.action === "prompted" && id(activityId)) + return `${payload.organizationId}:${sessionId}:prompted:${activityId}`; + return undefined; + } + // The delivery timestamp may change on a retry. The remainder is signed + // event data; webhookId identifies the subscription, not a unique event. + const { webhookTimestamp: _, ...stable } = payload; + const bytes = new TextEncoder().encode(JSON.stringify(stable)); + const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + const digest = Array.from(hash, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${payload.organizationId}:${payload.type}:${digest}`; +} + +export async function handleLinearWebhook(request: Request, deps: LinearWebhookDeps): Promise { + if (request.method !== "POST") return answer(405, "method_not_allowed"); + if (!deps.secret || !deps.applicationId) return answer(503, "linear_disabled"); + const signature = request.headers.get("linear-signature"); + if (!signature || !/^[a-fA-F0-9]{64}$/.test(signature)) return answer(401, "invalid_signature"); + const receivedAt = deps.clock(); + try { + const body = await boundedBody(request); + if (!body) return answer(413, "too_large"); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(deps.secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"], + ); + const signatureBytes = Uint8Array.from(signature.match(/../g)!, (part) => parseInt(part, 16)); + if (!(await crypto.subtle.verify("HMAC", key, signatureBytes, body as Uint8Array))) + return answer(401, "invalid_signature"); + let decoded: unknown; + try { + decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(body)); + } catch { + return answer(400, "invalid_payload"); + } + const payload = record(decoded); + if ( + typeof payload.webhookTimestamp !== "number" || + !Number.isFinite(payload.webhookTimestamp) || + Math.abs(receivedAt - payload.webhookTimestamp) > MAX_SKEW_MS + ) + return answer(401, "expired_delivery"); + if (typeof payload.type !== "string" || typeof payload.action !== "string" || !id(payload.organizationId)) + return answer(400, "invalid_payload"); + if ( + payload.oauthClientId !== deps.applicationId || + (deps.organizationId && payload.organizationId !== deps.organizationId) + ) + return answer(403, "wrong_installation"); + if (!TYPES.has(payload.type)) return answer(200, "ignored"); + const typed = payload as LinearWebhookEvent["payload"]; + const eventId = await eventKey(typed); + if (!eventId) return answer(400, "invalid_payload"); + const created = await deps.accept({ key: eventId, receivedAt, payload: typed }); + // Linear requires HTTP 200; another 2xx can still trigger redelivery. + return answer(200, created ? "accepted" : "duplicate"); + } catch { + // A non-2xx asks Linear to redeliver. The signed event is never acknowledged + // until durable storage accepted it, and errors cannot echo its contents. + return answer(503, "intake_unavailable"); + } +} diff --git a/src/channels/linear/workItems.test.ts b/src/channels/linear/workItems.test.ts new file mode 100644 index 000000000..ac4501265 --- /dev/null +++ b/src/channels/linear/workItems.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; +import { linearWorkItems } from "./workItems.js"; + +const issue = (team = "team", visibility = "private") => ({ + id: "issue", + identifier: "ENG-1", + title: "Fix login", + description: "Context", + url: "https://linear.app/issue/ENG-1", + priority: 2, + state: { id: "todo", name: "Todo", type: "unstarted" }, + team: { id: team, visibility, states: { nodes: [{ id: "done", name: "Done", type: "completed" }] } }, + assignee: { id: "human", name: "Human" }, + delegate: { id: "bot", name: "Switchboard" }, +}); +function fixture() { + const person = { + id: "human", + active: true, + app: false, + canAccessAnyPublicTeam: true, + organization: { id: "org" }, + teams: { nodes: [{ id: "team" }], pageInfo: { hasNextPage: false } }, + }; + const query = vi.fn(async (query: string, variables: Record): Promise> => { + if (query.includes("SwitchboardPerson")) return { user: person }; + if (query.includes("SwitchboardWorkItemUpdate")) return { issueUpdate: { success: true, issue: issue() } }; + if (query.includes("SwitchboardChild")) + return { issueCreate: { success: true, issue: { ...issue(), id: "child" } } }; + if (query.includes("SwitchboardDelegated")) + return { + issues: { nodes: [issue(), issue("outside", "private")], pageInfo: { hasNextPage: true, endCursor: "next" } }, + }; + if (query.includes("SwitchboardWorkItem")) + return { issue: issue(String(variables.id) === "outside" ? "outside" : "team") }; + throw new Error("unexpected query"); + }); + const actor = { id: "linear:org:human", actions: ["work-items:read", "work-items:write"] }; + return { person, query, actor, deps: { organizationId: "org", appUserId: "bot", query } }; +} + +describe("Linear work items", () => { + it("recognizes inherited access to a restricted child but never opens a private child by its parent", async () => { + const f = fixture(); + const original = f.query.getMockImplementation()!; + let visibility = "restricted"; + f.query.mockImplementation(async (query, variables) => { + if (!query.includes("query SwitchboardWorkItem(")) return original(query, variables); + const row = issue("child", visibility); + return { issue: { ...row, team: { ...row.team, restrictedBy: { id: "team" } } } }; + }); + await expect(linearWorkItems(f.deps, f.actor, { op: "get", id: "issue" })).resolves.toHaveProperty( + "teamId", + "child", + ); + visibility = "private"; + await expect(linearWorkItems(f.deps, f.actor, { op: "get", id: "issue" })).rejects.toThrow( + "linear_work_item_denied", + ); + }); + it("allows a public team only for people with public-team access and rejects a foreign workspace identity", async () => { + const f = fixture(); + const original = f.query.getMockImplementation()!; + f.query.mockImplementation(async (query, variables) => + query.includes("query SwitchboardWorkItem(") ? { issue: issue("outside", "public") } : original(query, variables), + ); + await expect(linearWorkItems(f.deps, f.actor, { op: "get", id: "issue" })).resolves.toMatchObject({ + teamId: "outside", + }); + f.person.canAccessAnyPublicTeam = false; + await expect(linearWorkItems(f.deps, f.actor, { op: "get", id: "issue" })).rejects.toThrow( + "linear_work_item_denied", + ); + await expect( + linearWorkItems(f.deps, { ...f.actor, id: "linear:other:human" }, { op: "get", id: "issue" }), + ).rejects.toThrow("linear_human_required"); + }); + it("paginates membership and refuses a false mutation receipt or unknown status", async () => { + const f = fixture(); + f.query.mockResolvedValueOnce({ + user: { ...f.person, teams: { nodes: [], pageInfo: { hasNextPage: true, endCursor: "more" } } }, + }); + await expect(linearWorkItems(f.deps, f.actor, { op: "get", id: "issue" })).resolves.toHaveProperty("id", "issue"); + expect(f.query.mock.calls[1]![1]).toMatchObject({ after: "more" }); + await expect(linearWorkItems(f.deps, f.actor, { op: "update", id: "issue", state: "Not a state" })).rejects.toThrow( + "linear_unknown_or_ambiguous_state", + ); + const original = f.query.getMockImplementation()!; + f.query.mockImplementation(async (query, variables) => + query.includes("mutation") ? { issueUpdate: { success: false } } : original(query, variables), + ); + await expect(linearWorkItems(f.deps, f.actor, { op: "update", id: "issue", title: "New title" })).rejects.toThrow( + "linear_work_item_write_failed", + ); + }); + it("checks the current human's access and denies a private issue outside their teams even for an operator", async () => { + const f = fixture(); + expect(await linearWorkItems(f.deps, { ...f.actor, actions: "all" }, { op: "get", id: "issue" })).toMatchObject({ + identifier: "ENG-1", + availableStates: [{ id: "done", name: "Done", type: "completed" }], + }); + await expect(linearWorkItems(f.deps, { ...f.actor, actions: "all" }, { op: "get", id: "outside" })).rejects.toThrow( + "linear_work_item_denied", + ); + f.person.active = false; + await expect(linearWorkItems(f.deps, f.actor, { op: "get", id: "issue" })).rejects.toThrow("linear_human_required"); + }); + it("filters the delegated queue by visibility and delegate, preserves pagination and never returns a private outsider", async () => { + const f = fixture(); + const result = await linearWorkItems(f.deps, f.actor, { op: "delegated", after: "previous", limit: 10 }); + expect(result).toMatchObject({ items: [{ identifier: "ENG-1" }], nextCursor: "next" }); + const call = f.query.mock.calls.find(([q]) => q.includes("SwitchboardDelegated"))!; + expect(call[1]).toMatchObject({ + after: "previous", + first: 10, + filter: { + delegate: { id: { eq: "bot" } }, + team: { + or: [ + { id: { in: ["team"] } }, + { and: [{ visibility: { eq: "restricted" } }, { restrictedBy: { id: { in: ["team"] } } }] }, + { visibility: { eq: "public" } }, + ], + }, + }, + }); + f.person.canAccessAnyPublicTeam = false; + await linearWorkItems(f.deps, f.actor, { op: "delegated" }); + expect(f.query.mock.calls.filter(([q]) => q.includes("SwitchboardDelegated")).at(-1)![1]).toMatchObject({ + filter: { + team: { + or: [ + { id: { in: ["team"] } }, + { and: [{ visibility: { eq: "restricted" } }, { restrictedBy: { id: { in: ["team"] } } }] }, + ], + }, + }, + }); + }); + it("requires the write grant and updates only requested fields, preserving human assignee and delegation", async () => { + const f = fixture(); + await expect( + linearWorkItems( + f.deps, + { ...f.actor, actions: ["work-items:read"] }, + { op: "update", id: "issue", state: "Done" }, + ), + ).rejects.toThrow("linear_work_item_denied"); + expect(f.query.mock.calls.some(([q]) => q.includes("mutation"))).toBe(false); + await linearWorkItems(f.deps, f.actor, { op: "update", id: "issue", state: "Done", priority: 1 }); + expect(f.query.mock.calls.find(([q]) => q.includes("SwitchboardWorkItemUpdate"))![1]).toEqual({ + id: "issue", + input: { stateId: "done", priority: 1 }, + }); + }); + it("creates children in the visible parent's team without assigning them or triggering another agent", async () => { + const f = fixture(); + await linearWorkItems(f.deps, f.actor, { op: "create_child", parentId: "issue", title: "Add a regression test" }); + expect(f.query.mock.calls.find(([q]) => q.includes("SwitchboardChild"))![1]).toEqual({ + input: { parentId: "issue", teamId: "team", title: "Add a regression test" }, + }); + }); +}); diff --git a/src/channels/linear/workItems.ts b/src/channels/linear/workItems.ts new file mode 100644 index 000000000..ad777042a --- /dev/null +++ b/src/channels/linear/workItems.ts @@ -0,0 +1,152 @@ +import type { WorkItem, WorkItemRequest, WorkItemResult } from "../../core/workItems.js"; +import { object, required, string } from "./api.js"; +import { linearPerson, linearTeamAllows, type LinearAccessDeps, type LinearPersonIdentity } from "./access.js"; + +/** Transport identity comes from the dispatcher, never from a tool argument. + * Config grants admit actions; team facts below cap access at the person's + * current Linear access, even when config grants every channel. */ +export type LinearWorkItemActor = LinearPersonIdentity; +type Deps = LinearAccessDeps; + +const FIELDS = `id identifier title description url priority + state { id name type } team { id visibility restrictedBy { id } } + assignee { id name } delegate { id name }`; + +function item(row: Record): WorkItem { + const state = object(row.state); + const states = object(object(row.team).states).nodes; + if (typeof row.priority !== "number") throw new Error("linear_invalid_response"); + const person = (value: unknown) => { + const user = object(value); + return user.id ? { id: required(user.id), name: required(user.name) } : undefined; + }; + return { + id: required(row.id), + identifier: required(row.identifier), + title: required(row.title), + description: string(row.description), + url: required(row.url), + priority: row.priority, + state: { id: required(state.id), name: required(state.name), type: required(state.type) }, + ...(Array.isArray(states) + ? { + availableStates: states.map((s) => { + const value = object(s); + return { id: required(value.id), name: required(value.name), type: required(value.type) }; + }), + } + : {}), + teamId: required(object(row.team).id), + assignee: person(row.assignee), + delegate: person(row.delegate), + }; +} + +function text(value: unknown, max: number, empty = false): string { + if (typeof value !== "string" || (!empty && !value.trim()) || value.length > max) + throw new Error("linear_invalid_work_item_input"); + return value; +} + +export async function linearWorkItems( + deps: Deps, + identity: LinearWorkItemActor, + request: WorkItemRequest, +): Promise { + if (!["get", "delegated", "update", "create_child", "comment"].includes(request.op)) + throw new Error("linear_invalid_work_item_input"); + const person = await linearPerson(deps, identity); + const { members, publicAccess } = person; + const action = request.op === "get" || request.op === "delegated" ? "work-items:read" : "work-items:write"; + const allowed = (row: Record) => linearTeamAllows(person, action, object(row.team)); + if (request.op === "delegated") { + const first = request.limit ?? 25; + if (!Number.isInteger(first) || first < 1 || first > 50) throw new Error("linear_invalid_work_item_input"); + const teams: Record[] = [ + { id: { in: [...members] } }, + { and: [{ visibility: { eq: "restricted" } }, { restrictedBy: { id: { in: [...members] } } }] }, + ]; + if (publicAccess) teams.push({ visibility: { eq: "public" } }); + const data = await deps.query( + `query SwitchboardDelegated($first: Int!, $after: String, $filter: IssueFilter!) { + issues(first: $first, after: $after, filter: $filter, orderBy: updatedAt) { + nodes { ${FIELDS} } pageInfo { hasNextPage endCursor } + } + }`, + { + first, + after: request.after === undefined ? undefined : text(request.after, 512), + filter: { + delegate: { id: { eq: deps.appUserId } }, + team: { or: teams }, + }, + }, + ); + const connection = object(data.issues), + info = object(connection.pageInfo); + if (!Array.isArray(connection.nodes)) throw new Error("linear_invalid_response"); + return { + items: connection.nodes + .map(object) + .filter((row) => object(row.delegate).id === deps.appUserId && allowed(row)) + .map(item), + ...(info.hasNextPage === true ? { nextCursor: required(info.endCursor) } : {}), + }; + } + const id = text(request.op === "create_child" ? request.parentId : request.id, 256); + const data = await deps.query( + `query SwitchboardWorkItem($id: String!) { + issue(id: $id) { ${FIELDS} team { id visibility states(first: 100) { nodes { id name type } } } } + }`, + { id }, + ); + const row = object(data.issue); + if (!allowed(row)) throw new Error("linear_work_item_denied"); + if (request.op === "get") return item(row); + if (request.op === "comment") { + const data = await deps.query( + `mutation SwitchboardWorkItemComment($input: CommentCreateInput!) { + commentCreate(input: $input) { success comment { url } } + }`, + { input: { issueId: required(row.id), body: text(request.body, 50_000) } }, + ); + const result = object(data.commentCreate); + if (result.success !== true) throw new Error("linear_work_item_write_failed"); + return { url: required(object(result.comment).url) }; + } + const input: Record = {}; + if (request.title !== undefined) input.title = text(request.title, 500); + if (request.description !== undefined) input.description = text(request.description, 100_000, true); + if (request.op === "create_child") { + input.title = text(request.title, 500); + input.teamId = required(object(row.team).id); + input.parentId = required(row.id); + } else { + if (request.priority !== undefined) { + if (!Number.isInteger(request.priority) || request.priority < 0 || request.priority > 4) + throw new Error("linear_invalid_work_item_input"); + input.priority = request.priority; + } + if (request.state !== undefined) { + const wanted = text(request.state, 256).toLowerCase(); + const states = object(object(row.team).states).nodes; + if (!Array.isArray(states)) throw new Error("linear_invalid_response"); + const matches = states + .map(object) + .filter((s) => s.id === request.state || required(s.name).toLowerCase() === wanted); + if (matches.length !== 1) throw new Error("linear_unknown_or_ambiguous_state"); + input.stateId = required(matches[0]!.id); + } + if (Object.keys(input).length === 0) throw new Error("linear_empty_work_item_update"); + } + const create = request.op === "create_child"; + const result = await deps.query( + create + ? `mutation SwitchboardChild($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { ${FIELDS} } } }` + : `mutation SwitchboardWorkItemUpdate($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { ${FIELDS} } } }`, + create ? { input } : { id: required(row.id), input }, + ); + const payload = object(result[create ? "issueCreate" : "issueUpdate"]); + if (payload.success !== true) throw new Error("linear_work_item_write_failed"); + return item(object(payload.issue)); +} diff --git a/src/channels/slack/attachments.test.ts b/src/channels/slack/attachments.test.ts index e01c080be..8d752cf1e 100644 --- a/src/channels/slack/attachments.test.ts +++ b/src/channels/slack/attachments.test.ts @@ -272,6 +272,9 @@ describe("classifyDocument (secret-file denylist overrides text classification)" expect(classifyDocument("text/plain", "notes.txt")).toBe("text"); expect(classifyDocument("text/csv", "data.csv")).toBe("text"); expect(classifyDocument("application/octet-stream", "main.ts")).toBe("text"); + expect(classifyDocument("application/octet-stream", "src.v1/main.TS")).toBe("text"); + expect(classifyDocument("application/octet-stream", "folder.txt/no-extension")).toBeNull(); + expect(classifyDocument("application/octet-stream", ".ts")).toBeNull(); expect(classifyDocument("text/plain", "app.log")).toBe("text"); }); }); diff --git a/src/channels/slack/attachments.ts b/src/channels/slack/attachments.ts index 1f007bec8..0a225abf5 100644 --- a/src/channels/slack/attachments.ts +++ b/src/channels/slack/attachments.ts @@ -2,7 +2,8 @@ // model (images, PDFs, text/code — never a secret-shaped file) and their // downloads within the per-message and thread-wide budgets. -import { extname } from "node:path"; +import { classifyDocument, isSecretFile } from "../attachmentTypes.js"; +export { classifyDocument, isSecretFile } from "../attachmentTypes.js"; import type { DocumentAttachment, ImageAttachment, StagedFile } from "../../core/types.js"; import { processSecrets } from "../../secrets.js"; @@ -18,113 +19,6 @@ export const MAX_HISTORY_IMAGE_BYTES = 24 * 1024 * 1024; // Document ingestion (mirrors images): PDFs (native document block where the // provider supports it) and text/code/CSV/log files (inlined as fenced text). -const PDF_TYPE = "application/pdf"; -// Text-ish mimetypes beyond the `text/*` family that Slack may report. -// `application/json` is deliberately absent: JSON is a common container for -// credentials (service-account keys, token dumps), so a file is never inlined -// just because Slack tags it application/json — the denylist below plus the -// extension allowlist decide, never the JSON mimetype on its own. -const TEXT_MIME_TYPES = new Set([ - "application/xml", - "application/yaml", - "application/x-yaml", - "application/toml", - "application/x-sh", - "application/javascript", - "application/typescript", -]); -// Mimetypes Slack assigns when it can't identify a file — fall back to the -// filename extension to decide whether it's a text/code file. -const GENERIC_MIME_TYPES = new Set(["application/octet-stream", "binary/octet-stream", ""]); -// Extensions inlined as text under the generic-mimetype fallback. JSON (`.json`, -// `.jsonl`) and config formats (`.env`, `.ini`, `.cfg`, `.conf`) are absent by -// design — the first two are frequent secret containers, the rest are covered by -// the secret-file denylist — so a generic-typed config/JSON file is not "fair -// game" for inlining just because of its extension. -const TEXT_EXTENSIONS = new Set([ - ".txt", - ".md", - ".markdown", - ".log", - ".csv", - ".tsv", - ".rst", - ".yaml", - ".yml", - ".toml", - ".xml", - ".html", - ".htm", - ".css", - ".scss", - ".less", - ".ts", - ".tsx", - ".js", - ".jsx", - ".mjs", - ".cjs", - ".py", - ".rb", - ".go", - ".rs", - ".java", - ".kt", - ".c", - ".h", - ".cpp", - ".hpp", - ".cc", - ".cs", - ".php", - ".swift", - ".sh", - ".bash", - ".zsh", - ".sql", - ".r", - ".pl", - ".lua", - ".dart", - ".scala", - ".clj", - ".ex", - ".exs", - ".vue", - ".svelte", - ".graphql", - ".proto", - ".dockerfile", -]); -// Secret-file denylist — filename shapes whose contents are likely credentials, -// private keys, or secret config. Matching files are skipped-with-note and their -// bytes NEVER reach the model prompt. This OVERRIDES text classification -// (checked before the text-mimetype/extension allowlist), because the whole risk -// is a secret file whose mimetype/extension otherwise reads as harmless text. -// -// Matched on the filename, case-insensitive, and independent of -// `node:path.extname` — which returns "" for dotfiles like `.env` and `.npmrc`, -// so an extname-based check would miss exactly the files that matter most. -const SECRET_FILE_EXTENSIONS = [".pem", ".key", ".p12", ".pfx", ".npmrc", ".netrc", ".ini", ".cfg", ".conf"]; -const SECRET_FILE_PREFIXES = ["id_rsa"]; - -/** Does this filename look like a secret/credential/key/config file? Case- - * insensitive; conservative (a false match only skips a file, never leaks one). - * Exported for tests. */ -export function isSecretFile(name: string | undefined): boolean { - const n = (name ?? "").trim().toLowerCase(); - if (!n) return false; - // `.env` in any position: bare `.env`, dotfiles (`.env.local`, - // `.env.production`), and suffixed configs (`config.env`, `prod.env`). - if (n.includes(".env")) return true; - // SSH / private-key material by filename prefix (`id_rsa`, `id_rsa.pub`, …). - if (SECRET_FILE_PREFIXES.some((p) => n.startsWith(p))) return true; - // Credential JSON blobs — the common shapes secrets ship in. - if (n === "credentials.json") return true; - if (n.endsWith(".json") && (n.includes("service-account") || n.endsWith("-key.json"))) return true; - // Secret-ish extensions, including dotfiles `extname` can't see. - return SECRET_FILE_EXTENSIONS.some((ext) => n.endsWith(ext)); -} const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; // per-file cap; PDFs run larger than images export const MAX_DOCS_PER_MESSAGE = 10; // Thread-wide budget, spent newest-first (recent files matter most). Sized to @@ -132,25 +26,6 @@ export const MAX_DOCS_PER_MESSAGE = 10; export const MAX_HISTORY_DOCS = 20; export const MAX_HISTORY_DOCUMENT_BYTES = 32 * 1024 * 1024; -/** Classify a file for document ingestion: a PDF, an inlinable text/code file, - * or neither. A secret-file denylist match (`isSecretFile`) is classified as - * neither — before any text check — so credentials never inline. Otherwise text - * detection prefers the mimetype and falls back to the filename extension only - * when Slack reports a generic/unknown type. Exported for tests. */ -export function classifyDocument(mimetype: string | undefined, name: string | undefined): "pdf" | "text" | null { - if (mimetype === PDF_TYPE) return "pdf"; - // Secret-file denylist OVERRIDES text classification: a credentials/key/config - // file is skipped, never decoded into the prompt, even when its mimetype - // (application/json, text/plain) or extension would otherwise mark it text. - if (isSecretFile(name)) return null; - const mt = mimetype ?? ""; - if (mt.startsWith("text/") || TEXT_MIME_TYPES.has(mt)) return "text"; - if (GENERIC_MIME_TYPES.has(mt) && TEXT_EXTENSIONS.has(extname(name ?? "").toLowerCase())) { - return "text"; - } - return null; -} - export interface SlackFile { id?: string; name?: string; diff --git a/src/channels/startup.test.ts b/src/channels/startup.test.ts new file mode 100644 index 000000000..411f579b2 --- /dev/null +++ b/src/channels/startup.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { channelsToStart, linearBridgeBaseUrl } from "./startup.js"; +import { RemoteLinearInbox } from "./linear/bridge.js"; + +describe("channel startup", () => { + it("routes Linear intake to a separate local edge while keeping the bot's public origin", async () => { + const env = { LINEAR_BRIDGE_URL: "http://localhost:8080", PUBLIC_BASE_URL: "http://localhost:8082" }; + let requested: string | undefined; + const inbox = new RemoteLinearInbox({ + baseUrl: linearBridgeBaseUrl(env), + token: "bridge", + fetch: async (url) => { + requested = String(url); + return Response.json({ result: null }); + }, + }); + await inbox.claim(); + expect(requested).toBe("http://localhost:8080/internal/linear"); + expect(env.PUBLIC_BASE_URL).toBe("http://localhost:8082"); + expect(linearBridgeBaseUrl({ PUBLIC_BASE_URL: "https://bot.example" })).toBe("https://bot.example"); + }); + it("rejects missing or unsafe Linear bridge configuration before starting the consumer", () => { + expect(() => linearBridgeBaseUrl({})).toThrow("LINEAR_BRIDGE_URL or PUBLIC_BASE_URL"); + for (const url of [ + "http://remote.example", + "https://user:secret@bot.example", + "https://bot.example/path", + "https://bot.example?token=x", + "invalid", + ]) { + expect(() => linearBridgeBaseUrl({ LINEAR_BRIDGE_URL: url, PUBLIC_BASE_URL: "https://bot.example" })).toThrow( + "LINEAR_BRIDGE_URL", + ); + } + }); + it("starts Linear without Slack credentials and retains combined installations", () => { + expect(channelsToStart(new Set(["LINEAR_BRIDGE_TOKEN"]))).toEqual({ slack: false, linear: true }); + expect(channelsToStart(new Set(["LINEAR_BRIDGE_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"]))).toEqual({ + slack: true, + linear: true, + }); + expect(channelsToStart(new Set(["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"]))).toEqual({ slack: true, linear: false }); + }); + it("names incomplete Slack credentials even when Linear is configured", () => { + expect(() => channelsToStart(new Set(["SLACK_BOT_TOKEN", "LINEAR_BRIDGE_TOKEN"]))).toThrow("SLACK_APP_TOKEN"); + expect(() => channelsToStart(new Set(["SLACK_APP_TOKEN", "LINEAR_BRIDGE_TOKEN"]))).toThrow("SLACK_BOT_TOKEN"); + expect(() => channelsToStart(new Set())).toThrow("No channel configured"); + }); +}); diff --git a/src/channels/startup.ts b/src/channels/startup.ts new file mode 100644 index 000000000..f1df6da84 --- /dev/null +++ b/src/channels/startup.ts @@ -0,0 +1,34 @@ +/** Startup checks presence only; credentials remain in the secret store. */ +export function channelsToStart(present: ReadonlySet): { slack: boolean; linear: boolean } { + const bot = present.has("SLACK_BOT_TOKEN"), + app = present.has("SLACK_APP_TOKEN"); + if (bot !== app) throw new Error(`Missing required env var ${bot ? "SLACK_APP_TOKEN" : "SLACK_BOT_TOKEN"}`); + const linear = present.has("LINEAR_BRIDGE_TOKEN"); + if (!bot && !linear) throw new Error("No channel configured: set Slack tokens or LINEAR_BRIDGE_TOKEN"); + return { slack: bot, linear }; +} + +/** The edge can run separately from the server that hosts run pages. */ +export function linearBridgeBaseUrl(env: { LINEAR_BRIDGE_URL?: string; PUBLIC_BASE_URL?: string }): string { + const value = env.LINEAR_BRIDGE_URL ?? env.PUBLIC_BASE_URL; + if (!value) throw new Error("LINEAR_BRIDGE_TOKEN requires LINEAR_BRIDGE_URL or PUBLIC_BASE_URL"); + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("LINEAR_BRIDGE_URL must be an HTTPS or HTTP loopback origin"); + } + if ( + (url.protocol !== "https:" && + !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) + throw new Error( + "LINEAR_BRIDGE_URL must be an HTTPS or HTTP loopback origin without credentials, a path, query or fragment", + ); + return url.origin; +} diff --git a/src/config.test.ts b/src/config.test.ts index ac74fb993..adb87fc20 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -612,7 +612,7 @@ describe("grants config — the one shape", () => { it("an unknown actor id prefix fails the load naming the id", () => { expect(() => load(withGrants(` "discord:123":\n actions: all\n`))).toThrow( - /config\.yaml: grants\["discord:123"\].*slack:, http:, mcp:, access:, schedule:/, + /config\.yaml: grants\["discord:123"\].*slack:, linear:, http:, mcp:, access:, schedule:/, ); }); @@ -630,7 +630,7 @@ describe("grants config — the one shape", () => { for (const id of ["slack:U*", "schedule:*", "access:svc:*", "agent:*"]) { expect(() => load(withGrants(` "${id}":\n actions: all\n`)), id).toThrow( new RegExp( - `config\\.yaml: grants\\["${id.replace(/\*/g, "\\*")}"\\].*slack:\\*, http:\\*, mcp:\\*, access:\\*`, + `config\\.yaml: grants\\["${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\].*slack:\\*, linear:\\*, http:\\*, mcp:\\*, access:\\*`, ), ); } diff --git a/src/core/authz/actor.test.ts b/src/core/authz/actor.test.ts index c7e2a0b26..400eefea4 100644 --- a/src/core/authz/actor.test.ts +++ b/src/core/authz/actor.test.ts @@ -43,6 +43,27 @@ const source: GrantsSource = { }; const lookup = (id: string) => grantsFor(id, source); +describe("Linear actor authorization", () => { + it("namespaces the workspace and human, shares the chat baseline and keeps grants independent of Slack", () => { + const parsed = parseGrantsConfig({ + "linear:org:alice": { actions: ["repo:write"] }, + "linear:*": { actions: ["runs:read"] }, + "slack:alice": { actions: "all", repos: "all" }, + }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) throw new Error(parsed.errors.join("; ")); + const a = resolveChatActor( + { userId: "linear:org:alice", channelId: "linear:org:team", threadKey: "linear:org:session" }, + (id) => grantsFor(id, { grants: parsed.grants, agentNames: ["general"] }), + ); + expect(a.kind).toBe("user"); + expect(a.id).toBe("linear:org:alice"); + expect(a.grants.actions).toEqual(set(...CHAT_OPEN_ACTIONS, "agent:run:general", "runs:read", "repo:write")); + expect(a.grants.repos).toEqual(set()); + expect(grantsFor("linear:other:alice", { grants: parsed.grants }).actions).not.toContain("repo:write"); + }); +}); + describe("actorIdFor — platform-namespaced ids (invariant 4)", () => { it("one prefix per surface; Access service tokens under access:svc:", () => { expect(actorIdFor("slack", "UALICE")).toBe("slack:UALICE"); diff --git a/src/core/authz/actor.ts b/src/core/authz/actor.ts index c5737bcdf..3296bfe14 100644 --- a/src/core/authz/actor.ts +++ b/src/core/authz/actor.ts @@ -10,7 +10,8 @@ import type { Actor, Grants } from "./types.js"; // says what an actor MAY do. /** What an adapter can prove about a caller's surface. */ -export type ActorSurface = "slack" | "http" | "mcp" | "access-browser" | "access-service" | "cli" | "schedule"; +export type ActorSurface = + "slack" | "linear" | "http" | "mcp" | "access-browser" | "access-service" | "cli" | "schedule"; export interface ActorInput { surface: ActorSurface; @@ -33,6 +34,8 @@ export function actorIdFor(surface: ActorSurface, subjectId: string): string { switch (surface) { case "slack": return `slack:${subjectId}`; + case "linear": + return `linear:${subjectId}`; case "http": return `http:${subjectId}`; case "mcp": @@ -51,6 +54,7 @@ export function actorIdFor(surface: ActorSurface, subjectId: string): string { function kindFor(surface: ActorSurface): Actor["kind"] { switch (surface) { case "slack": + case "linear": case "access-browser": case "cli": return "user"; @@ -99,6 +103,7 @@ export function chatActorOf( const CHAT_SURFACES: Readonly> = { slack: "slack", + linear: "linear", http: "http", mcp: "mcp", cli: "cli", diff --git a/src/core/authz/grants.test.ts b/src/core/authz/grants.test.ts index 63d3c6b61..16d04f956 100644 --- a/src/core/authz/grants.test.ts +++ b/src/core/authz/grants.test.ts @@ -104,7 +104,7 @@ describe("parseGrantsConfig — the native `grants` block", () => { expect(p.ok, id).toBe(false); if (p.ok) continue; expect(p.errors).toEqual([expect.stringContaining(`grants["${id}"]`)]); - expect(p.errors[0], id).toMatch(/slack:\*, http:\*, mcp:\*, access:\*/); + expect(p.errors[0], id).toMatch(/slack:\*, linear:\*, http:\*, mcp:\*, access:\*/); } }); }); diff --git a/src/core/authz/grants.ts b/src/core/authz/grants.ts index 24d3fa4f5..ea4bd2234 100644 --- a/src/core/authz/grants.ts +++ b/src/core/authz/grants.ts @@ -16,7 +16,7 @@ export const ALL_GRANTS: Grants = Object.freeze({ actions: "all", channels: "all /** The namespaces a native `grants` key may use (invariant 4). `cli:` is not * configurable (the local CLI always holds everything) and `agent:` actors * derive their grants from their principal, so neither is listed. */ -export const GRANT_ACTOR_PREFIXES = ["slack", "http", "mcp", "access", "schedule"] as const; +export const GRANT_ACTOR_PREFIXES = ["slack", "linear", "http", "mcp", "access", "schedule"] as const; /** The surfaces whose every authenticated actor may be granted at once with one * `:*` entry: who may authenticate there is decided elsewhere (Cloudflare @@ -25,7 +25,7 @@ export const GRANT_ACTOR_PREFIXES = ["slack", "http", "mcp", "access", "schedule * `schedule:` (a schedule is an individually named job the registry declares), * `access:svc:` (a service token is a named credential, not a browser session — * `access:*` never reaches one), and the unconfigurable `cli:` and `agent:`. */ -export const SURFACE_GRANT_PREFIXES = ["slack", "http", "mcp", "access"] as const; +export const SURFACE_GRANT_PREFIXES = ["slack", "linear", "http", "mcp", "access"] as const; /** The `:*` key for the surface `actorId` authenticated on — `slack:*` for * `slack:U…`, `access:*` for a browser `access:` — or undefined when its @@ -45,13 +45,15 @@ export function agentRunAction(agent: string): string { } /** The actions of the commands the `open` chat gate admitted before they became - * policy rows: what EVERY Slack user holds. A command group not listed here + * policy rows: what every authenticated Slack or Linear person holds. A command group not listed here * is closed to chat users until config grants it (fail-closed). * `config:write` is not here: `config set channel` is held where `grants` say * so (admins through `actions: all`) and nowhere else. */ export const CHAT_OPEN_ACTIONS: readonly string[] = [ "help:read", "status:read", + "work-items:read", + "runs:stop:self", "config:read", "repo:read", "friction:read", @@ -251,7 +253,7 @@ export interface GrantsTable { * replacing an actor's own entry, never listed as an actor (a surface is not * someone `adminsHint` can name). */ surfaces: Map; - /** What every `slack:` user holds, listed or not: the open chat commands and `agent:run:` for every unrestricted agent. */ + /** What every Slack or Linear person holds, listed or not: the open chat commands and `agent:run:` for every unrestricted agent. */ everyone: Grants; /** What every Access browser session (`access:`, never `access:svc:`) holds: * each registered group's read and the two personal chat writes. */ @@ -259,13 +261,13 @@ export interface GrantsTable { restrict: Restriction; } -/** The baseline an actor id inherits by its namespace, listed or not: a `slack:` - * user holds what `everyone` does; an Access browser session holds every +/** The baseline an actor id inherits by its namespace, listed or not: a Slack + * or Linear person holds what `everyone` does; an Access browser session holds every * `:read`. Every other namespace (`schedule:`, `access:svc:`, `http:`, * `mcp:`) is a credential or a job that holds exactly what names it — an * unlisted one is `NO_GRANTS` (fail-closed). */ export function namespaceBaseline(actorId: string, table: Pick): Grants { - if (actorId.startsWith("slack:")) return table.everyone; + if (actorId.startsWith("slack:") || actorId.startsWith("linear:")) return table.everyone; if (actorId.startsWith("access:") && !actorId.startsWith("access:svc:")) return table.browser; return NO_GRANTS; } diff --git a/src/core/authz/policy.test.ts b/src/core/authz/policy.test.ts index a93c7a087..4d07af818 100644 --- a/src/core/authz/policy.test.ts +++ b/src/core/authz/policy.test.ts @@ -60,6 +60,49 @@ const commandRow = (action: string, commandId: string, allow: readonly Actor[], }); const CASES: Record = { + "runs:stop run [has-grant(runs:stop:self) & is-self]": { + allow: [ + [ + { ...A.noGrants, grants: { ...A.noGrants.grants, actions: new Set(["runs:stop:self"]) } }, + run({ channel: "priv", userId: A.noGrants.id }), + ], + ], + deny: [ + [A.noGrants, run({ channel: "priv", userId: A.noGrants.id })], + [ + { ...A.noGrants, grants: { ...A.noGrants.grants, actions: new Set(["runs:stop:self"]) } }, + run({ channel: "priv", userId: "slack:UBOB" }), + ], + ], + }, + "runs:stop run [has-grant(runs:write) & member-of]": { + allow: [[A.member, run({ channel: "priv", userId: "slack:UERIN" })]], + deny: [[A.nonMember, foreignPrivRun]], + }, + "runs:stop run [has-grant(runs:write) & all-channels]": { + allow: [[A.admin, foreignPrivRun]], + deny: [[A.nonMember, foreignPrivRun]], + }, + ...Object.fromEntries( + ["read", "write"].map((verb) => { + const action = `work-items:${verb}`; + const reader = { + ...A.noGrants, + grants: { ...A.noGrants.grants, actions: new Set([action]) }, + memberOf: new Set([CHANNELS.priv.id]), + }; + return [ + `${action} channel [has-grant(${action}) & member-of]`, + { + allow: [[reader, channel(CHANNELS.priv)]], + deny: [ + [A.noGrants, channel(CHANNELS.pub1)], + [{ ...reader, memberOf: new Set() }, channel(CHANNELS.priv)], + ], + }, + ]; + }), + ), // Record 0037: who may point the bot at a channel's thread. Asked for a // pointing actor (one membership, the origin, no grants): public from // anywhere, private only from inside, denied elsewhere for an admin too; diff --git a/src/core/authz/policy.ts b/src/core/authz/policy.ts index b4a61aa0e..106646423 100644 --- a/src/core/authz/policy.ts +++ b/src/core/authz/policy.ts @@ -55,6 +55,11 @@ export const POLICY: readonly Rule[] = [ // Stopping a run needs the write grant AND visibility of the run. { action: "runs:write", resource: "run", when: [grant("runs:write"), MEMBER_OF] }, { action: "runs:write", resource: "run", when: [grant("runs:write"), ALL_CHANNELS] }, + // Native session cancellation: a person may stop their own run without + // acquiring operator rights to other runs or run-management commands. + { action: "runs:stop", resource: "run", when: [grant("runs:stop:self"), IS_SELF] }, + { action: "runs:stop", resource: "run", when: [grant("runs:write"), MEMBER_OF] }, + { action: "runs:stop", resource: "run", when: [grant("runs:write"), ALL_CHANNELS] }, // List-shaped `runs.*`: the grant admits the command; the store predicate narrows the rows. { action: "runs:read", resource: "command", when: [grant("runs:read")] }, { action: "runs:write", resource: "command", when: [grant("runs:write")] }, @@ -67,6 +72,11 @@ export const POLICY: readonly Rule[] = [ // no caller until the references dispatch step lands behind its flag. { action: "conversation:read", resource: "channel", when: [MEMBER_OF] }, + // Work tracking requires both the action grant and current platform access. + // Its adapter supplies fresh memberOf facts and caps public access for guests. + { action: "work-items:read", resource: "channel", when: [grant("work-items:read"), MEMBER_OF] }, + { action: "work-items:write", resource: "channel", when: [grant("work-items:write"), MEMBER_OF] }, + // ── review ─────────────────────────────────────────────────────────────── // `review abridge` spends one Opus-class call and rewrites a stored record: // the grant admits the command (admins through `all`, operators by name; diff --git a/src/core/boot.test.ts b/src/core/boot.test.ts index bfa10bb2f..4cedff2c6 100644 --- a/src/core/boot.test.ts +++ b/src/core/boot.test.ts @@ -335,7 +335,7 @@ describe("reclaimRuns", () => { card: { channel: "C1", ts: "alive.1" }, threadKey: "slack:C1:1.0", startedAt: expect.any(Number), - meta: { agent: "review" }, + meta: { agent: "review", userId: "slack:UALICE" }, }), ]); expect(ledger.live.get("alive")!.ownerGen).toBe("g1"); diff --git a/src/core/boot.ts b/src/core/boot.ts index 0e264b7cd..01fdce835 100644 --- a/src/core/boot.ts +++ b/src/core/boot.ts @@ -82,7 +82,7 @@ export interface LiveElsewhere { * thread is steered into the run's durable inbox, not run afresh. */ threadKey: string; startedAt: number; - meta: { agent?: string }; + meta: { agent?: string; userId?: string }; } /** A reclaimed run the resume launcher continues (item 38): its row (ours @@ -261,7 +261,7 @@ export async function reclaimRuns(opts: ReclaimOptions): Promise card: row.card, threadKey: row.threadKey, startedAt: row.startedAt, - meta: { ...(row.meta.agent !== undefined ? { agent: row.meta.agent } : {}) }, + meta: { userId: row.meta.userId, ...(row.meta.agent !== undefined ? { agent: row.meta.agent } : {}) }, }); } } catch (err) { diff --git a/src/core/budgets.ts b/src/core/budgets.ts index 9c45abfae..d50828c72 100644 --- a/src/core/budgets.ts +++ b/src/core/budgets.ts @@ -28,6 +28,22 @@ export const minutesToMs = (minutes: number): number => minutes * MINUTE_MS; * as a literal where it is used. */ export const DAY_MS = 24 * 60 * MINUTE_MS; +/** Linear transport lifetimes: browser authorization, token renewal, signed + * webhook freshness, consumer leases and completed-delivery deduplication. */ +export const LINEAR_TIMING = { + apiTimeoutMs: 10_000, + /** One authenticated batch can read 20 files, each with its own API deadline. */ + fileBridgeTimeoutMs: 4 * MINUTE_MS, + childBridgeTimeoutMs: 2 * MINUTE_MS, + progressMs: 5_000, + oauthStateMs: 10 * MINUTE_MS, + refreshMarginMs: 5 * MINUTE_MS, + webhookSkewMs: MINUTE_MS, + deliveryLeaseMs: 2 * MINUTE_MS, + ackLeaseMs: MINUTE_MS, + deliveryRetentionMs: 14 * DAY_MS, +} as const; + /** How long the confirmation a routed write is offered as stays pending * (docs/decisions/0044; docs/reference/specs/routing-and-config.md item 25): * the connect ticket's ten minutes. The bot passes it to the config object, diff --git a/src/core/coordinator/clarification.test.ts b/src/core/coordinator/clarification.test.ts new file mode 100644 index 000000000..c007ea893 --- /dev/null +++ b/src/core/coordinator/clarification.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import type { IncomingMessage } from "../types.js"; +import type { RunView } from "../runsService.js"; +import type { CoordinatorInstance, CoordinatorUnit } from "./contract.js"; +import { InMemoryCoordinatorInstanceStore } from "./instanceStore.js"; +import { coordinatorClarificationFor, CoordinatorClarificationRefusal } from "./clarification.js"; + +async function fixture(platform = "linear", preset: "coding" | "review" = "coding") { + const msg: IncomingMessage = { + userId: `${platform}:alice`, + channelId: `${platform}:team`, + threadKey: `${platform}:session`, + text: "Keep the current behavior.", + }; + const instance: CoordinatorInstance = { + id: "plan-answer", + kind: "ship", + userId: msg.userId, + channelId: msg.channelId, + threadKey: `${platform}:parent`, + repo: "acme/api", + branch: "plan/answer/u1", + base: "release", + createdAt: 0, + caps: { maxRounds: 3, maxMinutes: 60 }, + }; + const unit: CoordinatorUnit = { + instanceId: instance.id, + unit: "U10", + slug: "u1", + branch: instance.branch, + threadKey: preset === "coding" ? msg.threadKey : `${platform}:coding`, + reviewThread: { threadKey: preset === "review" ? msg.threadKey : `${platform}:review` }, + pr: { number: 7, url: "https://github.com/acme/api/pull/7" }, + dependsOn: [], + rounds: [], + startedAt: 60_000, + }; + const newest: RunView = { + id: "run-question", + finished: true, + status: "completed", + awaitingInput: true, + agent: preset, + startedAt: 60_000, + eventCount: 1, + userId: msg.userId, + channelId: msg.channelId, + threadKey: msg.threadKey, + parentInstanceId: instance.id, + idempotencyKey: `${instance.id}:U10/0/${preset}`, + }; + const instances = new InMemoryCoordinatorInstanceStore(); + await instances.put(instance); + await instances.putUnits([unit]); + return { msg, instance, unit, newest, instances, directives: { text: msg.text }, now: 11 * 60_000 }; +} + +describe("coordinator clarification context", () => { + it.each(["linear", "slack"])("keeps the %s unit branch, round, base and remaining clock", async (platform) => { + const f = await fixture(platform); + expect(await coordinatorClarificationFor(f)).toMatchObject({ + preset: "coding", + remainingMinutes: 50, + targetText: expect.stringContaining(f.unit.branch), + tag: { parentInstanceId: f.instance.id, idempotencyKey: f.newest.idempotencyKey, base: "release" }, + }); + }); + + it.each([false, true])("resumes a review against the unit's recorded PR (legacy thread: %s)", async (legacy) => { + const f = await fixture("linear", "review"); + if (!legacy) { + f.unit.threadKey = f.msg.threadKey; + delete f.unit.reviewThread; + await f.instances.putUnits([f.unit]); + } + expect(await coordinatorClarificationFor(f)).toMatchObject({ + preset: "review", + targetText: "https://github.com/acme/api/pull/7", + }); + await f.instances.putUnits([{ ...f.unit, pr: undefined }]); + await expect(coordinatorClarificationFor(f)).rejects.toThrow(CoordinatorClarificationRefusal); + }); + + it.each(["userId", "channelId", "threadKey", "authenticatedAs", "postedBy"] as const)( + "refuses a reply with a different %s", + async (key) => { + const f = await fixture(); + f.msg[key] = "linear:other"; + await expect(coordinatorClarificationFor(f)).rejects.toThrow(CoordinatorClarificationRefusal); + }, + ); + + it("refuses an ended unit, a foreign round and an exhausted clock", async () => { + const f = await fixture(); + await f.instances.putUnits([{ ...f.unit, ending: { kind: "stopped", report: "Stopped", at: f.now } }]); + await expect(coordinatorClarificationFor(f)).rejects.toThrow(CoordinatorClarificationRefusal); + await f.instances.putUnits([f.unit]); + await expect( + coordinatorClarificationFor({ ...f, newest: { ...f.newest, idempotencyKey: "other:U10/0/coding" } }), + ).rejects.toThrow(CoordinatorClarificationRefusal); + await expect(coordinatorClarificationFor({ ...f, now: 62 * 60_000 })).rejects.toThrow(/time budget/); + }); + + it("leaves an explicit new agent and settled work independent", async () => { + const f = await fixture(); + expect( + await coordinatorClarificationFor({ ...f, directives: { text: f.msg.text, agent: "general" } }), + ).toBeUndefined(); + expect( + await coordinatorClarificationFor({ ...f, newest: { ...f.newest, awaitingInput: undefined } }), + ).toBeUndefined(); + }); +}); diff --git a/src/core/coordinator/clarification.ts b/src/core/coordinator/clarification.ts new file mode 100644 index 000000000..1193836c0 --- /dev/null +++ b/src/core/coordinator/clarification.ts @@ -0,0 +1,112 @@ +import type { RequestDirectives } from "../../directives.js"; +import type { GithubApi } from "../../execution/githubApi.js"; +import { MIN_BOUNDARY_MINUTES } from "../../config/validate.js"; +import { MINUTE_MS } from "../budgets.js"; +import type { IncomingMessage } from "../types.js"; +import type { RunView, RunsService } from "../runsService.js"; +import { childRequestText } from "../dispatch/spawn.js"; +import { contractFor } from "./briefs.js"; +import { + unitOfIdempotencyKey, + type CoordinatorInstance, + type CoordinatorUnit, + type CoordinatorTag, +} from "./contract.js"; +import type { CoordinatorInstanceStore } from "./instanceStore.js"; + +export class CoordinatorClarificationRefusal extends Error {} + +export interface CoordinatorClarification { + preset: "coding" | "review"; + tag: CoordinatorTag; + instance: CoordinatorInstance; + unit: CoordinatorUnit; + targetText: string; + remainingMinutes?: number; +} + +/** Stored lineage selects the task; the dispatcher still authorizes the new turn. */ +export async function coordinatorClarificationFor(input: { + newest: RunView | undefined; + msg: IncomingMessage; + directives: RequestDirectives; + instances: CoordinatorInstanceStore | undefined; + now: number; +}): Promise { + const { newest: run, msg, directives, instances, now } = input; + if (!run?.finished || run.status !== "completed" || !run.awaitingInput || !run.parentInstanceId) return; + if (run.agent !== "coding" && run.agent !== "review") return; + // An explicit change of agent starts independent work, not a unit continuation. + if (directives.agent !== undefined && directives.agent !== run.agent) return; + if (run.userId !== msg.userId || run.authenticatedAs !== msg.authenticatedAs) + throw new CoordinatorClarificationRefusal("Only the original requester can answer this coordinator question."); + if (!instances || !run.idempotencyKey) + throw new CoordinatorClarificationRefusal("The coordinator context is unavailable; please retry."); + const instance = await instances.get(run.parentInstanceId); + if ( + !instance || + instance.userId !== msg.userId || + instance.channelId !== msg.channelId || + instance.authenticatedAs !== msg.authenticatedAs || + instance.postedBy !== msg.postedBy + ) + throw new CoordinatorClarificationRefusal("The coordinator requester could not be verified."); + if (!run.idempotencyKey.startsWith(`${instance.id}:`)) + throw new CoordinatorClarificationRefusal("The coordinator round could not be verified."); + const unitId = unitOfIdempotencyKey(run.idempotencyKey); + const unit = (await instances.listUnits(instance.id)).find((row) => row.unit === unitId); + const thread = run.agent === "review" ? (unit?.reviewThread?.threadKey ?? unit?.threadKey) : unit?.threadKey; + if (!unit || unit.ending || thread !== msg.threadKey) + throw new CoordinatorClarificationRefusal("This coordinator question no longer belongs to an active unit."); + const remainingMinutes = + instance.caps === undefined + ? undefined + : Math.floor(instance.caps.maxMinutes - (now - (unit.startedAt ?? instance.createdAt)) / MINUTE_MS); + if (remainingMinutes !== undefined && remainingMinutes < MIN_BOUNDARY_MINUTES) + throw new CoordinatorClarificationRefusal( + "This coordinator's time budget has elapsed; re-issue the task to continue it.", + ); + if (run.agent === "review" && unit.pr === undefined) + throw new CoordinatorClarificationRefusal("The coordinator's review target is unavailable; please retry."); + return { + preset: run.agent, + tag: { + parentInstanceId: instance.id, + idempotencyKey: run.idempotencyKey, + ...(instance.base !== undefined ? { base: instance.base } : {}), + }, + instance, + unit, + targetText: + run.agent === "review" + ? `https://github.com/${instance.repo}/pull/${unit.pr!.number}` + : childRequestText({ preset: "coding", repo: instance.repo, ref: unit.branch, prompt: "" }), + ...(remainingMinutes !== undefined ? { remainingMinutes } : {}), + }; +} + +/** Rebuild the unit contract only after fresh agent and repository authorization. */ +export async function coordinatorClarificationContract( + context: CoordinatorClarification, + deps: { github: Pick; runs: Pick }, +) { + const { instance, unit } = context; + return contractFor(instance, unit, { + readRepoFile: async (path, opts) => { + try { + const file = await deps.github.readFile(instance.repo, path, instance.base ?? "main", opts); + return { content: file.content, truncated: file.truncated }; + } catch { + return undefined; + } + }, + readRunFacts: async () => undefined, + readShipRequest: async () => { + if (!instance.runId) return undefined; + const result = await deps.runs.getRun(instance.runId, { include: "messages" }); + if (!result.ok || result.value.userId !== instance.userId) return undefined; + const input = result.value.events?.find((event) => event.type === "input"); + return input?.type === "input" ? input.text : undefined; + }, + }); +} diff --git a/src/core/coordinator/driver.test.ts b/src/core/coordinator/driver.test.ts index a71d0ab3f..a7f82ec1d 100644 --- a/src/core/coordinator/driver.test.ts +++ b/src/core/coordinator/driver.test.ts @@ -1524,3 +1524,59 @@ describe("the plan runner's driver — a unit whose pull request already merged expect(bad.of("finish")).toEqual([{ parentInstanceId: INSTANCE, outcome: "failed" }]); }); }); + +describe("coordinator driver clarification", () => { + it("reports an unanswered question's deadline and finishes without another child or merge", async () => { + const s = steps(); + const deadline = T0 + 240 * MIN; + const question = { id: "run-c0", finished: true, status: "completed", awaitingInput: true }; + const b = bot({ + plan: [planAnswer([row("U10")])], + "unit-start": [started("U10")], + branch: [branched("U10")], + spawn: [spawned("run-c0")], + "read-record": [record(question, deadline - 10_000), record(question, deadline)], + "pr-check": [prNone()], + round: [acked()], + "unit-end": [ok({ ok: true, told: true }, deadline)], + finish: [ok({ ok: true, runId: "run-parent" }, deadline)], + }); + await runPlan(s.runner, b.client, INSTANCE); + expect(s.taken.filter((step) => step.kind === "sleep")).toEqual([expect.objectContaining({ ms: 10_000 })]); + expect(b.of("spawn")).toHaveLength(1); + expect(b.of("merge")).toHaveLength(0); + expect(b.of("unit-end")).toEqual([ + expect.objectContaining({ ending: expect.objectContaining({ kind: "wall_clock_cap" }) }), + ]); + expect(b.of("finish")).toHaveLength(1); + }); + + it("keeps polling a question through durable sleeps before proceeding with the completed result", async () => { + const s = steps(); + const b = bot({ + plan: [planAnswer([row("U10")])], + "unit-start": [started("U10")], + branch: [branched("U10")], + spawn: [spawned("run-c0"), spawned("run-r1", T0 + 10 * MIN)], + "read-record": [ + record({ id: "run-c0", finished: true, status: "completed", awaitingInput: true }, T0 + MIN), + record({ id: "run-c0", finished: true, status: "completed", awaitingInput: true }, T0 + 2 * MIN), + codingDone("run-answer", T0 + 10 * MIN), + reviewApproved("run-r1", T0 + 20 * MIN), + ], + "pr-check": [prNone(), prOpen(T0 + 10 * MIN)], + round: [acked(), acked(), acked(), acked()], + merge: [ok({ ok: true, outcome: "merged", sha: MERGED }, T0 + 21 * MIN)], + "unit-end": [ok({ ok: true, told: true }, T0 + 21 * MIN)], + finish: [ok({ ok: true, runId: "run-parent" }, T0 + 21 * MIN)], + }); + const result = await runPlan(s.runner, b.client, INSTANCE); + expect(result.outcome).toBe("completed"); + expect(s.taken.filter((step) => step.kind === "sleep")).toHaveLength(2); + expect(b.of("spawn")).toHaveLength(2); + expect(b.of("read-record")).toHaveLength(4); + const routes = b.calls.map((call) => call.route); + const firstRead = routes.indexOf("read-record"); + expect(routes.slice(firstRead, firstRead + 3)).toEqual(["read-record", "read-record", "read-record"]); + }); +}); diff --git a/src/core/coordinator/driver.ts b/src/core/coordinator/driver.ts index d7a540b65..64d4f6f23 100644 --- a/src/core/coordinator/driver.ts +++ b/src/core/coordinator/driver.ts @@ -260,7 +260,8 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn { const run = a.body.run; if (a.body.ok !== true || !isRecord(run) || typeof run.finished !== "boolean") throw new UnreadableAnswer("read-record", a, "run"); - if (!run.finished) return { type: "read-record", step, run: { finished: false }, at: a.body.at }; + const identity = typeof run.id === "string" ? { runId: run.id } : {}; + if (!run.finished) return { type: "read-record", step, run: { finished: false, ...identity }, at: a.body.at }; if (typeof run.status !== "string") throw new UnreadableAnswer("read-record", a, "status"); // The typed artifacts as the bot's record carries them — shape-checked where // they were written (the run record's validator), read here as they are. @@ -286,7 +287,9 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn { step, run: { finished: true, + ...identity, status: run.status as Extract["status"], + ...(run.awaitingInput === true ? { awaitingInput: true as const } : {}), ...(finalReply !== undefined ? { finalReply } : {}), ...(pr !== undefined ? { pr } : {}), ...(headSha !== undefined ? { headSha } : {}), diff --git a/src/core/descriptionTurn.ts b/src/core/descriptionTurn.ts index 2551ce1fc..0d214d976 100644 --- a/src/core/descriptionTurn.ts +++ b/src/core/descriptionTurn.ts @@ -42,7 +42,7 @@ import type { Span } from "./trace/types.js"; * (`postStepLease`, decision 0046). */ export const DESCRIPTION_TURN_MAX_TURNS = 8; /** The tools the turn may call (model-proxy item 6): read the pull request - * and the change, submit the description, report on the card — never edit + * and the change, submit the description, ask for missing input, report on the card — never edit * or push. The proxy trims each request's tool list to these. */ export const DESCRIPTION_TURN_TOOLS: readonly string[] = [ "github_issue_get", @@ -52,6 +52,7 @@ export const DESCRIPTION_TURN_TOOLS: readonly string[] = [ "grep", "submit_pr_description", "update_status", + "request_input", ]; /** What the turn is about: the pushed branch, its proven head, and the open @@ -113,6 +114,7 @@ export function descriptionFollowUp(t: DescriptionTurnTarget): string { `2. Compare them with the change as it now stands at \`${short}\` (diff_digest where available; otherwise git diff against the base).`, `3. Call submit_pr_description with the object that describes the PR as it is NOW: carry forward what the existing body says that is still true (a dependency bump's release notes belong in why), add what you pushed, and anchor the pointers at \`${t.headSha}\`.`, `Do not push again and do not open a PR — Switchboard re-renders the PR's title and body from your object at ${short}. Then reply in one line.`, + "If missing information from the requester prevents an accurate description, call request_input with the question and end the turn. Switchboard waits for the answer before updating the PR.", ].join("\n"); } diff --git a/src/core/dispatch/admission.test.ts b/src/core/dispatch/admission.test.ts index c480c2934..af1534d09 100644 --- a/src/core/dispatch/admission.test.ts +++ b/src/core/dispatch/admission.test.ts @@ -251,6 +251,58 @@ async function resumeOf( } describe("admit — the thread admission claim", () => { + it("defers another requester without writing either inbox, locally or across generations", async () => { + for (const where of ["here", "elsewhere"]) { + for (const owner of ["slack:OTHER", undefined]) { + const f = setup("use my permissions"); + f.io.isolateFollowUps = true; + if (where === "here") f.admission.claim(THREAD, { agent: "general", userId: owner }); + else + f.elsewhere.replace([ + { threadKey: THREAD, runId: "run-1", startedAt: 4000, meta: { agent: "general", userId: owner } }, + ]); + expect(await admit(f.deps, f.ctx)).toEqual({ kind: "deferred" }); + expect(f.ledger.pushes).toEqual([]); + expect(f.admission.get(THREAD)?.inbox.size ?? 0).toBe(0); + expect(f.replies).toEqual([]); + if (where === "elsewhere") expect(f.admission.get(THREAD)).toBeUndefined(); + } + } + }); + it("allows the original requester to steer isolated work here or across generations", async () => { + for (const where of ["here", "elsewhere"]) { + const f = setup("continue", { ledger: new RecordingLedger({ pushSeq: () => 1 }) }); + f.io.isolateFollowUps = true; + if (where === "here") + f.admission.claim(THREAD, { agent: "general", userId: f.message.userId }).live.runId = "run-1"; + else + f.elsewhere.replace([ + { threadKey: THREAD, runId: "run-1", startedAt: 4000, meta: { agent: "general", userId: f.message.userId } }, + ]); + expect(await admit(f.deps, f.ctx)).toEqual({ kind: "steered", where }); + } + }); + it("uses a channel's nonterminal acknowledgement for a follow-up here or on another generation", async () => { + for (const where of ["here", "elsewhere"]) { + const admission = new ThreadAdmission(); + const elsewhere = new ThreadsElsewhere(); + if (where === "here") admission.claim(THREAD, { agent: "general", now: 4_000 }).live.runId = "run-1"; + else elsewhere.replace([{ threadKey: THREAD, runId: "run-1", startedAt: 4_000, meta: { agent: "general" } }]); + const { deps, ctx, io, replies } = setup("and test the fix", { + admission, + elsewhere, + ledger: new RecordingLedger({ pushSeq: () => 1 }), + }); + const acknowledgements: string[] = []; + io.acknowledge = async (text) => { + acknowledgements.push(text); + }; + expect(await admit(deps, ctx)).toEqual({ kind: "steered", where }); + expect(acknowledgements).toHaveLength(1); + expect(acknowledgements[0]).toMatch(/^↪ Folded into/); + expect(replies).toEqual([]); + } + }); it("a free thread is claimed for the resolved agent: proceed, and the slot is the thread's live run", async () => { const { deps, ctx, admission, replies } = setup("write the report"); const outcome = await admit(deps, ctx); diff --git a/src/core/dispatch/admission.ts b/src/core/dispatch/admission.ts index 7b9ef3b08..978ac16c7 100644 --- a/src/core/dispatch/admission.ts +++ b/src/core/dispatch/admission.ts @@ -279,7 +279,7 @@ export interface AdmissionContext { }; } -/** How the admission claim ended. Every kind but `proceed` and `redispatch` +/** How the admission claim ended. Every kind but `proceed`, `redispatch` and `deferred` * means the thread has been answered and the dispatch is over — no card, no * run, no workspace. */ export type AdmissionOutcome = @@ -287,6 +287,8 @@ export type AdmissionOutcome = | { kind: "proceed"; admitted: LiveThread } /** The run this message was steered into ended during the round trip: the message runs fresh, as its own dispatch. */ | { kind: "redispatch" } + /** No model or inbox effect: the adapter must keep this request for a later turn. */ + | { kind: "deferred" } /** Folded into the run in flight — here, or on another generation through its durable inbox — and acked. */ | { kind: "steered"; where: "here" | "elsewhere" } /** Not run, and told why: the sender may not run the live agent, or asked for @@ -338,6 +340,7 @@ export async function admit(deps: AdmissionDeps, ctx: AdmissionContext): Promise // the steer ack's "N in" is the run's elapsed time, not the resume's. let claim = admission.claim(msg.threadKey, { agent: agentName, + userId: msg.userId, ...(carriedRow ? { now: carriedRow.startedAt } : {}), }); if (claim.kind === "live" && restart) { @@ -400,6 +403,7 @@ export async function admit(deps: AdmissionDeps, ctx: AdmissionContext): Promise return { kind: "refused", reason: "coordinator_thread_live" }; } if (claim.kind === "live") { + if (io.isolateFollowUps && claim.live.userId !== msg.userId) return { kind: "deferred" }; // The gate above ran against THIS message's resolved agent; a steered // follow-up is read by the LIVE agent, so its sender must be allowed to // run that one too (invariant 3 — no path runs an agent for a user the @@ -482,6 +486,10 @@ export async function admit(deps: AdmissionDeps, ctx: AdmissionContext): Promise await refuseSilently("coordinator_thread_live", async () => {}); return { kind: "refused", reason: "coordinator_thread_live" }; } + if (elsewhere && io.isolateFollowUps && elsewhere.userId !== msg.userId) { + admission.release(msg.threadKey, claim.live); + return { kind: "deferred" }; + } if (elsewhere && farAgent === undefined) { // No agent on the row: the no-agent-switch gate cannot be judged, so the // message is not steered into it (a claim always records the agent; this @@ -536,7 +544,7 @@ export async function admit(deps: AdmissionDeps, ctx: AdmissionContext): Promise deps.threadsElsewhere.forget(msg.threadKey); console.log(`[dispatch] ${msg.threadKey} run ${elsewhere.runId} is no longer on the ledger — running fresh`); // Take the slot back for the fresh run below. - const again = admission.claim(msg.threadKey, { agent: agentName }); + const again = admission.claim(msg.threadKey, { agent: agentName, userId: msg.userId }); if (again.kind === "live") { // Someone claimed it during the round trip: this message steers into them as any follow-up would. return { kind: "redispatch" }; diff --git a/src/core/dispatch/awaitChildren.test.ts b/src/core/dispatch/awaitChildren.test.ts index eb38d71e3..e16cc6e9d 100644 --- a/src/core/dispatch/awaitChildren.test.ts +++ b/src/core/dispatch/awaitChildren.test.ts @@ -254,3 +254,18 @@ describe("waitCapabilityFor — what a waiting tool watches", () => { await expect(cap.sleep(1)).resolves.toBeUndefined(); }); }); + +describe("waiting for child clarification", () => { + it("a question remains nonterminal and can be replaced by a running continuation", () => { + const watch = new ChildrenWatch(["child"]); + watch.observe("child", { kind: "awaiting_input", finalReply: "Which repository?" }); + expect(watch.allEnded).toBe(false); + expect(watch.pending()).toEqual(["child"]); + expect(decideWait(inputs({ children: watch.snapshot() }))).toEqual({ kind: "end", why: "awaiting_input" }); + expect(decideWait(inputs({ children: watch.snapshot(), stop: "hard" }))).toEqual({ kind: "end", why: "stop" }); + expect(watch.observe("child", { kind: "running", continuedBy: "reply" })).toBe(true); + expect(decideWait(inputs({ children: watch.snapshot() })).kind).toBe("wait"); + watch.observe("child", { kind: "ended", status: "completed", continuedBy: "reply" }); + expect(watch.allEnded).toBe(true); + }); +}); diff --git a/src/core/dispatch/awaitChildren.ts b/src/core/dispatch/awaitChildren.ts index 711267ef2..16e9bab47 100644 --- a/src/core/dispatch/awaitChildren.ts +++ b/src/core/dispatch/awaitChildren.ts @@ -38,6 +38,8 @@ export type ChildEndStatus = RunStatus | "refused" | "stopped"; export type ChildState = /** Live — in this process, or under another generation (`elsewhere`). */ | { kind: "running"; activity?: string; elsewhere?: boolean; continuedBy?: string } + /** The turn ended with a question; the child task is not complete. */ + | { kind: "awaiting_input"; activity?: string; finalReply?: string; continuedBy?: string } /** Ended: its terminal status, its last activity line, its final reply when * it wrote one, and the gate's name when a gate refused it. */ | { @@ -52,7 +54,7 @@ export type ChildState = | { kind: "not_found" }; /** A child the wait is done with: ended, or not there to wait for. */ -export const isTerminal = (state: ChildState): boolean => state.kind !== "running"; +export const isTerminal = (state: ChildState): boolean => state.kind === "ended" || state.kind === "not_found"; /** * The children's states as the wait accumulates them, keyed by the ids the @@ -81,7 +83,7 @@ export class ChildrenWatch { return this.states.get(id); } - /** The children still live — the ones the next tick re-reads. */ + /** Children without a terminal result, including those awaiting input. */ pending(): string[] { return [...this.states].filter(([, s]) => !isTerminal(s)).map(([id]) => id); } @@ -96,7 +98,7 @@ export class ChildrenWatch { } /** Why the wait ended, in order of precedence. */ -export type WaitEnd = "all_ended" | "stop" | "follow_up" | "budget" | "timeout"; +export type WaitEnd = "all_ended" | "awaiting_input" | "stop" | "follow_up" | "budget" | "timeout"; export interface WaitInputs { children: ReadonlyMap; @@ -118,7 +120,9 @@ export type WaitDecision = { kind: "end"; why: WaitEnd } | { kind: "wait"; until * The one rule. A wait whose every child is terminal is complete and says so, * whatever else is true — the result is whole. Then a stop ends it at once * (soft or hard: no new step is owed, the parent wraps up); then a follow-up - * in the parent's inbox (the parent's next step reads it); then the budget's + * in the parent's inbox (the parent's next step reads it); then a child's + * clarification returns immediately so the parent can ask for the missing + * information without reporting the child complete; then the budget's * edge (the parent writes up what returned and names what still runs); then * the caller's timeout — the budget is named when both have passed, being the * stronger fact. Otherwise the wait goes on until the earlier bound. @@ -128,6 +132,8 @@ export function decideWait(inputs: WaitInputs): WaitDecision { if ([...children.values()].every(isTerminal)) return { kind: "end", why: "all_ended" }; if (stop !== undefined) return { kind: "end", why: "stop" }; if (followUpPending) return { kind: "end", why: "follow_up" }; + if ([...children.values()].some((state) => state.kind === "awaiting_input")) + return { kind: "end", why: "awaiting_input" }; if (now >= budgetEndsAt) return { kind: "end", why: "budget" }; if (timeoutAt !== undefined && now >= timeoutAt) return { kind: "end", why: "timeout" }; return { kind: "wait", until: Math.min(budgetEndsAt, timeoutAt ?? Number.POSITIVE_INFINITY) }; diff --git a/src/core/dispatch/channelAccess.ts b/src/core/dispatch/channelAccess.ts new file mode 100644 index 000000000..e42e147a7 --- /dev/null +++ b/src/core/dispatch/channelAccess.ts @@ -0,0 +1,40 @@ +import type { ChannelIO, IncomingMessage } from "../types.js"; +import type { LedgerWriteThrough } from "../runLedger/writeThrough.js"; +import { closeRestartRow, closeResumedRow, type RestartContext, type ResumeContext } from "./admission.js"; + +/** Check before commands, history and admission, including restored requests. + * A failed lookup has no execution effects, so durable callers can retry. */ +export async function checkChannelAccess( + ledger: LedgerWriteThrough, + ctx: { io: ChannelIO; msg: IncomingMessage; resume?: ResumeContext; restart?: RestartContext }, +): Promise<"allow" | "deny" | "retry"> { + if (!ctx.io.checkAccess) return "allow"; + let allowed: boolean; + try { + allowed = await ctx.io.checkAccess(ctx.msg.userId); + } catch { + // A reclaimed row remains durable without a new heartbeat. The next + // recovery sweep can reclaim it after the lease expires. + return "retry"; + } + if (allowed) return "allow"; + const { resume, restart } = ctx; + const row = resume?.row ?? restart?.row; + if (row) { + const adopted = ledger.adopt({ + runId: row.runId, + threadKey: row.threadKey, + state: row.state, + lastStep: resume?.lastStep.step ?? 0, + lastSeq: resume?.lastSeq ?? 0, + ...(row.meta.session ? { session: row.meta.session } : {}), + }); + try { + if (resume) await closeResumedRow(adopted, resume, "requester lost channel access"); + else if (restart) await closeRestartRow(adopted, restart, "requester lost channel access"); + } finally { + await adopted.close(); + } + } + return "deny"; +} diff --git a/src/core/dispatch/commandRun.test.ts b/src/core/dispatch/commandRun.test.ts index 9996b5fb4..c6511ee29 100644 --- a/src/core/dispatch/commandRun.test.ts +++ b/src/core/dispatch/commandRun.test.ts @@ -15,6 +15,7 @@ import type { ChannelIO, IncomingMessage } from "../types.js"; import type { FastPathDeps } from "./fastPath.js"; import { isInlineRunCommand, + runInlineCommandRun, recordRefusal, recordRoutedDecision, runChatCommand, @@ -91,6 +92,35 @@ const ROUTE: RouteEventFields = { }; describe("runChatCommand — the machinery moved from the fast path", () => { + it.each(["stop", "unavailable"])( + "awaits channel admission and records a stop before executing a command (%s)", + async (mode) => { + const d = deps(); + const { message, io, ending, trace } = request("repo test", d); + let release!: () => void; + io.runStarted = ({ id }) => + new Promise((resolve, reject) => { + release = () => { + if (mode === "unavailable") { + reject(new Error("binding unavailable")); + return; + } + d.runRegistry.requestStopById(id, "hard", { kind: "chat", id: "test" }); + resolve(); + }; + }); + const execute = vi.fn(async () => ({ ok: true, text: "executed" })); + const running = runInlineCommandRun(d, message, "repo.test", io, execute, ending, trace); + const rejected = expect(running).rejects.toThrow("stopped"); + await vi.waitFor(() => expect(release).toBeDefined()); + expect(execute).not.toHaveBeenCalled(); + release(); + await rejected; + expect(execute).not.toHaveBeenCalled(); + expect(d.runRegistry.getById("run-cmd")).toMatchObject({ finished: true, status: "stopped_hard" }); + ending.drain(false); + }, + ); it("records an inline run for a command that does work (`repo.test`) and none for one that answers from local state (`config.show`) — exactly as the fast path did", async () => { expect(isInlineRunCommand("repo.test")).toBe(true); expect(isInlineRunCommand("mcp.promote")).toBe(true); // a promote does work: an org entry and a ticket diff --git a/src/core/dispatch/commandRun.ts b/src/core/dispatch/commandRun.ts index 8348ca84e..8d05cfed8 100644 --- a/src/core/dispatch/commandRun.ts +++ b/src/core/dispatch/commandRun.ts @@ -13,6 +13,7 @@ // follows it is recorded by `runChatCommand` because its route carries an // outcome — whatever the command, announced to no surface unless the command // was a run anyway. +import { awaitChannelAdmission, RunAdmissionStopped } from "./runStart.js"; import { systemClock } from "../trace/index.js"; import { COMMAND_RUN_AGENT, DOOR_RUN_AGENT } from "../runOwner.js"; import type { Span } from "../trace/types.js"; @@ -320,7 +321,6 @@ export async function runInlineCommandRun< // spans so far backfill, then `run.command` and the reply follow live. A // natural-language fall-through rebinds the same root to the agent run next. trace.bindRun(run.id, (e) => registry.publish(run.id, e)); - if (announce) io.runStarted?.({ id: run.id }); registry.publish(run.id, { type: "input", text: redactSecrets(msg.text), @@ -335,7 +335,11 @@ export async function runInlineCommandRun< // where a routed agent run carries the same event. if (opts.route) registry.publish(run.id, { type: "route", ...opts.route, at: clock() }); let result: T | undefined; + let stoppedBeforeExecution = false; try { + if (announce) await awaitChannelAdmission(io, registry, run.id); + stoppedBeforeExecution = run.control.hardSignal.aborted; + if (stoppedBeforeExecution) throw new RunAdmissionStopped(run.control); // The command's deterministic body is the run's one counted step (`tools` // for a command run); a resident op's own steps graft under it. result = await root.span( @@ -361,10 +365,14 @@ export async function runInlineCommandRun< // A thrown command still gets an `answer`: the same `⚠️ ` line the // dispatcher's outer handler replies with, so the record explains its // `failed` status and the channel reply stays a projection of it. - registry.publish(run.id, { type: "answer", text: redactSecrets(errorReply(err)), at: clock() }); + registry.publish(run.id, { + type: "answer", + text: redactSecrets(err instanceof RunAdmissionStopped ? err.message : errorReply(err)), + at: clock(), + }); throw err; } finally { - const status: RunStatus = result?.ok ? "completed" : "failed"; + const status: RunStatus = stoppedBeforeExecution ? "stopped_hard" : result?.ok ? "completed" : "failed"; registry.finish(run.id, status); if (announce) io.runFinished?.({ id: run.id, status }); // Sealed by the caller's drain after its reply (or at once, with no reply, diff --git a/src/core/dispatch/lineage.test.ts b/src/core/dispatch/lineage.test.ts new file mode 100644 index 000000000..0cecced45 --- /dev/null +++ b/src/core/dispatch/lineage.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; +import { tellParent, type ThreadLineage } from "./lineage.js"; +import { ThreadAdmission } from "../threadAdmission.js"; +import type { DispatchFollowUp } from "./admission.js"; +import { NO_GRANTS } from "../authz/types.js"; + +describe("child replies retain requester isolation", () => { + it.each(["linear:org:bob", undefined])( + "does not steer a parent owned by %s from another person's child reply", + async (owner) => { + const pushInbox = vi.fn(async () => 1); + const deps = { + runs: { + getRun: async () => ({ + ok: true as const, + value: { + id: "parent", + finished: false, + startedAt: 1, + eventCount: 0, + threadKey: "linear:org:parent", + agent: "general", + ...(owner ? { userId: owner } : {}), + }, + }), + }, + config: { canRunAgent: () => true, grantsFor: () => NO_GRANTS }, + runLedger: { pushInbox }, + admission: new ThreadAdmission(), + isolateFollowUps: true, + }; + const lineage: ThreadLineage = { parentRunId: "parent", child: { runId: "child", live: false } }; + const msg = { + userId: "linear:org:alice", + channelId: "linear:org:team", + threadKey: "linear:org:child", + text: "Change the task", + }; + expect(await tellParent(deps, lineage, msg, { kind: "started", runId: "continuation" })).toBe("refused"); + expect(pushInbox).not.toHaveBeenCalled(); + deps.runs.getRun = async () => ({ + ok: true, + value: { + id: "parent", + finished: false, + startedAt: 1, + eventCount: 0, + threadKey: "linear:org:parent", + agent: "general", + userId: msg.userId, + }, + }); + expect(await tellParent(deps, lineage, msg, { kind: "started", runId: "continuation" })).toBe("steered"); + expect(pushInbox).toHaveBeenCalledOnce(); + }, + ); +}); diff --git a/src/core/dispatch/lineage.ts b/src/core/dispatch/lineage.ts index 8657e0a1e..f0ab6979d 100644 --- a/src/core/dispatch/lineage.ts +++ b/src/core/dispatch/lineage.ts @@ -105,6 +105,7 @@ export async function tellParent( runLedger: Pick; clock?: Clock; admission: ThreadAdmission; + isolateFollowUps?: boolean; }, lineage: ThreadLineage, msg: IncomingMessage, @@ -114,6 +115,9 @@ export async function tellParent( if (!res.ok) return "parent_ended"; const parent = res.value; if (parent.finished || parent.threadKey === undefined || parent.agent === undefined) return "parent_ended"; + // A reply in a child's conversation is still input from that person. The + // lineage notification must not bypass the channel's requester isolation. + if (deps.isolateFollowUps && parent.userId !== msg.userId) return "refused"; const out = await steerRun( deps, { diff --git a/src/core/dispatch/outcome.ts b/src/core/dispatch/outcome.ts index cef1ea299..b3c4a46e7 100644 --- a/src/core/dispatch/outcome.ts +++ b/src/core/dispatch/outcome.ts @@ -10,6 +10,8 @@ export interface DispatchOutcome { status: "completed" | "refused" | "failed" | "stopped"; refusal?: string; + /** Admission made no run/inbox effects; a durable channel should retry later. */ + deferred?: true; /** The refusal's cause, from the one code→cause table (src/core/refusal.ts). */ cause?: "request" | "policy" | "system"; } diff --git a/src/core/dispatch/provision.test.ts b/src/core/dispatch/provision.test.ts index 1444387f5..cff6b1719 100644 --- a/src/core/dispatch/provision.test.ts +++ b/src/core/dispatch/provision.test.ts @@ -396,7 +396,8 @@ describe("registerRun — the run's row on every surface before the attach", () expect(out.channelVisibility).toBe("unknown"); expect(out.liveUrl).toBe("https://sb.example/runs/run-p?t=tok"); expect(r.admitted.runLink).toBe(out.liveUrl); - expect(started).toEqual(["run-p"]); + // The caller first records the registered run, then awaits channel admission. + expect(started).toEqual([]); const summary = registry.getById("run-p")!; expect(summary.label).toBe('coding · acme/api · "fix the login bug"'); expect(summary).toMatchObject({ diff --git a/src/core/dispatch/provision.ts b/src/core/dispatch/provision.ts index 34aeb80ea..aea879b9f 100644 --- a/src/core/dispatch/provision.ts +++ b/src/core/dispatch/provision.ts @@ -355,7 +355,6 @@ export interface RegisterRunContext { export async function registerRun(deps: ProvisionDeps, ctx: RegisterRunContext): Promise { const { msg, - io, agent, resolved, directives, @@ -449,7 +448,6 @@ export async function registerRun(deps: ProvisionDeps, ctx: RegisterRunContext): const liveUrl = liveViewLink(run.id, run.token); shell.setLink(liveUrl ? { url: liveUrl, label: "Live run" } : undefined); if (liveUrl) admitted.runLink = liveUrl; - io.runStarted?.({ id: run.id }); // The run's stream is live from here (docs/reference/specs/tracing.md item 6): the // spans so far — the root, the ack card, the repo resolution — are // backfilled, and the attach and the resident's grafted steps stream as diff --git a/src/core/dispatch/record.test.ts b/src/core/dispatch/record.test.ts index b218c5bab..be97d95d1 100644 --- a/src/core/dispatch/record.test.ts +++ b/src/core/dispatch/record.test.ts @@ -477,6 +477,13 @@ describe("assembleRunRecord — the handoff on the record", () => { }; const reclaimed = reclaimedRunRecord({ row, events: [], status: "interrupted", finishedAt: 5_000 }); expect(reclaimed).toMatchObject({ id: "run-child", seed: "parent" }); + row.state.question = "Which repository?"; + expect(reclaimedRunRecord({ row, events: [], status: "completed", finishedAt: 5_000 })).toMatchObject({ + awaitingInput: true, + }); + expect(reclaimedRunRecord({ row, events: [], status: "interrupted", finishedAt: 5_000 })).not.toHaveProperty( + "awaitingInput", + ); expect(isRunRecord(reclaimed)).toBe(true); }); diff --git a/src/core/dispatch/record.ts b/src/core/dispatch/record.ts index 0855e3db1..84aa07c1d 100644 --- a/src/core/dispatch/record.ts +++ b/src/core/dispatch/record.ts @@ -1,3 +1,4 @@ +import { questionText } from "../question.js"; // The record stage of the dispatch pipeline (docs/decisions/0024-dispatcher-as-a-staged-pipeline.md): // what a run leaves behind. The channel-visibility stamp every run is created // with, and the ONE `RunRecord` assembly every run goes through before @@ -193,6 +194,7 @@ export function reclaimedRunRecord(input: { }; return assembleRunRecord({ run: { id: row.runId }, + ...(status === "completed" && questionText(row.state.question) ? { awaitingInput: true as const } : {}), snap, agent: row.meta.agent, model: row.meta.model, @@ -298,6 +300,7 @@ export function assembleRunRecord(input: { /** The typed handoff the run submitted (docs/reference/specs/agent-ship.md item 14), * as the tool accepted it; redacted HERE, the one assembly, so no caller * can forget. Omitted (not set undefined) when the run submitted none. */ + awaitingInput?: true; handoff?: Handoff; /** The verdict a review run submitted and the head it reviewed, the * dispositions a fix round submitted (run-history item 2) — redacted HERE @@ -385,6 +388,9 @@ export function assembleRunRecord(input: { ...(referencesOfEvents(events).length > 0 ? { references: referencesOfEvents(events) } : {}), ...(msg.sourceUrl !== undefined ? { sourceUrl: msg.sourceUrl } : {}), ...(msg.userName !== undefined ? { userName: msg.userName } : {}), + ...(input.awaitingInput && input.status === "completed" && seal?.replyOk !== false + ? { awaitingInput: true as const } + : {}), ...(input.handoff !== undefined ? { handoff: redactHandoff(input.handoff) } : {}), ...(input.verdict !== undefined ? { verdict: redactVerdict(input.verdict) } : {}), ...(input.reviewHead !== undefined ? { reviewHead: input.reviewHead } : {}), @@ -528,6 +534,7 @@ export interface FinishRecordContext { root: Span; ledgerRun: LedgerRun | undefined; /** The handoff the run loop captured from `submit_handoff`, when one was submitted. */ + awaitingInput?: true; handoff?: Handoff; /** The verdict a review run submitted and the head it reviewed; the dispositions a fix round submitted. */ verdict?: ReviewVerdict; @@ -573,6 +580,7 @@ export function registerFinishRecord(deps: RecordDeps, ctx: FinishRecordContext) root, ledgerRun, handoff, + awaitingInput, verdict, reviewHead, dispositions, @@ -603,6 +611,7 @@ export function registerFinishRecord(deps: RecordDeps, ctx: FinishRecordContext) status: failedAfterFinish && status === "completed" ? "failed" : status, diagnosis, seal, + ...(awaitingInput ? { awaitingInput } : {}), ...(handoff !== undefined ? { handoff } : {}), ...(verdict !== undefined ? { verdict } : {}), ...(reviewHead !== undefined ? { reviewHead } : {}), diff --git a/src/core/dispatch/reply.test.ts b/src/core/dispatch/reply.test.ts index 4be5ae53e..df9d9aa8b 100644 --- a/src/core/dispatch/reply.test.ts +++ b/src/core/dispatch/reply.test.ts @@ -359,6 +359,10 @@ describe("renderRefusal — the one rendering of a Refusal", () => { // Every code in the closed table is accounted for: rendered here, built by // another module's tested builder, or silent by design. const provenElsewhere: RefusalCode[] = [ + // dispatcher.test.ts proves the access and clarification refusal text, + // including nonterminal delivery when another requester owns the question. + "channel_access", + "coordinator_clarification", "pr_head_unknown", "branch_moved", // (record 0054): each producer's own test proves its sentences @@ -711,6 +715,22 @@ describe("deliverAnswer — the answer reaches the thread", () => { return { ctx, replies, closes, releases, sealed, states }; } + it("delivers a review question without formatting an earlier verdict as the answer", async () => { + const s = finishedRun(); + const question = vi.fn(async () => {}); + await deliverAnswer({ + ...s.ctx, + answer: "Which revision should I review?", + awaitingInput: true, + verdict: { verdict: "approve", summary: "Earlier revision was fine" }, + io: { ...s.ctx.io, question }, + }); + expect(question).toHaveBeenCalledExactlyOnceWith("Which revision should I review?"); + expect(s.replies).toEqual([]); + expect(JSON.stringify(s.closes)).toContain("❓"); + expect(JSON.stringify(s.closes)).toContain("○ step"); + }); + it("delivered: the card closes ✅ with the checked-off checklist, the reply carries the answer (a review's with its run link), the run is sealed replyOk, the workspace is released after", async () => { const s = finishedRun(); expect(await deliverAnswer(s.ctx)).toEqual({ kind: "delivered" }); diff --git a/src/core/dispatch/reply.ts b/src/core/dispatch/reply.ts index f4c38a0c0..842e6d2d0 100644 --- a/src/core/dispatch/reply.ts +++ b/src/core/dispatch/reply.ts @@ -455,7 +455,7 @@ export function refusalQuestion(refusal: Refusal): string { * refusal in ack's clothing. */ export async function replyAck(io: ChannelIO, text: string): Promise { - return io.reply(text); + return (io.acknowledge ?? io.reply).call(io, text); } /** * The one caller of a channel's `offer` (record 0054): the Block Kit an @@ -565,6 +565,7 @@ export type Delivery = { kind: "delivered" } | { kind: "fenced" }; /** What `deliverAnswer` reads off the dispatch. */ export interface DeliveryContext { + awaitingInput?: true; msg: IncomingMessage; io: ChannelIO; agent: AgentDef; @@ -603,6 +604,7 @@ export async function deliverAnswer(ctx: DeliveryContext): Promise { agent, run, answer, + awaitingInput, liveUrl, prNote, stopped, @@ -649,7 +651,7 @@ export async function deliverAnswer(ctx: DeliveryContext): Promise { // only — the `answer` event published above stays the model's own words // and link-free. const channelAnswer = - agent.name === "review" + agent.name === "review" && !awaitingInput ? buildReviewChannelReply({ answer, verdict: ctx.verdict, @@ -676,13 +678,18 @@ export async function deliverAnswer(ctx: DeliveryContext): Promise { card.done( shell.close({ kind: "done", - icon: stopped === "hard" ? "⛔" : stopped === "soft" ? "⏹" : "✅", - detail: stopped ? checklistAsLeft() : checklistCheckedOff(), + icon: stopped === "hard" ? "⛔" : stopped === "soft" ? "⏹" : awaitingInput ? "❓" : "✅", + detail: stopped || awaitingInput ? checklistAsLeft() : checklistCheckedOff(), ...doneLines(runDiagnosis), }), ), ), - () => root.span("post.reply", () => io.reply(prNote ? `${channelAnswer}\n\n${prNote}` : channelAnswer)), + () => + root.span("post.reply", () => + awaitingInput && !stopped && io.question + ? io.question(channelAnswer) + : io.reply(prNote ? `${channelAnswer}\n\n${prNote}` : channelAnswer), + ), // A null channel's reply resolves but reaches nobody: the seal says // `replyOk: false` with the reason (run-history.md item 38). io.undeliverable !== undefined ? { undelivered: io.undeliverable } : undefined, diff --git a/src/core/dispatch/runLoop.test.ts b/src/core/dispatch/runLoop.test.ts index 8e2056f4b..5a6af5017 100644 --- a/src/core/dispatch/runLoop.test.ts +++ b/src/core/dispatch/runLoop.test.ts @@ -9,6 +9,7 @@ import { getAgent } from "../../agents/registry.js"; import { declaredProfile } from "../../config/profile.js"; import { InMemoryGithubApi } from "../../execution/githubApi.js"; import type { Provider } from "../provider.js"; +import { requestInputTool } from "../../tools/question.js"; import { ExecSandboxRestartedError, type Executor } from "../../execution/executor.js"; import { channelOf, startRequestRoot } from "../requestTrace.js"; import { createRunEnding } from "../runEnding.js"; @@ -355,6 +356,192 @@ function setup( } describe("runLoop — the model turn and everything that rides on it", () => { + it.each(["verdict", "description", "re-review"] as const)( + "delivers a question from the %s turn without another model turn or automatic publication", + async (stage) => { + const head = "a".repeat(40); + const moved = "b".repeat(40); + const commits = (sha: string): PrCommitList => ({ + commits: [ + { sha: head, message: "feat: the change" }, + ...(sha === moved ? [{ sha: moved, message: "fix: the change" }] : []), + ], + files: ["src/x.ts"], + filesTruncated: false, + }); + const post = vi.fn(async () => {}); + const openPr = vi.fn(); + const updatePr = vi.fn(); + const fake = watched(piHarness, { answer: "Initial write-up" }); + const end = vi.fn(async () => {}); + const followUp = vi.fn(async (input) => { + await requestInputTool.run({ question: "Which behavior should this preserve?" }, input.toolContext); + if (stage === "description") + input.toolContext.onPrDescription?.({ + title: "fix(core): preserve the requested behavior", + tldr: "Preserves the behavior.", + why: "The caller needs it.", + pointers: [{ label: "Behavior", text: "The change.", anchor: { path: "src/x.ts", from: 1, to: 1 } }], + feedbackWanted: "Confirm the behavior.", + verified: "Unit tests.", + decisions: [], + risk: "none", + validation: { criteria: [{ criterion: "Behavior", proof: "Unit tests" }] }, + }); + return "Closing prose must not replace the question."; + }); + const open = fake.harness.open; + fake.harness.open = async (deps, run) => ({ ...(await open(deps, run)), followUp, end }); + const h = setup("", { + agent: stage === "description" ? "coding" : "review", + coding: stage === "description", + executor: { + exec: async (cmd) => { + if (cmd.includes("--abbrev-ref HEAD")) return "feature/change"; + if (cmd.includes("ls-remote")) return `${head}\trefs/heads/feature/change\n`; + return head; + }, + }, + repoCtx: { + repo: "o/r", + ref: "main", + baseRef: "main", + ...(stage === "description" ? {} : { pr: 42 }), + } as RepoContext, + review: { head, currentHead: stage === "re-review" ? moved : head, commits, post }, + }); + h.deps.harness = { ...h.deps.harness!, harnesses: roster(fake.harness) }; + h.deps.findOpenPrByHead = async () => ({ number: 42, htmlUrl: "https://github.com/o/r/pull/42" }); + h.deps.openPullRequest = openPr; + h.deps.updatePullRequest = updatePr; + const out = answered(await runLoop(h.deps, h.ctx)); + expect(out).toMatchObject({ answer: "Which behavior should this preserve?", awaitingInput: true }); + expect(followUp).toHaveBeenCalledTimes(1); + if (stage !== "re-review") expect(followUp.mock.calls[0]![0].tools).toContain("request_input"); + expect(post).not.toHaveBeenCalled(); + expect(openPr).not.toHaveBeenCalled(); + expect(updatePr).not.toHaveBeenCalled(); + expect(end).toHaveBeenCalledExactlyOnceWith(); + h.ending.drain(undefined); + await h.writer.settled(); + expect(await h.store.get("run-l")).toMatchObject({ awaitingInput: true }); + }, + ); + + it("a hard stop during a final question turn clears waiting state without posting a review", async () => { + const head = "a".repeat(40); + const post = vi.fn(async () => {}); + const fake = watched(piHarness, { answer: "Initial write-up" }); + const open = fake.harness.open; + fake.harness.open = async (deps, run) => ({ + ...(await open(deps, run)), + followUp: async (input) => { + await requestInputTool.run({ question: "Which behavior?" }, input.toolContext); + run.control?.requestStop("hard"); + return "Stopped."; + }, + }); + const h = setup("", { + agent: "review", + executor: { exec: async () => head }, + repoCtx: { repo: "o/r", pr: 42 } as RepoContext, + review: { head, post }, + }); + h.deps.harness = { ...h.deps.harness!, harnesses: roster(fake.harness) }; + const state = vi.fn(); + const ledgerRun = new NullLedgerRun("run-l", h.store); + ledgerRun.setState = state; + const out = answered(await runLoop(h.deps, { ...h.ctx, ledgerRun })); + expect(out.awaitingInput).toBeUndefined(); + expect(state).toHaveBeenLastCalledWith({ question: null }); + expect(post).not.toHaveBeenCalled(); + h.ending.drain(undefined); + await h.writer.settled(); + const record = await h.store.get("run-l"); + expect(record?.status).toBe("stopped_hard"); + expect(record?.awaitingInput).toBeUndefined(); + }); + + it("keeps a typed question in the turn outcome, receipt and durable run record", async () => { + let turn = 0; + const provider: Provider = { + name: "fake", + complete: async () => + ++turn === 1 + ? { + content: [{ type: "tool_use", id: "q", name: "request_input", input: { question: "Which repository?" } }], + stopReason: "tool_use", + } + : { content: [{ type: "text", text: "Closing text" }], stopReason: "end_turn" }, + }; + const runFinished = vi.fn(); + const h = setup("", { provider, io: { runFinished } }); + const state = vi.fn(); + const ledgerRun = new NullLedgerRun("run-l", h.store); + ledgerRun.setState = state; + const out = answered(await runLoop(h.deps, { ...h.ctx, ledgerRun })); + expect(out).toMatchObject({ answer: "Which repository?", awaitingInput: true }); + expect(state).toHaveBeenCalledWith({ question: "Which repository?" }); + expect(runFinished).toHaveBeenCalledWith({ id: "run-l", status: "completed", awaitingInput: true }); + h.ending.drain(undefined); + await h.writer.settled(); + expect(await h.store.get("run-l")).toMatchObject({ awaitingInput: true }); + }); + + it("ends the model process when a question skips the publishing steps", async () => { + const fake = watched(piHarness); + const end = vi.fn(async () => {}); + const open = fake.harness.open; + fake.harness.open = async (deps, run) => { + run.toolContext.onQuestion?.("Which repository?"); + return { ...(await open(deps, run)), end }; + }; + const h = setup(""); + h.deps.harness = { ...h.deps.harness!, harnesses: roster(fake.harness) }; + const out = answered(await runLoop(h.deps, h.ctx)); + expect(out.awaitingInput).toBe(true); + expect(end).toHaveBeenCalledExactlyOnceWith(); + h.ending.drain(undefined); + await h.writer.settled(); + }); + + it("clears a pending question when a follow-up arrives before the turn finishes", async () => { + const fake = watched(piHarness, { answer: "I used your answer." }); + const open = fake.harness.open; + fake.harness.open = async (deps, run) => { + run.toolContext.onQuestion?.("Which repository?"); + run.onEvent?.({ type: "input", messageId: "follow-up", text: "Use acme/api" }); + return open(deps, run); + }; + const h = setup(""); + h.deps.harness = { ...h.deps.harness!, harnesses: roster(fake.harness) }; + const out = answered(await runLoop(h.deps, h.ctx)); + expect(out.answer).toBe("I used your answer."); + expect(out.awaitingInput).toBeUndefined(); + h.ending.drain(undefined); + await h.writer.settled(); + }); + + it("binds work tracking to the resolved requester before a model can call an issue tool", async () => { + const request = vi.fn(async () => ({ items: [] })); + const workItems = vi.fn(() => ({ request })); + let turn = 0; + const provider: Provider = { + name: "fake", + async complete() { + return turn++ === 0 + ? { + content: [{ type: "tool_use", id: "t1", name: "work_items_delegated", input: {} }], + stopReason: "tool_use", + } + : { content: [{ type: "text", text: "No delegated issues." }], stopReason: "end_turn" }; + }, + }; + const h = setup("", { agent: "coding", provider, io: { workItems } }); + await runLoop(h.deps, h.ctx); + expect(workItems).toHaveBeenCalledWith(expect.objectContaining({ id: h.ctx.msg.userId })); + expect(request).toHaveBeenCalledWith({ op: "delegated", after: undefined, limit: undefined }); + }); // docs/reference/specs/agent-coding.md item 10: the thread's file upload // rides the tool context only when the channel has one — a coding run's // `attach_file` posts through the requesting thread's `attachFile`. @@ -2676,6 +2863,9 @@ describe("the pi harness — a preset without a workspace, as a child of the bot expect(tools).toEqual([ "web_fetch", "update_status", + "request_input", + "work_item_get", + "work_items_delegated", "github_repos", "github_file", "github_tree", @@ -2684,6 +2874,10 @@ describe("the pi harness — a preset without a workspace, as a child of the bot "github_issue_get", "github_actions_run", "github_actions_job_log", + "work_item_update", + "work_item_create_child", + "work_item_comment", + "github_issue_create", "github_issue_update", "github_issue_comment", @@ -2860,7 +3054,15 @@ describe("the pi harness — a preset without a workspace, as a child of the bot expect(start.env.SWITCHBOARD_HARNESS_URL).toBe("http://127.0.0.1:8080"); expect(start.env.SWITCHBOARD_RUN_BEARER).toBe("sbr_run-l.s3cret"); const tools = start.args[start.args.indexOf("--tools") + 1].split(","); - expect(tools).toEqual(["web_fetch", "web_search", "update_status", ...GITHUB_READS]); + expect(tools).toEqual([ + "web_fetch", + "web_search", + "update_status", + "request_input", + "work_item_get", + "work_items_delegated", + ...GITHUB_READS, + ]); for (const own of ["read", "bash", "edit", "write", "grep", "find", "ls"]) expect(tools).not.toContain(own); expect(refusals).toEqual([ { @@ -3001,6 +3203,9 @@ describe("the pi harness — a preset without a workspace, as a child of the bot "get_run_status", "web_fetch", "update_status", + "request_input", + "work_item_get", + "work_items_delegated", ...GITHUB_READS, ]); for (const own of ["read", "bash", "edit", "write", "grep", "find", "ls"]) expect(tools).not.toContain(own); @@ -3170,6 +3375,42 @@ describe("a resume with the answer in hand (the `finish` plan)", () => { seq, }); + it("an operator stop takes precedence over a restored question", async () => { + const s = setup("must not run"); + const resume = finishing("Stopped summary", { + state: { question: "Which repository?" }, + events: [ + { type: "run_note", kind: "stop_requested", mode: "soft", summary: "stop", at: 1, seq: 1 }, + { type: "run_note", kind: "stopped", mode: "soft", summary: "stopped", at: 2, seq: 2 }, + ], + }); + const out = answered(await runLoop(s.deps, { ...s.ctx, resume, messages: resume.plan.messages })); + expect(out.awaitingInput).toBeUndefined(); + expect(out.answer).not.toContain("Which repository?"); + s.ending.drain(undefined); + await s.writer.settled(); + }); + + it("restores a pending question without more model calls or automatic PR or review publication", async () => { + for (const agent of ["coding", "review"]) { + const post = vi.fn(async () => {}); + const s = setup("must not run", { + agent, + ...(agent === "coding" ? { coding: true } : { review: { head: "abc", post } }), + repoCtx: { repo: "acme/api", pr: 1 }, + }); + const openPullRequest = vi.fn(); + s.deps.openPullRequest = openPullRequest; + const resume = finishing("Closing text", { agent, state: { question: "Which repository?" } }); + const out = answered(await runLoop(s.deps, { ...s.ctx, resume, messages: resume.plan.messages })); + expect(out).toMatchObject({ answer: "Which repository?", awaitingInput: true }); + expect(post).not.toHaveBeenCalled(); + expect(openPullRequest).not.toHaveBeenCalled(); + s.ending.drain(undefined); + await s.writer.settled(); + } + }); + it("the model is never called: the transcript's final turn is the answer, published and finished `completed`, and a `resumed` note on the stream says the loop had ended before the restart", async () => { const s = setup("", { provider: neverCalled() }); const resume = finishing("The answer, written before the restart."); diff --git a/src/core/dispatch/runLoop.ts b/src/core/dispatch/runLoop.ts index 2ebb8e25c..705f01fd3 100644 --- a/src/core/dispatch/runLoop.ts +++ b/src/core/dispatch/runLoop.ts @@ -1,3 +1,4 @@ +import { questionText } from "../question.js"; // The run stage's loop (docs/decisions/0024-dispatcher-as-a-staged-pipeline.md): // the model turn and everything that rides on it. The card frame the loop // paints (checklist, activity line, the shutdown notice); the run on the pi @@ -118,6 +119,7 @@ const ENDING_NOTE_MAX = 500; * reply stage calls after the answer landed. The review post-step runs * inside the loop (agent-review.md item 18), so its inputs stay here. */ export interface RunOutcome { + awaitingInput?: true; kind: "answered"; answer: string; reviewHead: string | undefined; @@ -334,6 +336,11 @@ export async function runLoop(deps: RunDeps, ctx: RunLoopContext): Promise { + question = text; + ledgerRun?.setState({ question: text }); + }; let checklist: string | undefined = typeof restored.checklist === "string" ? restored.checklist : undefined; // Typed (`StatusActivity`): a bash call rides as its full command, which // the Slack card draws as a code block; everything else as its one line. @@ -385,6 +392,10 @@ export async function runLoop(deps: RunDeps, ctx: RunLoopContext): Promise { + if (e.type === "input" && question !== undefined) { + question = undefined; + ledgerRun?.setState({ question: null }); + } registry.publish(run.id, e); // feed the external live-view stream if (isSpanRecord(e)) return; // timing, not activity (docs/reference/specs/tracing.md): the card and its clock ignore it if (e.type === "lease") { @@ -694,8 +705,8 @@ export async function runLoop(deps: RunDeps, ctx: RunLoopContext): Promise run.control.requested === "hard" || relaunchEndedRun; + * land between them. A question also suspends this tail until its answer. */ + const tailSkipped = (): boolean => run.control.requested === "hard" || relaunchEndedRun || question !== undefined; // Give the workspace back now rather than at the inactivity sweep: a // resident's pool user is a scarce slot (docs/reference/specs/resident-repos.md item // 16a). The release mode is paired to the round's agent by the attach @@ -757,6 +768,8 @@ export async function runLoop(deps: RunDeps, ctx: RunLoopContext): Promise registry.publish(run.id, e), nextIndex: nextStagedIndex, @@ -781,6 +794,8 @@ export async function runLoop(deps: RunDeps, ctx: RunLoopContext): Promise { + try { + await io.runStarted?.({ id: runId }); + } catch { + registry.requestStopById(runId, "hard", { kind: "chat", id: "channel:admission-lost" }); + } +} + +/** Inline runs have no model loop to carry a stop outcome to the dispatcher. */ +export class RunAdmissionStopped extends Error { + constructor(readonly control: RunControl) { + super("Run stopped before execution."); + this.name = "RunAdmissionStopped"; + } +} diff --git a/src/core/dispatch/ship.test.ts b/src/core/dispatch/ship.test.ts index 103daeb8b..06e8731b9 100644 --- a/src/core/dispatch/ship.test.ts +++ b/src/core/dispatch/ship.test.ts @@ -156,6 +156,35 @@ describe("runShipBranch — the agent:ship fork hands every admitted request to beforeEach(() => vi.stubEnv("PUBLIC_BASE_URL", "")); afterEach(() => vi.unstubAllEnvs()); + it.each(["stop", "unavailable"])( + "awaits channel admission and stops before creating a coordinator (%s)", + async (mode) => { + const s = setup("slack:UADMIN"); + let release!: () => void; + s.io.runStarted = ({ id }) => + new Promise((resolve, reject) => { + release = () => { + if (mode === "unavailable") { + reject(new Error("binding unavailable")); + return; + } + s.registry.requestStopById(id, "hard", { kind: "chat", id: "test" }); + resolve(); + }; + }); + const running = runShipBranch(s.deps, s.msg, s.io, s.ctx); + const rejected = expect(running).rejects.toThrow("stopped"); + await vi.waitFor(() => expect(release).toBeDefined()); + expect(s.created).toEqual([]); + release(); + await rejected; + expect(s.created).toEqual([]); + expect(s.registry.getById("run-s")).toMatchObject({ finished: true, status: "stopped_hard" }); + s.ending.drain(false); + await s.writer.settled(); + expect(await s.store.get("run-s")).toMatchObject({ status: "stopped_hard" }); + }, + ); it("refused at the preflight (ship allowed, coding not): one `dispatch.refuse` outcome, the card closes 🚫 naming the missing grant, the reply names it, no run exists and the runner is never asked", async () => { const s = setup("slack:UREV"); await runShipBranch(s.deps, s.msg, s.io, s.ctx); diff --git a/src/core/dispatch/ship.ts b/src/core/dispatch/ship.ts index ef33e0999..0a6a72de3 100644 --- a/src/core/dispatch/ship.ts +++ b/src/core/dispatch/ship.ts @@ -9,6 +9,7 @@ // the runner's steps call back into the bot from there), so no round runs in // this branch and a bot death under a pipeline interrupts a child, never the // pipeline. The branch reads the run slice plus the seams only ship needs. +import { awaitChannelAdmission, RunAdmissionStopped } from "./runStart.js"; import type { AgentDef } from "../../agents/registry.js"; import { chatActorOf } from "../authz/actor.js"; import type { RunProfile } from "../../config/profile.js"; @@ -251,7 +252,6 @@ export async function runShipBranch( ...(msg.authenticatedAs !== undefined ? { authenticatedAs: msg.authenticatedAs } : {}), }, ); - io.runStarted?.({ id: run.id }); const publishText = ( type: "input" | "context" | "answer", text: string, @@ -390,7 +390,15 @@ export async function runShipBranch( run: directives.renewals, }); const shim = processShimOptions; + let stoppedBeforeExecution = false; try { + await awaitChannelAdmission(io, registry, run.id); + stoppedBeforeExecution = run.control.hardSignal.aborted; + if (stoppedBeforeExecution) { + const stopped = new RunAdmissionStopped(run.control); + publishText("answer", stopped.message); + throw stopped; + } // The hand-off (agent-ship.md item 16): the request — a plan, a task, or a // resume at review — becomes a plan runner instance; the bot writes the // records, asks its shim for the Workflow, and this run ends with where the @@ -436,7 +444,7 @@ export async function runShipBranch( // RunStatus is the run-store contract (shared with the memory worker): a // refused hand-off still answered the request, so record and registry say // `completed` — the refusal lives in the reply and the card close below. - const status: RunStatus = outcome === undefined ? "failed" : "completed"; + const status: RunStatus = stoppedBeforeExecution ? "stopped_hard" : outcome === undefined ? "failed" : "completed"; registry.finish(run.id, status); const snap = registry.snapshot(run.id, run.token); const finishedAt = snap?.finishedAt ?? clock(); @@ -480,7 +488,9 @@ export async function runShipBranch( // card's total is the run's. if (outcome === undefined) await root - .span("post.card_close", () => card.done(shell.close({ kind: "done", icon: "❌", ...doneLines(diagnosis) }))) + .span("post.card_close", () => + card.done(shell.close({ kind: "done", icon: stoppedBeforeExecution ? "⛔" : "❌", ...doneLines(diagnosis) })), + ) .catch(() => {}); } if (!outcome) return; // unreachable: the finally above rethrew diff --git a/src/core/dispatch/spawn.test.ts b/src/core/dispatch/spawn.test.ts index 35a19a7bd..9e3e5501c 100644 --- a/src/core/dispatch/spawn.test.ts +++ b/src/core/dispatch/spawn.test.ts @@ -221,6 +221,38 @@ describe("spawnChild — the one path a child run is born through", () => { expect(seen).toEqual([true, false]); }); + it("keeps work tracking and ongoing-work acknowledgements on the opened child's channel", async () => { + const ch = channel(); + const acknowledged: string[] = []; + const actorIds: string[] = []; + ch.childIo.acknowledge = async (text) => { + acknowledged.push(text); + }; + ch.childIo.isolateFollowUps = true; + ch.childIo.checkAccess = async (id) => id === PARENT_MSG.userId; + ch.childIo.workItems = (actor) => { + actorIds.push(actor.id); + return { request: async () => ({ items: [] }) }; + }; + const { dispatch } = fakeDispatch(async (_msg, io) => { + expect(io.isolateFollowUps).toBe(true); + expect(await io.checkAccess?.(PARENT_MSG.userId)).toBe(true); + expect(await io.checkAccess?.("another-person")).toBe(false); + await io.acknowledge?.("Still working"); + const actor = { + kind: "user" as const, + id: PARENT_MSG.userId, + grants: { actions: new Set(), channels: new Set(), repos: new Set() }, + }; + expect(await io.workItems?.(actor).request({ op: "delegated" })).toEqual({ items: [] }); + return { status: "completed" }; + }); + await spawnChild(deps(dispatch), parent(ch.io), { preset: "research", prompt: "q" }); + expect(acknowledged).toEqual(["Still working"]); + expect(actorIds).toEqual([PARENT_MSG.userId]); + expect(ch.childReplies).toEqual([]); + }); + it("a spawn from a run at depth 1 is refused `spawn_depth` before anything else: no thread opened, no dispatch", async () => { const { dispatch } = fakeDispatch(registers("run-child")); const ch = channel(); diff --git a/src/core/dispatch/spawn.ts b/src/core/dispatch/spawn.ts index 61b5f3b78..bbcb3e720 100644 --- a/src/core/dispatch/spawn.ts +++ b/src/core/dispatch/spawn.ts @@ -204,15 +204,22 @@ function watchedChild( status: (initial) => io.status(initial), history: () => io.history(), runStarted: (started) => { - io.runStarted?.(started); + const pending = io.runStarted?.(started); on.started(started.id); + return pending; }, }; if (io.attach) watched.attach = (file) => io.attach!(file); if (io.attachFile) watched.attachFile = (file) => io.attachFile!(file); if (io.uploadTicket) watched.uploadTicket = (file) => io.uploadTicket!(file); + if (io.copyAttachment) watched.copyAttachment = (file, key, signal) => io.copyAttachment!(file, key, signal); + if (io.workItems) watched.workItems = (actor) => io.workItems!(actor); + if (io.question) watched.question = (text) => io.question!(text); + if (io.checkAccess) watched.checkAccess = (userId) => io.checkAccess!(userId); + if (io.isolateFollowUps) watched.isolateFollowUps = true; + if (io.acknowledge) watched.acknowledge = (text) => io.acknowledge!(text); if (io.runFinished) watched.runFinished = (receipt) => io.runFinished!(receipt); - if (io.openThread) watched.openThread = (lead) => io.openThread!(lead); + if (io.openThread) watched.openThread = (lead, options) => io.openThread!(lead, options); return watched; } diff --git a/src/core/dispatch/staging.test.ts b/src/core/dispatch/staging.test.ts index 19d12e39a..e7bf308f1 100644 --- a/src/core/dispatch/staging.test.ts +++ b/src/core/dispatch/staging.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { InMemoryArtifactStore } from "../../artifacts/store.js"; import type { Executor } from "../../execution/executor.js"; import type { RunEvent } from "../runEvents.js"; @@ -106,6 +106,51 @@ describe("staging — the commands", () => { }); describe("staging — copy then pull", () => { + it("passes hard cancellation into a channel copy and never publishes or pulls the cancelled file", async () => { + const stop = new AbortController(); + const store = storeWithSlack(); + const rec = recorder(); + const events: RunEvent[] = []; + let received: AbortSignal | undefined; + const copyAttachment = vi.fn(async (_file: StagedFile, _key: string, signal?: AbortSignal) => { + received = signal; + stop.abort(new Error("stopped")); + signal?.throwIfAborted(); + }); + const { outcomes } = await stageIntoWorkspace([clip], { + store, + copyAttachment, + signal: stop.signal, + threadKey: THREAD, + nextIndex: stagingIndex(), + executor: rec.executor, + resident: false, + publish: (event) => void events.push(event), + }); + expect(received).toBe(stop.signal); + expect(outcomes[0]?.error).toBeDefined(); + expect(events).toEqual([]); + expect(store.copies).toEqual([]); + }); + it("uses a channel's private copy while keeping shared workspace pulls and artifact receipts", async () => { + const store = storeWithSlack(); + const copyAttachment = vi.fn(async () => {}); + const events: RunEvent[] = []; + const rec = recorder(); + const { outcomes } = await stageIntoWorkspace([clip], { + store, + copyAttachment, + threadKey: "linear:org:session", + nextIndex: stagingIndex(), + publish: (e) => void events.push(e), + executor: rec.executor, + resident: false, + }); + expect(copyAttachment).toHaveBeenCalledWith(clip, outcomes[0]!.key, undefined); + expect(store.copies).toEqual([]); + expect(outcomes[0]?.error).toBeUndefined(); + expect(events).toMatchObject([{ type: "artifact", direction: "in", key: outcomes[0]!.key }]); + }); it("copies every file under the thread's inbound key, publishes an `artifact` event per copy after it answered, then pulls each in order; a resident gets the exclude first", async () => { const store = storeWithSlack(); const events: RunEvent[] = []; diff --git a/src/core/dispatch/staging.ts b/src/core/dispatch/staging.ts index 74331cfe0..b6408c14d 100644 --- a/src/core/dispatch/staging.ts +++ b/src/core/dispatch/staging.ts @@ -95,6 +95,8 @@ export function noWorkspaceLine(files: readonly StagedFile[]): string { export interface CopyDeps { store: ArtifactStore; + copyAttachment?: (file: StagedFile, key: string, signal?: AbortSignal) => Promise; + signal?: AbortSignal; /** The thread the files belong to: the key's first segment. */ threadKey: string; /** The run's staging counter (`stagingIndex()`): ONE sequence across every @@ -137,7 +139,10 @@ export async function copyStaged(files: readonly StagedFile[], deps: CopyDeps): const basename = stagedBasename(index, file.name); const key = inboundKey(deps.threadKey, file.messageId, index, file.name); try { - await deps.store.copyFromUrl({ url: file.url, size: file.size, key }); + deps.signal?.throwIfAborted(); + if (deps.copyAttachment) await deps.copyAttachment(file, key, deps.signal); + else await deps.store.copyFromUrl({ url: file.url, size: file.size, key }, deps.signal); + deps.signal?.throwIfAborted(); deps.publish?.({ type: "artifact", direction: "in", @@ -171,14 +176,30 @@ export interface PullDeps { export async function pullStaged(outcomes: readonly StagedOutcome[], deps: PullDeps): Promise { const landed = outcomes.filter((o) => o.error === undefined); if (landed.length === 0) return [...outcomes]; + const cancelled = () => outcomes.map((o) => ({ ...o, error: o.error ?? "workspace staging was cancelled" })); + if (deps.signal?.aborted) return cancelled(); const timeoutMs = deps.timeoutMs ?? BASH_TIMEOUT_MAX_MS; const opts = { timeoutMs, ...(deps.signal ? { signal: deps.signal } : {}) }; - if (deps.resident) await deps.executor.exec(excludeCommand(), opts); + if (deps.resident) { + try { + await deps.executor.exec(excludeCommand(), opts); + } catch (error) { + if (deps.signal?.aborted) return cancelled(); + throw error; + } + } const pulled = new Map(); for (const o of landed) { - const url = await deps.store.presignGet(o.key); - const out = await deps.executor.exec(pullCommandFor(url, o.basename), opts); - pulled.set(o.key, parseExitPrefix(out).failed ? `the pull into the workspace failed: ${out.trim()}` : undefined); + if (deps.signal?.aborted) return cancelled(); + try { + const url = await deps.store.presignGet(o.key); + deps.signal?.throwIfAborted(); + const out = await deps.executor.exec(pullCommandFor(url, o.basename), opts); + pulled.set(o.key, parseExitPrefix(out).failed ? `the pull into the workspace failed: ${out.trim()}` : undefined); + } catch (error) { + if (deps.signal?.aborted) return cancelled(); + throw error; + } } return outcomes.map((o) => { const error = o.error ?? pulled.get(o.key); diff --git a/src/core/dispatcher.test.ts b/src/core/dispatcher.test.ts index 57dfb365e..66ee26e3b 100644 --- a/src/core/dispatcher.test.ts +++ b/src/core/dispatcher.test.ts @@ -26,7 +26,7 @@ import { InMemoryArtifactStore } from "../artifacts/store.js"; import { ExecInfraError, ExecSandboxRestartedError } from "../execution/executor.js"; import { classifyError } from "./trace/classify.js"; import { ResidentNeedsRefError } from "../execution/resident.js"; -import type { ChannelIO, HistoryItem, RunReceipt, StatusUpdate } from "./types.js"; +import type { ChannelIO, HistoryItem, IncomingMessage, RunReceipt, StatusUpdate } from "./types.js"; import { activeRunCount, dispatch, dispatchClick, type CoreDeps, type DispatchOutcome } from "./dispatcher.js"; import { CONFIRMATION_TTL_MS } from "./budgets.js"; import type { AuditEntry } from "./commandRegistry.js"; @@ -315,6 +315,7 @@ function ledgerBackedStore(ledger: InMemoryRunLedger, store: RunStore): RunStore for (const record of ledger.finished.values()) await store.put(record); }; return { + stopWaiting: (id, stop) => store.stopWaiting(id, stop), put: (record, trace) => store.put(record, trace), abandoned: () => {}, get: async (id) => { @@ -2970,6 +2971,7 @@ describe("review post-step", () => { deps.runRegistry = new RunRegistry({ genId: () => "r-order", genToken: () => "t-order" }); const inner = new InMemoryRunStore(); const store: RunStore = { + stopWaiting: (id, stop) => inner.stopWaiting(id, stop), put: async (r) => { order.push(`record:${r.status}`); return inner.put(r); @@ -7332,6 +7334,23 @@ describe("inline command runs + run receipts", () => { return new RunRegistry({ genId: () => `${prefix}-${++i}`, genToken: () => "tok" }); } + it("reports a command stopped during channel admission without invoking it or recording a refusal", async () => { + const deps = makeDeps(YAML_FIXTURE, capturingProvider()); + deps.frictionLedger = new InMemoryFrictionLedger(); + deps.runRegistry = sequentialRegistry("cancelled"); + const { invoked } = wireCommands(deps); + const { io, replies, receipts } = receiptIO(); + io.runStarted = async () => { + throw new Error("delivery admission unavailable"); + }; + expect(await dispatch(deps, msg("friction report"), io)).toEqual({ status: "stopped" }); + expect(invoked).toEqual([]); + expect(receipts).toEqual([{ id: "cancelled-1", status: "stopped_hard" }]); + expect(replies).toEqual(["Run stopped before execution."]); + expect(deps.runRegistry.listActive()).toHaveLength(1); + expect(deps.runRegistry.getById("cancelled-1")).toMatchObject({ finished: true, status: "stopped_hard" }); + }); + it("`friction report` is a run: input + answer events, finished, labeled, receipt `completed`", async () => { const deps = makeDeps(YAML_FIXTURE, capturingProvider()); deps.frictionLedger = new InMemoryFrictionLedger(); @@ -8983,6 +9002,17 @@ workspaceDir: __WORKDIR__ return { deps, provider, instances, created }; } + it("reports a ship request stopped during channel admission without handing off or recording a refusal", async () => { + const { deps, created } = shipDeps(); + const { io, replies } = fakeIO(); + io.runStarted = async () => { + throw new Error("delivery admission unavailable"); + }; + expect(await dispatch(deps, msg(TASK_MSG, "slack:UADMIN"), io)).toEqual({ status: "stopped" }); + expect(created).toEqual([]); + expect(replies).toEqual(["Run stopped before execution."]); + }); + /** The one generated plan instance a request became, with its U1 row — what * the runner is handed. The instance's id is content-derived (`plan-`), * so it is found by the run id stamped on it. */ @@ -9739,6 +9769,35 @@ describe("thread admission (docs/reference/specs/thread-admission.md)", () => { ), ); + it("an isolated channel defers another person and later binds a fresh run to that person", async () => { + const { provider, requests, firstStarted, settle } = gatedProvider(); + const deps = makeDeps(YAML_FIXTURE, provider); + deps.admission = new ThreadAdmission(); + const first = fakeIO(), + second = fakeIO(); + first.io.isolateFollowUps = second.io.isolateFollowUps = true; + const actors: string[] = []; + second.io.workItems = (actor) => { + actors.push(actor.id); + return { request: async () => ({ items: [] }) }; + }; + const run = dispatch(deps, threadMsg("write the report"), first.io); + await firstStarted; + const other = threadMsg("a separate request", "slack:UY"); + expect(await dispatch(deps, other, second.io)).toMatchObject({ deferred: true }); + expect(second.replies).toEqual([]); + expect(second.statuses).toEqual([]); + expect(actors).toEqual([]); + expect(deps.admission.get(other.threadKey)?.inbox.size).toBe(0); + settle().answer("first answer"); + await run; + expect(requests).toHaveLength(1); + expect(await dispatch(deps, other, second.io)).not.toHaveProperty("deferred"); + expect(actors).toEqual(["slack:UY"]); + expect(requests).toHaveLength(2); + expect(second.replies).toEqual(["answer 2"]); + }); + it("a thread reply while a run is in flight is steered: no second run, the follow-up reaches the live run at its next step, the reply says where it went", async () => { vi.stubEnv("PUBLIC_BASE_URL", "https://sb.example"); let ids = 0; @@ -9934,6 +9993,26 @@ describe("thread admission (docs/reference/specs/thread-admission.md)", () => { expect(deps.admission!.size).toBe(0); }); + it("reports an unconsumed follow-up that cannot restart while its access lookup is unavailable", async () => { + const registry = new RunRegistry({ genId: () => "r1", genToken: () => "t" }); + const { provider, requests, firstStarted, settle } = gatedProvider(); + const deps = makeDeps(YAML_FIXTURE, provider); + deps.runRegistry = registry; + deps.admission = new ThreadAdmission(); + const first = fakeIO(); + const run = dispatch(deps, threadMsg("write the report"), first.io); + await firstStarted; + const second = fakeIO(); + second.io.checkAccess = vi.fn().mockResolvedValueOnce(true).mockRejectedValue(new Error("offline")); + await dispatch(deps, threadMsg("and also the numbers"), second.io); + await foldedIn(registry, "r1", "and also the numbers"); + settle().fail(new Error("provider exploded")); + await run; + expect(requests).toHaveLength(1); + expect(second.replies.at(-1)).toContain("has not started"); + expect(second.replies.at(-1)).toContain("send it again"); + }); + it("after an operator stop, an unconsumed follow-up is not run — its sender is told the run was stopped before reading it", async () => { let ids = 0; const registry = new RunRegistry({ genId: () => `r${++ids}`, genToken: () => "t" }); @@ -10056,6 +10135,96 @@ describe("run ledger write-through (docs/reference/specs/run-history.md item 35) return { io, replies }; } + it("rechecks restored channel access: denials close rows and outages leave them reclaimable", async () => { + for (const kind of ["resume", "restart"] as const) + for (const temporary of [false, true]) { + const ledger = new InMemoryRunLedger(() => 10_000); + await ledger.claim({ + runId: "old", + threadKey: "slack:CX:1.0", + gen: "gen-OLD", + leaseMs: 30_000, + startedAt: 5_000, + phase: kind === "restart" ? "attaching" : "live", + meta: { + channelId: "slack:CX", + userId: "slack:UX", + threadKey: "slack:CX:1.0", + agent: "general", + model: "anthropic/general-model", + }, + system: "stored prompt", + tools: [], + }); + const messages: ChatMessage[] = [{ role: "user", content: [{ type: "text", text: "hello" }] }]; + if (kind === "resume") { + await ledger.seed("old", "gen-OLD", [{ idx: 0, message: messages[0]! }]); + await ledger.step( + "old", + "gen-OLD", + { + step: 0, + seq: 0, + turnIndex: 1, + inFlight: [], + inboxConsumedSeq: 0, + remainingMs: 300_000, + turn: 0, + iteration: 0, + }, + [], + ); + } + ledger.live.get("old")!.leaseUntil = 0; + const [reclaimed] = await ledger.reclaim("gen-T", 10_000, 30_000); + const provider = capturingProvider("must not run"); + const { deps, writer } = wired(provider, { ledger }); + const { io, replies } = ioWithCard(); + io.history = vi.fn(async () => []); + io.checkAccess = vi.fn(async () => { + if (temporary) throw new Error("offline"); + return false; + }); + let options: Parameters[3]; + if (kind === "restart") options = { restart: { row: reclaimed.row, inbox: [] } }; + else { + const plan = planResume({ + transcript: { complete: true, compactions: [], turns: 1, messages }, + lastStep: reclaimed.lastStep!, + tools: knownToolsFor(getAgent("general")), + }); + if (plan.kind !== "resume") throw new Error(plan.kind); + options = { + resume: { + row: reclaimed.row, + lastStep: reclaimed.lastStep!, + plan, + events: [], + lastSeq: 0, + repoCtx: {}, + inbox: [], + }, + }; + } + const outcome = await dispatch(deps, msg("hello"), io, options); + await writer.settled(); + expect(io.history).not.toHaveBeenCalled(); + expect(provider.requests).toHaveLength(0); + expect(io.checkAccess).toHaveBeenCalledWith("slack:UX"); + if (temporary) { + expect(outcome).toMatchObject({ deferred: true }); + expect(replies).toEqual([]); + expect(ledger.finished.has("old")).toBe(false); + expect((await ledger.reclaim("gen-next", 40_001, 30_000)).map((r) => r.row.runId)).toEqual(["old"]); + } else { + expect(outcome).toEqual({ status: "refused", refusal: "channel_access", cause: "policy" }); + expect(ledger.live.has("old")).toBe(false); + expect(ledger.finished.get("old")?.status).toBe("interrupted"); + expect(replies).toHaveLength(1); + } + } + }); + it("claims the run once its prompt exists (system, tools, card, meta, seed), records each step before its tools, appends events, takes finishing before the reply and finishes through the ledger", async () => { const seen: { rowAtFirstCall?: ReturnType; @@ -15222,6 +15391,69 @@ describe("the request router (docs/reference/specs/routing-and-config.md item 21 // nothing and is told where the file can be worked with; a refused steer // copies nothing; without a store nothing is staged. describe("inbound staging (record 0033)", () => { + it.each(["stop", "unavailable"])( + "awaits channel admission before file copies, workspace attach or model execution (%s)", + async (mode) => { + const registry = new RunRegistry({ genId: () => "admission-stop", genToken: () => "t" }); + const provider = capturingProvider(); + const deps = makeDeps(REMOTE_YAML_FIXTURE, provider); + deps.runRegistry = registry; + deps.artifacts = storeWithSlack(); + const { io } = fakeIO(); + const copy = vi.fn(); + io.copyAttachment = copy; + let release!: () => void; + io.runStarted = ({ id }) => + new Promise((resolve, reject) => { + release = () => { + if (mode === "unavailable") { + reject(new Error("binding unavailable")); + return; + } + registry.requestStopById(id, "hard", { kind: "chat", id: "test" }); + resolve(); + }; + }); + vi.mocked(makeExecutor).mockClear(); + const running = dispatch(deps, { ...msg("agent:coding read this", "slack:UADMIN"), staged: [clip] }, io); + await vi.waitFor(() => expect(release).toBeDefined()); + expect(copy).not.toHaveBeenCalled(); + expect(makeExecutor).not.toHaveBeenCalled(); + expect(provider.requests).toEqual([]); + release(); + expect((await running).status).toBe("stopped"); + expect(copy).not.toHaveBeenCalled(); + expect(makeExecutor).not.toHaveBeenCalled(); + expect(provider.requests).toEqual([]); + expect(registry.getById("admission-stop")).toBeNull(); + }, + ); + it("a hard stop cancels an admitted file copy before any model turn or workspace pull", async () => { + vi.stubEnv("SANDBOX_TOKEN", "tok"); + vi.stubEnv("GITHUB_APP_ID", ""); + const registry = new RunRegistry({ genId: () => "staging-stop", genToken: () => "t" }); + const provider = capturingProvider(); + const deps = makeDeps(REMOTE_YAML_FIXTURE, provider); + deps.runRegistry = registry; + deps.artifacts = storeWithSlack(); + const { commands, executor } = recordingExecutor(); + vi.mocked(makeExecutor).mockResolvedValueOnce({ executor }); + const { io } = fakeIO(); + let signal: AbortSignal | undefined; + io.copyAttachment = (_file, _key, abort) => + new Promise((_resolve, reject) => { + signal = abort; + abort?.addEventListener("abort", () => reject(new Error("copy cancelled")), { once: true }); + }); + const running = dispatch(deps, { ...msg("agent:coding read this", "slack:UADMIN"), staged: [clip] }, io); + await vi.waitFor(() => expect(signal).toBeDefined()); + registry.requestStop("staging-stop", "t", "hard"); + const ended = await running; + expect(signal?.aborted).toBe(true); + expect(ended.status).toBe("stopped"); + expect(provider.requests).toEqual([]); + expect(commands.some((command) => command.includes("attachments/"))).toBe(false); + }); afterEach(() => { vi.unstubAllEnvs(); vi.mocked(makeExecutor).mockClear(); @@ -16945,6 +17177,278 @@ describe("the confirmation through dispatch() and dispatchClick(): offered when }); }); +describe("current channel access before dispatch", () => { + it("refuses revoked access before commands, history, admission or model work", async () => { + for (const text of ["help", "write a report"]) { + const provider = capturingProvider("never"); + const deps = makeDeps(YAML_FIXTURE, provider); + deps.admission = new ThreadAdmission(); + const { io, replies } = fakeIO(); + io.checkAccess = vi.fn(async () => false); + io.history = vi.fn(async () => []); + const outcome = await dispatch(deps, msg(text), io); + expect(outcome).toEqual({ status: "refused", refusal: "channel_access", cause: "policy" }); + expect(io.checkAccess).toHaveBeenCalledWith("slack:UX"); + expect(io.history).not.toHaveBeenCalled(); + expect(provider.requests).toHaveLength(0); + expect(deps.invoked).toEqual([]); + expect(deps.admission.size).toBe(0); + expect(replies).toEqual([ + "I can’t start work here because your access to this conversation could not be verified.", + ]); + } + }); + it("defers a failed access lookup without answering or consuming the request", async () => { + const provider = capturingProvider("allowed"); + const deps = makeDeps(YAML_FIXTURE, provider); + const { io, replies } = fakeIO(); + io.checkAccess = vi.fn().mockRejectedValueOnce(new Error("unavailable")).mockResolvedValue(true); + io.history = vi.fn(async () => []); + expect(await dispatch(deps, msg("write a report"), io)).toMatchObject({ deferred: true }); + expect(io.history).not.toHaveBeenCalled(); + expect(provider.requests).toHaveLength(0); + expect(replies).toEqual([]); + expect(await dispatch(deps, msg("write a report"), io)).not.toHaveProperty("deferred"); + expect(replies).toEqual(["allowed"]); + }); +}); + +describe("clarification through dispatch", () => { + it("reports a model failure instead of sending a pending question", async () => { + let calls = 0; + const provider: Provider = { + name: "fake", + complete: async () => { + if (++calls === 1) + return { + content: [{ type: "tool_use", id: "q1", name: "request_input", input: { question: "Which repository?" } }], + stopReason: "tool_use", + }; + throw new Error("model unavailable"); + }, + }; + const deps = makeDeps(YAML_FIXTURE, provider); + const { io, replies } = fakeIO(); + io.question = vi.fn(); + io.runFinished = vi.fn(); + expect(await dispatch(deps, msg("look into the issue"), io)).toMatchObject({ status: "failed" }); + expect(io.question).not.toHaveBeenCalled(); + expect(io.runFinished).toHaveBeenCalledWith(expect.objectContaining({ status: "failed" })); + expect(replies.join("\n")).toContain("model unavailable"); + }); + it("asks the recorded question and continues from the next reply without announcing completion", async () => { + let calls = 0; + const requests: CompletionRequest[] = []; + const provider: Provider = { + name: "fake", + complete: async (request) => { + requests.push(structuredClone(request)); + if (++calls === 1) + return { + content: [{ type: "tool_use", id: "q1", name: "request_input", input: { question: "Which repository?" } }], + stopReason: "tool_use", + }; + return { + content: [{ type: "text", text: calls === 2 ? "The model's closing text" : "Using that repository." }], + stopReason: "end_turn", + }; + }, + }; + const deps = makeDeps(YAML_FIXTURE, provider); + const first = fakeIO(); + const questions: string[] = []; + first.io.question = async (text) => { + questions.push(text); + }; + first.io.runFinished = vi.fn(); + await dispatch(deps, msg("look into the issue"), first.io); + expect(questions).toEqual(["Which repository?"]); + expect(first.replies).toEqual([]); + expect(first.io.runFinished).toHaveBeenCalledWith(expect.objectContaining({ awaitingInput: true })); + const second = fakeIO([ + { role: "user", text: "look into the issue" }, + { role: "assistant", text: questions[0]! }, + ]); + await dispatch(deps, msg("Use acme/api"), second.io); + expect(second.replies).toEqual(["Using that repository."]); + expect(JSON.stringify(requests.at(-1)?.messages)).toContain("Which repository?"); + expect(JSON.stringify(requests.at(-1)?.messages)).toContain("Use acme/api"); + }); +}); + +describe("coordinator clarification replies", () => { + it("refuses another person's answer without closing the waiting session", async () => { + const provider = capturingProvider(); + const deps = makeDeps(YAML_FIXTURE, provider); + await threadWithFinishedRun(deps, "coding", { + awaitingInput: true, + parentInstanceId: "plan-answer", + idempotencyKey: "plan-answer:U10/0/coding", + }); + const { io, replies } = fakeIO([{ role: "assistant", text: "Which behavior do you want?" }]); + io.question = vi.fn(async () => {}); + const outcome = await dispatch(deps, msg("Keep the existing behavior"), io); + expect(outcome).toEqual({ status: "refused", refusal: "coordinator_clarification", cause: "policy" }); + expect(io.question).toHaveBeenCalledExactlyOnceWith( + "Only the original requester can answer this coordinator question.", + ); + expect(replies).toEqual([]); + expect(provider.requests).toEqual([]); + }); + + it("defers an answer when its durable coordinator context cannot be read", async () => { + const provider = capturingProvider(); + const deps = makeDeps(YAML_FIXTURE, provider); + await threadWithFinishedRun(deps, "coding", { + userId: "slack:UX", + awaitingInput: true, + parentInstanceId: "plan-answer", + idempotencyKey: "plan-answer:U10/0/coding", + }); + const instances = new InMemoryCoordinatorInstanceStore(); + vi.spyOn(instances, "get").mockRejectedValueOnce(new Error("offline")); + deps.coordinatorInstances = instances; + const { io, replies } = fakeIO([{ role: "assistant", text: "Which behavior do you want?" }]); + const outcome = await dispatch(deps, msg("Keep the existing behavior"), io); + expect(outcome.deferred).toBe(true); + expect(replies).toEqual([]); + expect(provider.requests).toEqual([]); + }); + + it.each([false, true])( + "continues the same branch and coordinator tag, with fresh repository access (denied=%s)", + async (denied) => { + vi.stubEnv("SANDBOX_TOKEN", "tok"); + vi.stubEnv("GITHUB_APP_ID", ""); + const provider = capturingProvider("The answer is incorporated."); + const deps = makeDeps(REMOTE_YAML_FIXTURE, provider); + const registry = new RunRegistry({ genId: () => "run-answer", genToken: () => "token" }); + deps.runRegistry = registry; + await threadWithFinishedRun(deps, "coding", { + awaitingInput: true, + parentInstanceId: "plan-answer", + idempotencyKey: "plan-answer:U10/0/coding", + }); + const writer = createRunHistoryWriter({ store: deps.runStore, warn: () => {}, sleep: async () => {} }); + deps.runHistoryWriter = writer; + const instances = new InMemoryCoordinatorInstanceStore(); + await instances.put({ + id: "plan-answer", + kind: "ship", + userId: "slack:UADMIN", + channelId: "slack:CX", + threadKey: "slack:CX:1.0", + repo: "acme/api", + branch: "plan/answer/u1", + base: "main", + createdAt: Date.now(), + plan: { id: "answer", path: "plan.md" }, + caps: { maxRounds: 2, maxMinutes: 120 }, + }); + await instances.putUnits([ + { + instanceId: "plan-answer", + unit: "U10", + slug: "u1", + branch: "plan/answer/u1", + dependsOn: [], + rounds: [], + threadKey: "slack:CX:1.0", + startedAt: Date.now(), + }, + ]); + deps.coordinatorInstances = instances; + const api = new InMemoryGithubApi({ + "acme/api": { + files: { + "plan.md": + "# Plan\n\n### U10. Preserve behavior\n\n- **Goal**: Retain the existing API behavior.\n- **Dependencies**: none\n", + }, + }, + }); + const readFile = vi.spyOn(api, "readFile"); + deps.githubApi = api; + const resolve = vi.fn(async (_request: IncomingMessage) => ({ repo: "acme/api", ref: "plan/answer/u1" })); + deps.resolveRepoContext = resolve; + if (denied) vi.spyOn(deps.config, "canUseRepo").mockReturnValue(false); + else + vi.mocked(makeExecutor).mockResolvedValueOnce({ + executor: { exec: async () => "", readFile: async () => "", writeFile: async () => "" }, + }); + const { io, replies } = fakeIO([{ role: "assistant", text: "Which behavior do you want?" }]); + const outcome = await dispatch(deps, msg("Keep the existing behavior", "slack:UADMIN"), io); + await writer.settled(); + if (denied) { + expect(outcome.status).toBe("refused"); + expect(provider.requests).toEqual([]); + expect(readFile).not.toHaveBeenCalled(); + } else { + expect(outcome.status, replies.join("\n")).toBe("completed"); + const record = await deps.runStore.get("run-answer"); + expect(record).toMatchObject({ + agent: "coding", + repo: "acme/api", + parentInstanceId: "plan-answer", + idempotencyKey: "plan-answer:U10/0/coding", + }); + expect(record?.events).toContainEqual(expect.objectContaining({ type: "coordinator_tag", base: "main" })); + expect(JSON.stringify(provider.requests)).toContain("Retain the existing API behavior"); + expect(resolve.mock.calls[0]?.[0]).toMatchObject({ + userId: "slack:UADMIN", + text: expect.stringContaining("plan/answer/u1"), + }); + expect(JSON.stringify(provider.requests)).toContain("Keep the existing behavior"); + } + }, + ); + + it.each(["coding", "review"] as const)( + "resolves the original %s preset before fresh authorization instead of treating an answer as general chat", + async (preset) => { + const provider = capturingProvider(); + const deps = makeDeps(YAML_FIXTURE.replace("agents: [coding]", "agents: [coding, review]"), provider); + await threadWithFinishedRun(deps, preset, { + userId: "slack:UX", + awaitingInput: true, + parentInstanceId: "plan-answer", + idempotencyKey: `plan-answer:U10/0/${preset}`, + }); + const instances = new InMemoryCoordinatorInstanceStore(); + await instances.put({ + id: "plan-answer", + kind: "ship", + userId: "slack:UX", + channelId: "slack:CX", + threadKey: "slack:CX:1.0", + repo: "acme/api", + branch: "plan/answer/u1", + createdAt: Date.now(), + plan: { id: "answer", path: "plan.md" }, + caps: { maxRounds: 2, maxMinutes: 120 }, + }); + await instances.putUnits([ + { + instanceId: "plan-answer", + unit: "U10", + slug: "u1", + branch: "plan/answer/u1", + dependsOn: [], + rounds: [], + threadKey: "slack:CX:1.0", + startedAt: Date.now(), + reviewThread: { threadKey: "slack:CX:1.0" }, + pr: { number: 7, url: "https://github.com/acme/api/pull/7" }, + }, + ]); + deps.coordinatorInstances = instances; + const { io } = fakeIO([{ role: "assistant", text: "Which behavior do you want?" }]); + const outcome = await dispatch(deps, msg("Keep the existing behavior"), io); + expect(outcome).toMatchObject({ status: "refused", refusal: "agent_allowlist" }); + expect(provider.requests).toEqual([]); + }, + ); +}); + // Feature: record 0051's reply-as-event and gone-instance rules (thread-admission; routing-and-config item 21) // — a plain reply into a thread owned by an unfinished unit with no live run is // one thread event on the unit: appended with mode `steer`, the instance diff --git a/src/core/dispatcher.ts b/src/core/dispatcher.ts index 1c607cff4..d1b004e74 100644 --- a/src/core/dispatcher.ts +++ b/src/core/dispatcher.ts @@ -1,3 +1,10 @@ +import { awaitChannelAdmission, RunAdmissionStopped } from "./dispatch/runStart.js"; +import { + coordinatorClarificationFor, + coordinatorClarificationContract, + CoordinatorClarificationRefusal, + type CoordinatorClarification, +} from "./coordinator/clarification.js"; import { getAgent } from "../agents/registry.js"; import type { LedgerRun } from "./runLedger/writeThrough.js"; import { systemClock } from "./trace/index.js"; @@ -25,10 +32,11 @@ import { type RestartContext, type ResumeContext, } from "./dispatch/admission.js"; +import { checkChannelAccess } from "./dispatch/channelAccess.js"; import { answerChatCommand, type FastPathDeps } from "./dispatch/fastPath.js"; import { actorIdsOf, cancelPending, consumeAndRun, REFUSED_REASON } from "./dispatch/confirm.js"; import { postSettledOutcome, recordRefusal, recordRoutedDecision } from "./dispatch/commandRun.js"; -import { COMMAND_RUN_AGENT } from "./runOwner.js"; +import { COMMAND_RUN_AGENT, DOOR_RUN_AGENT } from "./runOwner.js"; import { redactedInput } from "./dispatch/route.js"; import type { Actor } from "./authz/types.js"; import { @@ -38,7 +46,7 @@ import { referenceRefusalCode, type ReferenceDeps, } from "./dispatch/references.js"; -import { resolveChatActor } from "./authz/actor.js"; +import { chatActorOf, resolveChatActor } from "./authz/actor.js"; import { referencesOn } from "../config.js"; import { readRequest, resolveProfile, resolveRun, resolveTarget, type ResolveDeps } from "./dispatch/resolve.js"; import { compoundBrief, routeRequest, type RouteDecided, type RouteDeps } from "./dispatch/route.js"; @@ -75,7 +83,7 @@ import { type ProvisionDeps, } from "./dispatch/provision.js"; import type { FrictionDiagnosis } from "./runFriction.js"; -import { claimRun, type RunDeps } from "./dispatch/run.js"; +import { claimRun, githubCapabilityFor, type RunDeps } from "./dispatch/run.js"; import { runLoop } from "./dispatch/runLoop.js"; import { afterReply, deliverAnswer, type ReplyDeps } from "./dispatch/reply.js"; import { writeTombstone } from "./dispatch/record.js"; @@ -337,6 +345,7 @@ export async function dispatch( // object and the outer finally stamps its status before the promise settles, // so the function resolves exactly when it did before it answered anything. const ended: DispatchOutcome = { status: "completed" }; + let admissionStopped: RunAdmissionStopped | undefined; const resume = opts.resume; const restart = opts.restart; const clock = deps.clock ?? systemClock; @@ -392,7 +401,7 @@ export async function dispatch( // the one table — stamped, the side work (a card close, a release) run // inside the span, the sentence rendered by the ONE renderer, and the // decision recorded as a `door` run. - const refuse = async (refusal: Refusal, side?: () => Promise) => { + const refuse = async (refusal: Refusal, side?: () => Promise, replyIo: ChannelIO = io) => { const cause = stampRefusal(refusal.code); await root.span( "dispatch.refuse", @@ -401,7 +410,7 @@ export async function dispatch( // The store rides along so a question with a guess can mint its Yes // (record 0054): the renderer offers Yes and No where the channel can // show them, and the line to type everywhere else. - await renderRefusal(refusal, io, { confirmations: deps.confirmations }); + await renderRefusal(refusal, replyIo, { confirmations: deps.confirmations }); await recordRefusalOnce(refusal); }, { attrs: { outcome: refusal.code, refusal: refusal.code, cause } }, @@ -533,6 +542,24 @@ export async function dispatch( }, }; try { + if (io.checkAccess) { + const access = await root.span("dispatch.channel_access", () => + checkChannelAccess(deps.runLedger, { msg, io, resume, restart }), + ); + if (access === "retry") { + ended.deferred = true; + return ended; + } + if (access === "deny") { + await refuse( + refusalOf( + "channel_access", + "I can’t start work here because your access to this conversation could not be verified.", + ), + ); + return ended; + } + } // Stage A (dispatch/fastPath.ts): a message that names a registered chat // command is answered inline — never a model turn, and before the history // fetch, so a command costs none. @@ -555,12 +582,47 @@ export async function dispatch( opts.parent || opts.coordinator || resume || restart || history.length === 0 ? undefined : await readThread(runsService, msg.threadKey); + let clarification: CoordinatorClarification | undefined; + try { + clarification = await coordinatorClarificationFor({ + newest: thread?.find((run) => run.agent !== DOOR_RUN_AGENT), + msg, + directives, + instances: deps.coordinatorInstances, + now: clock(), + }); + } catch (error) { + if (!(error instanceof CoordinatorClarificationRefusal)) { + // No admission or model work has begun. Let durable intake retry a + // missing store response instead of closing the waiting conversation. + ended.deferred = true; + return ended; + } + // A refused reply must not close another person's waiting Linear session. + await refuse( + refusalOf("coordinator_clarification", error.message), + undefined, + io.question ? { ...io, reply: (text) => io.question!(text) } : io, + ); + return ended; + } + if (clarification?.remainingMinutes !== undefined) + directives.budget = Math.min(directives.budget ?? Number.POSITIVE_INFINITY, clarification.remainingMinutes); + if (clarification?.instance.addressSeverity !== undefined) + directives.severity = clarification.instance.addressSeverity; const lineage = lineageOf(thread?.[0]); const parent: ParentRun | undefined = opts.parent ?? (lineage ? lineageParent(lineage) : undefined); const tellLineage = async (heard: LineageHeard) => { if (!lineage) return; await tellParent( - { runs: runsService, config: deps.config, runLedger: deps.runLedger, clock, admission }, + { + runs: runsService, + config: deps.config, + runLedger: deps.runLedger, + clock, + admission, + isolateFollowUps: io.isolateFollowUps, + }, lineage, msg, heard, @@ -572,7 +634,7 @@ export async function dispatch( // the thread's newest finished run with a session log (routing-and-config // item 3) — else the config scopes; the model and the effort from the // thread's user turns, then the scopes. - const stickyAgent = thread ? stickyAgentOf(thread) : undefined; + const stickyAgent = clarification?.preset ?? (thread ? stickyAgentOf(thread) : undefined); // The same page names the pull request the thread's work lives on // (resident-repos item 29): the one its newest finished run opened, for // the target resolution below. @@ -610,7 +672,13 @@ export async function dispatch( // ` in a pipeline thread still means what it says. A live thread is // the live run's (admission steers below); a session or no owner is the // sticky path and the router, exactly as before. - if (thread && !threadLive && directives.agent === undefined && deps.coordinatorInstances !== undefined) { + if ( + thread && + !clarification && + !threadLive && + directives.agent === undefined && + deps.coordinatorInstances !== undefined + ) { const owner = await ownerOf(thread, (id) => deps.coordinatorInstances!.listUnits(id), msg.threadKey); if (owner.kind === "unit") { // The same gate a live steer passes (admission's allowlist check): the @@ -747,6 +815,7 @@ export async function dispatch( }); // A reply folded into the live child of a spawned thread: its parent hears it now. if (outcome.kind === "steered") await tellLineage({ kind: "steered" }); + if (outcome.kind === "deferred") ended.deferred = true; if (outcome.kind !== "proceed") return ended; admitted = outcome.admitted; const taken = await adoptCarriedRun(deps, admissionCtx); @@ -780,14 +849,14 @@ export async function dispatch( modelCard, decisions: cardDecisions, } = resolveTarget(deps, { - msg, - history, + msg: clarification ? { ...msg, text: clarification.targetText } : msg, + history: clarification ? [] : history, agent, profile, resolved, resume, root, - ...(threadPr ? { records: { pr: threadPr } } : {}), + ...(threadPr && !clarification ? { records: { pr: threadPr } } : {}), }); // Cross-session memory — READ path, started here (dispatch/provision.ts) so @@ -894,8 +963,16 @@ export async function dispatch( // compound's first turn is its brief (dispatch/route.ts): the message as // typed, then the parts for the conductor to spawn — the record's `input` // stays the message, its `route` event carries the parts. - const contractBlock = opts.contract - ? renderContract(opts.contract, { maxChars: DEFAULT_CONTRACT_MAX_CHARS }).text + const contract = + opts.contract ?? + (clarification + ? await coordinatorClarificationContract(clarification, { + github: githubCapabilityFor(deps, chatActorOf(deps.config, msg)).api, + runs: runsService, + }) + : undefined); + const contractBlock = contract + ? renderContract(contract, { maxChars: DEFAULT_CONTRACT_MAX_CHARS }).text : undefined; const requestText = route?.parts ? compoundBrief(directives.text, route.parts) : directives.text; // Where the conversation starts (run-history item 52), and every row and @@ -982,7 +1059,8 @@ export async function dispatch( // the spawn's dispatch options are gone with the process that spawned it, // so the tag is rebuilt from the adopted row's meta and the // `coordinator_tag` event the spawning dispatch published. - const coordinator = opts.coordinator ?? (resume ? carriedCoordinatorTag(resume.row, resume.events) : undefined); + const coordinator = + opts.coordinator ?? clarification?.tag ?? (resume ? carriedCoordinatorTag(resume.row, resume.events) : undefined); const registration = await registerRun(deps, { msg, io, @@ -1013,6 +1091,25 @@ export async function dispatch( }); const { run, runId, channelVisibility, liveUrl, publishText, publishMeta } = registration; registered = run; + await awaitChannelAdmission(io, registry, run.id); + if (run.control.hardSignal.aborted) { + stoppedWhileAttaching = "hard"; + await ending.sealAfterReply( + () => + root.span("dispatch.stop", () => + card.done( + shell.close({ + kind: "not_started", + icon: "⛔", + reason: "stopped before the run started", + ...closeLines(clock(), false), + }), + ), + ), + () => root.span("post.reply", () => io.reply("Run stopped before execution.")), + ); + return ended; + } // What the session seed could not do (session-log item 9), on the record // before the first turn — the run is not changed by it. for (const summary of seedNotes) @@ -1045,6 +1142,8 @@ export async function dispatch( staged.length > 0 && agent.machine !== "none" ? copyStaged(staged, { store: deps.artifacts!, + copyAttachment: io.copyAttachment?.bind(io), + signal: run.control.hardSignal, threadKey: msg.threadKey, nextIndex: nextStagedIndex, publish: (e) => registry.publish(runId, e), @@ -1248,6 +1347,7 @@ export async function dispatch( store: deps.artifacts!, executor, resident: resident !== undefined, + signal: run.control.hardSignal, }); workspaceFiles.record(pulled); line = attachmentsLine(pulled); @@ -1552,7 +1652,16 @@ export async function dispatch( console.log(`[dispatch] ${msg.threadKey} run ${run.id} restarts from its request: ${ran.note}`); return ended; } - const { answer, prNote, toolCalls, runDiagnosis, checklistAsLeft, checklistCheckedOff, releaseWorkspace } = ran; + const { + answer, + awaitingInput, + prNote, + toolCalls, + runDiagnosis, + checklistAsLeft, + checklistCheckedOff, + releaseWorkspace, + } = ran; // The card's final icon tells the stop apart from a normal finish: ⏹ soft // (a summary was written), ⛔ hard (aborted, no summary). @@ -1563,6 +1672,7 @@ export async function dispatch( // ledger, the card close, the reply, the seal — the workspace released // after. A fenced run is another generation's now: nothing more from here. const delivery = await deliverAnswer({ + ...(awaitingInput ? { awaitingInput } : {}), msg, io, agent, @@ -1584,7 +1694,7 @@ export async function dispatch( releaseWorkspace, root, }); - if (delivery.kind === "fenced") return ended; + if (delivery.kind === "fenced" || awaitingInput) return ended; // After the reply (dispatch/reply.ts): the memory reflection pass. The // review post-step ran inside the run loop, before the stream finished. @@ -1603,6 +1713,16 @@ export async function dispatch( }); return ended; } catch (err) { + if (err instanceof RunAdmissionStopped) { + admissionStopped = err; + await ending + .sealAfterReply( + async () => {}, + () => root.span("post.reply", () => io.reply(err.message)), + ) + .catch(() => {}); + return ended; + } caught = true; // The catch-all is the last line (record 0054): an uncaught throw is a // `system`/`uncaught` refusal on the trace, counted like any other — @@ -1729,8 +1849,8 @@ export async function dispatch( admitted, // A stop that ended the attach counts as the loop's stop would: the // request ends `stopped`, and a follow-up queued during the wait is told. - stopCounts: runLoopStarted || stoppedWhileAttaching !== undefined, - control: registered?.control, + stopCounts: runLoopStarted || stoppedWhileAttaching !== undefined || admissionStopped !== undefined, + control: registered?.control ?? admissionStopped?.control, }); if (settled.kind === "dropped") await tellDropped(root, settled.pending); const stopMode = (settled.kind === "handed-on" ? undefined : settled.stopMode) ?? stoppedWhileAttaching; @@ -1755,18 +1875,32 @@ export async function dispatch( ...(restartRequest.restartOf !== undefined ? { restartOf: restartRequest.restartOf } : {}), ...(restartRequest.coordinator !== undefined ? { coordinator: restartRequest.coordinator } : {}), }); - await dispatch(deps, restart.msg, io, restart.opts).catch((err: unknown) => - console.error( - `[dispatch] ${msg.threadKey} restart from the request failed: ${err instanceof Error ? err.message : String(err)}`, - ), - ); + await dispatch(deps, restart.msg, io, restart.opts) + .then(async (outcome) => { + if (outcome.deferred) + await io.reply( + "The request has not started because access could not be checked. Please send it again to retry.", + ); + }) + .catch((err: unknown) => + console.error( + `[dispatch] ${msg.threadKey} restart from the request failed: ${err instanceof Error ? err.message : String(err)}`, + ), + ); } else if (settled.kind === "handed-on") { const fresh = prepareFreshTurn(deps, { agent: settled.agent, pending: settled.pending, clock }); - await dispatch(deps, fresh.msg, fresh.io, fresh.opts).catch((err: unknown) => - console.error( - `[dispatch] ${msg.threadKey} fresh turn for unconsumed follow-ups failed: ${err instanceof Error ? err.message : String(err)}`, - ), - ); + await dispatch(deps, fresh.msg, fresh.io, fresh.opts) + .then(async (outcome) => { + if (outcome.deferred) + await fresh.io.reply( + "The follow-up has not started because access could not be checked. Please send it again to retry.", + ); + }) + .catch((err: unknown) => + console.error( + `[dispatch] ${msg.threadKey} fresh turn for unconsumed follow-ups failed: ${err instanceof Error ? err.message : String(err)}`, + ), + ); } // The ledger heartbeat stops with the run (the finish write, in flight // through the writer, closes the row itself). @@ -1807,6 +1941,7 @@ export interface ClickRequest { */ export async function dispatchClick(deps: CoreDeps, click: ClickRequest): Promise { const ended: DispatchOutcome = { status: "completed" }; + let admissionStopped: RunAdmissionStopped | undefined; const clock = deps.clock ?? systemClock; const trace = startRequestRoot(deps, { channel: click.actor.origin ? channelOf(click.actor.origin.channelId) : undefined, @@ -1917,6 +2052,16 @@ export async function dispatchClick(deps: CoreDeps, click: ClickRequest): Promis if (res.result.ok && res.result.followUp) postSettledOutcome(res.result.followUp, io, root); return ended; } catch (err) { + if (err instanceof RunAdmissionStopped) { + admissionStopped = err; + await ending + .sealAfterReply( + async () => {}, + () => root.span("post.reply", () => io.reply(err.message)), + ) + .catch(() => {}); + return ended; + } caught = true; await ending .sealAfterReply( @@ -1928,7 +2073,13 @@ export async function dispatchClick(deps: CoreDeps, click: ClickRequest): Promis } finally { // The backstop, as in dispatch(): a finished run no reply reached is sealed and written. ending.drain(undefined); - ended.status = caught ? "failed" : refused ? "refused" : (redispatched?.status ?? "completed"); + ended.status = caught + ? "failed" + : refused + ? "refused" + : admissionStopped + ? "stopped" + : (redispatched?.status ?? "completed"); root.end(caught ? "error" : "ok", { status: ended.status }); activeRuns--; } diff --git a/src/core/frictionLedger.test.ts b/src/core/frictionLedger.test.ts index 3d6b78f54..ddc24c2d9 100644 --- a/src/core/frictionLedger.test.ts +++ b/src/core/frictionLedger.test.ts @@ -182,6 +182,7 @@ describe("RunStoreFrictionLedger", () => { it("a run store that cannot be read rejects with its error — the friction command names the cause", async () => { const brokenStore: RunStore = { + stopWaiting: async () => "not_found", put: async () => ({ ok: true, retained: 0, stored: false, rewritten: false }), abandoned: () => {}, get: async () => null, @@ -219,6 +220,7 @@ describe("RunStoreFrictionLedger", () => { const inner = new InMemoryRunStore({ now: () => NOW }); for (let i = 0; i < 3; i++) await inner.put(runRecord(`r${i}`, NOW - i)); const spy: RunStore = { + stopWaiting: (id, stop) => inner.stopWaiting(id, stop), put: (r) => inner.put(r), abandoned: () => {}, get: async (id) => { diff --git a/src/core/harness/pi/relay.test.ts b/src/core/harness/pi/relay.test.ts index 904068d15..1437f3e61 100644 --- a/src/core/harness/pi/relay.test.ts +++ b/src/core/harness/pi/relay.test.ts @@ -497,12 +497,15 @@ describe("the research preset on pi: the web toolset relayed, run in the bot as return w; }; - it("serves exactly the web toolset's definitions (web_fetch, web_search, update_status and the GitHub reads) and none of pi's own tools", () => { + it("serves exactly the web toolset's definitions (web_fetch, web_search, update_status, request_input and the GitHub reads) and none of pi's own tools", () => { const { harness } = research(); expect(relayedToolDefinitions(harness).map((d) => d.name)).toEqual([ "web_fetch", "web_search", "update_status", + "request_input", + "work_item_get", + "work_items_delegated", "github_repos", "github_file", "github_tree", diff --git a/src/core/prTitleVocabulary.json b/src/core/prTitleVocabulary.json index df2f85a80..926987938 100644 --- a/src/core/prTitleVocabulary.json +++ b/src/core/prTitleVocabulary.json @@ -29,6 +29,7 @@ "slack", "http", "mcp", + "linear", "agents", "review", "coding", diff --git a/src/core/question.ts b/src/core/question.ts new file mode 100644 index 000000000..083171eca --- /dev/null +++ b/src/core/question.ts @@ -0,0 +1,6 @@ +/** A question is a typed turn outcome, never inferred from punctuation. */ +export function questionText(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const text = value.trim(); + return text.length > 0 && text.length <= 4000 ? text : undefined; +} diff --git a/src/core/refusal.ts b/src/core/refusal.ts index 462c024b6..03b63ac22 100644 --- a/src/core/refusal.ts +++ b/src/core/refusal.ts @@ -41,6 +41,8 @@ export interface CommandGuessHint { const CAUSE_OF = { // the dispatch gates (dispatcher.ts `refuse(code)`) agent_allowlist: "policy", + channel_access: "policy", + coordinator_clarification: "policy", profile_bounded: "policy", repo_not_visible: "policy", repo_unverified: "system", diff --git a/src/core/runLedger/resume.test.ts b/src/core/runLedger/resume.test.ts index d047b7160..ecf67422a 100644 --- a/src/core/runLedger/resume.test.ts +++ b/src/core/runLedger/resume.test.ts @@ -41,6 +41,7 @@ const TOOLS: KnownTool[] = [ { name: "github_issue_comment" }, { name: "update_status" }, { name: "submit_verdict" }, + { name: "request_input" }, { name: "submit_handoff" }, { name: "mcp_jira_create_ticket" }, // a bridged tool that mutates: known, not side-effect-free, not on the safe list ]; @@ -76,7 +77,15 @@ describe("settlementFor — D4", () => { action: "synthetic", text: expect.stringMatching(/restarted while this mcp_jira_create_ticket call was in flight/), }); - for (const name of ["read", "web_fetch", "github_file", "update_status", "submit_verdict", "submit_handoff"]) { + for (const name of [ + "read", + "web_fetch", + "github_file", + "update_status", + "submit_verdict", + "submit_handoff", + "request_input", + ]) { expect(settlementFor(call("c", name), toolMap)).toEqual({ toolUse: call("c", name), action: "rerun" }); } // pi's whole-file write is a mutation like any other: its effects are the container's, unknowable after a restart. diff --git a/src/core/runLedger/resume.ts b/src/core/runLedger/resume.ts index 2eefa8a89..a59f3ce91 100644 --- a/src/core/runLedger/resume.ts +++ b/src/core/runLedger/resume.ts @@ -98,6 +98,7 @@ export type ResumePlan = * restart note the rebuilt session ends on (harness-pi.md item 8). */ export const RERUN_SAFE_TOOLS: ReadonlySet = new Set([ "update_status", + "request_input", "submit_verdict", "submit_pr_description", "submit_handoff", diff --git a/src/core/runLedger/threadsElsewhere.ts b/src/core/runLedger/threadsElsewhere.ts index 3680f7f22..bbdaadc4a 100644 --- a/src/core/runLedger/threadsElsewhere.ts +++ b/src/core/runLedger/threadsElsewhere.ts @@ -14,6 +14,7 @@ export interface ThreadElsewhere { runId: string; agent?: string; + userId?: string; startedAt: number; } @@ -21,12 +22,15 @@ export class ThreadsElsewhere { private byThread = new Map(); /** The sweep's current listing, replacing the previous one entirely. */ - replace(rows: Iterable<{ threadKey: string; runId: string; startedAt: number; meta: { agent?: string } }>): void { + replace( + rows: Iterable<{ threadKey: string; runId: string; startedAt: number; meta: { agent?: string; userId?: string } }>, + ): void { const next = new Map(); for (const r of rows) { next.set(r.threadKey, { runId: r.runId, startedAt: r.startedAt, + ...(r.meta.userId !== undefined ? { userId: r.meta.userId } : {}), ...(r.meta.agent !== undefined ? { agent: r.meta.agent } : {}), }); } diff --git a/src/core/runRecord.ts b/src/core/runRecord.ts index 844cf84cf..817880edf 100644 --- a/src/core/runRecord.ts +++ b/src/core/runRecord.ts @@ -1,7 +1,7 @@ import type { ChannelVisibility, Predicate } from "./authz/types.js"; import type { BoundaryScope, Identity, MachineClass, RunProfile } from "../config/profile.js"; -import type { RunEvent } from "./runEvents.js"; -import { isHeadMaterial, isSpanRecord } from "./runEvents.js"; +import type { RunActor, RunEvent, StopMode } from "./runEvents.js"; +import { ACTOR_ID_PATTERN, isHeadMaterial, isSpanRecord } from "./runEvents.js"; import { isRunUsage, type RunUsage } from "./runUsage.js"; import type { PushedBranch } from "../execution/residentRebind.js"; import { isHandoffShape, type Handoff } from "./ship/handoff.js"; @@ -58,7 +58,54 @@ export interface RunReference { messages: number; } +export interface InputStop { + at: number; + by: RunActor; + mode: StopMode; +} + +export function isInputStop(value: unknown): value is InputStop { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + if (typeof v.at !== "number" || !Number.isFinite(v.at) || v.at < 0 || (v.mode !== "soft" && v.mode !== "hard")) + return false; + if (typeof v.by !== "object" || v.by === null) return false; + const by = v.by as Record; + return ( + ["access", "mcp", "cli", "chat"].includes(String(by.kind)) && + typeof by.id === "string" && + ACTOR_ID_PATTERN.test(by.id) + ); +} + +/** A stop is monotonic: retrying a turn's original history cannot reopen input. */ +export function preserveInputStop(record: RunRecord, previous?: InputStop): RunRecord { + const inputStop = previous ?? record.inputStop; + if (!inputStop) return record; + const { awaitingInput: _waiting, ...rest } = record; + return { ...rest, inputStop, status: inputStop.mode === "hard" ? "stopped_hard" : "stopped_soft" }; +} + +export function stopWaitingRecord(record: RunRecord, stop: InputStop): RunRecord | undefined { + if (!isInputStop(stop)) return undefined; + if (record.inputStop) { + const prior = record.inputStop; + return prior.at === stop.at && + prior.mode === stop.mode && + prior.by.kind === stop.by.kind && + prior.by.id === stop.by.id + ? record + : undefined; + } + if (record.status !== "completed" || !record.awaitingInput || record.finishedAt > stop.at) return undefined; + return preserveInputStop(record, stop); +} + export interface RunRecord { + /** This completed turn asked a question; the task still needs user input. */ + awaitingInput?: true; + /** Cancellation of this turn's pending question, retained across history retries. */ + inputStop?: InputStop; /** The run registry id (unguessable; safe to print — it is not the view token). */ id: string; /** The human run label from the runs index. */ @@ -863,6 +910,7 @@ export function normalizeStored; + if (r.inputStop !== undefined && !isInputStop(r.inputStop)) return false; if (typeof r.id !== "string" || !RUN_ID_PATTERN.test(r.id)) return false; if ( !isOptionalString(r.label) || diff --git a/src/core/runStore.test.ts b/src/core/runStore.test.ts index 1ba490911..a437bc191 100644 --- a/src/core/runStore.test.ts +++ b/src/core/runStore.test.ts @@ -64,6 +64,24 @@ interface Harness { function contract(name: string, make: (policy?: Partial) => Harness) { describe(`${name} — RunStore contract`, () => { + it("stops a waiting question durably and preserves the stop across late record writes", async () => { + const { store } = make(); + const question = record("question", NOW, { awaitingInput: true }); + const stop = { at: NOW + 1, mode: "hard" as const, by: { kind: "chat" as const, id: "slack:UALICE" } }; + await store.put(question); + expect(await store.stopWaiting("question", { ...stop, at: NOW - 1 })).toBe("conflict"); + expect(await store.stopWaiting("question", stop)).toBe("stopped"); + expect(await store.stopWaiting("question", stop)).toBe("stopped"); + await store.put(question); + expect(await store.get("question")).toMatchObject({ status: "stopped_hard", inputStop: stop }); + expect((await store.get("question"))?.awaitingInput).toBeUndefined(); + expect(await store.getSummary("question")).toMatchObject({ status: "stopped_hard", inputStop: stop }); + expect(await store.stopWaiting("question", { ...stop, at: NOW + 2 })).toBe("conflict"); + await store.put(record("done", NOW)); + expect(await store.stopWaiting("done", stop)).toBe("conflict"); + expect(await store.stopWaiting("missing", stop)).toBe("not_found"); + }); + it("round-trips a 2 MB record and lists without events", async () => { const { store } = make(); const big = record("big", NOW, { events: events(40, 50_000) }); @@ -421,6 +439,19 @@ describe("FileRunStore", () => { const make = (dir: string, policy: Partial = {}, clock = { now: NOW }) => new FileRunStore(dir, { policy: { ...DEFAULT_RETENTION_POLICY, ...policy }, now: () => clock.now }); + it("retains a question stop across reopen and an index interrupted before its replacement", async () => { + const dir = tmpDir(); + const question = record("question", NOW, { awaitingInput: true }); + const stop = { at: NOW + 1, mode: "hard" as const, by: { kind: "chat" as const, id: "slack:UALICE" } }; + await make(dir).put(question); + const oldIndex = readFileSync(join(dir, "index.jsonl"), "utf8"); + expect(await make(dir).stopWaiting("question", stop)).toBe("stopped"); + expect(await make(dir).get("question")).toMatchObject({ inputStop: stop, status: "stopped_hard" }); + writeFileSync(join(dir, "index.jsonl"), oldIndex); + await make(dir).put(question); + expect(await make(dir).get("question")).toMatchObject({ inputStop: stop, status: "stopped_hard" }); + }); + it("writes .json with mode 0600 inside a 0700 directory, temp-then-rename", async () => { const dir = tmpDir(); await make(dir).put(record("a", NOW)); diff --git a/src/core/runStore.ts b/src/core/runStore.ts index 8f1d6a776..219e479b4 100644 --- a/src/core/runStore.ts +++ b/src/core/runStore.ts @@ -12,6 +12,9 @@ import { import type { TraceOptions } from "./trace/types.js"; import type { Secrets } from "../secrets.js"; import { + preserveInputStop, + stopWaitingRecord, + type InputStop, applyRetention, clampListLimit, clampRetentionPolicy, @@ -73,7 +76,11 @@ export interface RunEventsPage { nextAfterSeq?: number; } +export type StopWaitingResult = "stopped" | "conflict" | "not_found"; + export interface RunStore { + /** Atomically close a completed turn's question; repeated identical stops succeed. */ + stopWaiting(id: string, stop: InputStop): Promise; /** `trace.span`: the caller's span, under which a Worker store's request is an * `http.client` span (docs/reference/specs/tracing.md item 24); the local stores ignore it. */ put(record: RunRecord, trace?: TraceOptions): Promise; @@ -142,6 +149,9 @@ export function usageReportOfRecords( * read is the not-found shape, `list` is empty, `put` accepts and keeps * nothing (`stored: false`, the same word a record outside retention gets). */ export class NullRunStore implements RunStore { + async stopWaiting(_id: string, _stop: InputStop): Promise { + return "not_found"; + } abandoned(): void { // a store keeps nothing in flight per record (run-history item 54): nothing to settle } @@ -253,10 +263,21 @@ export class InMemoryRunStore implements RunStore { abandoned(): void { // a store keeps nothing in flight per record (run-history item 54): nothing to settle } + async stopWaiting(id: string, stop: InputStop): Promise { + if (!isValidRunId(id)) return "not_found"; + const entry = this.records.get(id); + if (!entry || !this.retained().some((row) => row.id === id)) return "not_found"; + const changed = stopWaitingRecord(entry.record, stop); + if (!changed) return "conflict"; + await this.put(changed); + return "stopped"; + } + async put(record: RunRecord): Promise { if (!isValidRunId(record.id)) return { ok: true, retained: this.records.size, stored: false, rewritten: false }; - const bytes = utf8ByteLength(JSON.stringify(record)); const prev = this.records.get(record.id); + record = preserveInputStop(record, prev?.record.inputStop); + const bytes = utf8ByteLength(JSON.stringify(record)); const rewritten = prev !== undefined && !sameStoredVersion({ ...prev.record, bytes: prev.bytes }, { ...record, bytes }); this.records.set(record.id, { record: clone(record), bytes }); @@ -407,14 +428,40 @@ export class FileRunStore implements RunStore { abandoned(): void { // a store keeps nothing in flight per record (run-history item 54): nothing to settle } + async stopWaiting(id: string, stop: InputStop): Promise { + if (!isValidRunId(id) || !applyRetention(this.readIndex(), this.policy, this.now()).some((r) => r.id === id)) + return "not_found"; + let record: unknown; + try { + record = JSON.parse(readFileSync(this.recordPath(id), "utf8")); + } catch { + return "not_found"; + } + if (!isRunRecord(record) || record.id !== id) return "not_found"; + const changed = stopWaitingRecord(record, stop); + if (!changed) return "conflict"; + await this.put(changed); + return "stopped"; + } + async put(record: RunRecord): Promise { if (!isValidRunId(record.id)) return { ok: true, retained: this.readIndex().length, stored: false, rewritten: false }; this.ensureDir(); - const content = JSON.stringify(record); - const bytes = utf8ByteLength(content); const index = this.readIndex(); const prev = index.find((r) => r.id === record.id); + // The record lands before the index. A crash between those writes must + // not let a retried finish erase a stop already present in the record. + let inputStop = prev?.inputStop; + try { + const previous: unknown = JSON.parse(readFileSync(this.recordPath(record.id), "utf8")); + if (isRunRecord(previous) && previous.id === record.id) inputStop = previous.inputStop ?? inputStop; + } catch { + /* A new or torn record has no additional stop marker. */ + } + record = preserveInputStop(record, inputStop); + const content = JSON.stringify(record); + const bytes = utf8ByteLength(content); const rewritten = prev !== undefined && !sameStoredVersion(prev, { ...record, bytes }); this.writeAtomic(this.recordPath(record.id), content); const item = toListItem(record, bytes); diff --git a/src/core/runStoreWorker.test.ts b/src/core/runStoreWorker.test.ts index f264d35e9..83cc852be 100644 --- a/src/core/runStoreWorker.test.ts +++ b/src/core/runStoreWorker.test.ts @@ -80,6 +80,20 @@ const OPTS = { }; describe("WorkerRunStore", () => { + it("sends waiting cancellation to the atomic route and rejects malformed acknowledgements", async () => { + const stop = { at: NOW, mode: "hard" as const, by: { kind: "chat" as const, id: "linear:org:alice" } }; + let reply: unknown = { result: "stopped" }; + const { fetch, calls } = fakeFetch(() => ({ status: 200, body: reply })); + const store = new WorkerRunStore({ ...OPTS, fetch }); + expect(await store.stopWaiting("question", stop)).toBe("stopped"); + expect(calls[0].url).toBe("https://state.example/runs/stop-waiting"); + expect(calls[0].body).toEqual({ storeKey: "runs:default", id: "question", stop }); + expect(await store.stopWaiting("../bad", stop)).toBe("not_found"); + expect(calls).toHaveLength(1); + reply = { result: "unknown" }; + await expect(store.stopWaiting("question", stop)).rejects.toThrow(PermanentStoreError); + }); + it("put sends a 1.9 MB record as a string body (Content-Length left to the runtime), the bearer, and the policy", async () => { const { fetch, calls } = fakeFetch(() => ({ status: 200, diff --git a/src/core/runStoreWorker.ts b/src/core/runStoreWorker.ts index 7dc89cd16..3c3c4d449 100644 --- a/src/core/runStoreWorker.ts +++ b/src/core/runStoreWorker.ts @@ -2,6 +2,7 @@ import { errorSuffix } from "./workerError.js"; import { tracedFetch } from "./trace/tracedFetch.js"; import type { Span, TraceOptions } from "./trace/types.js"; import { + type InputStop, isRunListItem, isRunRecord, normalizeStored, @@ -12,7 +13,7 @@ import { type RunRecord, type StoredRunEvent, } from "./runRecord.js"; -import type { PutResult, RunEventsOptions, RunEventsPage, RunStore } from "./runStore.js"; +import type { StopWaitingResult, PutResult, RunEventsOptions, RunEventsPage, RunStore } from "./runStore.js"; import { isRunUsageRows, reportOfUsageRows, type RunUsageQuery, type RunUsageReport } from "./runUsage.js"; // The DURABLE RunStore (docs/decisions/0006-runs-have-two-lives.md): an HTTPS client to the RunHistoryDO on the @@ -88,7 +89,14 @@ export interface WorkerRunStoreOptions { /** The Worker's routes, as a span names them. */ type RunStoreRoute = - "/runs/put" | "/runs/get" | "/runs/summary" | "/runs/list" | "/runs/events" | "/runs/delete" | "/runs/usage"; + | "/runs/stop-waiting" + | "/runs/put" + | "/runs/get" + | "/runs/summary" + | "/runs/list" + | "/runs/events" + | "/runs/delete" + | "/runs/usage"; export class WorkerRunStore implements RunStore { private readonly baseUrl: string; @@ -102,6 +110,14 @@ export class WorkerRunStore implements RunStore { abandoned(): void { // a store keeps nothing in flight per record (run-history item 54): nothing to settle } + async stopWaiting(id: string, stop: InputStop): Promise { + if (!RUN_ID_PATTERN.test(id)) return "not_found"; + const data = await this.post("/runs/stop-waiting", { storeKey: this.opts.storeKey, id, stop }); + if (data.result !== "stopped" && data.result !== "conflict" && data.result !== "not_found") + throw new PermanentStoreError("run store /runs/stop-waiting returned a malformed result"); + return data.result; + } + async put(record: RunRecord, trace?: TraceOptions): Promise { if (!RUN_ID_PATTERN.test(record.id)) throw new PermanentStoreError(`run store: refusing to put malformed id ${JSON.stringify(record.id)}`); diff --git a/src/core/runsService.test.ts b/src/core/runsService.test.ts index aea6ade03..da99f4974 100644 --- a/src/core/runsService.test.ts +++ b/src/core/runsService.test.ts @@ -82,6 +82,36 @@ function expectNoToken(value: unknown): void { } describe("RunsService.getRun", () => { + it.each(["missing", "tombstone", "unavailable"])( + "a required finished record retries %s history and recovers its waiting marker", + async (state) => { + const { reg, tick, store, svc } = setup(); + const run = reg.create("coding · question"); + if (state === "tombstone") await store!.put(record(run.id, NOW, { startedAt: NOW, status: "interrupted" })); + tick(1_000); + reg.finish(run.id, "completed"); + if (state === "unavailable") + vi.spyOn(store!, "getSummary").mockRejectedValueOnce(new Error("history temporarily unavailable")); + await expect(svc.getRun(run.id, { requireRecord: true })).rejects.toThrow(/temporarily unavailable/); + await store!.put(record(run.id, NOW + 1_000, { awaitingInput: true })); + const retry = await svc.getRun(run.id, { requireRecord: true }); + expect(retry).toMatchObject({ ok: true, value: { finished: true, status: "completed", awaitingInput: true } }); + await store!.stopWaiting(run.id, { at: NOW + 2_000, by: actor, mode: "hard" }); + expect(await svc.getRun(run.id, { requireRecord: true })).toMatchObject({ + ok: true, + value: { status: "stopped_hard", inputStop: { mode: "hard" } }, + }); + }, + ); + + it("requires a history store only once the run has finished", async () => { + const { reg, svc } = setup(null); + const run = reg.create("coding · live"); + expect(await svc.getRun(run.id, { requireRecord: true })).toMatchObject({ ok: true, value: { finished: false } }); + reg.finish(run.id, "completed"); + await expect(svc.getRun(run.id, { requireRecord: true })).rejects.toThrow(/temporarily unavailable/); + }); + it("returns a live run as finished:false with no token and no events unless asked", async () => { const { reg, svc } = setup(); const { id } = reg.create("coding · acme/x"); @@ -263,6 +293,7 @@ describe("RunsService.getRun", () => { it("a finished run still in the registry carries verdict, reviewHead, reviewPost, dispositions and handoff from the store the moment the store holds its record — identity, status and events stay the registry's, and only the summary row is read", async () => { const inner = new InMemoryRunStore({ now: () => NOW }); const store: RunStore = { + stopWaiting: (id, stop) => inner.stopWaiting(id, stop), put: (r) => inner.put(r), abandoned: () => {}, get: vi.fn((id: string) => inner.get(id)), @@ -333,6 +364,7 @@ describe("RunsService.getRun", () => { it("a finished registry row whose record has not landed carries no artifacts and is not persisted — the start tombstone lends nothing, not even its status; a live row never asks the store; a store that throws is one warning and the registry row", async () => { const inner = new InMemoryRunStore({ now: () => NOW }); const store: RunStore = { + stopWaiting: (id, stop) => inner.stopWaiting(id, stop), put: (r) => inner.put(r), abandoned: () => {}, get: vi.fn((id: string) => inner.get(id)), @@ -708,6 +740,7 @@ describe("RunsService.listRuns — read merge", () => { return rest; }); const store: RunStore = { + stopWaiting: async () => "not_found", put: vi.fn(), abandoned: () => {}, get: vi.fn(async () => null), @@ -728,6 +761,7 @@ describe("RunsService.listRuns — read merge", () => { it("degrades to live rows + storeUnavailable when the store throws, warning once per failure (the message, never a token); active is unaffected", async () => { const broken: RunStore = { + stopWaiting: async () => "not_found", put: vi.fn(), abandoned: () => {}, get: vi.fn(async () => { @@ -1104,6 +1138,7 @@ describe("RunsService — summary-only persisted reads", () => { }), ); const store: RunStore = { + stopWaiting: (id, stop) => inner.stopWaiting(id, stop), put: (r) => inner.put(r), abandoned: () => {}, get: vi.fn((id: string) => inner.get(id)), @@ -1242,6 +1277,28 @@ describe("RunsService.getRunFriction", () => { }); describe("RunsService.stopRun", () => { + it("reads and stops a waiting question while its finished turn is still in the registry", async () => { + const { reg, tick } = testRegistry(); + const store = new InMemoryRunStore({ now: () => NOW + 100 }); + const run = reg.create(); + tick(10); + reg.finish(run.id); + const question = record(run.id, NOW + 10, { awaitingInput: true }); + await store.put(question); + const svc = createRunsService({ registry: reg, store, clock: () => NOW + 20 }); + expect(await svc.getRun(run.id)).toMatchObject({ ok: true, value: { awaitingInput: true } }); + expect(await svc.stopRun(run.id, "hard", actor)).toMatchObject({ ok: true, value: { state: "stopped" } }); + await store.put(question); + const stopped = await svc.getRun(run.id); + expect(stopped).toMatchObject({ + ok: true, + value: { status: "stopped_hard", inputStop: { at: NOW + 20, by: actor } }, + }); + expect(stopped.ok && stopped.value.awaitingInput).toBeUndefined(); + const restarted = createRunsService({ registry: new RunRegistry(), store }); + expect(await restarted.getRun(run.id)).toMatchObject({ ok: true, value: { status: "stopped_hard" } }); + }); + it("live: drives the control and publishes stop_requested with the structured actor", async () => { const { reg, svc } = setup(); const { id, token, control } = reg.create(); diff --git a/src/core/runsService.ts b/src/core/runsService.ts index 62079c90f..062d6d7ae 100644 --- a/src/core/runsService.ts +++ b/src/core/runsService.ts @@ -1,6 +1,6 @@ import { matchesPredicate } from "./authz/predicate.js"; import type { ChannelVisibility, Predicate, Resource } from "./authz/types.js"; -import type { RunActor, RunEvent, StopMode } from "./runEvents.js"; +import { sanitizeActor, type RunActor, type RunEvent, type StopMode } from "./runEvents.js"; import { systemClock } from "./trace/clock.js"; import { SPAN_SCHEMA } from "./normalizeSpans.js"; import { analyzeRunFriction, type FrictionOptions, type FrictionDiagnosis } from "./runFriction.js"; @@ -11,6 +11,7 @@ import { RUN_LIST_MAX_LIMIT, toVisibilityFilter, utf8ByteLength, + type InputStop, type RunListItem, type RunRecord, type RunSession, @@ -69,6 +70,9 @@ export type Result = { ok: true; value: T } | { ok: false; error: "not_found" * expresses the outcome as `status`). */ export interface RunView { + /** The most recent turn asked for input; this is a turn outcome, not live session state. */ + awaitingInput?: true; + inputStop?: InputStop; id: string; label?: string; agent?: string; @@ -269,7 +273,7 @@ export interface RunFrictionView { export interface StopRunView { id: string; mode: StopMode; - state: "stopping"; + state: "stopping" | "stopped"; } /** One hit of a session search (session-log item 11): the log turn, whose it @@ -338,10 +342,12 @@ export interface RunsService { * registry's rows. Empty without a ledger; a ledger that cannot be read is a * warning and empty. */ liveElsewhere(visibleTo: Predicate): Promise; - getRun(id: string, opts?: { include?: "messages" }): Promise>; + /** Coordinators require the finished record: a missing or unavailable store + * must not make a cached question look like completed work. */ + getRun(id: string, opts?: { include?: "messages"; requireRecord?: true }): Promise>; getRunEvents(id: string, opts: { afterSeq?: number; limit?: number }): Promise>; getRunFriction(id: string): Promise>; - stopRun(id: string, mode: StopMode, actor: RunActor): Promise>; + stopRun(id: string, mode: StopMode, actor: RunActor, options?: { receivedAt: number }): Promise>; /** A ship unit's runs in round order (agent-ship item 17): the coding * thread's and the review thread's runs, live and finished, cut at the round * boundaries the unit's row records, under `visibleTo`. `not_found` for a @@ -632,23 +638,47 @@ export function createRunsService(deps: RunsServiceDeps): RunsService { * wakes a reader is sent, so the reader that follows sees the verdict the * record landed with, never the row's silence. A store without the record * yet — or holding only the start tombstone, which carries none — lends - * nothing; a store that throws is one warning and nothing. */ + * nothing; a store that throws is one warning and nothing. A coordinator's + * required read instead retries until the terminal record is available. */ const storedArtifacts = async ( id: string, + requireRecord = false, + expectedStatus?: RunRecord["status"], ): Promise< - Pick + Pick< + RunView, + | "verdict" + | "reviewHead" + | "reviewPost" + | "dispositions" + | "handoff" + | "pr" + | "usage" + | "cost" + | "awaitingInput" + | "inputStop" + | "status" + > > => { let row: RunListItem | null; try { row = await storeSummary(id); } catch (err) { + if (requireRecord) throw err; warn( `[runs] history store read failed for ${id} — serving the registry row without its record: ${describe(err)}`, ); return {}; } + if (requireRecord && (!row || (!row.inputStop && row.status !== expectedStatus))) + throw new Error("The finished run record is temporarily unavailable"); if (!row) return {}; return { + ...(row.inputStop + ? { inputStop: row.inputStop, status: row.status } + : row.awaitingInput + ? { awaitingInput: true as const } + : {}), ...(row.verdict !== undefined ? { verdict: row.verdict } : {}), ...(row.reviewHead !== undefined ? { reviewHead: row.reviewHead } : {}), ...(row.reviewPost !== undefined ? { reviewPost: row.reviewPost } : {}), @@ -791,7 +821,7 @@ export function createRunsService(deps: RunsServiceDeps): RunsService { // A finished row inside the registry's window: its identity, stop state, // finish fields and events are the registry's; the record's typed // artifacts are the store's to supply (item 21). A live row never asks. - if (summary.finished) Object.assign(view, await storedArtifacts(id)); + if (summary.finished) Object.assign(view, await storedArtifacts(id, opts.requireRecord, summary.status)); return { ok: true, value: view }; } // Live on the ledger, not here (item 41): the row and the events it holds. @@ -878,13 +908,12 @@ export function createRunsService(deps: RunsServiceDeps): RunsService { return { ok: true, value: { id, finished: true, diagnosis: summary.diagnosis } }; }, - async stopRun(id, mode, actor) { + async stopRun(id, mode, actor, options) { const res = registry.requestStopById(id, mode, actor); if (res.ok) return { ok: true, value: { id, mode: res.mode, state: "stopping" } }; - if (res.reason === "finished") return conflict; // Live on the ledger under another generation (item 41): the stop rides the // row; the owner reads it on its next heartbeat. - if (ledger && RUN_ID_PATTERN.test(id)) { + if (res.reason !== "finished" && ledger && RUN_ID_PATTERN.test(id)) { try { const r = await ledger.requestStop(id, mode); if (r.ok) return { ok: true, value: { id, mode, state: "stopping" } }; @@ -892,8 +921,19 @@ export function createRunsService(deps: RunsServiceDeps): RunsService { warn(`[runs] run ledger stop failed for ${id}: ${describe(err)}`); } } - // Not in the registry: a persisted run is over (409), anything else is unknown. - return (await storeSummary(id)) ? conflict : notFound; + const summary = await storeSummary(id); + if (!summary) return res.reason === "finished" ? conflict : notFound; + if (!store || (!summary.awaitingInput && !summary.inputStop)) return conflict; + const result = await store.stopWaiting(id, { + at: options?.receivedAt ?? clock(), + mode, + by: sanitizeActor(actor), + }); + return result === "stopped" + ? { ok: true, value: { id, mode, state: "stopped" } } + : result === "conflict" + ? conflict + : notFound; }, authorizeLive(id, token) { diff --git a/src/core/secretsManifest.test.ts b/src/core/secretsManifest.test.ts index 0f2d28b69..013e3c915 100644 --- a/src/core/secretsManifest.test.ts +++ b/src/core/secretsManifest.test.ts @@ -54,7 +54,7 @@ describe("deploy/secrets.manifest.json", () => { } }); - it("every bot secret reaches the container: the shim forwards each manifest `bot` entry (a secret on the Worker the container never sees is a silent misconfiguration — MCP_CREDENTIAL_KEY once was)", () => { + it("every bot secret reaches its declared boundary: container by default, edge only when explicitly marked", () => { const src = readFileSync(resolve(ROOT, "deploy/cloudflare/worker.ts"), "utf8"); const fn = /function containerEnv\(env: Env\)[\s\S]*?\n\}/.exec(src); if (!fn) throw new Error("deploy/cloudflare/worker.ts: no containerEnv()"); @@ -63,7 +63,9 @@ describe("deploy/secrets.manifest.json", () => { const forwarded = new Set([...list[1].matchAll(/"([A-Z][A-Z0-9_]*)"/g)].map((m) => m[1])); for (const m of fn[0].matchAll(/^\s*([A-Z][A-Z0-9_]*): env\.\1,/gm)) forwarded.add(m[1]); for (const s of manifest.secrets) { - if (s.workers.includes("bot")) + if (s.workers.includes("bot") && s.forwardToContainer === false) + expect(forwarded, `${s.name} is edge-only and must not reach the container`).not.toContain(s.name); + else if (s.workers.includes("bot")) expect(forwarded, `${s.name} is put on the bot Worker but never forwarded into the container`).toContain( s.name, ); diff --git a/src/core/ship/coordinator.test.ts b/src/core/ship/coordinator.test.ts index dc0e21f2f..e23df2359 100644 --- a/src/core/ship/coordinator.test.ts +++ b/src/core/ship/coordinator.test.ts @@ -2033,3 +2033,76 @@ describe("the severity gate — an approve's findings held to the level in force expect(d.action).toMatchObject({ type: "end", ending: { kind: "round_cap", maxRounds: 1 } }); }); }); + +describe("coordinator child clarification", () => { + it.each(["coding", "review"])("ends an unanswered %s question at the unit deadline after restart", (agent) => { + const d = fresh(input()); + if (agent === "review") throughRoundZero(d); + else d.answer({ type: "branch", ok: true, at: T0 }); + const deadline = T0 + 240 * MIN; + const question = finished({ status: "completed", awaitingInput: true }); + runChild(d, "run-question", question, deadline - 10_000); + expect(d.action).toMatchObject({ type: "sleep", ms: 10_000 }); + const restored = new Driver(JSON.parse(JSON.stringify(d.state))); + restored.answer({ type: "sleep" }); + restored.answer({ type: "read-record", run: question, at: deadline }); + expect(restored.action).toMatchObject({ + type: "end", + ending: { kind: agent === "review" ? "review_pending" : "wall_clock_cap" }, + }); + expect(restored.state.spentMs.waiting).toBe(10_000); + }); + + it("ends a question first observed after the deadline without another sleep", () => { + const d = fresh(input()); + d.answer({ type: "branch", ok: true, at: T0 }); + runChild(d, "run-question", finished({ status: "completed", awaitingInput: true }), T0 + 241 * MIN); + expect(d.action).toMatchObject({ type: "end", ending: { kind: "wall_clock_cap" } }); + }); + + it("follows a continuing run and keeps the final run id for later round briefs", () => { + const d = fresh(input()); + d.answer({ type: "branch", ok: true, at: T0 }); + runChild(d, "run-question", finished({ status: "completed", awaitingInput: true }), T0 + MIN); + d.answer({ type: "sleep" }); + d.answer({ type: "read-record", run: { finished: false, runId: "run-answer" }, at: T0 + 2 * MIN }); + expect(d.action).toMatchObject({ type: "wait", runId: "run-answer" }); + expect(d.state.lastCodingRunId).toBe("run-answer"); + d.answer({ type: "wait", outcome: "event" }); + d.answer({ type: "read-record", run: finished({ runId: "run-answer", status: "completed" }), at: T0 + 3 * MIN }); + expect(d.action.type).toBe("pr-check"); + expect(d.state.lastCodingRunId).toBe("run-answer"); + }); + + it.each(["coding", "review"])("waits durably for a %s question without acting on earlier artifacts", (agent) => { + const d = fresh(input()); + if (agent === "review") throughRoundZero(d); + else d.answer({ type: "branch", ok: true, at: T0 }); + const at = T0 + 12 * MIN; + const question = finished({ + status: "completed", + awaitingInput: true, + finalReply: "Which behavior do you want?", + pr: { number: 7, url: PR_URL, created: true }, + verdict: { verdict: "approve", findings: [] }, + reviewPosted: true, + }); + runChild(d, "run-question", question, at); + expect(d.action).toMatchObject({ type: "sleep", ms: 30_000 }); + expect(d.state.ending).toBeUndefined(); + const restored = new Driver(JSON.parse(JSON.stringify(d.state))); + expect(restored.action).toEqual(d.action); + restored.answer({ type: "sleep" }); + expect(restored.action).toMatchObject({ type: "read-record", runId: "run-question" }); + restored.answer({ type: "read-record", run: question, at: at + 30_000 }); + expect(restored.action).toMatchObject({ type: "sleep", ms: 30_000 }); + expect(restored.state.spentMs.waiting).toBe(30_000); + restored.answer({ type: "sleep" }); + restored.answer({ + type: "read-record", + run: finished({ status: "stopped_hard", awaitingInput: true }), + at: at + 60_000, + }); + expect(restored.action).toMatchObject({ type: "end", ending: { kind: "stopped", mode: "hard" } }); + }); +}); diff --git a/src/core/ship/coordinator.ts b/src/core/ship/coordinator.ts index 0dc41274a..05dcbfb52 100644 --- a/src/core/ship/coordinator.ts +++ b/src/core/ship/coordinator.ts @@ -463,10 +463,13 @@ export type CoordinatorAction = /** A child's facts as `read-record` answers them: live, or finished with the * typed artifacts its run recorded. */ export type ChildFacts = - | { finished: false } + | { finished: false; runId?: string } | { finished: true; + runId?: string; status: RunStatus; + /** The turn asked a question; no earlier artifact completes the unit. */ + awaitingInput?: true; finalReply?: string; /** A coding child's `pr_opened`. */ pr?: { number: number; url: string; created: boolean }; @@ -714,7 +717,9 @@ type Phase = | { at: "busy-wait"; round: RoundRef; runId?: string; n: number } /** `until`: when the child's budget plus the margin runs out, counted from the spawn's answer — the wait's last slice ends there. */ | { at: "wait"; round: RoundRef; runId: string; n: number; until: number } - | { at: "read"; round: RoundRef; runId: string; n: number; until: number } + | { at: "read"; round: RoundRef; runId: string; n: number; until: number; awaitingInput?: true } + /** A durable sleep, not another wait on the already-consumed finish event. */ + | { at: "input-wait"; round: RoundRef; runId: string; n: number; until: number } | { at: "pr-check"; round: RoundRef; @@ -928,6 +933,12 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction { runId: p.runId, timeoutMs: waitSliceMs(s.clock, p.until), }; + case "input-wait": + return { + type: "sleep", + step: `${roundStep(s, p.round)}/input/${p.n}`, + ms: Math.min(30_000, Math.max(1, remainingMs(s))), + }; case "read": return { type: "read-record", step: `${roundStep(s, p.round)}/read/${p.n}`, runId: p.runId }; case "pr-check": @@ -1428,7 +1439,8 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract; + const runId = r.run.runId ?? p.runId; + const current = + runId === p.runId + ? clocked + : { + ...clocked, + ...(p.round.kind === "review" + ? { reviewRunByRound: { ...clocked.reviewRunByRound, [p.round.index]: runId } } + : p.round.kind === "findings" + ? { + lastCodingRunId: runId, + findingsRunByRound: { ...clocked.findingsRunByRound, [p.round.index]: runId }, + } + : { lastCodingRunId: runId }), + }; + if (r.run.finished && r.run.status === "completed" && r.run.awaitingInput) { + if (remainingMs(current) <= 0) return end(current, capEnding(current, p.round)); + return { + state: { + ...current, + phase: { at: "input-wait", round: p.round, runId, n: p.n + 1, until: p.until }, + }, + notes: [], + }; + } if (!r.run.finished) return { state: { - ...clocked, - phase: { at: "wait", round: p.round, runId: p.runId, n: p.n + 1, until: p.until }, + ...current, + phase: { at: "wait", round: p.round, runId, n: p.n + 1, until: p.until }, }, notes: [], }; @@ -1568,16 +1613,16 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition { // branch to recover, so its interruption still ends the unit at once. if (p.round.kind !== "review") return { - state: { ...clocked, phase: { at: "pr-check", round: p.round, runId: p.runId, dead: "interrupted" } }, + state: { ...current, phase: { at: "pr-check", round: p.round, runId, dead: "interrupted" } }, notes: [], }; - return end(clocked, { kind: "interrupted", round: p.round, runId: p.runId, reviewRounds: s.reviewRounds }, [ + return end(current, { kind: "interrupted", round: p.round, runId, reviewRounds: s.reviewRounds }, [ roundNote(p.round, "aborted"), ]); } return p.round.kind === "review" - ? settleReview(clocked, p.round, r.run) - : settleCoding(clocked, p.round, p.runId, r.run); + ? settleReview(current, p.round, r.run) + : settleCoding(current, p.round, runId, r.run); } case "pr-check": return settlePrCheck(clocked, p, (ret as Extract).pr); diff --git a/src/core/threadAdmission.ts b/src/core/threadAdmission.ts index 7aed0bc42..19b41b784 100644 --- a/src/core/threadAdmission.ts +++ b/src/core/threadAdmission.ts @@ -122,6 +122,7 @@ export class FollowUpInbox { /** The run a thread is currently occupied by, as admission sees it. */ export interface LiveThread { agent: string; + userId?: string; inbox: FollowUpInbox; startedAt: number; /** Set by the owning dispatch once its run is registered (the run page link @@ -141,11 +142,12 @@ export type ClaimOutcome = export class ThreadAdmission { private readonly live = new Map>(); - claim(threadKey: string, run: { agent: string; now?: number }): ClaimOutcome { + claim(threadKey: string, run: { agent: string; now?: number; userId?: string }): ClaimOutcome { const existing = this.live.get(threadKey); if (existing) return { kind: "live", live: existing }; const live: LiveThread = { agent: run.agent, + ...(run.userId !== undefined ? { userId: run.userId } : {}), inbox: new FollowUpInbox(), startedAt: run.now ?? systemClock(), }; diff --git a/src/core/types.ts b/src/core/types.ts index d17237d33..82d5e4eb0 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -4,6 +4,9 @@ // talk back through. Everything else — config resolution, permissions, agent // selection, execution — is channel-agnostic and lives in the dispatcher. +import type { Actor } from "./authz/types.js"; +import type { WorkItems } from "./workItems.js"; + /** An image the user attached, already downloaded and base64-encoded. */ export interface ImageAttachment { /** e.g. "image/png" — adapters only pass types every provider accepts */ @@ -200,6 +203,8 @@ export type RunFinalStatus = "completed" | "failed" | "stopped_soft" | "stopped_ * run id (the `/runs/:id` record) and its terminal status. Never the view * token — a receipt names the run, it does not grant access to it. */ export interface RunReceipt { + /** The turn finished by asking for input; its task is still waiting. */ + awaitingInput?: true; id: string; status: RunFinalStatus; } @@ -218,11 +223,15 @@ export interface OpenedThread { } /** A minted one-shot upload (`ChannelIO.uploadTicket`): where the container - * POSTs the bytes, and the call that shares the uploaded file into the - * conversation once the POST succeeded. */ + * streams the bytes, and the call that shares the uploaded file into the + * conversation once the upload succeeded. */ export interface UploadTicket { - /** Accepts one POST of exactly the ticketed size; single use, short-lived. */ + /** Accepts exactly the ticketed size; single use, short-lived. */ url: string; + /** Defaults to POST for existing channels. */ + method?: "POST" | "PUT"; + /** Storage-signed headers; never an installation credential. */ + headers?: Record; /** Share the uploaded file into the conversation with `lead` as its message. */ complete(lead: string): Promise; } @@ -246,8 +255,21 @@ export interface ConfirmationOffer { } export interface ChannelIO { + /** Ask for input without marking the channel’s session complete. */ + question?(text: string): Promise; + /** Refresh platform access before reading context or running a command. + * False refuses the request; a failed lookup defers it for durable retry. */ + checkAccess?(userId: string): Promise; + /** Queue another requester's follow-up until it can run with their own grants. + * The adapter must durably retry a dispatch that returns `deferred`. */ + isolateFollowUps?: boolean; + /** Bind work-tracking tools to the resolved actor, outside model arguments. */ + workItems?(actor: Actor): WorkItems; /** Post a reply in the conversation. Adapter handles chunking/formatting. */ reply(text: string): Promise; + /** Confirm a follow-up reached ongoing work, without completing its session. + * Channels without a separate activity type use an ordinary reply. */ + acknowledge?(text: string): Promise; /** * Present when this channel has nowhere to deliver a reply (the resumed-run * null channel, docs/reference/specs/run-history.md item 38): the reason, e.g. @@ -283,6 +305,9 @@ export interface ChannelIO { * out and the tool posts the run-page link through `reply` instead. */ uploadTicket?(file: { name: string; size: number }): Promise; + /** Copy a private incoming file into the configured artifact store at the + * credential-holding edge. The core still owns keys, workspace pulls and receipts. */ + copyAttachment?(file: StagedFile, key: string, signal?: AbortSignal): Promise; /** * Show the confirmation a routed write is offered as * (docs/reference/specs/routing-and-config.md item 25, record 0044): the full @@ -314,9 +339,11 @@ export interface ChannelIO { * Called once by the core the moment a run has been CREATED in the registry * (before it executes), with the run id. The async HTTP ingress path uses it * to answer `202 Accepted` with the run id while the run continues in the - * background; Slack/CLI need nothing from it. Optional, like runFinished. + * background; Slack/CLI need nothing from it. A channel may return a promise + * to confirm durable admission; execution waits for it and fails closed if + * it rejects. Optional, like runFinished. */ - runStarted?(started: { id: string }): void; + runStarted?(started: { id: string }): void | Promise; /** * Open a thread of this channel's own for a child run * (docs/reference/specs/thread-admission.md item 6): post `lead` where a new @@ -325,6 +352,8 @@ export interface ChannelIO { * channel (HTTP, MCP) has no thread to open, and a spawn from such a channel * is refused by name (`spawn_unsupported`); it never falls back to the * parent's own thread. + * A durable caller may supply an idempotency key for adapters that can + * reconcile thread creation across retries; the lead must stay the same. */ - openThread?(lead: string): Promise; + openThread?(lead: string, options?: { idempotencyKey: string }): Promise; } diff --git a/src/core/verdictTurn.ts b/src/core/verdictTurn.ts index 16cf4b09d..14478e3a9 100644 --- a/src/core/verdictTurn.ts +++ b/src/core/verdictTurn.ts @@ -36,8 +36,14 @@ import type { Span } from "./trace/types.js"; * decision 0046). */ export const VERDICT_TURN_MAX_TURNS = 4; /** The tools the turn may call (model-proxy item 6): read the head, submit - * the verdict, report on the card — nothing that writes. */ -export const VERDICT_TURN_TOOLS: readonly string[] = ["bash", "read", "submit_verdict", "update_status"]; + * the verdict, ask for missing input, report on the card — nothing that writes. */ +export const VERDICT_TURN_TOOLS: readonly string[] = [ + "bash", + "read", + "submit_verdict", + "update_status", + "request_input", +]; /** The pull request the review was of — what the turn names. */ export interface VerdictTurnTarget { @@ -52,6 +58,7 @@ export function verdictFollowUp(t: VerdictTurnTarget): string { `Your review of ${t.repo}#${t.number} ended without calling submit_verdict. Switchboard writes the verdict as the first line of the posted review from that call alone — without it the review posts as "No verdict submitted — not approving", whatever your write-up concluded.`, "Call submit_verdict now, exactly once, with the verdict your write-up already states: `approve` when you found no blocking issue (nits alone are not blocking), otherwise `request_changes`; a one-line summary; `head` = the output of `git rev-parse HEAD` in the checkout you reviewed; and `findings` — one structured entry per issue your write-up reports, with the ids you used (F1, F2, …), a severity of exactly blocking|major|minor|nit, the file (plus line when it points at one) and a one-line title.", "Do not re-read the diff and do not rewrite the review — your write-up stands as the review's text; only the verdict is missing. Then reply in one line.", + "If missing information from the requester prevents a verdict, call request_input with the question and end the turn. Switchboard waits for the answer before posting the review.", ].join("\n"); } diff --git a/src/core/workItems.ts b/src/core/workItems.ts new file mode 100644 index 000000000..cdaec1d24 --- /dev/null +++ b/src/core/workItems.ts @@ -0,0 +1,27 @@ +/** Channel-neutral work tracking. The channel binds identity outside tool input. */ +export interface WorkItem { + id: string; + identifier: string; + title: string; + description?: string; + url: string; + priority: number; + state: { id: string; name: string; type: string }; + availableStates?: { id: string; name: string; type: string }[]; + teamId: string; + assignee?: { id: string; name: string }; + delegate?: { id: string; name: string }; +} + +export type WorkItemRequest = + | { op: "get"; id: string } + | { op: "delegated"; after?: string; limit?: number } + | { op: "update"; id: string; title?: string; description?: string; priority?: number; state?: string } + | { op: "create_child"; parentId: string; title: string; description?: string } + | { op: "comment"; id: string; body: string }; + +export type WorkItemResult = WorkItem | { items: WorkItem[]; nextCursor?: string } | { url: string }; + +export interface WorkItems { + request(input: WorkItemRequest): Promise; +} diff --git a/src/deploy/sandboxRuntimeSupervisor.test.ts b/src/deploy/sandboxRuntimeSupervisor.test.ts index 2f8c7e30d..b1f692dbd 100644 --- a/src/deploy/sandboxRuntimeSupervisor.test.ts +++ b/src/deploy/sandboxRuntimeSupervisor.test.ts @@ -198,7 +198,9 @@ describe("the runtime supervisor, driven", () => { dirs.push(dir); const s = startSupervisor(dir, fakeRuntime(dir, ["exit:1", "live"]), ["--flag", "value"]); try { - await until(() => safeRead(s.starts) >= 2, 5_000); + // The fixture writes its counter before its log. Observe the restart's + // log itself rather than racing that second write after the counter. + await expect.poll(s.log, { timeout: 5_000 }).toContain("start 2 args=--flag value"); expect(s.log()).toContain("start 1 args=--flag value"); expect(s.log()).toContain("start 2 args=--flag value"); expect(s.stderr()).toContain("sandbox-runtime-supervisor: the runtime exited with status 1; starting it again"); diff --git a/src/deploy/secrets.ts b/src/deploy/secrets.ts index 2a6244182..8593eadb9 100644 --- a/src/deploy/secrets.ts +++ b/src/deploy/secrets.ts @@ -35,6 +35,8 @@ export const manifestSchema = z.object({ .union([z.boolean(), z.array(z.enum(DEPLOY_ORDER as [WorkerName, ...WorkerName[]])).min(1)]) .optional(), note: z.string().optional(), + /** False for credentials used only by the bot's edge Worker. */ + forwardToContainer: z.boolean().optional(), }), ) .min(1), diff --git a/src/index.ts b/src/index.ts index ee8f9921e..e95b14fb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,15 @@ import { installationPath } from "./deploy/operatorRoot.js"; import { openConfigStore } from "./config.js"; import { capabilitiesFrom } from "./core/capabilities.js"; import { PiAiProviders } from "./core/harness/piAi.js"; +import { channelsToStart, linearBridgeBaseUrl } from "./channels/startup.js"; import { createSlackApp } from "./channels/slack.js"; +import { RemoteLinearApi, RemoteLinearInbox, type LinearTransport } from "./channels/linear/bridge.js"; +import { LinearConsumer } from "./channels/linear/consumer.js"; +import { LinearChannelIO } from "./channels/linear/io.js"; +import { linearThread } from "./channels/linear/session.js"; +import { recoverLinearDelivery } from "./channels/linear/recovery.js"; +import { stopLinearSession } from "./channels/linear/control.js"; +import { handleLinearLifecycle, type LinearLiveWork } from "./channels/linear/lifecycle.js"; import { SlackChannelDirectory } from "./channels/slackChannelDirectory.js"; import { SlackConversationReader } from "./channels/slack/references.js"; import { createIngressHandler, parseIngressTokens } from "./channels/http.js"; @@ -157,11 +165,14 @@ const INTERRUPTED_WRITE_BUDGET_MS = 10_000; * CLI's `start` (src/cli.ts) — the same process from the same directory. */ export async function runBot(): Promise { - for (const v of ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"]) { - if (!processSecrets.get(v)) { - console.error(`Missing required env var ${v}`); - process.exit(1); - } + let channels: ReturnType; + try { + channels = channelsToStart( + new Set(["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "LINEAR_BRIDGE_TOKEN"].filter((key) => processSecrets.get(key))), + ); + } catch (error) { + console.error(error instanceof Error ? error.message : "Invalid channel configuration"); + process.exit(1); } // Build identity for /healthz (docs/reference/specs/slack-channel.md item 8): written by @@ -577,15 +588,80 @@ export async function runBot(): Promise { // registration, every surface. deps.commands = commands; // --- end command registry --- - const { app, receiver, statusClient } = createSlackApp(deps); + const slack = channels.slack ? createSlackApp(deps) : undefined; + const linearBearer = processSecrets.get("LINEAR_BRIDGE_TOKEN"); + let linearTransport: LinearTransport | undefined; + let linearConsumer: LinearConsumer | undefined; + if (linearBearer) { + const baseUrl = linearBridgeBaseUrl({ + LINEAR_BRIDGE_URL: process.env.LINEAR_BRIDGE_URL, + PUBLIC_BASE_URL: process.env.PUBLIC_BASE_URL, + }); + if (!ledgerClient) throw new Error("LINEAR_BRIDGE_TOKEN requires durable run history and its run ledger"); + linearTransport = { baseUrl, token: linearBearer.reveal(), fetch }; + const transport = linearTransport; + const linearInbox = new RemoteLinearInbox(transport); + linearConsumer = new LinearConsumer({ + ...(artifacts + ? { + maxStagedBytes: + config.config.artifacts?.inbound?.maxBytesPerMessage ?? ARTIFACT_DEFAULTS.maxBytesPerMessage, + } + : {}), + inbox: linearInbox, + api: (organizationId) => new RemoteLinearApi(transport, organizationId), + clock: systemClock, + warn: (message) => console.warn(message), + dispatch: (msg, io) => dispatch(deps, msg, io), + stop: (input, io) => stopLinearSession({ config, runs: runsService, inbox: linearInbox }, input, io), + recover: (delivery, msg) => recoverLinearDelivery({ ledger: ledgerClient, store: runStore }, delivery, msg), + other: (event) => + handleLinearLifecycle( + { + api: (org) => new RemoteLinearApi(transport, org), + live: async () => { + const live = new Map( + defaultRunRegistry + .listActive() + .filter((run) => !run.finished) + .map((run) => [run.id, run]), + ); + for (const row of await ledgerClient.listLive()) + if (!live.has(row.runId)) live.set(row.runId, { ...row.meta, id: row.runId, startedAt: row.startedAt }); + return [...live.values()]; + }, + halt: async (id) => { + defaultRunRegistry.requestStopById(id, "hard", { + kind: "chat", + id: `linear:${event.payload.organizationId}:access-removed`, + }); + await ledgerClient.requestStop(id, "hard"); + }, + }, + event, + ), + leaseLost: (id) => { + defaultRunRegistry.requestStopById(id, "hard", { kind: "chat", id: "linear:lease-lost" }); + }, + }); + } // A thread's channel handle rebuilt from a stored row's parts, with no // triggering event (run-history item 38): what a resumed run replies through // and what a coordinator's child is dispatched into. Slack from the key's // channel and ts (and the row's card, when it has one); HTTP and MCP have // no thread to speak into, so their handle logs; any other platform, none. const threadIoFor = (thread: { threadKey: string; userId: string; cardTs?: string }): ChannelIO | undefined => { + const linear = linearThread(thread.threadKey); + if (linear && linearTransport) + return new LinearChannelIO({ + api: new RemoteLinearApi(linearTransport, linear.organizationId), + sessionId: linear.sessionId, + clock: systemClock, + warn: (message) => console.warn(message), + }); const [platform, channel, threadTs] = thread.threadKey.split(":"); - if (platform === "slack" && channel && threadTs) { + if (platform === "slack" && channel && threadTs && slack) { + const { app, statusClient } = slack; return resumeSlackIO( app.client, { @@ -605,65 +681,68 @@ export async function runBot(): Promise { // channel is public or private — cached per channel per TTL, `unknown` on any // failure — so a public channel's runs are readable by everyone and a private // channel's or DM's stay grants-only. Non-Slack ids keep the static answer. - const channelDirectory = new SlackChannelDirectory(app.client); - deps.channelDirectory = channelDirectory; - // Membership: a dashboard session linked to its person carries the - // channels that person is in, from `users.conversations` cached per person. The - // events keep the cache fresh — a join or leave forgets that person, a move of - // the bot's own reach forgets everyone (the bot joining a channel makes that - // channel visible on every member's set at once), and so does a reconnect - // (Socket Mode replays nothing missed) — so the TTL is only the bound on a - // missed event. - slackChannelsOf = (actorId) => channelDirectory.channelsOf(actorId); - let directoryBotUserId: string | undefined; - const membershipMoved = async (user: string) => { - try { - directoryBotUserId ??= (await app.client.auth.test()).user_id ?? undefined; - } catch { - channelDirectory.forgetAll(); // who moved is unknown: forgetting more is always safe - return; - } - if (user === directoryBotUserId) channelDirectory.forgetAll(); - else channelDirectory.forgetMember(`slack:${user}`); - }; - app.event("member_joined_channel", async ({ event }) => membershipMoved(event.user)); - app.event("member_left_channel", async ({ event }) => membershipMoved(event.user)); - for (const moved of [ - "channel_left", - "group_left", - "channel_archive", - "group_archive", - "channel_deleted", - "group_deleted", - ] as const) - app.event(moved, async () => channelDirectory.forgetAll()); - receiver.client.on("connected", () => channelDirectory.forgetAll()); - // The conversation reader (record 0037): a permalink to another thread the - // bot is in becomes a quoted, untrusted block on the request turn — this - // workspace's URL grammar, one fresh `conversations.info` per classification, - // a text-only fetch. Inert until `references.enabled` is set; the host it - // recognises is read from `auth.test` once the socket is up. - const conversationReader = new SlackConversationReader(app.client); - deps.conversationReaders = [conversationReader]; - // Connect tickets bind to the requester's email when Slack can tell us - // (`users:read.email`); without the scope the lookup yields undefined and the - // ticket binds to the first Access identity that opens it instead. - slackEmailLookup = (userId) => - userId.startsWith("slack:") - ? resolveUserEmail(app.client, userId.slice("slack:".length)) - : Promise.resolve(undefined); - slackNameLookup = (userId) => - userId.startsWith("slack:") - ? resolveUserName(app.client, userId.slice("slack:".length)) - : Promise.resolve(undefined); - slackNameDirectory = slackNames(app.client); - slackPost = async (channel, text) => { - await app.client.chat.postMessage({ channel, text: mdToMrkdwn(text) }); - }; - // The dashboard link (record 0042): a browser session's Access email names - // its Slack person, resolved once per gate pass through a cached reverse - // lookup — identity, never authority. - slackPersonByEmail = (email) => resolvePersonByEmail(app.client, email); + if (slack) { + const { app, receiver } = slack; + const channelDirectory = new SlackChannelDirectory(app.client); + deps.channelDirectory = channelDirectory; + // Membership: a dashboard session linked to its person carries the + // channels that person is in, from `users.conversations` cached per person. The + // events keep the cache fresh — a join or leave forgets that person, a move of + // the bot's own reach forgets everyone (the bot joining a channel makes that + // channel visible on every member's set at once), and so does a reconnect + // (Socket Mode replays nothing missed) — so the TTL is only the bound on a + // missed event. + slackChannelsOf = (actorId) => channelDirectory.channelsOf(actorId); + let directoryBotUserId: string | undefined; + const membershipMoved = async (user: string) => { + try { + directoryBotUserId ??= (await app.client.auth.test()).user_id ?? undefined; + } catch { + channelDirectory.forgetAll(); // who moved is unknown: forgetting more is always safe + return; + } + if (user === directoryBotUserId) channelDirectory.forgetAll(); + else channelDirectory.forgetMember(`slack:${user}`); + }; + app.event("member_joined_channel", async ({ event }) => membershipMoved(event.user)); + app.event("member_left_channel", async ({ event }) => membershipMoved(event.user)); + for (const moved of [ + "channel_left", + "group_left", + "channel_archive", + "group_archive", + "channel_deleted", + "group_deleted", + ] as const) + app.event(moved, async () => channelDirectory.forgetAll()); + receiver.client.on("connected", () => channelDirectory.forgetAll()); + // The conversation reader (record 0037): a permalink to another thread the + // bot is in becomes a quoted, untrusted block on the request turn — this + // workspace's URL grammar, one fresh `conversations.info` per classification, + // a text-only fetch. Inert until `references.enabled` is set; the host it + // recognises is read from `auth.test` once the socket is up. + const conversationReader = new SlackConversationReader(app.client); + deps.conversationReaders = [conversationReader]; + // Connect tickets bind to the requester's email when Slack can tell us + // (`users:read.email`); without the scope the lookup yields undefined and the + // ticket binds to the first Access identity that opens it instead. + slackEmailLookup = (userId) => + userId.startsWith("slack:") + ? resolveUserEmail(app.client, userId.slice("slack:".length)) + : Promise.resolve(undefined); + slackNameLookup = (userId) => + userId.startsWith("slack:") + ? resolveUserName(app.client, userId.slice("slack:".length)) + : Promise.resolve(undefined); + slackNameDirectory = slackNames(app.client); + slackPost = async (channel, text) => { + await app.client.chat.postMessage({ channel, text: mdToMrkdwn(text) }); + }; + // The dashboard link (record 0042): a browser session's Access email names + // its Slack person, resolved once per gate pass through a cached reverse + // lookup — identity, never authority. + slackPersonByEmail = (email) => resolvePersonByEmail(app.client, email); + } // Work in flight = agent runs + the background memory reflections they spawn // + run-history writes still retrying (a record lost at SIGTERM is @@ -1227,17 +1306,19 @@ export async function runBot(): Promise { // launched (the launcher runs after this, and in-process admission takes // over the moment a resume is dispatched). threadsElsewhere.replace([...outcome.liveElsewhere, ...outcome.resumable.map((r) => r.row)]); - const closedCards = await closeReclaimedCards( - app.client, - outcome.closed.map((c) => ({ - status: c.status, - agent: c.agent, - card: c.card, - ...(c.note ? { note: c.note } : {}), - ...(c.routed ? { routed: true } : {}), - })), - (w) => console.warn(w), - ); + const closedCards = slack + ? await closeReclaimedCards( + slack.app.client, + outcome.closed.map((c) => ({ + status: c.status, + agent: c.agent, + card: c.card, + ...(c.note ? { note: c.note } : {}), + ...(c.routed ? { routed: true } : {}), + })), + (w) => console.warn(w), + ) + : 0; if (closedCards > 0) console.log(`[reclaim] closed ${closedCards} card(s) of runs the previous generation left`); // An interrupted ship pipeline's work stands on GitHub with nobody driving // it (run-history item 36): its thread is told, with the re-issue that @@ -1245,9 +1326,9 @@ export async function runBot(): Promise { for (const c of outcome.closed) { if (c.status !== "interrupted" || c.agent !== "ship" || !c.note) continue; const [platform, channel, threadTs] = c.threadKey.split(":"); - if (platform !== "slack" || !channel || !threadTs) continue; + if (platform !== "slack" || !channel || !threadTs || !slack) continue; try { - await app.client.chat.postMessage({ channel, thread_ts: threadTs, text: mdToMrkdwn(c.note) }); + await slack.app.client.chat.postMessage({ channel, thread_ts: threadTs, text: mdToMrkdwn(c.note) }); } catch (err) { console.warn( `[reclaim] ${c.runId}: the ship pipeline's thread could not be told: ${err instanceof Error ? err.message : String(err)}`, @@ -1315,7 +1396,7 @@ export async function runBot(): Promise { // No ledger: nothing is ever on its way back, so the door's verdict is final from the start. takeover.settle(); } - await app.start(); + await slack?.app.start(); // The runs the boot reclaim found resumable continue now that the socket is // up (run-history item 38), and the reclaim repeats every lease interval so a // row whose lease was still current at boot is taken once it expires. @@ -1334,6 +1415,7 @@ export async function runBot(): Promise { }); } + linearConsumer?.start(); console.log( `switchboard running (providers: ${completions.names().join(", ")}; default agent: ${config.config.defaults.agent})`, ); @@ -1359,6 +1441,7 @@ export async function runBot(): Promise { const drain = async (signal: string) => { if (draining) return; draining = true; + linearConsumer?.stop(); drainStartedAt = systemClock(); // The drain is one `drain` root on the span log (docs/reference/specs/tracing.md item // 20): what signalled it, what it held, what it handed off and abandoned. @@ -1370,7 +1453,7 @@ export async function runBot(): Promise { `[drain] ${signal}: closing Slack socket, ${activeRunCount()} run(s) + ${pendingReflectionCount()} reflection(s) + ${pendingHistoryWrites()} history write(s) in flight`, ); setShutdownNotice(DEPLOY_RESTART_NOTICE); - await app.stop().catch(() => {}); + await slack?.app.stop().catch(() => {}); // The handoff (plan D8, run-history item 39): every run a resume can // continue is marked `handoff` on the ledger, so the next generation takes // it at once — whatever its lease — and carries on from its last step. Those diff --git a/src/load/doorReport.test.ts b/src/load/doorReport.test.ts index a8b0e34b9..ef4392386 100644 --- a/src/load/doorReport.test.ts +++ b/src/load/doorReport.test.ts @@ -231,6 +231,7 @@ describe("doorReport — hand-backs, the pastes that followed and the rate, per const registry = new RunRegistry({ now: () => NOW }); const store = new InMemoryRunStore({ now: () => NOW }); const broken: RunStore = { + stopWaiting: (id, stop) => store.stopWaiting(id, stop), put: (record) => store.put(record), abandoned: () => {}, get: (id) => store.get(id), diff --git a/src/secretEnv.mjs b/src/secretEnv.mjs index 177cb60be..b1971cdca 100644 --- a/src/secretEnv.mjs +++ b/src/secretEnv.mjs @@ -45,6 +45,7 @@ export const PUBLIC_ENV_NAMES = new Set([ "NODE_ENV", "PORT", "PUBLIC_BASE_URL", + "LINEAR_BRIDGE_URL", "STATE_WORKER_URL", "ACCESS_TEAM_DOMAIN", "ACCESS_AUD", diff --git a/src/tools/attach.test.ts b/src/tools/attach.test.ts index ae1d2496b..3ffdd51f9 100644 --- a/src/tools/attach.test.ts +++ b/src/tools/attach.test.ts @@ -212,6 +212,28 @@ describe("attach_file through the artifact store", () => { return { store, commands, log, executor, tickets, replies, events, ctx }; } + it("streams a PUT ticket with its signed header casing and completes only after upload succeeds", async () => { + for (const fail of [false, true]) { + const h = harness(fail ? { post: "exit 22: upload refused" } : {}); + h.ctx.uploadTicket = async () => ({ + url: "https://storage.example/file?signature=one-file", + method: "PUT", + headers: { + "Content-Disposition": "attachment; filename=report's.png", + "x-goog-content-length-range": "3145728,3145728", + }, + complete: async (lead) => { + h.tickets.completed.push(lead); + }, + }); + await attachFileTool.run({ path: "shots/page.png", comment: "The plot" }, h.ctx); + expect(h.commands[2]!.command).toContain("-X PUT"); + expect(h.commands[2]!.command).toContain("-H 'Content-Disposition: attachment; filename=report'\\''s.png'"); + expect(h.commands[2]!.command).toContain("-H 'x-goog-content-length-range: 3145728,3145728'"); + expect(h.tickets.completed).toEqual(fail ? [] : ["The plot"]); + } + }); + it("happy path: stat → presigned PUT → HEAD → artifact event → ticket → POST → complete, each command under the 20-minute cap, and the result names the size", async () => { const h = harness(); const out = await attachFileTool.run({ path: "shots/page.png", comment: "the page" }, h.ctx); diff --git a/src/tools/attach.ts b/src/tools/attach.ts index efe43ced4..883545a48 100644 --- a/src/tools/attach.ts +++ b/src/tools/attach.ts @@ -94,13 +94,20 @@ export function parsePutReport(output: string): { httpCode: number; sentBytes: n return m ? { httpCode: Number(m[1]), sentBytes: Number(m[2]) } : null; } -/** The command the container runs to POST the file to the channel's ticket. - * `--upload-file` streams the file from disk with its Content-Length; `-X POST` +/** The command the container runs to upload the file to the channel's ticket. + * `--upload-file` streams the file from disk with its Content-Length; `-X` * keeps the method the one-shot URL expects. `--data-binary @file` would read * the whole file into memory first — a 1 GiB attach died of * "curl: option --data-binary: out of memory" live. */ -export function postCommandFor(path: string, url: string): string { - return `curl -fsS --upload-file ${shellQuote(path)} -X POST ${shellQuote(url)}`; +export function postCommandFor( + path: string, + url: string, + options: Pick = {}, +): string { + const headers = Object.entries(options.headers ?? {}) + .map(([key, value]) => ` -H ${shellQuote(`${key}: ${value}`)}`) + .join(""); + return `curl -fsS --upload-file ${shellQuote(path)} -X ${options.method === "PUT" ? "PUT" : "POST"}${headers} ${shellQuote(url)}`; } /** The store path (record 0033). Returns the tool's result text. */ @@ -184,7 +191,7 @@ async function attachThroughStore( } catch (err) { return `error: the channel refused an upload ticket for ${name}: ${describe(err)}; ${kept}`; } - const postOut = await exec(postCommandFor(path, ticket.url), timeoutMs); + const postOut = await exec(postCommandFor(path, ticket.url, ticket), timeoutMs); if (parseExitPrefix(postOut).failed) { return `error: the upload of ${name} to the channel failed: ${postOut.trim()}; ${kept}`; } diff --git a/src/tools/diffDigest.test.ts b/src/tools/diffDigest.test.ts index 07e2c32c9..deea54368 100644 --- a/src/tools/diffDigest.test.ts +++ b/src/tools/diffDigest.test.ts @@ -189,41 +189,47 @@ describe("diff_digest tool on a real multi-commit branch (LocalExecutor)", () => }, }).toString(); - it("covers every commit's files and states exact totals even when the unified diff exceeds the output cap", async () => { - const dir = mkdtempSync(join(tmpdir(), "digest-")); - sh(dir, "git init -q -b main upstream"); - const up = join(dir, "upstream"); - writeFileSync(join(up, "README.md"), "hello\n"); - sh(up, "git add . && git commit -qm base"); - sh(up, "git checkout -qb feature"); - // commit 1: a file sorted FIRST alphabetically, bigger than the 120k cap on its own - writeFileSync( - join(up, "a-huge.txt"), - Array.from({ length: 6000 }, (_, i) => `line ${i} ${"x".repeat(20)}`).join("\n") + "\n", - ); - sh(up, "git add . && git commit -qm huge"); - // commit 2 + 3: files sorted AFTER it — the ones a cut diff loses - mkdirSync(join(up, "src"), { recursive: true }); - writeFileSync(join(up, "src/late.ts"), "export const a = 1;\nexport const b = 2;\n"); - sh(up, "git add . && git commit -qm late"); - writeFileSync(join(up, "zz-last.md"), "tail\n"); - writeFileSync(join(up, "README.md"), "hello\nworld\n"); - sh(up, "git add . && git commit -qm last"); - sh(up, "git checkout -q main"); // the upstream's HEAD is its default branch, as GitHub's is - // the clone the tool runs in, with origin/HEAD → main as a real clone has - sh(dir, "git clone -q --branch feature upstream wt"); - const wt = join(dir, "wt"); - - const reports: DigestReport[] = []; - const ctx: ToolContext = { executor: new LocalExecutor(wt), onDigest: (r) => reports.push(r) }; - const out = await diffDigestTool.run({}, ctx); - expect(out).toContain("4 files changed, +6004 -0"); - for (const f of ["a-huge.txt", "src/late.ts", "zz-last.md", "README.md"]) expect(out).toContain(f); - expect(reports).toEqual([ - { complete: true, base: "origin/HEAD", totals: { files: 4, additions: 6004, deletions: 0 } }, - ]); - rmSync(dir, { recursive: true, force: true }); - }); + // Repository creation, four commits and a clone share the test's budget; + // this proves exact totals and truncation handling, not filesystem latency. + it( + "covers every commit's files and states exact totals even when the unified diff exceeds the output cap", + { timeout: 15_000 }, + async () => { + const dir = mkdtempSync(join(tmpdir(), "digest-")); + sh(dir, "git init -q -b main upstream"); + const up = join(dir, "upstream"); + writeFileSync(join(up, "README.md"), "hello\n"); + sh(up, "git add . && git commit -qm base"); + sh(up, "git checkout -qb feature"); + // commit 1: a file sorted FIRST alphabetically, bigger than the 120k cap on its own + writeFileSync( + join(up, "a-huge.txt"), + Array.from({ length: 6000 }, (_, i) => `line ${i} ${"x".repeat(20)}`).join("\n") + "\n", + ); + sh(up, "git add . && git commit -qm huge"); + // commit 2 + 3: files sorted AFTER it — the ones a cut diff loses + mkdirSync(join(up, "src"), { recursive: true }); + writeFileSync(join(up, "src/late.ts"), "export const a = 1;\nexport const b = 2;\n"); + sh(up, "git add . && git commit -qm late"); + writeFileSync(join(up, "zz-last.md"), "tail\n"); + writeFileSync(join(up, "README.md"), "hello\nworld\n"); + sh(up, "git add . && git commit -qm last"); + sh(up, "git checkout -q main"); // the upstream's HEAD is its default branch, as GitHub's is + // the clone the tool runs in, with origin/HEAD → main as a real clone has + sh(dir, "git clone -q --branch feature upstream wt"); + const wt = join(dir, "wt"); + + const reports: DigestReport[] = []; + const ctx: ToolContext = { executor: new LocalExecutor(wt), onDigest: (r) => reports.push(r) }; + const out = await diffDigestTool.run({}, ctx); + expect(out).toContain("4 files changed, +6004 -0"); + for (const f of ["a-huge.txt", "src/late.ts", "zz-last.md", "README.md"]) expect(out).toContain(f); + expect(reports).toEqual([ + { complete: true, base: "origin/HEAD", totals: { files: 4, additions: 6004, deletions: 0 } }, + ]); + rmSync(dir, { recursive: true, force: true }); + }, + ); }); describe("diff_digest toolset wiring", () => { diff --git a/src/tools/github.test.ts b/src/tools/github.test.ts index e131bce28..637f4166e 100644 --- a/src/tools/github.test.ts +++ b/src/tools/github.test.ts @@ -17,6 +17,7 @@ import { githubActionsJobLogTool, } from "./github.js"; import { TOOLSETS } from "./toolsets.js"; +import { WORK_ITEM_READ_TOOLS, WORK_ITEM_WRITE_TOOLS } from "./workItems.js"; import type { ToolContext } from "./runnableTool.js"; import type { Executor } from "../execution/executor.js"; @@ -206,6 +207,8 @@ describe("toolset wiring", () => { const names = (key: string) => (TOOLSETS[key] ?? []).map((t) => t.name); const reads = GITHUB_READ_TOOLS.map((t) => t.name); const writes = GITHUB_ISSUE_WRITE_TOOLS.map((t) => t.name); + const workReads = WORK_ITEM_READ_TOOLS.map((t) => t.name); + const workWrites = WORK_ITEM_WRITE_TOOLS.map((t) => t.name); it("reads are in every toolset with a tool loop; issue writes only in assistant (general) and full (coding); none stays empty", () => { for (const key of ["full", "readonly", "web", "assistant", "explore", "conductor"]) @@ -220,15 +223,19 @@ describe("toolset wiring", () => { // docs/reference/specs/agent-conductor.md item 2: the five run tools, the // GitHub reads, URL reading and the status card — no shell, no files, no // writes; and the run tools are in no other toolset (dark by default). - it("conductor holds spawn_run, send_to_run, await_runs, list_runs, get_run_status, web_fetch, update_status and the GitHub reads — no shell, no files, no submit_*, no issue writes; no other toolset holds a run tool", () => { + it("conductor holds spawn_run, send_to_run, await_runs, list_runs, get_run_status, web_fetch, update_status, request_input and the GitHub reads — no shell, no files, no submit_*, no issue writes; no other toolset holds a run tool", () => { const runTools = ["spawn_run", "send_to_run", "await_runs", "list_runs", "get_run_status"]; - expect(names("conductor").sort()).toEqual([...runTools, "web_fetch", "update_status", ...reads].sort()); + expect(names("conductor").sort()).toEqual( + [...runTools, "web_fetch", "update_status", "request_input", ...reads, ...workReads].sort(), + ); for (const key of Object.keys(TOOLSETS).filter((k) => k !== "conductor")) for (const t of runTools) expect(names(key), `${key} ${t}`).not.toContain(t); }); - it("assistant has no shell, no file writes, no verdict/PR submission — GitHub + web_fetch + status only", () => { - expect(names("assistant").sort()).toEqual(["web_fetch", "update_status", ...reads, ...writes].sort()); + it("assistant has no shell, no file writes, no verdict/PR submission — GitHub, work tracking, web_fetch, status and questions only", () => { + expect(names("assistant").sort()).toEqual( + ["web_fetch", "update_status", "request_input", ...reads, ...writes, ...workReads, ...workWrites].sort(), + ); }); // docs/reference/specs/agent-explore.md item 2: the investigation preset's @@ -236,9 +243,20 @@ describe("toolset wiring", () => { // tools and the GitHub reads; nothing that submits a verdict, a description, // dispositions or a handoff, or writes an issue. Its shell and file reads // are pi's own tools in its cold sandbox, never rows of this table. - it("explore relays update_status, web_fetch, web_search, the skill tools, the session tools and the GitHub reads — no submit_*, no issue writes, and none of pi's own workspace tools", () => { + it("explore relays update_status, request_input, web_fetch, web_search, the skill tools, the session tools and the GitHub reads — no submit_*, no issue writes, and none of pi's own workspace tools", () => { expect(names("explore").sort()).toEqual( - ["update_status", "web_fetch", "web_search", "list_skills", "use_skill", "recall", "notes", ...reads].sort(), + [ + "update_status", + "request_input", + "web_fetch", + "web_search", + "list_skills", + "use_skill", + "recall", + "notes", + ...reads, + ...workReads, + ].sort(), ); expect(names("explore").filter((n) => n.startsWith("submit_"))).toEqual([]); }); diff --git a/src/tools/question.test.ts b/src/tools/question.test.ts new file mode 100644 index 000000000..864c55d80 --- /dev/null +++ b/src/tools/question.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from "vitest"; +import { requestInputTool } from "./question.js"; +import type { ToolContext } from "./runnableTool.js"; + +describe("request_input", () => { + it("records a bounded question and reports unavailable or invalid requests honestly", async () => { + const onQuestion = vi.fn(); + const ctx = { onQuestion } as unknown as ToolContext; + for (const question of [undefined, " ", 4, "x".repeat(4001)]) { + expect(await requestInputTool.run({ question }, ctx)).toMatch(/^error:/); + } + expect(onQuestion).not.toHaveBeenCalled(); + expect(await requestInputTool.run({ question: "Which repository?" }, {} as ToolContext)).toMatch(/^error:/); + expect(await requestInputTool.run({ question: " Which repository? " }, ctx)).toContain("End your turn"); + expect(onQuestion).toHaveBeenCalledWith("Which repository?"); + }); +}); diff --git a/src/tools/question.ts b/src/tools/question.ts new file mode 100644 index 000000000..ca8020af9 --- /dev/null +++ b/src/tools/question.ts @@ -0,0 +1,24 @@ +import { questionText } from "../core/question.js"; +import type { RunnableTool } from "./runnableTool.js"; + +export const requestInputTool: RunnableTool = { + name: "request_input", + failsInText: true, + description: + "Ask the user for missing information required to continue. Supply one clear question, with Markdown options when useful. Call this only when blocked on their answer, then end your turn without further work. Switchboard sends the recorded question and waits for a reply; it does not publish a PR or review verdict from this turn. A later call replaces the question. Do not use this to request credentials or to bypass authorization.", + inputSchema: { + type: "object", + properties: { + question: { type: "string", minLength: 1, maxLength: 4000, description: "The question shown to the user" }, + }, + required: ["question"], + additionalProperties: false, + }, + async run(input, ctx) { + const question = questionText(input.question); + if (!question) return "error: question must contain 1–4000 characters"; + if (!ctx.onQuestion) return "error: this run cannot request user input"; + ctx.onQuestion(question); + return "Question recorded. End your turn now; Switchboard will ask it and wait for the user's reply."; + }, +}; diff --git a/src/tools/runnableTool.ts b/src/tools/runnableTool.ts index 7619ae4e7..077bcaac2 100644 --- a/src/tools/runnableTool.ts +++ b/src/tools/runnableTool.ts @@ -30,8 +30,13 @@ import type { GithubCapability } from "./github.js"; import type { RunsReadCapability, SteerCapability } from "./runs.js"; import type { SessionCapability } from "./session.js"; import type { WebCapability } from "./web.js"; +import type { WorkItems } from "../core/workItems.js"; export interface ToolContext { + /** Record a question for the turn’s reply, without posting it mid-run. */ + onQuestion?: (question: string) => void; + /** Work tracking bound to this run's resolved actor by its channel. */ + workItems?: WorkItems; executor: Executor; /** The tool call's id (the provider's `tool_use` id; pi's `toolCallId`), * the same id the call's `tool_call`/`tool_result` events carry: what a tool diff --git a/src/tools/runs.test.ts b/src/tools/runs.test.ts index eb85083d4..f1014c3b5 100644 --- a/src/tools/runs.test.ts +++ b/src/tools/runs.test.ts @@ -947,3 +947,74 @@ describe("a child is its thread — the reads follow the thread's newest run", ( expect("finalReply" in followed).toBe(false); }); }); + +describe("child clarification stays unfinished", () => { + it.each(["slack", "linear"])( + "reports a persisted %s question as awaiting_input and follows the human continuation", + async (channel) => { + const w = world(); + const requester: Actor = { ...alice, id: `${channel}:alice` }; + const first = persisted("r-question", { + parentRunId: "run-p", + userId: requester.id, + channelId: `${channel}:team`, + threadKey: `${channel}:team:question`, + sourceUrl: "https://example.com/child-session", + awaitingInput: true, + events: [{ type: "answer", text: "Which repository should I inspect?", seq: 1 }], + eventCount: 1, + storedEventCount: 1, + }); + await w.store.put(first); + const { wait, slept } = waitFor(w); + const ctx = ctxFor(w, requester, { runId: "run-p", wait }); + const status = JSON.parse(String(await getRunStatusTool.run({ id: first.id }, ctx))); + expect(status.status).toBe("awaiting_input"); + expect(String(status.finalReply)).toContain("Which repository"); + expect(String(status.finalReply)).toMatch(/untrusted/i); + const listed = JSON.parse(String(await listRunsTool.run({}, ctx))); + expect(listed[0].status).toBe("awaiting_input"); + const question = report(await awaitRunsTool.run({ ids: [first.id] }, ctx)); + expect(question.ended).toBe("awaiting_input"); + expect(question.runs[0]).toMatchObject({ id: first.id, status: "awaiting_input" }); + expect(String(question.runs[0].finalReply)).toContain("Which repository"); + expect(String(question.runs[0].finalReply)).toMatch(/untrusted/i); + expect(question.note).toContain("request_input"); + expect(question.runs[0].url).toBe(first.sourceUrl); + expect(slept).toEqual([]); + const other = w.registry.create("research · other", { + agent: "research", + userId: requester.id, + channelId: first.channelId, + threadKey: `${channel}:other`, + channelVisibility: "public", + parentRunId: "run-p", + }); + const mixed = report(await awaitRunsTool.run({ ids: [first.id, other.id] }, ctx)); + expect(mixed.ended).toBe("awaiting_input"); + expect(mixed.runs.map((row) => row.status)).toEqual(["awaiting_input", "running"]); + expect(mixed.note).toContain(`Still running (they keep running): ${other.id}.`); + expect(mixed.note).not.toContain(`Still running (they keep running): ${first.id}`); + expect(slept).toEqual([]); + + const later = w.registry.create("research · child", { + agent: "research", + userId: first.userId, + channelId: first.channelId, + channelVisibility: "public", + threadKey: first.threadKey, + parentRunId: "run-p", + }); + const live = JSON.parse(String(await getRunStatusTool.run({ id: first.id }, ctx))); + expect(live).toMatchObject({ status: "running", continuedBy: later.id }); + expect(live.finalReply).toBeUndefined(); + w.registry.publish(later.id, { type: "answer", text: "The repository uses a durable queue." }); + w.registry.finish(later.id, "completed"); + const done = report(await awaitRunsTool.run({ ids: [first.id] }, ctx)); + expect(done.ended).toBe("all_ended"); + expect(done.runs[0]).toMatchObject({ status: "completed", continuedBy: later.id }); + expect(String(done.runs[0].finalReply)).toContain("durable queue"); + expect(String(done.runs[0].finalReply)).not.toContain("Which repository"); + }, + ); +}); diff --git a/src/tools/runs.ts b/src/tools/runs.ts index fd615dda6..b463fa195 100644 --- a/src/tools/runs.ts +++ b/src/tools/runs.ts @@ -56,13 +56,19 @@ export interface SteerCapability { const UNAVAILABLE = "run tools are not available in this context."; const NOT_FOUND = "not_found"; +/** A completed turn may still be waiting for a person to finish the task. */ +function statusOf(view: RunView): string { + if (!view.finished) return "running"; + return view.awaitingInput && view.status === "completed" ? "awaiting_input" : (view.status ?? "finished"); +} + /** One run as the tools show it: identity, where it is, what it is doing — * never the capability token (`RunView` carries none), never event text. */ function rowOf(v: RunView): Record { return { id: v.id, ...(v.agent !== undefined ? { agent: v.agent } : {}), - status: v.finished ? (v.status ?? "finished") : "running", + status: statusOf(v), ...(v.activity !== undefined ? { activity: v.activity } : {}), ...(v.parentRunId !== undefined ? { parentRunId: v.parentRunId } : {}), ...(v.label !== undefined ? { label: v.label } : {}), @@ -105,7 +111,7 @@ function rowFollowing(view: RunView, current: RunView): Record const { activity: _activity, finishedAt: _finishedAt, ...identity } = rowOf(view); return { ...identity, - status: current.finished ? (current.status ?? "finished") : "running", + status: statusOf(current), ...(current.activity !== undefined ? { activity: current.activity } : {}), ...(current.finishedAt !== undefined ? { finishedAt: current.finishedAt } : {}), continuedBy: current.id, @@ -325,6 +331,17 @@ async function readChild( // Only a finished run's events are read — once, for its final reply. const full = await service.getRun(current.id, { include: "messages" }); const finalReply = full.ok ? finalReplyOf(full.value) : undefined; + if (statusOf(current) === "awaiting_input") { + return { + state: { + kind: "awaiting_input", + ...(current.activity !== undefined ? { activity: current.activity } : {}), + ...(finalReply !== undefined ? { finalReply } : {}), + ...continued, + }, + view, + }; + } return { state: { kind: "ended", @@ -343,6 +360,13 @@ function waitNote(why: WaitEnd, running: string[]): string { switch (why) { case "all_ended": return "Every run named has ended."; + case "awaiting_input": + return ( + "A child needs information before its task can finish. Relay its question with request_input, " + + "including the child's thread link so the person can answer there. Do not mark it complete or restart it. " + + "After the reply, await the same child id again to follow its continuation." + + still + ); case "stop": return `A stop was requested of this run — wrap up now.${still}`; case "follow_up": @@ -369,6 +393,14 @@ function childRow(id: string, state: ChildState, view: RunView | undefined): Rec ...(state.elsewhere ? { elsewhere: true } : {}), ...(state.continuedBy !== undefined ? { continuedBy: state.continuedBy } : {}), }; + case "awaiting_input": + return { + ...identity, + status: "awaiting_input", + ...(state.activity !== undefined ? { activity: state.activity } : {}), + ...(state.finalReply !== undefined ? { finalReply: state.finalReply } : {}), + ...(state.continuedBy !== undefined ? { continuedBy: state.continuedBy } : {}), + }; case "ended": return { ...identity, @@ -406,7 +438,7 @@ export const awaitRunsTool: RunnableTool = { "Wait for runs — normally your children — to end, and get each one's end as data: its terminal status (completed, failed, " + "refused, stopped_soft, stopped_hard, interrupted) with its final reply wrapped as untrusted content; `running` for one still " + "live when the wait was cut; `not_found` for an unknown id or one the requester may not read. The wait ends at the first of: " + - "every named run ended; `timeoutMinutes` (optional, whole minutes); the edge of your own budget (a minute before your clock " + + "every named run ended; a child asks for clarification (`awaiting_input`, with its question); `timeoutMinutes` (optional, whole minutes); the edge of your own budget (a minute before your clock " + "runs out — write up what came back and name what is still running, which keeps running); a stop; a follow-up landing in " + "this thread (it rides your next turn). `ended` says which. An interrupted child is reported, never restarted. A child is " + "its thread: when a person's reply in a child's thread started a later run there, the child's row follows that run — " + @@ -460,7 +492,7 @@ export const awaitRunsTool: RunnableTool = { followUpPending: wait.followUpsArrived() > arrivedAtStart, }); if (decision.kind === "end") { - const running = watch.pending(); + const running = watch.pending().filter((id) => watch.get(id)?.kind === "running"); return JSON.stringify({ ended: decision.why, waitedMs: now - startedAt, @@ -495,8 +527,8 @@ export const listRunsTool: RunnableTool = { name: "list_runs", description: "List runs as the person who asked you may see them: by default this run's own children (`scope: children`), or every run " + - "they may read (`scope: all`); `status` picks live (`active`), finished, or both (default). One row per run: id, preset, " + - "status (running, or the terminal status), the latest activity line, the parent run, the label, the thread and a link. " + + "they may read (`scope: all`); `status` picks live (`active`), finished turns (including questions), or both (default). One row per run: id, preset, " + + "status (running, awaiting_input, or the terminal status), the latest activity line, the parent run, the label, the thread and a link. " + "Never a run's messages — get_run_status answers those.", inputSchema: { type: "object", diff --git a/src/tools/toolsets.ts b/src/tools/toolsets.ts index a344ca2c1..eda2603d6 100644 --- a/src/tools/toolsets.ts +++ b/src/tools/toolsets.ts @@ -13,6 +13,8 @@ // out. There is one loop now and no filter: the table is exactly what is // relayed. +import { WORK_ITEM_READ_TOOLS, WORK_ITEM_WRITE_TOOLS } from "./workItems.js"; +import { requestInputTool } from "./question.js"; import { attachFileTool } from "./attach.js"; import { diffDigestTool } from "./diffDigest.js"; import { GITHUB_ISSUE_WRITE_TOOLS, GITHUB_READ_TOOLS } from "./github.js"; @@ -50,6 +52,7 @@ export const TOOLSETS: Record = { full: [ attachFileTool, updateStatusTool, + requestInputTool, submitPrDescriptionTool, submitHandoffTool, submitDispositionsTool, @@ -57,25 +60,37 @@ export const TOOLSETS: Record = { diffDigestTool, listSkillsTool, useSkillTool, + ...WORK_ITEM_READ_TOOLS, ...GITHUB_READ_TOOLS, + ...WORK_ITEM_WRITE_TOOLS, ...GITHUB_ISSUE_WRITE_TOOLS, ...SESSION_TOOLS, ], readonly: [ updateStatusTool, + requestInputTool, submitVerdictTool, webFetchTool, diffDigestTool, listSkillsTool, useSkillTool, + ...WORK_ITEM_READ_TOOLS, ...GITHUB_READ_TOOLS, ...SESSION_TOOLS, ], - web: [webFetchTool, webSearchTool, updateStatusTool, ...GITHUB_READ_TOOLS], + web: [webFetchTool, webSearchTool, updateStatusTool, requestInputTool, ...WORK_ITEM_READ_TOOLS, ...GITHUB_READ_TOOLS], /** The general agent: no workspace, no shell — GitHub reads + issue writes * and URL reading, so a plain mention can answer from the repos and act on * issues without being re-sent to another agent. */ - assistant: [webFetchTool, updateStatusTool, ...GITHUB_READ_TOOLS, ...GITHUB_ISSUE_WRITE_TOOLS], + assistant: [ + webFetchTool, + updateStatusTool, + requestInputTool, + ...WORK_ITEM_READ_TOOLS, + ...GITHUB_READ_TOOLS, + ...WORK_ITEM_WRITE_TOOLS, + ...GITHUB_ISSUE_WRITE_TOOLS, + ], /** The explore agent (docs/reference/specs/agent-explore.md): the web with * search, the skills, the GitHub reads and the status card — and nothing * that writes: no `submit_*`, no issue writes. Its shell and file reads are @@ -83,10 +98,12 @@ export const TOOLSETS: Record = { * and the wall is the read-scoped credential its machine holds. */ explore: [ updateStatusTool, + requestInputTool, webFetchTool, webSearchTool, listSkillsTool, useSkillTool, + ...WORK_ITEM_READ_TOOLS, ...GITHUB_READ_TOOLS, ...SESSION_TOOLS, ], @@ -95,7 +112,14 @@ export const TOOLSETS: Record = { * steer or await a run — beside the GitHub reads, URL reading and the status * card. No shell, no files, no writes: a conductor coordinates and never * does a child's job. */ - conductor: [...RUN_TOOLS, webFetchTool, updateStatusTool, ...GITHUB_READ_TOOLS], + conductor: [ + ...RUN_TOOLS, + webFetchTool, + updateStatusTool, + requestInputTool, + ...WORK_ITEM_READ_TOOLS, + ...GITHUB_READ_TOOLS, + ], none: [], }; diff --git a/src/tools/workItems.test.ts b/src/tools/workItems.test.ts new file mode 100644 index 000000000..40a4bee86 --- /dev/null +++ b/src/tools/workItems.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { TOOLSETS } from "./toolsets.js"; +import { WORK_ITEM_READ_TOOLS, WORK_ITEM_WRITE_TOOLS } from "./workItems.js"; +import type { ToolContext } from "./runnableTool.js"; + +describe("work-item tools", () => { + it("keeps writes out of read-only presets and ignores model-supplied identity and assignment fields", async () => { + for (const preset of ["readonly", "web", "explore", "conductor"]) + expect(TOOLSETS[preset]!.some((tool) => WORK_ITEM_WRITE_TOOLS.includes(tool))).toBe(false); + for (const preset of ["full", "assistant"]) + expect(WORK_ITEM_WRITE_TOOLS.every((tool) => TOOLSETS[preset]!.includes(tool))).toBe(true); + const request = vi.fn(async () => ({ url: "https://tracker.example/issue" })); + const ctx = { workItems: { request } } as unknown as ToolContext; + const update = WORK_ITEM_WRITE_TOOLS.find((t) => t.name === "work_item_update")!; + await update.run({ id: "ENG-1", title: "Fix", actor: "admin", assigneeId: "bot", delegateId: "other" }, ctx); + expect(request).toHaveBeenCalledWith({ + op: "update", + id: "ENG-1", + title: "Fix", + description: undefined, + priority: undefined, + state: undefined, + }); + }); + it("reports an unavailable capability or a refused write as failure", async () => { + expect(await WORK_ITEM_READ_TOOLS[0]!.run({ id: "ENG-1" }, {} as ToolContext)).toMatch(/^error:/); + const ctx = { + workItems: { + request: async () => { + throw new Error("request denied"); + }, + }, + } as unknown as ToolContext; + expect(await WORK_ITEM_WRITE_TOOLS[0]!.run({ id: "ENG-1", title: "Fix" }, ctx)).toBe("error: request denied"); + }); +}); diff --git a/src/tools/workItems.ts b/src/tools/workItems.ts new file mode 100644 index 000000000..4770de007 --- /dev/null +++ b/src/tools/workItems.ts @@ -0,0 +1,89 @@ +import type { RunnableTool } from "./runnableTool.js"; +import type { WorkItemRequest } from "../core/workItems.js"; + +function tool( + name: string, + description: string, + properties: Record, + required: string[], + request: (input: Record) => WorkItemRequest, + read = false, +): RunnableTool { + return { + name, + description, + inputSchema: { type: "object", properties, required, additionalProperties: false }, + ...(read ? { sideEffectFree: true as const } : {}), + failsInText: true, + async run(input, ctx) { + if (!ctx.workItems) return "error: work-item tools are not available in this conversation"; + try { + return JSON.stringify(await ctx.workItems.request(request(input))); + } catch (error) { + return `error: ${error instanceof Error ? error.message : "work-item request failed"}`; + } + }, + }; +} +const str = { type: "string" }; +const id = { type: "string", description: "Issue identifier or ID, such as ENG-123" }; + +export const WORK_ITEM_READ_TOOLS = [ + tool( + "work_item_get", + "Read an issue in this conversation's work tracker, including status, human assignee and delegated agent. Access is limited to the requesting person's visible teams.", + { id }, + ["id"], + (i) => ({ op: "get", id: i.id as string }), + true, + ), + tool( + "work_items_delegated", + "List issues delegated to Switchboard that the requesting person can access. This lists the delegated agent, not the human assignee. Follow nextCursor with after to read later pages.", + { after: str, limit: { type: "integer", minimum: 1, maximum: 50 } }, + [], + (i) => ({ op: "delegated", after: i.after as string | undefined, limit: i.limit as number | undefined }), + true, + ), +]; +export const WORK_ITEM_WRITE_TOOLS = [ + tool( + "work_item_update", + "Update only the issue fields requested. Preserve the human assignee and delegated agent. A completed agent run or an open PR does not mean the issue is Done; change status only when the requested work warrants it. Needs the work-items:write grant.", + { + id, + title: str, + description: str, + priority: { type: "integer", minimum: 0, maximum: 4 }, + state: { type: "string", description: "Exact team status name or ID" }, + }, + ["id"], + (i) => ({ + op: "update", + id: i.id as string, + title: i.title as string | undefined, + description: i.description as string | undefined, + priority: i.priority as number | undefined, + state: i.state as string | undefined, + }), + ), + tool( + "work_item_create_child", + "Create a subissue in its parent's team. Does not assign a person or automatically delegate another agent run. Needs the work-items:write grant.", + { parentId: id, title: str, description: str }, + ["parentId", "title"], + (i) => ({ + op: "create_child", + parentId: i.parentId as string, + title: i.title as string, + description: i.description as string | undefined, + }), + ), + tool( + "work_item_comment", + "Post a requested comment on an issue the person can access. Use ordinary conversation replies for progress; use this for durable issue notes or a requested PR link. Needs the work-items:write grant.", + { id, body: str }, + ["id", "body"], + (i) => ({ op: "comment", id: i.id as string, body: i.body as string }), + ), +];