From 63217087c0506121215e43db8b974fb4aa9d8ee9 Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 11 Oct 2026 03:58:31 +0200 Subject: [PATCH 1/5] feat(core): record every Code Mode run, and close its lease on revocation Each run opens with a record in the chat: its tool call, module hash and length, the exact agent version, the runtime and whether it enforces the CPU limit (plain workerd records cpu_limit_unsupported), then how it ended, or interrupted after a restart. A revocation of any of the agent permissions after the run opened closes its lease. Writes are admitted against the lease as they commit, not only when the call starts: draft writes, discards and creates in the same Workspace transaction, the rest right before the effect. --- apps/core/package.json | 2 +- apps/core/src/agent-builds.ts | 22 +- apps/core/src/agent-connections.ts | 8 +- apps/core/src/agent-memory.ts | 8 +- apps/core/src/agent-scope.ts | 122 +++- apps/core/src/agent.ts | 49 +- apps/core/src/code-mode.ts | 65 ++- .../migrations/0008_chat_code_runs.sql | 17 + .../migrations/meta/0008_snapshot.json | 532 ++++++++++++++++++ .../workspace/migrations/meta/_journal.json | 7 + .../src/db/workspace/migrations/migrations.js | 4 +- apps/core/src/db/workspace/schema.ts | 35 ++ apps/core/src/env.d.ts | 5 + apps/core/src/workspace.ts | 254 +++++++-- apps/core/test/agent-builds.test.ts | 113 +++- apps/core/test/agent-chat.ts | 18 + apps/core/test/agent.test.ts | 228 +++++++- apps/core/test/app-preview.test.ts | 4 +- apps/core/test/preview-repairs.test.ts | 16 +- apps/core/vite.config.ts | 2 + packages/shared/src/agent.ts | 2 + scripts/workerd-smoke.ts | 1 + 22 files changed, 1379 insertions(+), 135 deletions(-) create mode 100644 apps/core/src/db/workspace/migrations/0008_chat_code_runs.sql create mode 100644 apps/core/src/db/workspace/migrations/meta/0008_snapshot.json diff --git a/apps/core/package.json b/apps/core/package.json index 402406651..d33f7a5f8 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -12,7 +12,7 @@ }, "scripts": { "build": "node build-blueprints.ts && node ../../packages/compiler/build.ts ../web/dist && node build-extractor.ts ../web/dist", - "dev": "vp -C ../web build --mode development && vp run build && node ../connect/build.ts && wrangler d1 migrations apply DB --local --persist-to \"${DEV_PERSIST_TO:-.wrangler/state}\" && wrangler d1 migrations apply KNOWLEDGE --local --persist-to \"${DEV_PERSIST_TO:-.wrangler/state}\" && wrangler d1 migrations apply DB --local -c ../connect/wrangler.jsonc --persist-to \"${DEV_PERSIST_TO:-.wrangler/state}\" && wrangler dev -c wrangler.jsonc -c ../connect/wrangler.jsonc --persist-to \"${DEV_PERSIST_TO:-.wrangler/state}\" --var DEV_SKIP_ROUTER_SECRET:true --var DURABLE_OBJECT_JURISDICTION:none", + "dev": "vp -C ../web build --mode development && vp run build && node ../connect/build.ts && wrangler d1 migrations apply DB --local --persist-to \"${DEV_PERSIST_TO:-.wrangler/state}\" && wrangler d1 migrations apply KNOWLEDGE --local --persist-to \"${DEV_PERSIST_TO:-.wrangler/state}\" && wrangler d1 migrations apply DB --local -c ../connect/wrangler.jsonc --persist-to \"${DEV_PERSIST_TO:-.wrangler/state}\" && wrangler dev -c wrangler.jsonc -c ../connect/wrangler.jsonc --persist-to \"${DEV_PERSIST_TO:-.wrangler/state}\" --var DEV_SKIP_ROUTER_SECRET:true --var DURABLE_OBJECT_JURISDICTION:none --var RUNTIME_PROFILE:workerd", "deploy": "vp run build && wrangler deploy", "typegen": "wrangler types --include-runtime=false -c wrangler.jsonc -c ../connect/wrangler.jsonc", "db:migrate": "wrangler d1 migrations apply DB --remote && wrangler d1 migrations apply KNOWLEDGE --remote", diff --git a/apps/core/src/agent-builds.ts b/apps/core/src/agent-builds.ts index de27b9bad..96f92cb4b 100644 --- a/apps/core/src/agent-builds.ts +++ b/apps/core/src/agent-builds.ts @@ -25,7 +25,7 @@ import { WorkerEntrypoint, exports } from "cloudflare:workers"; import { z } from "zod"; import { asPerson } from "./agent-person.ts"; -import { chatAuthority, chatContext } from "./agent-scope.ts"; +import { admitEffect, chatAuthority, chatContext } from "./agent-scope.ts"; import type { AgentApi, AgentScope } from "./agent-scope.ts"; import { createFromBlueprint, listBlueprints } from "./app-blueprints.ts"; import { isPlainData } from "./app.ts"; @@ -536,10 +536,11 @@ export class BuildApi extends WorkerEntrypoint { * nothing. */ async #creating(create: () => Promise): Promise { - const { workspaceId, chatId } = this.ctx.props; + const { workspaceId, chatId, runId } = this.ctx.props; const taken = await workspace(this.env, workspaceId).takeCreate( chatId, - maxCreatesPerTurn + maxCreatesPerTurn, + runId ); if (!taken) { throw appErrors.create("app.creates_exhausted"); @@ -646,7 +647,8 @@ export class BuildApi extends WorkerEntrypoint { draft.base, Object.fromEntries(kept), unchanged, - draft.revision + draft.revision, + this.ctx.props.runId ); if (!saved) { throw appErrors.create("app.conflict"); @@ -673,8 +675,10 @@ export class BuildApi extends WorkerEntrypoint { "build.discard", async (by) => { const id = await this.#buildable(by, app); - const { workspaceId, chatId } = this.ctx.props; - await workspace(this.env, workspaceId).dropDraft(chatId, id); + const { workspaceId, chatId, runId } = this.ctx.props; + await workspace(this.env, workspaceId).dropDraft(chatId, id, { + runId, + }); }, () => ({ app: typeof app === "string" ? app : null }) ); @@ -750,6 +754,8 @@ export class BuildApi extends WorkerEntrypoint { reviewUnavailable: false, }; } + // The commit is admitted only while the run is still open. + await admitEffect(this.env, this.ctx.props); const proposed = await proposeDraft(this.env, by, id, over, message); // Committed and up for review: from here on it is proposed, however // the rest goes, and says so, so the agent doesn't try again (and @@ -764,7 +770,7 @@ export class BuildApi extends WorkerEntrypoint { } // Only the revision committed: a write since stays a draft. await workspace(this.env, workspaceId) - .dropDraft(chatId, id, draft.revision) + .dropDraft(chatId, id, { revision: draft.revision }) .catch(afterProposal("drop_draft", id, version)); const review = await reviewVersion(this.env, by, id, version).catch( afterProposal("review", id, version) @@ -795,6 +801,7 @@ export class BuildApi extends WorkerEntrypoint { async (by) => { const id = await this.#buildable(by, app); const subject = { type: "app", appId: id }; + await admitEffect(this.env, this.ctx.props); return await requestPermission( this.env, by, @@ -825,6 +832,7 @@ export class BuildApi extends WorkerEntrypoint { "build.resolveDependencies", async (by) => { const id = await this.#buildable(by, app); + await admitEffect(this.env, this.ctx.props); return await resolveDependencies( this.env, by, diff --git a/apps/core/src/agent-connections.ts b/apps/core/src/agent-connections.ts index d318b9769..41c82c8a3 100644 --- a/apps/core/src/agent-connections.ts +++ b/apps/core/src/agent-connections.ts @@ -10,6 +10,7 @@ import { WorkerEntrypoint, exports } from "cloudflare:workers"; import { z } from "zod"; import { + admitEffect, auditRefusal, auditedCall, chatAuthority, @@ -231,7 +232,10 @@ export class ConnectionsApi extends WorkerEntrypoint { method: "connections.request", detailOf: (asked: AskedConnection) => ({ permission: asked.id }), }, - async () => await askInChat(this.env, scope, ask) + async () => { + await admitEffect(this.env, scope); + return await askInChat(this.env, scope, ask); + } ); } catch (error) { throw forSandbox(error); @@ -280,6 +284,8 @@ export class ConnectionsApi extends WorkerEntrypoint { ); let result: ConnectResult; try { + // Admitted as it goes to connect, not only as the call began. + await admitEffect(this.env, scope); result = await this.env.CONNECT.call(request); } catch (error) { throw forSandbox(error); diff --git a/apps/core/src/agent-memory.ts b/apps/core/src/agent-memory.ts index f160e04a7..81019da1a 100644 --- a/apps/core/src/agent-memory.ts +++ b/apps/core/src/agent-memory.ts @@ -1,6 +1,11 @@ import { WorkerEntrypoint, exports } from "cloudflare:workers"; -import { chatAuthority, chatContext, requireOpenRun } from "./agent-scope.ts"; +import { + admitEffect, + chatAuthority, + chatContext, + requireOpenRun, +} from "./agent-scope.ts"; import type { AgentApi, AgentScope } from "./agent-scope.ts"; import { forSandbox } from "./bindings.ts"; import { saveUserMemory } from "./knowledge/memory.ts"; @@ -20,6 +25,7 @@ export class MemoryApi extends WorkerEntrypoint { const scope = this.ctx.props; await requireOpenRun(this.env, scope, "memory.saveUser"); try { + await admitEffect(this.env, scope); const saved = await saveUserMemory( this.env, chatAuthority(scope), diff --git a/apps/core/src/agent-scope.ts b/apps/core/src/agent-scope.ts index 7e5143340..564394b66 100644 --- a/apps/core/src/agent-scope.ts +++ b/apps/core/src/agent-scope.ts @@ -8,10 +8,12 @@ import { permissionErrors, } from "@grasp-os/shared/permissions"; import type { Authority } from "@grasp-os/shared/permissions"; +import { and, eq, gt } from "drizzle-orm"; import { drizzle } from "drizzle-orm/d1"; import { z } from "zod"; import { keepAuditEvent } from "./audit-outbox.ts"; +import { permissions } from "./db/core/schema.ts"; import { workspace } from "./durable-objects.ts"; import type { WorkContext } from "./restricted.ts"; @@ -99,13 +101,21 @@ export const chatContext = ({ /** * What the Workspace object says of one API call of a code run: it may go - * on, the run has made all the calls it may, or the run has ended; and - * whether it is the run's first call refused so, which alone is audited. + * on (with when the run opened), the run has made all the calls it may, a + * revocation closed it, or it has ended; and whether it is the run's first + * call refused so, which alone is audited. */ -export interface CodeRunCall { - call: "open" | "spent" | "ended"; - first: boolean; -} +export type CodeRunCall = + | { call: "open"; first: boolean; openedAt: number } + | { call: "spent" | "ended" | "revoked"; first: boolean }; + +/** + * Whether a code run may have a write admitted now (`admitFromCodeRun`): + * open, with when it opened, or closed, and why. + */ +export type CodeRunAdmission = + | { call: "open"; openedAt: number } + | { call: "ended" | "revoked" }; /** * Records with the chat what a call read from (collections, a @@ -171,11 +181,70 @@ export const auditAgentCall = async ( }); }; +/** + * Whether one of the agent's permissions was revoked after `since`: any + * of them, wherever it held (a chat's own too). A run open since before + * acts on authority it no longer has, so its lease closes; one revoke + * ends every run of the agent then under way, at most a run's wall-clock + * time of work. One read of the subject's index. + */ +const revokedSince = async ( + env: Env, + agentId: string, + since: number +): Promise => { + const [revoked] = await drizzle(env.DB) + .select({ id: permissions.id }) + .from(permissions) + .where( + and( + eq(permissions.subjectType, "agent"), + eq(permissions.subjectId, agentId), + eq(permissions.status, "revoked"), + gt(permissions.revokedAt, new Date(since)) + ) + ) + .limit(1); + return revoked !== undefined; +}; + +/** Why a call or write from a run that isn't open is refused. */ +const refusalCodes = { + spent: "agent.run_calls_spent", + ended: "agent.run_ended", + revoked: "agent.run_revoked", +} as const; + +/** The refusal of a call or write from a run that isn't open. */ +const runRefusal = (call: keyof typeof refusalCodes) => + agentErrors.create(refusalCodes[call]); + +/** + * Closes the run's lease as revoked when one of its agent's permissions + * was revoked since it opened: `true` when it was (and the run must be + * refused), whoever closed it first. + */ +const closedByRevocation = async ( + env: Env, + scope: AgentScope, + openedAt: number +): Promise => { + if (!(await revokedSince(env, scope.agentId, openedAt))) { + return false; + } + await workspace(env, scope.workspaceId).revokeCodeRun( + scope.chatId, + scope.runId + ); + return true; +}; + /** * Counts the call with the Workspace object, and refuses it from a code * run that has ended (the turn moved on, or the run was cancelled or timed - * out while its code kept running) or made all the calls it may. Every - * API checks it first, on every call. + * out while its code kept running), made all the calls it may, or was + * closed by a revocation of one of its agent's permissions, found here + * too. Every API checks it first, on every call. */ export const requireOpenRun = async ( env: Env, @@ -183,18 +252,20 @@ export const requireOpenRun = async ( method: string ): Promise => { const { workspaceId, chatId, runId } = scope; - const { call, first } = await workspace(env, workspaceId).callFromCodeRun( + const counted = await workspace(env, workspaceId).callFromCodeRun( chatId, runId ); - if (call === "open") { + const revoked = + counted.call === "open" && + (await closedByRevocation(env, scope, counted.openedAt)); + if (counted.call === "open" && !revoked) { return; } - const refusal = agentErrors.create( - call === "spent" ? "agent.run_calls_spent" : "agent.run_ended" - ); + const call = counted.call === "open" ? "revoked" : counted.call; + const refusal = runRefusal(call); // Once per run: code that goes on calling can't flood the audit log. - if (first) { + if (counted.first || revoked) { await auditAgentCall( env, scope, @@ -208,6 +279,29 @@ export const requireOpenRun = async ( throw refusal; }; +/** + * Admits a write or side effect of a code run, as it commits, after its + * call was let in (`requireOpenRun`): refused once the run has closed, + * whether it ended, was cancelled, ran out of time or lost a permission + * meanwhile. Counts no call. A write into the Workspace object itself is + * checked there instead, in the write's own transaction. + */ +export const admitEffect = async ( + env: Env, + scope: AgentScope +): Promise => { + const admission = await workspace(env, scope.workspaceId).admitFromCodeRun( + scope.chatId, + scope.runId + ); + if (admission.call !== "open") { + throw runRefusal(admission.call); + } + if (await closedByRevocation(env, scope, admission.openedAt)) { + throw runRefusal("revoked"); + } +}; + /** How a call that threw ended, and why, as the audit log names it. */ const endedBy = ( error: unknown diff --git a/apps/core/src/agent.ts b/apps/core/src/agent.ts index 2a53d96b2..ae6cf2151 100644 --- a/apps/core/src/agent.ts +++ b/apps/core/src/agent.ts @@ -14,9 +14,10 @@ import type { Memory } from "@grasp-os/shared/memory"; import { z } from "zod"; import type { AgentApi, AgentScope } from "./agent-scope.ts"; -import { describeRun, runCode } from "./code-mode.ts"; +import { describeRun, runCode, sourceHash } from "./code-mode.ts"; import type { CodeRun, CodeRunStatus } from "./code-mode.ts"; import type { AgentModel } from "./models.ts"; +import type { CodeRunOpening } from "./workspace.ts"; // The agent loop: Pi (pi-agent-core) in Code Mode. The model answers, or // calls one tool, `executeCode`, with code against the chat's typed APIs; @@ -209,16 +210,24 @@ const codeInput = (args: unknown): { code: string } => { }; /** - * The code runs of a chat that are running now. A stub answers only while - * the run it was made for is open, so code that is still running after its - * run was cancelled or timed out can't act any more. + * The code runs of a chat: each opened with its record, before its code + * loads, and closed as it ends. A stub answers, and a write is admitted, + * only while the run it was made for is open, so code still running after + * its run was cancelled, timed out or lost a permission can't act any + * more. `close` says how the run ended: as it says, or `revoked` when a + * revocation closed it first. */ export interface CodeRuns { - open: () => string; - close: (runId: string) => void; + open: (opening: CodeRunOpening) => string; + close: (runId: string, status: CodeRunStatus) => CodeRunStatus; } -/** Runs code with stubs that answer only while the run is open. */ +/** + * Runs code with stubs that answer only while the run is open: opened with + * its record before the code loads, closed however it ends. A run a + * revocation closed ends as `revoked`: what its code returned isn't + * handed on, as it may rest on what was revoked. + */ const runOpen = async ( { apis, @@ -227,18 +236,35 @@ const runOpen = async ( loader, codeTimeoutMs, }: Pick, + toolCallId: string, code: string, signal: AbortSignal | undefined ): Promise => { - const runId = runs.open(); + const runId = runs.open({ + toolCallId, + sourceHash: await sourceHash(code), + sourceChars: code.length, + }); const env = Object.fromEntries( apis.map((api) => [api.name, api.stub({ ...scope, runId })]) ); + // `runCode` never throws; should anything else, the run still closes. + let ran: CodeRunStatus = "crashed"; + let ended: CodeRunStatus; + let run: CodeRun; try { - return await runCode(loader, code, env, signal, codeTimeoutMs); + run = await runCode(loader, code, env, signal, codeTimeoutMs); + ran = run.status; } finally { - runs.close(runId); + ended = runs.close(runId, ran); } + return ended === run.status + ? run + : { + status: ended, + logs: run.logs, + error: agentErrors.create("agent.run_revoked").message, + }; }; /** The one tool: runs code against the chat's APIs in a locked isolate. */ @@ -259,9 +285,10 @@ const executeCode = ({ parameters: codeParameters, prepareArguments: codeInput, executionMode: "sequential", - execute: async (_toolCallId, { code }, signal) => { + execute: async (toolCallId, { code }, signal) => { const run = await runOpen( { apis, scope, runs, loader, codeTimeoutMs }, + toolCallId, code, signal ); diff --git a/apps/core/src/code-mode.ts b/apps/core/src/code-mode.ts index f56e79da6..fb444a520 100644 --- a/apps/core/src/code-mode.ts +++ b/apps/core/src/code-mode.ts @@ -1,4 +1,6 @@ +import piAgentCore from "@earendil-works/pi-agent-core/package.json" with { type: "json" }; import { deadline, whenAborted } from "@grasp-os/shared/deadline"; +import { compatibilityDate } from "@grasp-os/shared/runtime"; import type { WorkerEntrypoint } from "cloudflare:workers"; import { z } from "zod"; @@ -56,19 +58,58 @@ export const codeLimits = { * short of its CPU time), the runtime stopped it for CPU time * (`cpu_exceeded`) or anything else once it ran (`crashed`: memory, say), * it ran past its wall-clock time (`wall_expired`, a wait that can never - * settle too), or the turn was cancelled (`cancelled`). Only the host - * sets one: the harness returns every outcome of the code, so the code - * can't make a call reject, nor pick the message a status is read from. + * settle too), the turn was cancelled (`cancelled`), a permission of the + * agent was revoked while it ran (`revoked`: its lease closed then, + * whatever its code went on to do), or a restart ended it first + * (`interrupted`, which only its record says). Only the host sets one: + * the harness returns every outcome of the code, so the code can't make + * a call reject, nor pick the message a status is read from. */ -export type CodeRunStatus = - | "completed" - | "threw" - | "refused" - | "load_failed" - | "cpu_exceeded" - | "crashed" - | "wall_expired" - | "cancelled"; +export const codeRunStatuses = [ + "completed", + "threw", + "refused", + "load_failed", + "cpu_exceeded", + "crashed", + "wall_expired", + "cancelled", + "revoked", + "interrupted", +] as const; + +export type CodeRunStatus = (typeof codeRunStatuses)[number]; + +/** + * What a run ran on, kept with its record (`chat_code_runs`): the agent + * loop's exact version (pi-ai is pinned to the same one in the catalog), + * the runtime's compatibility date, and the runtime. Plain workerd (tests, + * local development, on-prem, which set `RUNTIME_PROFILE=workerd`) doesn't + * enforce the isolate's CPU limit, and says so: a busy loop there runs + * until its wall-clock limit lets go of it, still spinning. + */ +export const codeRuntime = (env: Pick) => { + const workerd = env.RUNTIME_PROFILE === "workerd"; + return { + agent: `${piAgentCore.name}@${piAgentCore.version}`, + compatibilityDate, + runtime: workerd ? ("workerd" as const) : ("cloudflare" as const), + cpuLimit: workerd + ? ("cpu_limit_unsupported" as const) + : ("enforced" as const), + }; +}; + +/** A module's SHA-256, in hex: what its run's record keeps of it. */ +export const sourceHash = async (code: string): Promise => { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(code) + ); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +}; /** * Most characters of each part of a run that core takes from the isolate: diff --git a/apps/core/src/db/workspace/migrations/0008_chat_code_runs.sql b/apps/core/src/db/workspace/migrations/0008_chat_code_runs.sql new file mode 100644 index 000000000..e27cf927c --- /dev/null +++ b/apps/core/src/db/workspace/migrations/0008_chat_code_runs.sql @@ -0,0 +1,17 @@ +CREATE TABLE `chat_code_runs` ( + `id` text PRIMARY KEY NOT NULL, + `chat_id` text NOT NULL, + `tool_call_id` text NOT NULL, + `source_hash` text NOT NULL, + `source_chars` integer NOT NULL, + `agent` text NOT NULL, + `compatibility_date` text NOT NULL, + `runtime` text NOT NULL, + `cpu_limit` text NOT NULL, + `status` text NOT NULL, + `started_at` integer NOT NULL, + `ended_at` integer, + FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `chat_code_runs_chat` ON `chat_code_runs` (`chat_id`,`started_at`); \ No newline at end of file diff --git a/apps/core/src/db/workspace/migrations/meta/0008_snapshot.json b/apps/core/src/db/workspace/migrations/meta/0008_snapshot.json new file mode 100644 index 000000000..a161e0b23 --- /dev/null +++ b/apps/core/src/db/workspace/migrations/meta/0008_snapshot.json @@ -0,0 +1,532 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5bab538b-08ee-4cf1-806a-8ba8b15f0790", + "prevId": "842142da-ee11-4b56-bd29-cd2164d918bc", + "tables": { + "audit_outbox": { + "name": "audit_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_attachments": { + "name": "chat_attachments", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_attachments_chat_id_chats_id_fk": { + "name": "chat_attachments_chat_id_chats_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chat_attachments_chat_id_run_id_pk": { + "columns": [ + "chat_id", + "run_id" + ], + "name": "chat_attachments_chat_id_run_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_code_runs": { + "name": "chat_code_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_chars": { + "name": "source_chars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compatibility_date": { + "name": "compatibility_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runtime": { + "name": "runtime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cpu_limit": { + "name": "cpu_limit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "chat_code_runs_chat": { + "name": "chat_code_runs_chat", + "columns": [ + "chat_id", + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_code_runs_chat_id_chats_id_fk": { + "name": "chat_code_runs_chat_id_chats_id_fk", + "tableFrom": "chat_code_runs", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_draft_files": { + "name": "chat_draft_files", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_draft_files_chat_id_app_id_chat_drafts_chat_id_app_id_fk": { + "name": "chat_draft_files_chat_id_app_id_chat_drafts_chat_id_app_id_fk", + "tableFrom": "chat_draft_files", + "tableTo": "chat_drafts", + "columnsFrom": [ + "chat_id", + "app_id" + ], + "columnsTo": [ + "chat_id", + "app_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chat_draft_files_chat_id_app_id_path_pk": { + "columns": [ + "chat_id", + "app_id", + "path" + ], + "name": "chat_draft_files_chat_id_app_id_path_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_drafts": { + "name": "chat_drafts", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base": { + "name": "base", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_drafts_chat_id_chats_id_fk": { + "name": "chat_drafts_chat_id_chats_id_fk", + "tableFrom": "chat_drafts", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chat_drafts_chat_id_app_id_pk": { + "columns": [ + "chat_id", + "app_id" + ], + "name": "chat_drafts_chat_id_app_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_messages_chat": { + "name": "chat_messages_chat", + "columns": [ + "chat_id", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_chat_id_chats_id_fk": { + "name": "chat_messages_chat_id_chats_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_sources": { + "name": "chat_sources", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_sources_chat_id_chats_id_fk": { + "name": "chat_sources_chat_id_chats_id_fk", + "tableFrom": "chat_sources", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chat_sources_chat_id_source_id_pk": { + "columns": [ + "chat_id", + "source_id" + ], + "name": "chat_sources_chat_id_source_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restricted": { + "name": "restricted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chats_person": { + "name": "chats_person", + "columns": [ + "person_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/core/src/db/workspace/migrations/meta/_journal.json b/apps/core/src/db/workspace/migrations/meta/_journal.json index bff6fb7c0..025a736ba 100644 --- a/apps/core/src/db/workspace/migrations/meta/_journal.json +++ b/apps/core/src/db/workspace/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1790793391689, "tag": "0007_chat_owner_required", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1791682971608, + "tag": "0008_chat_code_runs", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/core/src/db/workspace/migrations/migrations.js b/apps/core/src/db/workspace/migrations/migrations.js index 34eaea676..0e045516a 100644 --- a/apps/core/src/db/workspace/migrations/migrations.js +++ b/apps/core/src/db/workspace/migrations/migrations.js @@ -7,6 +7,7 @@ import m0004 from './0004_personal_chats.sql'; import m0005 from './0005_chat_drafts.sql'; import m0006 from './0006_chat_attachments.sql'; import m0007 from './0007_chat_owner_required.sql'; +import m0008 from './0008_chat_code_runs.sql'; export default { journal, @@ -18,7 +19,8 @@ m0003, m0004, m0005, m0006, -m0007 +m0007, +m0008 } } \ No newline at end of file diff --git a/apps/core/src/db/workspace/schema.ts b/apps/core/src/db/workspace/schema.ts index 00581fc2a..57bca1a11 100644 --- a/apps/core/src/db/workspace/schema.ts +++ b/apps/core/src/db/workspace/schema.ts @@ -155,3 +155,38 @@ export const chatAttachments = sqliteTable( }, (table) => [primaryKey({ columns: [table.chatId, table.runId] })] ); + +/** + * Every code run of a chat's agent, as evidence of what ran and how it + * ended: its run ID (the lease its stubs answer to, random and never + * reused), the tool call it ran for, the SHA-256 and length of its module + * (never the module itself: the transcript has the call), the agent and + * runtime it ran on, whether that runtime enforces the CPU limit, and its + * status: `running` until it ends, then how it ended (`CodeRunStatus` in + * code-mode.ts), or `interrupted` when a restart ended it first. Nothing + * here is ever run again: a restart ends a run's lease, and the agent's + * next step is a new run with a new ID. Deleted with the chat. + */ +export const chatCodeRuns = sqliteTable( + "chat_code_runs", + { + id: text().primaryKey(), + chatId: text("chat_id") + .$type() + .notNull() + .references(() => chats.id), + toolCallId: text("tool_call_id").notNull(), + sourceHash: text("source_hash").notNull(), + sourceChars: integer("source_chars").notNull(), + agent: text().notNull(), + compatibilityDate: text("compatibility_date").notNull(), + runtime: text({ enum: ["cloudflare", "workerd"] }).notNull(), + cpuLimit: text("cpu_limit", { + enum: ["enforced", "cpu_limit_unsupported"], + }).notNull(), + status: text().notNull(), + startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(), + endedAt: integer("ended_at", { mode: "timestamp_ms" }), + }, + (table) => [index("chat_code_runs_chat").on(table.chatId, table.startedAt)] +); diff --git a/apps/core/src/env.d.ts b/apps/core/src/env.d.ts index 491d5c55c..6fc19162a 100644 --- a/apps/core/src/env.d.ts +++ b/apps/core/src/env.d.ts @@ -30,6 +30,11 @@ interface __BaseEnv_Env { AUDIT_ARCHIVE_RETENTION_DAYS?: unknown; /** Days an ended workflow run keeps its details (src/workflows/retention.ts). */ RUN_RETENTION_DAYS?: unknown; + /** + * `workerd` where core runs on plain workerd (tests, local development, + * on-prem), whose isolates don't enforce a CPU limit (src/code-mode.ts). + */ + RUNTIME_PROFILE?: string; /** `none` where Durable Objects have no jurisdiction (src/durable-objects.ts). */ DURABLE_OBJECT_JURISDICTION?: string; /** diff --git a/apps/core/src/workspace.ts b/apps/core/src/workspace.ts index bf6a8cea4..194b68ea1 100644 --- a/apps/core/src/workspace.ts +++ b/apps/core/src/workspace.ts @@ -52,7 +52,7 @@ import { chatContext, workspaceAgentIdSchema, } from "./agent-scope.ts"; -import type { CodeRunCall } from "./agent-scope.ts"; +import type { CodeRunAdmission, CodeRunCall } from "./agent-scope.ts"; import { isMessage, runTurn } from "./agent.ts"; import type { TurnContext, TurnResult } from "./agent.ts"; import type { AppAnswer } from "./app.ts"; @@ -62,12 +62,14 @@ import { revokeChatPermissions } from "./chat-connections.ts"; import { chatMessageOf, partialOf } from "./chat-messages.ts"; import { ChatWatch } from "./chat-watch.ts"; import type { ChatListener, ChatState } from "./chat-watch.ts"; -import { codeLimits, codeTimeoutMs } from "./code-mode.ts"; +import { codeLimits, codeRuntime, codeTimeoutMs } from "./code-mode.ts"; +import type { CodeRunStatus } from "./code-mode.ts"; import { migrateOnWake } from "./db/migrate.ts"; import migrations from "./db/workspace/migrations/migrations.js"; import { auditOutbox, chatAttachments, + chatCodeRuns, chatDraftFiles, chatDrafts, chatMessages, @@ -173,6 +175,23 @@ export const maxChatChars = 4_000_000; */ const endedRunsKept = 1000; +/** + * A code run as the object keeps its lease: open, with the API calls it + * has made and when it opened (a revocation after that ends it), or + * closed, by its end or a revocation, and whether a call after that was + * logged yet. + */ +type CodeRunLease = + | { calls: number; openedAt: number } + | { closed: "ended" | "revoked"; reported: boolean }; + +/** What a code run's record keeps of its module, as it opens. */ +export interface CodeRunOpening { + toolCallId: string; + sourceHash: string; + sourceChars: number; +} + /** * The sources one API call of a code run read from, as it records them: * a few collections or a connection, each an identifier. @@ -296,13 +315,13 @@ export class Workspace extends DurableObject { readonly #turns = new Map(); /** - * Code runs, as `/`: the API calls an open run has made, or - * that it ended (`reported` once a call after its end was logged). Their - * stubs answer only while a run is open and has calls left. In memory, so - * a restart ends every run. Ended runs are kept, oldest first, only up to + * Code runs' leases, as `/` (`CodeRunLease`): their stubs + * answer, and their writes are admitted, only while a run is open and + * has calls left. In memory, so a restart ends every run (its record + * says `interrupted`). Closed runs are kept, oldest first, only up to * {@link endedRunsKept}: a run forgotten answers as ended, unlogged. */ - readonly #codeRuns = new Map(); + readonly #codeRuns = new Map(); /** * The response each chat's agent is writing now, as it streams in: what @@ -371,6 +390,20 @@ export class Workspace extends DurableObject { super(ctx, env); migrateOnWake(ctx, migrations); this.#closeInterruptedTurns(); + this.#closeInterruptedRuns(); + } + + /** + * Marks every code run still `running` as `interrupted`: no run is open + * when the object wakes, so each was ended by the restart, its lease + * with it. Nothing is run again. + */ + #closeInterruptedRuns(): void { + this.#db + .update(chatCodeRuns) + .set({ status: "interrupted", endedAt: new Date() }) + .where(eq(chatCodeRuns.status, "running")) + .run(); } /** @@ -765,14 +798,8 @@ export class Workspace extends DurableObject { ? permissionErrors.create("permission.person_inactive") : undefined, runs: { - open: () => { - const runId = crypto.randomUUID(); - this.#codeRuns.set(`${chat.id}/${runId}`, 0); - return runId; - }, - close: (runId) => { - this.#endCodeRun(`${chat.id}/${runId}`); - }, + open: (opening) => this.openCodeRun(chat.id, opening), + close: (runId, status) => this.closeCodeRun(chat.id, runId, status), }, loader: this.env.LOADER, codeTimeoutMs: codeTimeoutMs(this.env), @@ -920,20 +947,125 @@ export class Workspace extends DurableObject { return { memory, skills }; } - /** Marks a code run ended, and forgets the oldest ended runs past the cap. */ - #endCodeRun(key: string): void { - // Moved to the end, so the map keeps ended runs oldest first. + /** + * Opens a code run of the chat: its lease, and its record, as `running` + * on this runtime. Its ID is random, and the only way its stubs reach + * it. Called by the chat's turn as it starts a run (`runTurn`). + */ + openCodeRun(chatId: ChatId, opening: CodeRunOpening): string { + const id = crypto.randomUUID(); + this.#db + .insert(chatCodeRuns) + .values({ + id, + chatId, + ...opening, + ...codeRuntime(this.env), + status: "running", + startedAt: new Date(), + }) + .run(); + this.#codeRuns.set(`${chatId}/${id}`, { calls: 0, openedAt: Date.now() }); + return id; + } + + /** + * Closes a code run of the chat as it ended (`status`), unless a + * revocation closed it first: that is how it ended, whatever its code + * did after. Records it, and returns how it ended. + */ + closeCodeRun( + chatId: ChatId, + runId: string, + status: CodeRunStatus + ): CodeRunStatus { + const key = `${chatId}/${runId}`; + const lease = this.#codeRuns.get(key); + const ended: CodeRunStatus = + lease !== undefined && "closed" in lease && lease.closed === "revoked" + ? "revoked" + : status; + if (lease === undefined || !("closed" in lease)) { + this.#closeLease(key, "ended"); + } + this.#db + .update(chatCodeRuns) + .set({ status: ended, endedAt: new Date() }) + .where( + and( + eq(chatCodeRuns.id, runId), + eq(chatCodeRuns.chatId, chatId), + eq(chatCodeRuns.status, "running") + ) + ) + .run(); + return ended; + } + + /** + * Closes a code run's lease, and forgets the oldest closed runs past the + * cap. Moved to the end, so the map keeps closed runs oldest first. + */ + #closeLease(key: string, closed: "ended" | "revoked"): void { this.#codeRuns.delete(key); - this.#codeRuns.set(key, "ended"); - const ended = [...this.#codeRuns.keys()].filter( - (run) => typeof this.#codeRuns.get(run) !== "number" - ); + this.#codeRuns.set(key, { closed, reported: false }); + const ended = [...this.#codeRuns.keys()].filter((run) => { + const lease = this.#codeRuns.get(run); + return lease !== undefined && "closed" in lease; + }); const [oldest] = ended; if (ended.length > endedRunsKept && oldest !== undefined) { this.#codeRuns.delete(oldest); } } + /** + * Closes an open code run of the chat because a permission of its agent + * was revoked after it opened (agent-scope.ts): none of its calls or + * writes is admitted from then on. `false` when it was closed already. + */ + revokeCodeRun(chatId: ChatId, runId: string): boolean { + const key = `${chatId}/${runId}`; + const lease = this.#codeRuns.get(key); + if (lease === undefined || "closed" in lease) { + return false; + } + this.#closeLease(key, "revoked"); + // The call that found the revocation records it: later calls aren't. + this.#codeRuns.set(key, { closed: "revoked", reported: true }); + return true; + } + + /** + * Whether a code run of the chat may have a write admitted now, without + * counting a call: `open` with when it opened, or why not. The check a + * write makes as it commits, after its call was let in. + */ + admitFromCodeRun(chatId: ChatId, runId: string): CodeRunAdmission { + const lease = this.#codeRuns.get(`${chatId}/${runId}`); + if (lease === undefined) { + return { call: "ended" }; + } + if ("closed" in lease) { + return { call: lease.closed }; + } + return { call: "open", openedAt: lease.openedAt }; + } + + /** + * Refuses a write of a code run whose lease is closed: checked in the + * same transaction as the write, so a run that ended or was revoked + * writes nothing after. + */ + #requireOpenRun(chatId: ChatId, runId: string): void { + const { call } = this.admitFromCodeRun(chatId, runId); + if (call !== "open") { + throw agentErrors.create( + call === "revoked" ? "agent.run_revoked" : "agent.run_ended" + ); + } + } + /** * Stops `personId`'s own chat's running turn, if there is one: the model * request or code run in flight ends, and the turn answers `cancelled`. @@ -947,30 +1079,34 @@ export class Workspace extends DurableObject { /** * Counts one API call of a code run of the chat, and says whether it may * go on (see agent-apis.ts): `open` while the run is open and has made - * fewer than {@link codeLimits}' `subRequests` calls, `spent` past that, - * and `ended` once the run has ended, or for a run this object doesn't - * know (one from before a restart). A call after a run's end is logged - * once per run. + * fewer than {@link codeLimits}' `subRequests` calls (with when it + * opened, which a revocation is checked against), `spent` past that, + * `revoked` once a revocation closed it, and `ended` once the run has + * ended, or for a run this object doesn't know (one from before a + * restart). A call after a run closed is logged once per run. */ callFromCodeRun(chatId: ChatId, runId: string): CodeRunCall { const key = `${chatId}/${runId}`; - const state = this.#codeRuns.get(key); - if (typeof state === "number") { - if (state >= codeLimits.subRequests) { - // Counted once past the most, so only the first refusal says so. - this.#codeRuns.set(key, codeLimits.subRequests + 1); - return { call: "spent", first: state === codeLimits.subRequests }; + const lease = this.#codeRuns.get(key); + if (lease === undefined) { + return { call: "ended", first: false }; + } + if ("closed" in lease) { + if (lease.reported) { + return { call: lease.closed, first: false }; } - this.#codeRuns.set(key, state + 1); - return { call: "open", first: false }; + // Code still acting after its run closed: worth seeing in the logs. + log.warn("agent.run_ended", { chatId, runId, closed: lease.closed }); + this.#codeRuns.set(key, { ...lease, reported: true }); + return { call: lease.closed, first: true }; } - if (state === "ended") { - // Code still acting after its run ended: worth seeing in the logs. - log.warn("agent.run_ended", { chatId, runId }); - this.#codeRuns.set(key, "reported"); - return { call: "ended", first: true }; + if (lease.calls >= codeLimits.subRequests) { + // Counted once past the most, so only the first refusal says so. + this.#codeRuns.set(key, { ...lease, calls: codeLimits.subRequests + 1 }); + return { call: "spent", first: lease.calls === codeLimits.subRequests }; } - return { call: "ended", first: false }; + this.#codeRuns.set(key, { ...lease, calls: lease.calls + 1 }); + return { call: "open", first: false, openedAt: lease.openedAt }; } /** @@ -985,7 +1121,7 @@ export class Workspace extends DurableObject { runId: string, ids: readonly string[] ): boolean { - if (typeof this.#codeRuns.get(`${chatId}/${runId}`) !== "number") { + if (this.admitFromCodeRun(chatId, runId).call !== "open") { return false; } this.#keepSources(chatId, sourcesSchema.parse(ids)); @@ -1112,6 +1248,7 @@ export class Workspace extends DurableObject { this.ctx.storage.transactionSync(() => { this.#db.delete(chatMessages).where(eq(chatMessages.chatId, id)).run(); this.#db.delete(chatSources).where(eq(chatSources.chatId, id)).run(); + this.#db.delete(chatCodeRuns).where(eq(chatCodeRuns.chatId, id)).run(); this.#db .delete(chatAttachments) .where(eq(chatAttachments.chatId, id)) @@ -1375,7 +1512,8 @@ export class Workspace extends DurableObject { * `base`, and drops its changes to the paths in `unchanged` (back as the * base has them), only over the revision `revision` the write read (0: * there was none): `false`, and nothing written, when another write - * landed since, or the chat is gone. + * landed since, or the chat is gone. Refused, in the same transaction, + * once the code run `runId` that writes it has closed. */ saveDraft( chatId: ChatId, @@ -1383,9 +1521,11 @@ export class Workspace extends DurableObject { base: number | null, changes: Record, unchanged: readonly string[], - revision: number + revision: number, + runId: string ): boolean { const saved = this.ctx.storage.transactionSync(() => { + this.#requireOpenRun(chatId, runId); const [row] = this.#db .select({ revision: chatDrafts.revision }) .from(chatDrafts) @@ -1450,14 +1590,24 @@ export class Workspace extends DurableObject { } /** - * Drops the chat's draft of App `appId`; with `revision`, only while it - * is still at that revision (what a proposal committed): `false` when it - * changed since, or the chat is gone. + * Drops the chat's draft of App `appId`: for an open code run (`runId`, + * refused in the same transaction once it has closed), or, once a + * proposal committed it, only while it is still at the `revision` it + * committed: a proposal the run's lease admitted is followed through + * whatever came after. `false` when it changed since, or the chat is + * gone. */ - dropDraft(chatId: ChatId, appId: string, revision?: number): boolean { + dropDraft( + chatId: ChatId, + appId: string, + by: { runId: string } | { revision: number } + ): boolean { const dropped = this.ctx.storage.transactionSync(() => { + if ("runId" in by) { + this.#requireOpenRun(chatId, by.runId); + } const now = this.draft(chatId, appId); - if (revision !== undefined && now.revision !== revision) { + if ("revision" in by && now.revision !== by.revision) { return false; } if (this.chatState(chatId) === undefined) { @@ -1725,10 +1875,12 @@ export class Workspace extends DurableObject { } /** - * Takes one of the Apps the chat's agent may create this turn: `false` - * once it created `limit`. + * Takes one of the Apps the chat's agent may create this turn, for the + * code run `runId`, refused once that run has closed: `false` once it + * created `limit`. */ - takeCreate(chatId: ChatId, limit: number): boolean { + takeCreate(chatId: ChatId, limit: number, runId: string): boolean { + this.#requireOpenRun(chatId, runId); const turn = this.#turnBuilds(chatId); if (turn.created >= limit) { return false; diff --git a/apps/core/test/agent-builds.test.ts b/apps/core/test/agent-builds.test.ts index cd18c193b..ff00d185e 100644 --- a/apps/core/test/agent-builds.test.ts +++ b/apps/core/test/agent-builds.test.ts @@ -9,7 +9,14 @@ import { env } from "cloudflare:workers"; import { describe, expect, it, vi } from "vite-plus/test"; import { z } from "zod"; -import { chatOf, codeResults, codeStep, says } from "./agent-chat.ts"; +import { + chatOf, + codeResults, + codeStep, + says, + setObjectEnv, + testRunOpening, +} from "./agent-chat.ts"; import type { GatewayReply } from "./ai-gateway.ts"; import { outlook, release, requestGranted } from "./apps.ts"; import { allEvents } from "./audit-events.ts"; @@ -660,17 +667,59 @@ describe("building Apps from a chat", { timeout: 120_000 }, () => { }); }); + it("writes nothing from code still running after its run's wall-clock time", async () => { + const { chat, person, grant } = await setUp([ + codeStep(`export default async (self, env) => { + const app = await env.build.create({ name: ${JSON.stringify(appName)} }); + await scheduler.wait(2_000); + await env.build.write(app.id, { "screens/late.tsx": "export default () => null;" }); + };`), + says("It ran out of time."), + ]); + await grant(); + // Objects share their env: set back however the test ends. + await setObjectEnv(chat.stub, { CODE_RUN_TIMEOUT_MS: "1000" }); + try { + await chat.ask("Build it slowly."); + } finally { + await setObjectEnv(chat.stub, { CODE_RUN_TIMEOUT_MS: "" }); + } + const app = await createdApp(person.api); + + // The code goes on past its run's end; its write is refused, once. + await vi.waitFor( + async () => { + const events = await allEvents(); + expect( + events.filter( + ({ action, detail }) => + action === "agent.call" && + detail.chat === chat.chat.id && + detail.reason === "agent.run_ended" + ) + ).toHaveLength(1); + }, + { timeout: 15_000, interval: 100 } + ); + await expect( + chat.stub.draft(chatIdSchema.parse(chat.chat.id), app.id) + ).resolves.toStrictEqual({ base: null, changes: {}, revision: 0 }); + }); + it("lands a draft write only over the revision it read", async () => { const { chat } = await setUp([]); const chatId = chatIdSchema.parse(chat.chat.id); - const saved = await runInDurableObject(chat.stub, (instance) => [ - instance.saveDraft(chatId, "app-1", null, { "a.md": "a" }, [], 0), - // Another write read revision 0 too, and lost. - instance.saveDraft(chatId, "app-1", null, { "a.md": "b" }, [], 0), - instance.saveDraft(chatId, "app-1", null, { "b.md": "b" }, [], 1), - instance.draft(chatId, "app-1"), - ]); + const saved = await runInDurableObject(chat.stub, (instance) => { + const run = instance.openCodeRun(chatId, testRunOpening); + return [ + instance.saveDraft(chatId, "app-1", null, { "a.md": "a" }, [], 0, run), + // Another write read revision 0 too, and lost. + instance.saveDraft(chatId, "app-1", null, { "a.md": "b" }, [], 0, run), + instance.saveDraft(chatId, "app-1", null, { "b.md": "b" }, [], 1, run), + instance.draft(chatId, "app-1"), + ]; + }); expect(saved).toStrictEqual([ true, @@ -688,21 +737,24 @@ describe("building Apps from a chat", { timeout: 120_000 }, () => { const { chat } = await setUp([]); const chatId = chatIdSchema.parse(chat.chat.id); - const saved = await runInDurableObject(chat.stub, (instance) => [ - // A write reads revision 0; meanwhile another creates the draft and - // writes its only change back as the base has it. - instance.saveDraft(chatId, "app-1", null, { "a.md": "a" }, [], 0), - instance.saveDraft(chatId, "app-1", null, {}, ["a.md"], 1), - instance.draft(chatId, "app-1"), - // The write that read revision 0 lands on nothing. - instance.saveDraft(chatId, "app-1", null, { "b.md": "b" }, [], 0), - // Nor once the draft is dropped, as a proposal drops it. - instance.saveDraft(chatId, "app-1", null, { "c.md": "c" }, [], 2), - instance.dropDraft(chatId, "app-1", 3), - instance.saveDraft(chatId, "app-1", null, { "d.md": "d" }, [], 0), - instance.saveDraft(chatId, "app-1", null, { "d.md": "d" }, [], 3), - instance.draft(chatId, "app-1"), - ]); + const saved = await runInDurableObject(chat.stub, (instance) => { + const run = instance.openCodeRun(chatId, testRunOpening); + return [ + // A write reads revision 0; meanwhile another creates the draft and + // writes its only change back as the base has it. + instance.saveDraft(chatId, "app-1", null, { "a.md": "a" }, [], 0, run), + instance.saveDraft(chatId, "app-1", null, {}, ["a.md"], 1, run), + instance.draft(chatId, "app-1"), + // The write that read revision 0 lands on nothing. + instance.saveDraft(chatId, "app-1", null, { "b.md": "b" }, [], 0, run), + // Nor once the draft is dropped, as a proposal drops it. + instance.saveDraft(chatId, "app-1", null, { "c.md": "c" }, [], 2, run), + instance.dropDraft(chatId, "app-1", { revision: 3 }), + instance.saveDraft(chatId, "app-1", null, { "d.md": "d" }, [], 0, run), + instance.saveDraft(chatId, "app-1", null, { "d.md": "d" }, [], 3, run), + instance.draft(chatId, "app-1"), + ]; + }); expect(saved).toStrictEqual([ true, @@ -721,12 +773,15 @@ describe("building Apps from a chat", { timeout: 120_000 }, () => { const { chat } = await setUp([]); const chatId = chatIdSchema.parse(chat.chat.id); - const saved = await runInDurableObject(chat.stub, (instance) => [ - // A first write reads revision 0; a discard runs before it lands. - instance.dropDraft(chatId, "app-1"), - instance.saveDraft(chatId, "app-1", null, { "a.md": "a" }, [], 0), - instance.draft(chatId, "app-1"), - ]); + const saved = await runInDurableObject(chat.stub, (instance) => { + const run = instance.openCodeRun(chatId, testRunOpening); + return [ + // A first write reads revision 0; a discard runs before it lands. + instance.dropDraft(chatId, "app-1", { runId: run }), + instance.saveDraft(chatId, "app-1", null, { "a.md": "a" }, [], 0, run), + instance.draft(chatId, "app-1"), + ]; + }); expect(saved).toStrictEqual([ true, diff --git a/apps/core/test/agent-chat.ts b/apps/core/test/agent-chat.ts index a2670f5e1..852e1e169 100644 --- a/apps/core/test/agent-chat.ts +++ b/apps/core/test/agent-chat.ts @@ -1,5 +1,6 @@ import type { Message } from "@earendil-works/pi-ai"; import { workspaceIdSchema } from "@grasp-os/shared/ids"; +import type { ChatId } from "@grasp-os/shared/ids"; import { runInDurableObject } from "cloudflare:test"; import { env } from "cloudflare:workers"; @@ -177,3 +178,20 @@ export const setObjectEnv = async ( Object.assign(objectEnv, vars); }); }; + +/** What a test's own code run records of its module. */ +export const testRunOpening = { + toolCallId: "call_test", + sourceHash: "0".repeat(64), + sourceChars: 0, +}; + +/** + * Opens a code run of the chat outside any turn, for a test that writes + * a draft as the chat's code would: the run every draft write is + * admitted against. + */ +export const openTestRun = async ( + stub: WorkspaceStub, + chatId: ChatId +): Promise => await stub.openCodeRun(chatId, testRunOpening); diff --git a/apps/core/test/agent.test.ts b/apps/core/test/agent.test.ts index 373839a14..0f2ce0b21 100644 --- a/apps/core/test/agent.test.ts +++ b/apps/core/test/agent.test.ts @@ -1,6 +1,8 @@ +import piAgentCore from "@earendil-works/pi-agent-core/package.json" with { type: "json" }; import { agentErrors } from "@grasp-os/shared/agent"; import type { AuditEvent } from "@grasp-os/shared/audit"; import type { WorkspaceId } from "@grasp-os/shared/ids"; +import { chatIdSchema } from "@grasp-os/shared/ids"; import { memoryMaxLimit } from "@grasp-os/shared/memory"; import { modelErrors } from "@grasp-os/shared/models"; import { permissionErrors } from "@grasp-os/shared/permissions"; @@ -15,7 +17,7 @@ import { maxSteps, requestChars, } from "../src/agent.ts"; -import { codeLimits } from "../src/code-mode.ts"; +import { codeLimits, sourceHash } from "../src/code-mode.ts"; import { workspace } from "../src/durable-objects.ts"; import { chatOf, @@ -28,13 +30,16 @@ import { pointAtGateway, says, setObjectEnv, + testRunOpening, transcript, } from "./agent-chat.ts"; import type { WorkspaceStub } from "./agent-chat.ts"; import { fakeGateway } from "./ai-gateway.ts"; import type { GatewayReply } from "./ai-gateway.ts"; +import { requestGranted } from "./apps.ts"; import { allEvents } from "./audit-events.ts"; import { mockIdp } from "./idp.ts"; +import { collectionWithNote, readCollection } from "./knowledge.ts"; import { signedInApi, signedInWithRole } from "./sign-in.ts"; // A chat's agent, through the Workspace object: the loop, the code it runs @@ -1436,3 +1441,224 @@ describe("chat agent after a restart", () => { }); }); }); + +/** The chat's code run records, oldest first, as the object keeps them. */ +const runRecords = async (stub: WorkspaceStub, chatId: string) => + await runInDurableObject(stub, (_instance, state) => + state.storage.sql + .exec>( + "SELECT id, tool_call_id, source_hash, source_chars, agent, compatibility_date, runtime, cpu_limit, status FROM chat_code_runs WHERE chat_id = ? ORDER BY started_at", + chatId + ) + .toArray() + ); + +/** The call ID of each code step the chat's agent asked for. */ +const toolCallIds = async (stub: WorkspaceStub, chatId: string) => { + const messages = await transcript(stub, chatId); + return messages.flatMap((message) => + message.role === "assistant" + ? message.content.flatMap((block) => + block.type === "toolCall" ? [block.id] : [] + ) + : [] + ); +}; + +/** Whether `call` threw `code`, the way the object's RPC hands it on. */ +const refusedWith = async (call: () => unknown) => { + try { + await call(); + } catch (error) { + return agentErrors.codeOf(error) ?? "failed"; + } + return "admitted"; +}; + +describe("chat agent code run leases", () => { + it("keeps a record of every run: its call, its module's hash, the agent and runtime it ran on, and how it ended", async () => { + const code = "export default async () => 6 * 7;"; + const { stub, chat, ask } = await newChat(codeStep(code), says("42.")); + + await ask("What is 6 * 7?"); + + const [record] = await runRecords(stub, chat.id); + const [toolCallId] = await toolCallIds(stub, chat.id); + expect({ ...record, id: typeof record?.id }).toStrictEqual({ + id: "string", + tool_call_id: toolCallId, + source_hash: await sourceHash(code), + source_chars: code.length, + agent: `@earendil-works/pi-agent-core@${piAgentCore.version}`, + compatibility_date: "2026-09-15", + // Plain workerd doesn't enforce the isolate's CPU limit, and the + // record says so rather than claim it. + runtime: "workerd", + cpu_limit: "cpu_limit_unsupported", + status: "completed", + }); + }); + + it("refuses every write of a run whose lease closed, in the write's own transaction", async () => { + const { stub, chat } = await newChat(); + const chatId = chatIdSchema.parse(chat.id); + const write = (runId: string, revision: number) => async () => + await stub.saveDraft( + chatId, + "app-1", + null, + { "a.md": "a" }, + [], + revision, + runId + ); + + // Open: written. Ended (completed, cancelled, timed out): refused. + const ended = await stub.openCodeRun(chatId, testRunOpening); + const first = await refusedWith(write(ended, 0)); + await stub.closeCodeRun(chatId, ended, "wall_expired"); + // Revoked while open: refused as revoked, however it then ends. + const revoked = await stub.openCodeRun(chatId, testRunOpening); + await stub.revokeCodeRun(chatId, revoked); + + expect({ + first, + afterEnd: await refusedWith(write(ended, 1)), + discardAfterEnd: await refusedWith( + async () => await stub.dropDraft(chatId, "app-1", { runId: ended }) + ), + afterRevoke: await refusedWith(write(revoked, 1)), + createAfterRevoke: await refusedWith( + async () => await stub.takeCreate(chatId, 3, revoked) + ), + unknownRun: await refusedWith(write(crypto.randomUUID(), 1)), + ending: await stub.closeCodeRun(chatId, revoked, "completed"), + draft: await stub.draft(chatId, "app-1"), + }).toStrictEqual({ + first: "admitted", + afterEnd: "agent.run_ended", + discardAfterEnd: "agent.run_ended", + afterRevoke: "agent.run_revoked", + createAfterRevoke: "agent.run_revoked", + unknownRun: "agent.run_ended", + ending: "revoked", + draft: { base: null, changes: { "a.md": "a" }, revision: 1 }, + }); + await expect( + runRecords(stub, chat.id).then((records) => + records.map(({ status }) => status) + ) + ).resolves.toStrictEqual(["wall_expired", "revoked"]); + }); + + it("closes a run's lease when a permission of its agent is revoked while it runs, and admits nothing after", async () => { + const admin = await signedInApi(idp, "admin"); + const { collectionId } = await collectionWithNote(admin.api, { + name: `Handbook ${crypto.randomUUID()}`, + access: "everyone", + }); + // Long enough that the revoke always comes first, however slow the + // runner: the revoke closes the run, not the wait. + const { stub, chat, agent, ask } = await newChat( + codeStep( + "export default async (self, env) => { await env.chat.info(); await scheduler.wait(5_000); try { await env.chat.info(); return 'answered'; } catch (error) { return String(error.message); } };" + ), + says("Done.") + ); + const permission = await requestGranted( + idp, + admin, + readCollection(agent, collectionId, "HANDBOOK") + ); + + const reply = ask("Look twice."); + await vi.waitFor( + async () => { + await expect( + runRecords(stub, chat.id).then((records) => records.length) + ).resolves.toBe(1); + }, + { timeout: 10_000 } + ); + await admin.api.permissions.revoke(permission); + await reply; + + // What the code returned rests on what was revoked: not handed on. + await expect(codeResults(stub, chat.id)).resolves.toStrictEqual([ + { + isError: true, + text: `Error (revoked):\n${agentErrors.create("agent.run_revoked").message}`, + }, + ]); + await expect( + runRecords(stub, chat.id).then((records) => + records.map(({ status }) => status) + ) + ).resolves.toStrictEqual(["revoked"]); + await vi.waitFor( + async () => { + await expect( + refusalsIn(chat.id, "agent.run_revoked") + ).resolves.toStrictEqual([ + { + method: "chat.info", + outcome: "refused", + reason: "agent.run_revoked", + }, + ]); + }, + { timeout: 10_000, interval: 100 } + ); + }); + + it("ends a run a restart cuts short: its lease closes, its record says interrupted, and nothing runs again", async () => { + const { id, stub, chat, gateway, ask } = await newChat( + codeStep( + "export default async (self, env) => { await env.chat.info(); await scheduler.wait(5_000); return (await env.chat.info()).chatId; };" + ), + says("Done.") + ); + + const reply = ask("Look twice.").catch(() => "cut short"); + await vi.waitFor( + async () => { + await expect( + runRecords(stub, chat.id).then((records) => records.length) + ).resolves.toBe(1); + }, + { timeout: 10_000 } + ); + // The object dies, as a restart ends it: everything in flight fails. + await runInDurableObject(stub, (_instance, state) => { + state.abort("restart"); + }).catch(() => "aborted"); + await expect(reply).resolves.toBe("cut short"); + // The object as it wakes again. + const woken = workspace(env, id); + + const [record] = await runRecords(woken, chat.id); + expect(record?.status).toBe("interrupted"); + // Its lease went with the object: its stubs answer as ended. + await expect( + runInDurableObject(woken, (instance) => + instance.callFromCodeRun( + chatIdSchema.parse(chat.id), + String(record?.id) + ) + ) + ).resolves.toStrictEqual({ call: "ended", first: false }); + // Not run again: one step asked for, no result, no second request. + await scheduler.wait(6000); + const [calls, results, runs] = await Promise.all([ + toolCallIds(woken, chat.id), + codeResults(woken, chat.id), + runRecords(woken, chat.id), + ]); + expect({ + calls: calls.length, + results: results.length, + runs: runs.length, + requests: gateway.requests.length, + }).toStrictEqual({ calls: 1, results: 0, runs: 1, requests: 1 }); + }); +}); diff --git a/apps/core/test/app-preview.test.ts b/apps/core/test/app-preview.test.ts index c5964e3f4..26086f246 100644 --- a/apps/core/test/app-preview.test.ts +++ b/apps/core/test/app-preview.test.ts @@ -8,6 +8,7 @@ import { callApp } from "../src/app.ts"; import { personalWorkspaceId } from "../src/chats-rpc.ts"; import { workspace } from "../src/durable-objects.ts"; import { readStatistics } from "../src/statistics.ts"; +import { openTestRun } from "./agent-chat.ts"; import { pastAccessRecheck, release, @@ -221,7 +222,8 @@ const chatWithDraft = async ( 1, written, [], - revision + revision, + await openTestRun(chatsOf(person), chatId) ); if (!saved) { throw new Error("The draft wasn't written"); diff --git a/apps/core/test/preview-repairs.test.ts b/apps/core/test/preview-repairs.test.ts index 86d56fe97..5b5d22216 100644 --- a/apps/core/test/preview-repairs.test.ts +++ b/apps/core/test/preview-repairs.test.ts @@ -10,6 +10,7 @@ import { codeResults, codeStep, model, + openTestRun, pausedReply, pointAtGateway, says, @@ -357,7 +358,8 @@ describe("the repair loop, fed by the preview", { timeout: 180_000 }, () => { 1, { "screens/desk.tsx": screen, "app/server.ts": server(true) }, [], - 0 + 0, + await openTestRun(stub, chatIdSchema.parse(chatId)) ); await builder.api.chats.preview(chatId, app); // The next write and the check in one step: the panel previews the @@ -403,7 +405,8 @@ describe("the repair loop, fed by the preview", { timeout: 180_000 }, () => { 1, { "screens/desk.tsx": screen, "app/server.ts": server(true) }, [], - 0 + 0, + await openTestRun(stub, chatIdSchema.parse(chatId)) ); await builder.api.chats.preview(chatId, app); @@ -448,7 +451,8 @@ describe("the repair loop, fed by the preview", { timeout: 180_000 }, () => { 1, { "screens/desk.tsx": screen, "app/server.ts": mailing }, [], - revision + revision, + await openTestRun(stub, id) ); return revision + 1; }; @@ -540,7 +544,8 @@ describe("the repair loop, fed by the preview", { timeout: 180_000 }, () => { 1, { "screens/desk.tsx": screen, "app/server.ts": mailing }, [], - revision + revision, + await openTestRun(stub, id) ); return revision + 1; }; @@ -612,7 +617,8 @@ describe("the repair loop, fed by the preview", { timeout: 180_000 }, () => { 1, { "screens/desk.tsx": screen, "app/server.ts": mailing }, [], - revision + revision, + await openTestRun(stub, id) ); return revision + 1; }; diff --git a/apps/core/vite.config.ts b/apps/core/vite.config.ts index 175d1590c..9b6977ca7 100644 --- a/apps/core/vite.config.ts +++ b/apps/core/vite.config.ts @@ -146,6 +146,8 @@ export const coreProject = (test: UserWorkspaceConfig["test"]) => }, // workerd doesn't implement Durable Object jurisdictions. DURABLE_OBJECT_JURISDICTION: "none", + // Plain workerd: no CPU limit on Code Mode's isolates. + RUNTIME_PROFILE: "workerd", // So the test of a call that never ends doesn't wait a minute. APP_CALL_TIMEOUT_MS: "10000", // The engine's step limit, lowered below (`workflows`) so a test diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index c60339c80..a101842ae 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -18,6 +18,8 @@ export const agentErrors = defineErrorFamily({ "This chat is too long to go on. Start a new chat to ask more.", "agent.run_ended": "This code run has ended, so its APIs don't answer any more.", + "agent.run_revoked": + "A permission of this chat's agent was revoked while this code run was under way, so its APIs don't answer any more.", "agent.run_calls_spent": "This code run has made all the API calls one run may. Run the rest in another code step.", }); diff --git a/scripts/workerd-smoke.ts b/scripts/workerd-smoke.ts index 126abbd73..618aa89aa 100644 --- a/scripts/workerd-smoke.ts +++ b/scripts/workerd-smoke.ts @@ -67,6 +67,7 @@ const core :Workerd.Worker = ( bindings = [ (name = "ROUTER_SECRET", text = "${ROUTER_SECRET}"), (name = "DURABLE_OBJECT_JURISDICTION", text = "none"), + (name = "RUNTIME_PROFILE", text = "workerd"), # No LOADER (experimental in workerd) and no WORKFLOWS (no engine): # apps and screens need workerd's --experimental, and workflows don't # run on-prem (apps/core/src/workflows/engine.ts). From 9e63ed1a18e370d1c9c150b0c8d32caa507aac68 Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 11 Oct 2026 05:01:19 +0200 Subject: [PATCH 2/5] fix(core): notice every revocation that ends a Code Mode run, and only those A run reads revocations once more as it ends, so a revocation its code never called after still ends it as revoked, recorded once. A revoked run hands on neither its result nor its logs. Only a revoked permission that was granted and held where the run acts counts: a declined request or another chat permission no longer ends it. Draft writes, discards and creates are admitted with the revocation read too; admission reads revocations first and the lease last; refusals of connection calls and USER.md saves are recorded. A runtime is taken to enforce the CPU limit only when it says RUNTIME_PROFILE=cloudflare. Run records constrain their status and call ID and keep a chat latest 1000, and an index serves the revocation read. --- apps/core/src/agent-builds.ts | 4 + apps/core/src/agent-connections.ts | 5 +- apps/core/src/agent-memory.ts | 3 +- apps/core/src/agent-scope.ts | 145 +- apps/core/src/agent.ts | 20 +- apps/core/src/code-mode.ts | 55 +- apps/core/src/code-run-statuses.ts | 36 + .../0056_permissions_revoked_idx.sql | 1 + .../core/migrations/meta/0056_snapshot.json | 3921 +++++++++++++++++ .../src/db/core/migrations/meta/_journal.json | 7 + apps/core/src/db/core/schema.ts | 8 + .../migrations/0008_chat_code_runs.sql | 7 +- .../migrations/meta/0008_snapshot.json | 21 +- .../workspace/migrations/meta/_journal.json | 2 +- apps/core/src/db/workspace/schema.ts | 29 +- apps/core/src/env.d.ts | 6 +- apps/core/src/workspace.ts | 96 +- apps/core/test/agent-builds.test.ts | 134 +- apps/core/test/agent-knowledge.test.ts | 2 +- apps/core/test/agent.test.ts | 136 +- packages/shared/src/permissions.ts | 2 + 21 files changed, 4472 insertions(+), 168 deletions(-) create mode 100644 apps/core/src/code-run-statuses.ts create mode 100644 apps/core/src/db/core/migrations/0056_permissions_revoked_idx.sql create mode 100644 apps/core/src/db/core/migrations/meta/0056_snapshot.json diff --git a/apps/core/src/agent-builds.ts b/apps/core/src/agent-builds.ts index 96f92cb4b..d9abfe206 100644 --- a/apps/core/src/agent-builds.ts +++ b/apps/core/src/agent-builds.ts @@ -537,6 +537,8 @@ export class BuildApi extends WorkerEntrypoint { */ async #creating(create: () => Promise): Promise { const { workspaceId, chatId, runId } = this.ctx.props; + // Admitted as it takes the create, which a D1 write follows. + await admitEffect(this.env, this.ctx.props); const taken = await workspace(this.env, workspaceId).takeCreate( chatId, maxCreatesPerTurn, @@ -638,6 +640,7 @@ export class BuildApi extends WorkerEntrypoint { const unchanged = written.flatMap(([path, content]) => (content ?? undefined) === base.get(path) ? [path] : [] ); + await admitEffect(this.env, this.ctx.props); const saved = await workspace( this.env, this.ctx.props.workspaceId @@ -676,6 +679,7 @@ export class BuildApi extends WorkerEntrypoint { async (by) => { const id = await this.#buildable(by, app); const { workspaceId, chatId, runId } = this.ctx.props; + await admitEffect(this.env, this.ctx.props); await workspace(this.env, workspaceId).dropDraft(chatId, id, { runId, }); diff --git a/apps/core/src/agent-connections.ts b/apps/core/src/agent-connections.ts index 41c82c8a3..366c69e84 100644 --- a/apps/core/src/agent-connections.ts +++ b/apps/core/src/agent-connections.ts @@ -284,8 +284,9 @@ export class ConnectionsApi extends WorkerEntrypoint { ); let result: ConnectResult; try { - // Admitted as it goes to connect, not only as the call began. - await admitEffect(this.env, scope); + // Admitted as it goes to connect, not only as the call began; a + // refusal here is recorded here, as nothing else records this call. + await admitEffect(this.env, scope, "connections.call"); result = await this.env.CONNECT.call(request); } catch (error) { throw forSandbox(error); diff --git a/apps/core/src/agent-memory.ts b/apps/core/src/agent-memory.ts index 81019da1a..7504e3de5 100644 --- a/apps/core/src/agent-memory.ts +++ b/apps/core/src/agent-memory.ts @@ -25,7 +25,8 @@ export class MemoryApi extends WorkerEntrypoint { const scope = this.ctx.props; await requireOpenRun(this.env, scope, "memory.saveUser"); try { - await admitEffect(this.env, scope); + // Recorded here when refused: knowledge records only a save. + await admitEffect(this.env, scope, "memory.saveUser"); const saved = await saveUserMemory( this.env, chatAuthority(scope), diff --git a/apps/core/src/agent-scope.ts b/apps/core/src/agent-scope.ts index 564394b66..94c16d884 100644 --- a/apps/core/src/agent-scope.ts +++ b/apps/core/src/agent-scope.ts @@ -8,7 +8,7 @@ import { permissionErrors, } from "@grasp-os/shared/permissions"; import type { Authority } from "@grasp-os/shared/permissions"; -import { and, eq, gt } from "drizzle-orm"; +import { and, eq, gte, isNotNull, isNull, or } from "drizzle-orm"; import { drizzle } from "drizzle-orm/d1"; import { z } from "zod"; @@ -117,6 +117,17 @@ export type CodeRunAdmission = | { call: "open"; openedAt: number } | { call: "ended" | "revoked" }; +/** Why a call or write from a run that isn't open is refused. */ +const refusalCodes = { + spent: "agent.run_calls_spent", + ended: "agent.run_ended", + revoked: "agent.run_revoked", +} as const; + +/** The refusal of a call or write from a run that isn't open. */ +export const runRefusal = (call: keyof typeof refusalCodes) => + agentErrors.create(refusalCodes[call]); + /** * Records with the chat what a call read from (collections, a * connection), before the call hands over what it read: every later model @@ -135,8 +146,8 @@ export const recordSources = async ( runId, sources ); - if (!recorded) { - throw agentErrors.create("agent.run_ended"); + if (recorded !== "open") { + throw runRefusal(recorded); } }; @@ -182,15 +193,17 @@ export const auditAgentCall = async ( }; /** - * Whether one of the agent's permissions was revoked after `since`: any - * of them, wherever it held (a chat's own too). A run open since before - * acts on authority it no longer has, so its lease closes; one revoke - * ends every run of the agent then under way, at most a run's wall-clock - * time of work. One read of the subject's index. + * Whether a permission the agent was granted, holding where this chat's + * runs act, was revoked since `since`: one that held everywhere, or in + * this chat alone. Not a request that was never granted (a person + * declining a chat's request revokes one), nor another chat's: neither + * gave the run authority, so neither ends it. A run open since before acts + * on authority it no longer has, so its lease closes. One read of the + * subject's index (`permissions_revoked_idx`). */ -const revokedSince = async ( +export const revokedSince = async ( env: Env, - agentId: string, + { agentId, chatId }: Pick, since: number ): Promise => { const [revoked] = await drizzle(env.DB) @@ -201,42 +214,38 @@ const revokedSince = async ( eq(permissions.subjectType, "agent"), eq(permissions.subjectId, agentId), eq(permissions.status, "revoked"), - gt(permissions.revokedAt, new Date(since)) + // From the millisecond the run opened, that one included. No + // margin before it: a revocation just before a run opened is in + // the grants the run's calls read, and a margin would end the + // next run after any revoke. + gte(permissions.revokedAt, new Date(since)), + isNotNull(permissions.grantedAt), + or(isNull(permissions.chatId), eq(permissions.chatId, chatId)) ) ) .limit(1); return revoked !== undefined; }; -/** Why a call or write from a run that isn't open is refused. */ -const refusalCodes = { - spent: "agent.run_calls_spent", - ended: "agent.run_ended", - revoked: "agent.run_revoked", -} as const; - -/** The refusal of a call or write from a run that isn't open. */ -const runRefusal = (call: keyof typeof refusalCodes) => - agentErrors.create(refusalCodes[call]); - /** - * Closes the run's lease as revoked when one of its agent's permissions - * was revoked since it opened: `true` when it was (and the run must be - * refused), whoever closed it first. + * Closes the run's lease as revoked when a permission of its agent was + * revoked since it opened (`revokedSince`): `closed` when this call closed + * it (and alone records it), `closedBefore` when another got there first, + * `open` when nothing was revoked. */ -const closedByRevocation = async ( +const revocationOf = async ( env: Env, scope: AgentScope, openedAt: number -): Promise => { - if (!(await revokedSince(env, scope.agentId, openedAt))) { - return false; +): Promise<"open" | "closed" | "closedBefore"> => { + if (!(await revokedSince(env, scope, openedAt))) { + return "open"; } - await workspace(env, scope.workspaceId).revokeCodeRun( + const closed = await workspace(env, scope.workspaceId).revokeCodeRun( scope.chatId, scope.runId ); - return true; + return closed ? "closed" : "closedBefore"; }; /** @@ -244,7 +253,9 @@ const closedByRevocation = async ( * run that has ended (the turn moved on, or the run was cancelled or timed * out while its code kept running), made all the calls it may, or was * closed by a revocation of one of its agent's permissions, found here - * too. Every API checks it first, on every call. + * too. Every API checks it first, on every call. A run's refusals are + * recorded once: by the first refused call, or the one that found the + * revocation. */ export const requireOpenRun = async ( env: Env, @@ -256,50 +267,72 @@ export const requireOpenRun = async ( chatId, runId ); - const revoked = - counted.call === "open" && - (await closedByRevocation(env, scope, counted.openedAt)); - if (counted.call === "open" && !revoked) { + const revocation = + counted.call === "open" + ? await revocationOf(env, scope, counted.openedAt) + : "open"; + if (counted.call === "open" && revocation === "open") { return; } - const call = counted.call === "open" ? "revoked" : counted.call; - const refusal = runRefusal(call); - // Once per run: code that goes on calling can't flood the audit log. - if (counted.first || revoked) { + const refusal = runRefusal( + counted.call === "open" ? "revoked" : counted.call + ); + if ((counted.call !== "open" && counted.first) || revocation === "closed") { await auditAgentCall( env, scope, { method }, - { - outcome: "refused", - reason: refusal.code, - } + { outcome: "refused", reason: refusal.code } ); } throw refusal; }; /** - * Admits a write or side effect of a code run, as it commits, after its + * Admits a write or side effect of a code run as it commits, after its * call was let in (`requireOpenRun`): refused once the run has closed, * whether it ended, was cancelled, ran out of time or lost a permission - * meanwhile. Counts no call. A write into the Workspace object itself is - * checked there instead, in the write's own transaction. + * meanwhile. Counts no call. The slow read of revocations comes first and + * the lease's own check last, right before the effect. A write into the + * Workspace object checks the lease once more in its own transaction. + * + * With `method`, a refusal is recorded here, once a run (for a call that + * nothing else records); without, its caller records it (`auditedCall`). */ export const admitEffect = async ( env: Env, - scope: AgentScope + scope: AgentScope, + method?: string ): Promise => { - const admission = await workspace(env, scope.workspaceId).admitFromCodeRun( - scope.chatId, - scope.runId - ); - if (admission.call !== "open") { - throw runRefusal(admission.call); + const chats = workspace(env, scope.workspaceId); + const opened = await chats.admitFromCodeRun(scope.chatId, scope.runId); + let refused: "ended" | "revoked" | undefined; + let record = false; + if (opened.call === "open") { + const revocation = await revocationOf(env, scope, opened.openedAt); + if (revocation === "open") { + const now = await chats.admitFromCodeRun(scope.chatId, scope.runId); + refused = now.call === "open" ? undefined : now.call; + } else { + refused = "revoked"; + record = revocation === "closed"; + } + } else { + refused = opened.call; } - if (await closedByRevocation(env, scope, admission.openedAt)) { - throw runRefusal("revoked"); + if (refused === undefined) { + return; + } + const refusal = runRefusal(refused); + if (method !== undefined && (record || refused === "ended")) { + await auditAgentCall( + env, + scope, + { method }, + { outcome: "refused", reason: refusal.code } + ); } + throw refusal; }; /** How a call that threw ended, and why, as the audit log names it. */ diff --git a/apps/core/src/agent.ts b/apps/core/src/agent.ts index ae6cf2151..f34e026b4 100644 --- a/apps/core/src/agent.ts +++ b/apps/core/src/agent.ts @@ -15,7 +15,8 @@ import { z } from "zod"; import type { AgentApi, AgentScope } from "./agent-scope.ts"; import { describeRun, runCode, sourceHash } from "./code-mode.ts"; -import type { CodeRun, CodeRunStatus } from "./code-mode.ts"; +import type { CodeRun } from "./code-mode.ts"; +import type { CodeRunStatus } from "./code-run-statuses.ts"; import type { AgentModel } from "./models.ts"; import type { CodeRunOpening } from "./workspace.ts"; @@ -219,7 +220,7 @@ const codeInput = (args: unknown): { code: string } => { */ export interface CodeRuns { open: (opening: CodeRunOpening) => string; - close: (runId: string, status: CodeRunStatus) => CodeRunStatus; + close: (runId: string, status: CodeRunStatus) => Promise; } /** @@ -256,15 +257,18 @@ const runOpen = async ( run = await runCode(loader, code, env, signal, codeTimeoutMs); ran = run.status; } finally { - ended = runs.close(runId, ran); + ended = await runs.close(runId, ran); } - return ended === run.status - ? run - : { + // A run a revocation closed, as it ran or as it ended, hands on nothing + // of what it did: not what it returned, nor what it logged, which could + // carry what return can't. + return ended === "revoked" + ? { status: ended, - logs: run.logs, + logs: [], error: agentErrors.create("agent.run_revoked").message, - }; + } + : run; }; /** The one tool: runs code against the chat's APIs in a locked isolate. */ diff --git a/apps/core/src/code-mode.ts b/apps/core/src/code-mode.ts index fb444a520..049b5230c 100644 --- a/apps/core/src/code-mode.ts +++ b/apps/core/src/code-mode.ts @@ -4,6 +4,7 @@ import { compatibilityDate } from "@grasp-os/shared/runtime"; import type { WorkerEntrypoint } from "cloudflare:workers"; import { z } from "zod"; +import type { CodeRunStatus } from "./code-run-statuses.ts"; import { sandbox } from "./sandbox.ts"; // Code Mode: the agent acts by writing code against typed APIs, and the @@ -49,54 +50,26 @@ export const codeLimits = { logLines: 1000, } as const; -/** - * How a run ended, as the host saw it, never as the code says: it - * returned (`completed`), threw (`threw`, or sent back what the harness - * never returns), was refused before it loaded (`refused`: its source is - * over the limit), its module didn't load or export a function - * (`load_failed`, including anything the runtime ended while it loaded, - * short of its CPU time), the runtime stopped it for CPU time - * (`cpu_exceeded`) or anything else once it ran (`crashed`: memory, say), - * it ran past its wall-clock time (`wall_expired`, a wait that can never - * settle too), the turn was cancelled (`cancelled`), a permission of the - * agent was revoked while it ran (`revoked`: its lease closed then, - * whatever its code went on to do), or a restart ended it first - * (`interrupted`, which only its record says). Only the host sets one: - * the harness returns every outcome of the code, so the code can't make - * a call reject, nor pick the message a status is read from. - */ -export const codeRunStatuses = [ - "completed", - "threw", - "refused", - "load_failed", - "cpu_exceeded", - "crashed", - "wall_expired", - "cancelled", - "revoked", - "interrupted", -] as const; - -export type CodeRunStatus = (typeof codeRunStatuses)[number]; - /** * What a run ran on, kept with its record (`chat_code_runs`): the agent - * loop's exact version (pi-ai is pinned to the same one in the catalog), - * the runtime's compatibility date, and the runtime. Plain workerd (tests, - * local development, on-prem, which set `RUNTIME_PROFILE=workerd`) doesn't - * enforce the isolate's CPU limit, and says so: a busy loop there runs - * until its wall-clock limit lets go of it, still spinning. + * loop's exact version (pi-ai is pinned to the same one in the catalog, + * and exports no version of its own to read), the runtime's compatibility + * date, and whether the runtime enforces the isolate's CPU limit. Only a + * deployment that says `RUNTIME_PROFILE=cloudflare` is taken to: anything + * else (unset, plain workerd in tests, local development and on-prem, a + * typo) records `cpu_limit_unsupported`, so a record never claims a limit + * nobody vouched for. On plain workerd a synchronous busy loop isn't even + * bounded by the wall clock: it holds the thread the host's timer needs. */ export const codeRuntime = (env: Pick) => { - const workerd = env.RUNTIME_PROFILE === "workerd"; + const cloudflare = env.RUNTIME_PROFILE === "cloudflare"; return { agent: `${piAgentCore.name}@${piAgentCore.version}`, compatibilityDate, - runtime: workerd ? ("workerd" as const) : ("cloudflare" as const), - cpuLimit: workerd - ? ("cpu_limit_unsupported" as const) - : ("enforced" as const), + runtime: cloudflare ? ("cloudflare" as const) : ("workerd" as const), + cpuLimit: cloudflare + ? ("enforced" as const) + : ("cpu_limit_unsupported" as const), }; }; diff --git a/apps/core/src/code-run-statuses.ts b/apps/core/src/code-run-statuses.ts new file mode 100644 index 000000000..7ce505ece --- /dev/null +++ b/apps/core/src/code-run-statuses.ts @@ -0,0 +1,36 @@ +/** + * Every way a code run can end, as the host records it + * ({@link CodeRunStatus}). Apart from code-mode.ts, with nothing to + * import, so the Workspace object's schema can constrain its records to + * them. + */ +export const codeRunStatuses = [ + "completed", + "threw", + "refused", + "load_failed", + "cpu_exceeded", + "crashed", + "wall_expired", + "cancelled", + "revoked", + "interrupted", +] as const; + +/** + * How a run ended, as the host saw it, never as the code says: it + * returned (`completed`), threw (`threw`, or sent back what the harness + * never returns), was refused before it loaded (`refused`: its source is + * over the limit), its module didn't load or export a function + * (`load_failed`, including anything the runtime ended while it loaded, + * short of its CPU time), the runtime stopped it for CPU time + * (`cpu_exceeded`) or anything else once it ran (`crashed`: memory, say), + * it ran past its wall-clock time (`wall_expired`, a wait that can never + * settle too), the turn was cancelled (`cancelled`), a permission of the + * agent was revoked while it ran (`revoked`: its lease closed then, + * whatever its code went on to do), or a restart ended it first + * (`interrupted`, which only its record says). Only the host sets one: + * the harness returns every outcome of the code, so the code can't make + * a call reject, nor pick the message a status is read from. + */ +export type CodeRunStatus = (typeof codeRunStatuses)[number]; diff --git a/apps/core/src/db/core/migrations/0056_permissions_revoked_idx.sql b/apps/core/src/db/core/migrations/0056_permissions_revoked_idx.sql new file mode 100644 index 000000000..6437e2815 --- /dev/null +++ b/apps/core/src/db/core/migrations/0056_permissions_revoked_idx.sql @@ -0,0 +1 @@ +CREATE INDEX `permissions_revoked_idx` ON `permissions` (`subject_type`,`subject_id`,`status`,`revoked_at`); \ No newline at end of file diff --git a/apps/core/src/db/core/migrations/meta/0056_snapshot.json b/apps/core/src/db/core/migrations/meta/0056_snapshot.json new file mode 100644 index 000000000..b49829543 --- /dev/null +++ b/apps/core/src/db/core/migrations/meta/0056_snapshot.json @@ -0,0 +1,3921 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e053b944-fa2c-4dd8-9340-35dad4a82d53", + "prevId": "0a401c56-0719-412c-9381-170b54f310de", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oid": { + "name": "oid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + "provider_id", + "account_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_members": { + "name": "app_members", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "member_type": { + "name": "member_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "app_members_member_idx": { + "name": "app_members_member_idx", + "columns": [ + "member_type", + "member_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "app_members_app_id_apps_id_fk": { + "name": "app_members_app_id_apps_id_fk", + "tableFrom": "app_members", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "app_members_app_id_member_type_member_id_pk": { + "columns": [ + "app_id", + "member_type", + "member_id" + ], + "name": "app_members_app_id_member_type_member_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_statistic_steps": { + "name": "app_statistic_steps", + "columns": { + "step_key": { + "name": "step_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "measure": { + "name": "measure", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dimensions": { + "name": "dimensions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sum": { + "name": "sum", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "min": { + "name": "min", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max": { + "name": "max", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "committed": { + "name": "committed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_statistic_steps_step_key_attempt_app_id_measure_day_dimensions_pk": { + "columns": [ + "step_key", + "attempt", + "app_id", + "measure", + "day", + "dimensions" + ], + "name": "app_statistic_steps_step_key_attempt_app_id_measure_day_dimensions_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_statistics": { + "name": "app_statistics", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "measure": { + "name": "measure", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dimensions": { + "name": "dimensions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sum": { + "name": "sum", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "min": { + "name": "min", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max": { + "name": "max", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "app_statistics_app_day_idx": { + "name": "app_statistics_app_day_idx", + "columns": [ + "app_id", + "day" + ], + "isUnique": false + }, + "app_statistics_day_idx": { + "name": "app_statistics_day_idx", + "columns": [ + "day" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_statistics_app_id_measure_day_dimensions_pk": { + "columns": [ + "app_id", + "measure", + "day", + "dimensions" + ], + "name": "app_statistics_app_id_measure_day_dimensions_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_versions": { + "name": "app_versions", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent": { + "name": "parent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tree": { + "name": "tree", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "files": { + "name": "files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved": { + "name": "approved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workflows": { + "name": "workflows", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "workflow_calls": { + "name": "workflow_calls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "exports": { + "name": "exports", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "proposed_by": { + "name": "proposed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "records": { + "name": "records", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "app_versions_app_id_apps_id_fk": { + "name": "app_versions_app_id_apps_id_fk", + "tableFrom": "app_versions", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "app_versions_app_id_version_pk": { + "columns": [ + "app_id", + "version" + ], + "name": "app_versions_app_id_version_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apps": { + "name": "apps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blueprint": { + "name": "blueprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_version": { + "name": "current_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_version": { + "name": "pending_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_outbox": { + "name": "audit_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_outbox_rejected": { + "name": "audit_outbox_rejected", + "columns": { + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_outbox_rejected_id": { + "name": "audit_outbox_rejected_id", + "columns": [ + "id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "blueprints": { + "name": "blueprints", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tree": { + "name": "tree", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved": { + "name": "approved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "marked_by": { + "name": "marked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "marked_at": { + "name": "marked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "blueprints_app_version_idx": { + "name": "blueprints_app_version_idx", + "columns": [ + "app_id", + "version" + ], + "isUnique": true + } + }, + "foreignKeys": { + "blueprints_app_id_apps_id_fk": { + "name": "blueprints_app_id_apps_id_fk", + "tableFrom": "blueprints", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "business_store_members": { + "name": "business_store_members", + "columns": { + "store_id": { + "name": "store_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "member_type": { + "name": "member_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "business_store_members_member_idx": { + "name": "business_store_members_member_idx", + "columns": [ + "member_type", + "member_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "business_store_members_store_id_business_stores_id_fk": { + "name": "business_store_members_store_id_business_stores_id_fk", + "tableFrom": "business_store_members", + "tableTo": "business_stores", + "columnsFrom": [ + "store_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "business_store_members_store_id_member_type_member_id_pk": { + "columns": [ + "store_id", + "member_type", + "member_id" + ], + "name": "business_store_members_store_id_member_type_member_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "business_stores": { + "name": "business_stores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_schema_version": { + "name": "active_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_schema_hash": { + "name": "active_schema_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_operation_manifest_hash": { + "name": "active_operation_manifest_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "policy_generation": { + "name": "policy_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "physical_namespace_role": { + "name": "physical_namespace_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dependency_admission_refusals": { + "name": "dependency_admission_refusals", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "graph_hash": { + "name": "graph_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targets": { + "name": "targets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "policy_generation": { + "name": "policy_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "dependency_admission_refusals_app_id_apps_id_fk": { + "name": "dependency_admission_refusals_app_id_apps_id_fk", + "tableFrom": "dependency_admission_refusals", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dependency_admission_refusals_app_id_graph_hash_targets_policy_generation_reason_pk": { + "columns": [ + "app_id", + "graph_hash", + "targets", + "policy_generation", + "reason" + ], + "name": "dependency_admission_refusals_app_id_graph_hash_targets_policy_generation_reason_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dependency_approvers": { + "name": "dependency_approvers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "dependency_approvers_live_idx": { + "name": "dependency_approvers_live_idx", + "columns": [ + "subject_type", + "subject_id" + ], + "isUnique": true, + "where": "status = 'active'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dependency_build_leases": { + "name": "dependency_build_leases", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "graph_hash": { + "name": "graph_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "dependency_build_leases_expires_at_idx": { + "name": "dependency_build_leases_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dependency_build_leases_app_id_apps_id_fk": { + "name": "dependency_build_leases_app_id_apps_id_fk", + "tableFrom": "dependency_build_leases", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dependency_build_leases_app_id_graph_hash_target_pk": { + "columns": [ + "app_id", + "graph_hash", + "target" + ], + "name": "dependency_build_leases_app_id_graph_hash_target_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dependency_locks": { + "name": "dependency_locks", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "graph_hash": { + "name": "graph_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lock": { + "name": "lock", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "served_until": { + "name": "served_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "dependency_locks_app_id_apps_id_fk": { + "name": "dependency_locks_app_id_apps_id_fk", + "tableFrom": "dependency_locks", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dependency_locks_app_id_graph_hash_pk": { + "columns": [ + "app_id", + "graph_hash" + ], + "name": "dependency_locks_app_id_graph_hash_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dependency_policy": { + "name": "dependency_policy", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dependency_requests": { + "name": "dependency_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "graph_hash": { + "name": "graph_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targets": { + "name": "targets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot": { + "name": "snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direct": { + "name": "direct", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "packages": { + "name": "packages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "findings": { + "name": "findings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refused": { + "name": "refused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous": { + "name": "previous", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_via": { + "name": "requested_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_at": { + "name": "requested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "policy_generation": { + "name": "policy_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_generation": { + "name": "decided_generation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "dependency_requests_pending_idx": { + "name": "dependency_requests_pending_idx", + "columns": [ + "app_id" + ], + "isUnique": true, + "where": "status = 'pending'" + }, + "dependency_requests_app_idx": { + "name": "dependency_requests_app_idx", + "columns": [ + "app_id", + "status", + "decided_at" + ], + "isUnique": false + }, + "dependency_requests_graph_idx": { + "name": "dependency_requests_graph_idx", + "columns": [ + "app_id", + "graph_hash", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dependency_requests_app_id_apps_id_fk": { + "name": "dependency_requests_app_id_apps_id_fk", + "tableFrom": "dependency_requests", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_chats": { + "name": "guest_chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill": { + "name": "skill", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turns": { + "name": "turns", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "busy_until": { + "name": "busy_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "opened_at": { + "name": "opened_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended": { + "name": "ended", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "guest_chats_token_idx": { + "name": "guest_chats_token_idx", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "guest_chats_app_idx": { + "name": "guest_chats_app_idx", + "columns": [ + "app_id", + "created_at", + "id" + ], + "isUnique": false + }, + "guest_chats_open_idx": { + "name": "guest_chats_open_idx", + "columns": [ + "app_id", + "created_at", + "id" + ], + "isUnique": false, + "where": "ended IS NULL" + }, + "guest_chats_expires_idx": { + "name": "guest_chats_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guest_messages": { + "name": "guest_messages", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "guest_messages_chat_id_guest_chats_id_fk": { + "name": "guest_messages_chat_id_guest_chats_id_fk", + "tableFrom": "guest_messages", + "tableTo": "guest_chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "guest_messages_chat_id_seq_pk": { + "columns": [ + "chat_id", + "seq" + ], + "name": "guest_messages_chat_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hidden_connectors": { + "name": "hidden_connectors", + "columns": { + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hidden_by": { + "name": "hidden_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "hidden_connectors_source_connector_id_pk": { + "columns": [ + "source", + "connector_id" + ], + "name": "hidden_connectors_source_connector_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "improvement_signal_computations": { + "name": "improvement_signal_computations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "improvement_signal_computations_day_idx": { + "name": "improvement_signal_computations_day_idx", + "columns": [ + "day" + ], + "isUnique": false + }, + "improvement_signal_computations_started_idx": { + "name": "improvement_signal_computations_started_idx", + "columns": [ + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "improvement_signals": { + "name": "improvement_signals", + "columns": { + "computation": { + "name": "computation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "evidence": { + "name": "evidence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "improvement_signals_kind_idx": { + "name": "improvement_signals_kind_idx", + "columns": [ + "computation", + "kind", + "value" + ], + "isUnique": false + } + }, + "foreignKeys": { + "improvement_signals_computation_improvement_signal_computations_id_fk": { + "name": "improvement_signals_computation_improvement_signal_computations_id_fk", + "tableFrom": "improvement_signals", + "tableTo": "improvement_signal_computations", + "columnsFrom": [ + "computation" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "improvement_signals_computation_app_id_workflow_id_kind_subject_pk": { + "columns": [ + "computation", + "app_id", + "workflow_id", + "kind", + "subject" + ], + "name": "improvement_signals_computation_app_id_workflow_id_kind_subject_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitations": { + "name": "invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member_removals": { + "name": "member_removals", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "member_removals_organization_id_organizations_id_fk": { + "name": "member_removals_organization_id_organizations_id_fk", + "tableFrom": "member_removals", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_removals_user_id_users_id_fk": { + "name": "member_removals_user_id_users_id_fk", + "tableFrom": "member_removals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "member_removals_organization_id_user_id_pk": { + "columns": [ + "organization_id", + "user_id" + ], + "name": "member_removals_organization_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "members": { + "name": "members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "members_organization_user_idx": { + "name": "members_organization_user_idx", + "columns": [ + "organization_id", + "user_id" + ], + "isUnique": true + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "failures": { + "name": "failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "notifications_person_idx": { + "name": "notifications_person_idx", + "columns": [ + "person_id", + "updated_at", + "id" + ], + "isUnique": false + }, + "notifications_unread_idx": { + "name": "notifications_unread_idx", + "columns": [ + "person_id", + "type", + "app_id", + "workflow_id" + ], + "isUnique": true, + "where": "read_at IS NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onboarding_gate": { + "name": "onboarding_gate", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "threshold": { + "name": "threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "opened_at": { + "name": "opened_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "staff_ended_at": { + "name": "staff_ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "package_cleanups": { + "name": "package_cleanups", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "package_cleanups_created_at_idx": { + "name": "package_cleanups_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "permissions": { + "name": "permissions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_type": { + "name": "object_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_id": { + "name": "object_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actions": { + "name": "actions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_at": { + "name": "requested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_via": { + "name": "requested_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "personal": { + "name": "personal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "permissions_subject_idx": { + "name": "permissions_subject_idx", + "columns": [ + "subject_type", + "subject_id", + "status" + ], + "isUnique": false + }, + "permissions_revoked_idx": { + "name": "permissions_revoked_idx", + "columns": [ + "subject_type", + "subject_id", + "status", + "revoked_at" + ], + "isUnique": false + }, + "permissions_object_idx": { + "name": "permissions_object_idx", + "columns": [ + "object_type", + "object_id" + ], + "isUnique": false + }, + "permissions_live_binding_idx": { + "name": "permissions_live_binding_idx", + "columns": [ + "subject_type", + "subject_id", + "binding" + ], + "isUnique": true, + "where": "status <> 'revoked' AND chat_id IS NULL" + }, + "permissions_live_chat_binding_idx": { + "name": "permissions_live_chat_binding_idx", + "columns": [ + "chat_id", + "subject_type", + "subject_id", + "binding" + ], + "isUnique": true, + "where": "status <> 'revoked' AND chat_id IS NOT NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "platform_versions": { + "name": "platform_versions", + "columns": { + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "record_type_owners": { + "name": "record_type_owners", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "record_type_owners_collection_id_type_pk": { + "columns": [ + "collection_id", + "type" + ], + "name": "record_type_owners_collection_id_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "screen_artifacts": { + "name": "screen_artifacts", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artifact": { + "name": "artifact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "screen": { + "name": "screen", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "screen_artifacts_app_id_apps_id_fk": { + "name": "screen_artifacts_app_id_apps_id_fk", + "tableFrom": "screen_artifacts", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "screen_artifacts_app_id_artifact_pk": { + "columns": [ + "app_id", + "artifact" + ], + "name": "screen_artifacts_app_id_artifact_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "screen_builds": { + "name": "screen_builds", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release": { + "name": "release", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "screen": { + "name": "screen", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artifact": { + "name": "artifact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "screen_builds_app_id_apps_id_fk": { + "name": "screen_builds_app_id_apps_id_fk", + "tableFrom": "screen_builds", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "screen_builds_app_id_version_release_screen_pk": { + "columns": [ + "app_id", + "version", + "release", + "screen" + ], + "name": "screen_builds_app_id_version_release_screen_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "screen_policies": { + "name": "screen_policies", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "screen_policies_app_id_apps_id_fk": { + "name": "screen_policies_app_id_apps_id_fk", + "tableFrom": "screen_policies", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "staff": { + "name": "staff", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "columns": [ + "provider_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_members": { + "name": "team_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "team_members_membership_key_unique": { + "name": "team_members_membership_key_unique", + "columns": [ + "membership_key" + ], + "isUnique": true + }, + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "team_members_user_id_idx": { + "name": "team_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "teams": { + "name": "teams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "member_count": { + "name": "member_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verifications": { + "name": "verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workflow_decisions": { + "name": "workflow_decisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deciders": { + "name": "deciders", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "opened_at": { + "name": "opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workflow_decisions_run_step_idx": { + "name": "workflow_decisions_run_step_idx", + "columns": [ + "run_id", + "step" + ], + "isUnique": true + }, + "workflow_decisions_run_status_idx": { + "name": "workflow_decisions_run_status_idx", + "columns": [ + "run_id", + "status" + ], + "isUnique": false + }, + "workflow_decisions_status_opened_idx": { + "name": "workflow_decisions_status_opened_idx", + "columns": [ + "status", + "opened_at", + "id" + ], + "isUnique": false + }, + "workflow_decisions_decided_idx": { + "name": "workflow_decisions_decided_idx", + "columns": [ + "decided_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workflow_decisions_run_id_workflow_runs_id_fk": { + "name": "workflow_decisions_run_id_workflow_runs_id_fk", + "tableFrom": "workflow_decisions", + "tableTo": "workflow_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workflow_param_values": { + "name": "workflow_param_values", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "param": { + "name": "param", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_at": { + "name": "set_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_param_values_app_id_apps_id_fk": { + "name": "workflow_param_values_app_id_apps_id_fk", + "tableFrom": "workflow_param_values", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workflow_param_values_app_id_workflow_id_param_pk": { + "columns": [ + "app_id", + "workflow_id", + "param" + ], + "name": "workflow_param_values_app_id_workflow_id_param_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workflow_runs": { + "name": "workflow_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_by": { + "name": "started_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure": { + "name": "failure", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_key": { + "name": "trigger_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details_removed_at": { + "name": "details_removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workflow_runs_app_idx": { + "name": "workflow_runs_app_idx", + "columns": [ + "app_id", + "created_at" + ], + "isUnique": false + }, + "workflow_runs_created_idx": { + "name": "workflow_runs_created_idx", + "columns": [ + "created_at", + "id" + ], + "isUnique": false + }, + "workflow_runs_app_workflow_idx": { + "name": "workflow_runs_app_workflow_idx", + "columns": [ + "app_id", + "workflow_id", + "created_at" + ], + "isUnique": false + }, + "workflow_runs_status_ended_idx": { + "name": "workflow_runs_status_ended_idx", + "columns": [ + "status", + "ended_at", + "id" + ], + "isUnique": false + }, + "workflow_runs_trigger_key_idx": { + "name": "workflow_runs_trigger_key_idx", + "columns": [ + "trigger_key" + ], + "isUnique": true + }, + "workflow_runs_details_kept_idx": { + "name": "workflow_runs_details_kept_idx", + "columns": [ + "ended_at", + "id" + ], + "isUnique": false, + "where": "ended_at IS NOT NULL AND details_removed_at IS NULL" + } + }, + "foreignKeys": { + "workflow_runs_app_id_apps_id_fk": { + "name": "workflow_runs_app_id_apps_id_fk", + "tableFrom": "workflow_runs", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workflow_triggers": { + "name": "workflow_triggers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "param": { + "name": "param", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_zone": { + "name": "time_zone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filter": { + "name": "filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failed_starts": { + "name": "failed_starts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "workflow_triggers_position_idx": { + "name": "workflow_triggers_position_idx", + "columns": [ + "app_id", + "version", + "workflow_id", + "position" + ], + "isUnique": true + }, + "workflow_triggers_next_run_idx": { + "name": "workflow_triggers_next_run_idx", + "columns": [ + "next_run_at" + ], + "isUnique": false + }, + "workflow_triggers_address_idx": { + "name": "workflow_triggers_address_idx", + "columns": [ + "address" + ], + "isUnique": false + }, + "workflow_triggers_event_idx": { + "name": "workflow_triggers_event_idx", + "columns": [ + "event" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workflow_triggers_app_id_apps_id_fk": { + "name": "workflow_triggers_app_id_apps_id_fk", + "tableFrom": "workflow_triggers", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/core/src/db/core/migrations/meta/_journal.json b/apps/core/src/db/core/migrations/meta/_journal.json index 4b793deae..5a611adf5 100644 --- a/apps/core/src/db/core/migrations/meta/_journal.json +++ b/apps/core/src/db/core/migrations/meta/_journal.json @@ -393,6 +393,13 @@ "when": 1791659512125, "tag": "0055_business_store_members", "breakpoints": true + }, + { + "idx": 56, + "version": "6", + "when": 1791687062649, + "tag": "0056_permissions_revoked_idx", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/core/src/db/core/schema.ts b/apps/core/src/db/core/schema.ts index 4197205fc..1350a98e5 100644 --- a/apps/core/src/db/core/schema.ts +++ b/apps/core/src/db/core/schema.ts @@ -352,6 +352,14 @@ export const permissions = sqliteTable( table.subjectId, table.status ), + // Revocations of a subject's permissions since a moment: what ends a + // chat's code runs (`revokedSince` in agent-scope.ts). + index("permissions_revoked_idx").on( + table.subjectType, + table.subjectId, + table.status, + table.revokedAt + ), // The Apps granted to call an App's exports, found from that App // (app-provenance.ts): by object, not subject. index("permissions_object_idx").on(table.objectType, table.objectId), diff --git a/apps/core/src/db/workspace/migrations/0008_chat_code_runs.sql b/apps/core/src/db/workspace/migrations/0008_chat_code_runs.sql index e27cf927c..591831fdd 100644 --- a/apps/core/src/db/workspace/migrations/0008_chat_code_runs.sql +++ b/apps/core/src/db/workspace/migrations/0008_chat_code_runs.sql @@ -11,7 +11,10 @@ CREATE TABLE `chat_code_runs` ( `status` text NOT NULL, `started_at` integer NOT NULL, `ended_at` integer, - FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE no action + FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "chat_code_runs_status" CHECK("chat_code_runs"."status" IN ('running', 'completed', 'threw', 'refused', 'load_failed', 'cpu_exceeded', 'crashed', 'wall_expired', 'cancelled', 'revoked', 'interrupted')), + CONSTRAINT "chat_code_runs_tool_call_id" CHECK(length("chat_code_runs"."tool_call_id") BETWEEN 1 AND 200) ); --> statement-breakpoint -CREATE INDEX `chat_code_runs_chat` ON `chat_code_runs` (`chat_id`,`started_at`); \ No newline at end of file +CREATE INDEX `chat_code_runs_chat` ON `chat_code_runs` (`chat_id`,`started_at`);--> statement-breakpoint +CREATE INDEX `chat_code_runs_running` ON `chat_code_runs` (`status`) WHERE status = 'running'; \ No newline at end of file diff --git a/apps/core/src/db/workspace/migrations/meta/0008_snapshot.json b/apps/core/src/db/workspace/migrations/meta/0008_snapshot.json index a161e0b23..d9da6f223 100644 --- a/apps/core/src/db/workspace/migrations/meta/0008_snapshot.json +++ b/apps/core/src/db/workspace/migrations/meta/0008_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "5bab538b-08ee-4cf1-806a-8ba8b15f0790", + "id": "6ff8b1a6-ebe3-4f73-a2c7-318833ab78d1", "prevId": "842142da-ee11-4b56-bd29-cd2164d918bc", "tables": { "audit_outbox": { @@ -191,6 +191,14 @@ "started_at" ], "isUnique": false + }, + "chat_code_runs_running": { + "name": "chat_code_runs_running", + "columns": [ + "status" + ], + "isUnique": false, + "where": "status = 'running'" } }, "foreignKeys": { @@ -210,7 +218,16 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "checkConstraints": { + "chat_code_runs_status": { + "name": "chat_code_runs_status", + "value": "\"chat_code_runs\".\"status\" IN ('running', 'completed', 'threw', 'refused', 'load_failed', 'cpu_exceeded', 'crashed', 'wall_expired', 'cancelled', 'revoked', 'interrupted')" + }, + "chat_code_runs_tool_call_id": { + "name": "chat_code_runs_tool_call_id", + "value": "length(\"chat_code_runs\".\"tool_call_id\") BETWEEN 1 AND 200" + } + } }, "chat_draft_files": { "name": "chat_draft_files", diff --git a/apps/core/src/db/workspace/migrations/meta/_journal.json b/apps/core/src/db/workspace/migrations/meta/_journal.json index 025a736ba..f13c0780b 100644 --- a/apps/core/src/db/workspace/migrations/meta/_journal.json +++ b/apps/core/src/db/workspace/migrations/meta/_journal.json @@ -61,7 +61,7 @@ { "idx": 8, "version": "6", - "when": 1791682971608, + "when": 1791687051646, "tag": "0008_chat_code_runs", "breakpoints": true } diff --git a/apps/core/src/db/workspace/schema.ts b/apps/core/src/db/workspace/schema.ts index 57bca1a11..6de921e42 100644 --- a/apps/core/src/db/workspace/schema.ts +++ b/apps/core/src/db/workspace/schema.ts @@ -3,7 +3,9 @@ * first wake-up after a release. */ import type { ChatId } from "@grasp-os/shared/ids"; +import { sql } from "drizzle-orm"; import { + check, foreignKey, index, integer, @@ -12,6 +14,8 @@ import { text, } from "drizzle-orm/sqlite-core"; +import { codeRunStatuses } from "../../code-run-statuses.ts"; + export const chats = sqliteTable( "chats", { @@ -167,6 +171,9 @@ export const chatAttachments = sqliteTable( * here is ever run again: a restart ends a run's lease, and the agent's * next step is a new run with a new ID. Deleted with the chat. */ +/** Most characters of a tool call's ID a run's record keeps. */ +export const maxToolCallIdChars = 200; + export const chatCodeRuns = sqliteTable( "chat_code_runs", { @@ -184,9 +191,27 @@ export const chatCodeRuns = sqliteTable( cpuLimit: text("cpu_limit", { enum: ["enforced", "cpu_limit_unsupported"], }).notNull(), - status: text().notNull(), + status: text({ enum: ["running", ...codeRunStatuses] }).notNull(), startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(), endedAt: integer("ended_at", { mode: "timestamp_ms" }), }, - (table) => [index("chat_code_runs_chat").on(table.chatId, table.startedAt)] + (table) => [ + index("chat_code_runs_chat").on(table.chatId, table.startedAt), + // The runs a wake marks interrupted: few, found without a scan. + index("chat_code_runs_running") + .on(table.status) + .where(sql`status = 'running'`), + check( + "chat_code_runs_status", + sql`${table.status} IN (${sql.raw( + ["running", ...codeRunStatuses] + .map((status) => `'${status}'`) + .join(", ") + )})` + ), + check( + "chat_code_runs_tool_call_id", + sql`length(${table.toolCallId}) BETWEEN 1 AND ${sql.raw(String(maxToolCallIdChars))}` + ), + ] ); diff --git a/apps/core/src/env.d.ts b/apps/core/src/env.d.ts index 6fc19162a..72cb2ece2 100644 --- a/apps/core/src/env.d.ts +++ b/apps/core/src/env.d.ts @@ -31,8 +31,10 @@ interface __BaseEnv_Env { /** Days an ended workflow run keeps its details (src/workflows/retention.ts). */ RUN_RETENTION_DAYS?: unknown; /** - * `workerd` where core runs on plain workerd (tests, local development, - * on-prem), whose isolates don't enforce a CPU limit (src/code-mode.ts). + * `cloudflare` where core runs on Cloudflare's runtime, which enforces + * Code Mode's CPU limit; anything else, unset included, is taken as a + * runtime that doesn't (src/code-mode.ts). Tests, local development and + * on-prem say `workerd`. */ RUNTIME_PROFILE?: string; /** `none` where Durable Objects have no jurisdiction (src/durable-objects.ts). */ diff --git a/apps/core/src/workspace.ts b/apps/core/src/workspace.ts index 194b68ea1..b0c93db13 100644 --- a/apps/core/src/workspace.ts +++ b/apps/core/src/workspace.ts @@ -50,9 +50,14 @@ import { auditAgentCall, chatAuthority, chatContext, + revokedSince, workspaceAgentIdSchema, } from "./agent-scope.ts"; -import type { CodeRunAdmission, CodeRunCall } from "./agent-scope.ts"; +import type { + AgentScope, + CodeRunAdmission, + CodeRunCall, +} from "./agent-scope.ts"; import { isMessage, runTurn } from "./agent.ts"; import type { TurnContext, TurnResult } from "./agent.ts"; import type { AppAnswer } from "./app.ts"; @@ -63,7 +68,7 @@ import { chatMessageOf, partialOf } from "./chat-messages.ts"; import { ChatWatch } from "./chat-watch.ts"; import type { ChatListener, ChatState } from "./chat-watch.ts"; import { codeLimits, codeRuntime, codeTimeoutMs } from "./code-mode.ts"; -import type { CodeRunStatus } from "./code-mode.ts"; +import type { CodeRunStatus } from "./code-run-statuses.ts"; import { migrateOnWake } from "./db/migrate.ts"; import migrations from "./db/workspace/migrations/migrations.js"; import { @@ -71,6 +76,7 @@ import { chatAttachments, chatCodeRuns, chatDraftFiles, + maxToolCallIdChars, chatDrafts, chatMessages, chatSources, @@ -175,6 +181,12 @@ export const maxChatChars = 4_000_000; */ const endedRunsKept = 1000; +/** + * Most code run records a chat keeps, newest first: a long chat's run + * history, bounded (`chat_code_runs`). + */ +const codeRunRecordsKept = 1000; + /** * A code run as the object keeps its lease: open, with the API calls it * has made and when it opened (a revocation after that ends it), or @@ -799,7 +811,8 @@ export class Workspace extends DurableObject { : undefined, runs: { open: (opening) => this.openCodeRun(chat.id, opening), - close: (runId, status) => this.closeCodeRun(chat.id, runId, status), + close: async (runId, status) => + await this.#endCodeRun(scope, runId, status), }, loader: this.env.LOADER, codeTimeoutMs: codeTimeoutMs(this.env), @@ -954,17 +967,30 @@ export class Workspace extends DurableObject { */ openCodeRun(chatId: ChatId, opening: CodeRunOpening): string { const id = crypto.randomUUID(); - this.#db - .insert(chatCodeRuns) - .values({ - id, - chatId, - ...opening, - ...codeRuntime(this.env), - status: "running", - startedAt: new Date(), - }) - .run(); + this.ctx.storage.transactionSync(() => { + this.#db + .insert(chatCodeRuns) + .values({ + id, + chatId, + ...opening, + toolCallId: opening.toolCallId.slice(0, maxToolCallIdChars), + ...codeRuntime(this.env), + status: "running", + startedAt: new Date(), + }) + .run(); + // The chat keeps the records of its latest runs only. + this.#db + .delete(chatCodeRuns) + .where( + and( + eq(chatCodeRuns.chatId, chatId), + sql`${chatCodeRuns.id} NOT IN (SELECT ${chatCodeRuns.id} FROM ${chatCodeRuns} WHERE ${chatCodeRuns.chatId} = ${chatId} ORDER BY ${chatCodeRuns.startedAt} DESC LIMIT ${codeRunRecordsKept})` + ) + ) + .run(); + }); this.#codeRuns.set(`${chatId}/${id}`, { calls: 0, openedAt: Date.now() }); return id; } @@ -1002,6 +1028,33 @@ export class Workspace extends DurableObject { return ended; } + /** + * Ends a code run of the chat's turn: first asks whether a permission of + * its agent was revoked since it opened, so a revocation its code never + * called after is still noticed (and recorded once), then closes it + * (`closeCodeRun`) as revoked or as it ended. + */ + async #endCodeRun( + scope: Omit, + runId: string, + status: CodeRunStatus + ): Promise { + const admission = this.admitFromCodeRun(scope.chatId, runId); + if ( + admission.call === "open" && + (await revokedSince(this.env, scope, admission.openedAt)) && + this.revokeCodeRun(scope.chatId, runId) + ) { + await auditAgentCall( + this.env, + scope, + { method: "executeCode" }, + { outcome: "refused", reason: "agent.run_revoked" } + ); + } + return this.closeCodeRun(scope.chatId, runId, status); + } + /** * Closes a code run's lease, and forgets the oldest closed runs past the * cap. Moved to the end, so the map keeps closed runs oldest first. @@ -1111,21 +1164,22 @@ export class Workspace extends DurableObject { /** * Records what an API call of a code run of the chat read from (see - * agent-apis.ts), before the call hands over what it read: `false`, and - * nothing recorded, once the run has ended, so the call must not hand it - * over. Each source is kept once, for good; every later model request of + * agent-apis.ts), before the call hands over what it read: why not, and + * nothing recorded, once the run has ended or a revocation closed it, so + * the call must not hand it over (`open` when recorded). Each source is kept once, for good; every later model request of * the chat carries them all. */ recordSources( chatId: ChatId, runId: string, ids: readonly string[] - ): boolean { - if (this.admitFromCodeRun(chatId, runId).call !== "open") { - return false; + ): CodeRunAdmission["call"] { + const { call } = this.admitFromCodeRun(chatId, runId); + if (call !== "open") { + return call; } this.#keepSources(chatId, sourcesSchema.parse(ids)); - return true; + return call; } /** diff --git a/apps/core/test/agent-builds.test.ts b/apps/core/test/agent-builds.test.ts index ff00d185e..2e67cd592 100644 --- a/apps/core/test/agent-builds.test.ts +++ b/apps/core/test/agent-builds.test.ts @@ -14,9 +14,12 @@ import { codeResults, codeStep, says, + pointAtGateway, setObjectEnv, testRunOpening, } from "./agent-chat.ts"; +import type { WorkspaceStub } from "./agent-chat.ts"; +import { fakeGateway } from "./ai-gateway.ts"; import type { GatewayReply } from "./ai-gateway.ts"; import { outlook, release, requestGranted } from "./apps.ts"; import { allEvents } from "./audit-events.ts"; @@ -155,6 +158,53 @@ const setUp = async ( return { admin, builder, person, existing, chat, grant }; }; +/** + * Runs `during` with the object's code runs limited to a second, and every + * read of core's files two seconds slow: a call let in at once outlives + * its run while it waits on them. Set back however it ends. + */ +const slowFilesDuring = async ( + stub: WorkspaceStub, + during: () => Promise +): Promise => { + const { FILES: files } = env; + const slow = new Proxy(files, { + get: (target, property) => { + const value: unknown = Reflect.get(target, property); + if (property === "get" && typeof value === "function") { + return async (...args: unknown[]): Promise => { + await scheduler.wait(2000); + return await Reflect.apply(value, target, args); + }; + } + return typeof value === "function" + ? (...args: unknown[]): unknown => Reflect.apply(value, target, args) + : value; + }, + }); + await setObjectEnv(stub, { CODE_RUN_TIMEOUT_MS: "1000" }); + env.FILES = slow; + try { + await during(); + } finally { + env.FILES = files; + await setObjectEnv(stub, { CODE_RUN_TIMEOUT_MS: "" }); + } +}; + +/** The methods of the chat's calls refused because their run had ended. */ +const endedRefusals = async (chatId: string): Promise => { + const events = await allEvents(); + return events + .filter( + ({ action, detail }) => + action === "agent.call" && + detail.chat === chatId && + detail.reason === "agent.run_ended" + ) + .map(({ detail }) => detail.method); +}; + /** The App the agent created, as the person's session finds it. */ const createdApp = async ( api: Awaited>["api"] @@ -667,45 +717,73 @@ describe("building Apps from a chat", { timeout: 120_000 }, () => { }); }); - it("writes nothing from code still running after its run's wall-clock time", async () => { - const { chat, person, grant } = await setUp([ - codeStep(`export default async (self, env) => { - const app = await env.build.create({ name: ${JSON.stringify(appName)} }); - await scheduler.wait(2_000); - await env.build.write(app.id, { "screens/late.tsx": "export default () => null;" }); - };`), - says("It ran out of time."), - ]); + it("lands no draft write whose run ran out of time while it was under way", async () => { + const { chat, existing, grant } = await setUp([]); + await pointAtGateway( + chat.stub, + fakeGateway( + codeStep(`export default async (self, env) => { + await env.build.write(${JSON.stringify(existing)}, { "screens/late.tsx": "export default () => null;" }); + };`), + says("It ran out of time.") + ) + ); await grant(); - // Objects share their env: set back however the test ends. - await setObjectEnv(chat.stub, { CODE_RUN_TIMEOUT_MS: "1000" }); - try { - await chat.ask("Build it slowly."); - } finally { - await setObjectEnv(chat.stub, { CODE_RUN_TIMEOUT_MS: "" }); - } - const app = await createdApp(person.api); - // The code goes on past its run's end; its write is refused, once. + // The write is let in at once, then waits on the App's files past its + // run's end: refused as it commits. + await slowFilesDuring(chat.stub, async () => { + await chat.ask("Write it."); + }); + await vi.waitFor( async () => { - const events = await allEvents(); - expect( - events.filter( - ({ action, detail }) => - action === "agent.call" && - detail.chat === chat.chat.id && - detail.reason === "agent.run_ended" - ) - ).toHaveLength(1); + await expect(endedRefusals(chat.chat.id)).resolves.toStrictEqual([ + "build.write", + ]); }, { timeout: 15_000, interval: 100 } ); await expect( - chat.stub.draft(chatIdSchema.parse(chat.chat.id), app.id) + chat.stub.draft(chatIdSchema.parse(chat.chat.id), existing) ).resolves.toStrictEqual({ base: null, changes: {}, revision: 0 }); }); + it("commits no proposal whose run ran out of time while it was checked", async () => { + const { chat, builder, existing, grant } = await setUp([]); + await pointAtGateway( + chat.stub, + fakeGateway( + codeStep(`export default async (self, env) => { + await env.build.write(${JSON.stringify(existing)}, { "AGENTS.md": "# Ledger, again\\n" }); + };`), + says("Written."), + codeStep(`export default async (self, env) => { + await env.build.propose(${JSON.stringify(existing)}, "Again"); + };`), + says("It ran out of time.") + ) + ); + await grant(); + await chat.ask("Write it."); + + // Checked past its run's end: the commit after the check is refused. + await slowFilesDuring(chat.stub, async () => { + await chat.ask("Propose it."); + }); + + await vi.waitFor( + async () => { + await expect(endedRefusals(chat.chat.id)).resolves.toStrictEqual([ + "build.propose", + ]); + }, + { timeout: 30_000, interval: 200 } + ); + const listed = await builder.api.apps.list(); + expect(listed.find(({ id }) => id === existing)?.pendingVersion).toBeNull(); + }); + it("lands a draft write only over the revision it read", async () => { const { chat } = await setUp([]); const chatId = chatIdSchema.parse(chat.chat.id); diff --git a/apps/core/test/agent-knowledge.test.ts b/apps/core/test/agent-knowledge.test.ts index 0b696d160..e5aacc5b7 100644 --- a/apps/core/test/agent-knowledge.test.ts +++ b/apps/core/test/agent-knowledge.test.ts @@ -497,7 +497,7 @@ describe("a chat's Knowledge", setUpTime, () => { await expect( stub.recordSources(chat.id, crypto.randomUUID(), ["collection-1"]) - ).resolves.toBeFalsy(); + ).resolves.toBe("ended"); await ask("Hi."); const events = await eventsOf( diff --git a/apps/core/test/agent.test.ts b/apps/core/test/agent.test.ts index 0f2ce0b21..cb642a28e 100644 --- a/apps/core/test/agent.test.ts +++ b/apps/core/test/agent.test.ts @@ -17,7 +17,7 @@ import { maxSteps, requestChars, } from "../src/agent.ts"; -import { codeLimits, sourceHash } from "../src/code-mode.ts"; +import { codeLimits, codeRuntime, sourceHash } from "../src/code-mode.ts"; import { workspace } from "../src/durable-objects.ts"; import { chatOf, @@ -1442,6 +1442,34 @@ describe("chat agent after a restart", () => { }); }); +/** + * A revoked permission of `agentId`, stored as a revocation leaves one: + * granted before, or (`granted: false`) a request declined before anyone + * granted it; held everywhere, or in one chat. + */ +const revokedRow = async ( + agentId: string, + { granted, chatId }: { granted: boolean; chatId: string | null } +) => { + await env.DB.prepare( + `INSERT INTO permissions (id, subject_type, subject_id, object_type, object_id, + actions, binding, status, requested_by, requested_at, granted_by, granted_at, + revoked_by, revoked_at, chat_id) + VALUES (?, 'agent', ?, 'collection', ?, '["read"]', ?, 'revoked', 'test', 0, ?, ?, 'test', ?, ?)` + ) + .bind( + crypto.randomUUID(), + agentId, + crypto.randomUUID(), + `R${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`.toUpperCase(), + granted ? "test" : null, + granted ? 0 : null, + Date.now(), + chatId + ) + .run(); +}; + /** The chat's code run records, oldest first, as the object keeps them. */ const runRecords = async (stub: WorkspaceStub, chatId: string) => await runInDurableObject(stub, (_instance, state) => @@ -1611,6 +1639,112 @@ describe("chat agent code run leases", () => { ); }); + it("notices a revocation the code never called after: it ends revoked, and hands on neither its result nor its logs", async () => { + const admin = await signedInApi(idp, "admin"); + const { collectionId } = await collectionWithNote(admin.api, { + name: `Handbook ${crypto.randomUUID()}`, + access: "everyone", + }); + const { stub, chat, agent, ask } = await newChat( + codeStep( + "export default async (self, env) => { const info = await env.chat.info(); await scheduler.wait(3_000); console.log('leaked', info.chatId); return 'read before the revoke'; };" + ), + says("Done.") + ); + const permission = await requestGranted( + idp, + admin, + readCollection(agent, collectionId, "HANDBOOK") + ); + + const reply = ask("Look once."); + await vi.waitFor( + async () => { + await expect( + runRecords(stub, chat.id).then((records) => records.length) + ).resolves.toBe(1); + }, + { timeout: 10_000 } + ); + await admin.api.permissions.revoke(permission); + await reply; + + await expect(codeResults(stub, chat.id)).resolves.toStrictEqual([ + { + isError: true, + text: `Error (revoked):\n${agentErrors.create("agent.run_revoked").message}`, + }, + ]); + await expect( + runRecords(stub, chat.id).then((records) => + records.map(({ status }) => status) + ) + ).resolves.toStrictEqual(["revoked"]); + await vi.waitFor( + async () => { + await expect( + refusalsIn(chat.id, "agent.run_revoked") + ).resolves.toStrictEqual([ + { + method: "executeCode", + outcome: "refused", + reason: "agent.run_revoked", + }, + ]); + }, + { timeout: 10_000, interval: 100 } + ); + }); + + it("leaves a run alone when what was revoked never gave it authority: a request never granted, or another chat's permission", async () => { + const { stub, chat, agent, ask } = await newChat( + codeStep( + "export default async (self, env) => { await env.chat.info(); await scheduler.wait(1_500); return (await env.chat.info()).chatId; };" + ), + says("Done.") + ); + + const reply = ask("Look twice."); + await vi.waitFor( + async () => { + await expect( + runRecords(stub, chat.id).then((records) => records.length) + ).resolves.toBe(1); + }, + { timeout: 10_000 } + ); + // As a person declining the chat's request stores it, and as a + // revocation in another of the agent's chats does. + await revokedRow(agent.agentId, { granted: false, chatId: chat.id }); + await revokedRow(agent.agentId, { + granted: true, + chatId: crypto.randomUUID(), + }); + await reply; + + await expect(codeStatuses(stub, chat.id)).resolves.toStrictEqual([ + "completed", + ]); + await expect( + refusalsIn(chat.id, "agent.run_revoked") + ).resolves.toStrictEqual([]); + }); + + it("takes a CPU limit as enforced only where the deployment says it runs on Cloudflare", () => { + expect( + [undefined, "workerd", "Cloudflare", "cloudflare"].map( + (profile) => + codeRuntime(profile === undefined ? {} : { RUNTIME_PROFILE: profile }) + .cpuLimit + ) + ).toStrictEqual([ + "cpu_limit_unsupported", + "cpu_limit_unsupported", + "cpu_limit_unsupported", + "enforced", + ]); + }); + it("ends a run a restart cuts short: its lease closes, its record says interrupted, and nothing runs again", async () => { const { id, stub, chat, gateway, ask } = await newChat( codeStep( diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index 1075add39..193dfcd45 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -175,6 +175,8 @@ const platformBindingNames: ReadonlySet = new Set([ "ROUTER_SECRET", "ROUTER_SECRET_PREVIOUS", "RUN_RETENTION_DAYS", + // Whether Code Mode's CPU limit is enforced (core's src/code-mode.ts). + "RUNTIME_PROFILE", "SIGN_IN", // An App's statistics (core's src/statistics.ts), next to its permissions. "STATISTICS", From d33b9d78bf2b517413d667e866a9f3edf93bc4a1 Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 11 Oct 2026 05:24:34 +0200 Subject: [PATCH 3/5] fix(core): close a Code Mode run lease before reading its revocations A run lease now closes at once as the run ends, before anything else can fail or wait, so nothing the code still does is admitted after. Only then are revocations read; one found revises the run to revoked and withholds its result, and a read that fails counts as one. --- apps/core/src/agent-scope.ts | 14 +++- apps/core/src/workspace.ts | 59 +++++++++++---- apps/core/test/agent.test.ts | 143 +++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 19 deletions(-) diff --git a/apps/core/src/agent-scope.ts b/apps/core/src/agent-scope.ts index 94c16d884..4ce56c26a 100644 --- a/apps/core/src/agent-scope.ts +++ b/apps/core/src/agent-scope.ts @@ -217,8 +217,14 @@ export const revokedSince = async ( // From the millisecond the run opened, that one included. No // margin before it: a revocation just before a run opened is in // the grants the run's calls read, and a margin would end the - // next run after any revoke. + // next run after any revoke. The accepted residual is clock skew + // between machines: the Workspace object's clock and the one of + // the request that revoked may differ by a few milliseconds. gte(permissions.revokedAt, new Date(since)), + // Granted once: both ways a permission becomes active set + // granted_at with it (`grantPermission`, and a person's own + // decision in chat-connections.ts), so only a request declined + // before anyone granted it is revoked without one. isNotNull(permissions.grantedAt), or(isNull(permissions.chatId), eq(permissions.chatId, chatId)) ) @@ -296,8 +302,10 @@ export const requireOpenRun = async ( * the lease's own check last, right before the effect. A write into the * Workspace object checks the lease once more in its own transaction. * - * With `method`, a refusal is recorded here, once a run (for a call that - * nothing else records); without, its caller records it (`auditedCall`). + * With `method`, a refusal is recorded here (for a call that nothing else + * records): the one that found a revocation, or one from a run that had + * ended; without, its caller records it (`auditedCall`). So a revoked + * run's refusals are recorded per refused call, not once per run. */ export const admitEffect = async ( env: Env, diff --git a/apps/core/src/workspace.ts b/apps/core/src/workspace.ts index b0c93db13..b05013faa 100644 --- a/apps/core/src/workspace.ts +++ b/apps/core/src/workspace.ts @@ -1029,10 +1029,13 @@ export class Workspace extends DurableObject { } /** - * Ends a code run of the chat's turn: first asks whether a permission of - * its agent was revoked since it opened, so a revocation its code never - * called after is still noticed (and recorded once), then closes it - * (`closeCodeRun`) as revoked or as it ended. + * Ends a code run of the chat's turn. Its lease closes first, at once + * and whatever follows, so nothing it still does is admitted from here + * on. Then revocations since it opened are read once more, so one its + * code never called after is still noticed: the run is recorded as + * revoked (once), and the turn hands on nothing of it. A read that + * fails counts as a revocation: the run's result is withheld rather + * than trusted unchecked. */ async #endCodeRun( scope: Omit, @@ -1040,19 +1043,43 @@ export class Workspace extends DurableObject { status: CodeRunStatus ): Promise { const admission = this.admitFromCodeRun(scope.chatId, runId); - if ( - admission.call === "open" && - (await revokedSince(this.env, scope, admission.openedAt)) && - this.revokeCodeRun(scope.chatId, runId) - ) { - await auditAgentCall( - this.env, - scope, - { method: "executeCode" }, - { outcome: "refused", reason: "agent.run_revoked" } - ); + const ended = this.closeCodeRun(scope.chatId, runId, status); + if (admission.call !== "open" || ended === "revoked") { + return ended; + } + let revoked: boolean; + try { + revoked = await revokedSince(this.env, scope, admission.openedAt); + } catch (error) { + log.error("agent.revocation_read_failed", { + chatId: scope.chatId, + runId, + ...errorFields(error), + }); + revoked = true; } - return this.closeCodeRun(scope.chatId, runId, status); + if (!revoked) { + return ended; + } + this.#db + .update(chatCodeRuns) + .set({ status: "revoked" }) + .where( + and( + eq(chatCodeRuns.id, runId), + eq(chatCodeRuns.chatId, scope.chatId), + eq(chatCodeRuns.status, ended) + ) + ) + .run(); + // Recorded through the outbox, which never fails the turn. + await auditAgentCall( + this.env, + scope, + { method: "executeCode" }, + { outcome: "refused", reason: "agent.run_revoked" } + ); + return "revoked"; } /** diff --git a/apps/core/test/agent.test.ts b/apps/core/test/agent.test.ts index cb642a28e..8647603d5 100644 --- a/apps/core/test/agent.test.ts +++ b/apps/core/test/agent.test.ts @@ -1470,6 +1470,69 @@ const revokedRow = async ( .run(); }; +/** Points the object's database at `db` (objects may share their env). */ +const setObjectDb = async (stub: WorkspaceStub, db: D1Database) => { + await runInDurableObject(stub, (instance) => { + const objectEnv: unknown = Reflect.get(instance, "env"); + if (typeof objectEnv === "object" && objectEnv !== null) { + Reflect.set(objectEnv, "DB", db); + } + }); +}; + +/** + * Runs `during` with every read of revocations the object makes (the + * permissions query `revokedSince` sends) first going through `before`: + * a wait, or a throw. The rest of its database is untouched. Set back + * however it ends. + */ +const withRevocationReads = async ( + stub: WorkspaceStub, + before: () => Promise, + during: () => Promise +): Promise => { + const real = env.DB; + const slowed = (statement: D1PreparedStatement): D1PreparedStatement => + new Proxy(statement, { + get: (target, property) => { + const value: unknown = Reflect.get(target, property); + if (typeof value !== "function") { + return value; + } + if (property === "bind") { + return (...args: unknown[]) => slowed(target.bind(...args)); + } + return async (...args: unknown[]): Promise => { + await before(); + return await Reflect.apply(value, target, args); + }; + }, + }); + const db = new Proxy(real, { + get: (target, property) => { + const value: unknown = Reflect.get(target, property); + if (property === "prepare") { + return (query: string) => { + const statement = target.prepare(query); + // Only `revokedSince`'s read: revocations since a run opened. + return query.includes('"permissions"."revoked_at" >=') + ? slowed(statement) + : statement; + }; + } + return typeof value === "function" + ? (...args: unknown[]): unknown => Reflect.apply(value, target, args) + : value; + }, + }); + await setObjectDb(stub, db); + try { + await during(); + } finally { + await setObjectDb(stub, real); + } +}; + /** The chat's code run records, oldest first, as the object keeps them. */ const runRecords = async (stub: WorkspaceStub, chatId: string) => await runInDurableObject(stub, (_instance, state) => @@ -1745,6 +1808,86 @@ describe("chat agent code run leases", () => { ]); }); + it("closes a run's lease as it ends whatever the revocation read does, and withholds its result when the read fails", async () => { + const { stub, chat, ask } = await newChat( + codeStep( + "export default async () => { console.log('read'); return 'secret'; };" + ), + says("Done.") + ); + + await withRevocationReads( + stub, + async () => { + await Promise.resolve(); + throw new Error("D1 is down for a moment"); + }, + async () => { + await ask("Return it."); + } + ); + + await expect(codeResults(stub, chat.id)).resolves.toStrictEqual([ + { + isError: true, + text: `Error (revoked):\n${agentErrors.create("agent.run_revoked").message}`, + }, + ]); + const [record] = await runRecords(stub, chat.id); + expect(record?.status).toBe("revoked"); + await expect( + runInDurableObject(stub, (instance) => + instance.admitFromCodeRun( + chatIdSchema.parse(chat.id), + String(record?.id) + ) + ) + ).resolves.toStrictEqual({ call: "ended" }); + }); + + it("admits nothing a run does while its end reads revocations", async () => { + // The run ends at its wall clock while its code still waits; the code + // calls during the slow read of revocations that follows. + const { stub, chat, ask } = await newChat( + codeStep( + "export default async (self, env) => { await scheduler.wait(1_300); await env.chat.attachments(); };" + ), + says("Done.") + ); + await setObjectEnv(stub, { CODE_RUN_TIMEOUT_MS: "1000" }); + try { + await withRevocationReads( + stub, + async () => { + await scheduler.wait(2000); + }, + async () => { + await ask("Wait, then look."); + } + ); + } finally { + await setObjectEnv(stub, { CODE_RUN_TIMEOUT_MS: "" }); + } + + await vi.waitFor( + async () => { + await expect( + refusalsIn(chat.id, "agent.run_ended") + ).resolves.toStrictEqual([ + { + method: "chat.attachments", + outcome: "refused", + reason: "agent.run_ended", + }, + ]); + }, + { timeout: 10_000, interval: 100 } + ); + await expect(codeStatuses(stub, chat.id)).resolves.toStrictEqual([ + "wall_expired", + ]); + }); + it("ends a run a restart cuts short: its lease closes, its record says interrupted, and nothing runs again", async () => { const { id, stub, chat, gateway, ask } = await newChat( codeStep( From 1b7fd820e177e1da86a2805c7d76df3410f9868b Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 11 Oct 2026 14:02:26 +0200 Subject: [PATCH 4/5] fix(core): admit a Code Mode write right before it commits, after slow preparation The lease was checked before resolving dependencies, proposing a draft, saving USER.md, creating an App, asking for a permission or a connection, but what came after (fetching packages, storing the version files, preparing the document) could outlast the run, and the write then landed after its lease closed. Each of these now takes an admission and calls it right before its batch; the early check stays as a cheap refusal. --- apps/core/src/agent-builds.ts | 39 ++++++++++++-- apps/core/src/agent-connections.ts | 4 +- apps/core/src/agent-memory.ts | 6 ++- apps/core/src/app-blueprints.ts | 6 ++- apps/core/src/apps.ts | 12 ++++- apps/core/src/chat-connections.ts | 6 ++- apps/core/src/knowledge/memory.ts | 5 +- apps/core/src/packages/resolve.ts | 9 +++- apps/core/src/permissions.ts | 6 ++- apps/core/test/agent-builds.test.ts | 58 ++++++++++++++++++--- apps/core/test/agent.test.ts | 73 ++++++++++++++++++++++++++ apps/core/test/dependencies.test.ts | 81 ++++++++++++++++++++++++++++- 12 files changed, 284 insertions(+), 21 deletions(-) diff --git a/apps/core/src/agent-builds.ts b/apps/core/src/agent-builds.ts index d9abfe206..06f6b399f 100644 --- a/apps/core/src/agent-builds.ts +++ b/apps/core/src/agent-builds.ts @@ -437,6 +437,17 @@ export interface Proposal { /** Building Apps, as a chat's code does it. */ export class BuildApi extends WorkerEntrypoint { + /** + * Admits a write of this run's, handed to what writes it so it runs + * right before the write commits, after any slow preparation (fetching + * packages, building, storing files): a run that ended or lost a + * permission meanwhile writes nothing. The check each method makes + * before it starts is the cheap early refusal. + */ + readonly #admit = async (): Promise => { + await admitEffect(this.env, this.ctx.props); + }; + /** * Runs one build call as the chat's person, with the agent as the audit * log's actor for what it changes: once the agent may build Apps, and @@ -488,7 +499,9 @@ export class BuildApi extends WorkerEntrypoint { return await this.#build( "build.create", async (by) => - await this.#creating(async () => await createApp(this.env, by, input)), + await this.#creating( + async () => await createApp(this.env, by, input, this.#admit) + ), (created) => ({ app: created.id }) ); } @@ -521,7 +534,14 @@ export class BuildApi extends WorkerEntrypoint { "build.createFromBlueprint", async (by) => await this.#creating( - async () => await createFromBlueprint(this.env, by, blueprint, input) + async () => + await createFromBlueprint( + this.env, + by, + blueprint, + input, + this.#admit + ) ), (created) => ({ app: created.app.id, @@ -760,7 +780,14 @@ export class BuildApi extends WorkerEntrypoint { } // The commit is admitted only while the run is still open. await admitEffect(this.env, this.ctx.props); - const proposed = await proposeDraft(this.env, by, id, over, message); + const proposed = await proposeDraft( + this.env, + by, + id, + over, + message, + this.#admit + ); // Committed and up for review: from here on it is proposed, however // the rest goes, and says so, so the agent doesn't try again (and // meet nothing to commit). What follows is logged when it fails. @@ -812,7 +839,8 @@ export class BuildApi extends WorkerEntrypoint { typeof request === "object" && request !== null ? { ...request, subject } : request, - async (named, role) => await appFor(this.env, by, named, role) + async (named, role) => await appFor(this.env, by, named, role), + this.#admit ); }, (requested) => ({ @@ -842,7 +870,8 @@ export class BuildApi extends WorkerEntrypoint { by, typeof intent === "object" && intent !== null ? { ...intent, app: id } - : intent + : intent, + this.#admit ); }, (resolved) => ({ diff --git a/apps/core/src/agent-connections.ts b/apps/core/src/agent-connections.ts index 366c69e84..298ca6c3c 100644 --- a/apps/core/src/agent-connections.ts +++ b/apps/core/src/agent-connections.ts @@ -234,7 +234,9 @@ export class ConnectionsApi extends WorkerEntrypoint { }, async () => { await admitEffect(this.env, scope); - return await askInChat(this.env, scope, ask); + return await askInChat(this.env, scope, ask, async () => { + await admitEffect(this.env, scope); + }); } ); } catch (error) { diff --git a/apps/core/src/agent-memory.ts b/apps/core/src/agent-memory.ts index 7504e3de5..7846c558d 100644 --- a/apps/core/src/agent-memory.ts +++ b/apps/core/src/agent-memory.ts @@ -32,7 +32,11 @@ export class MemoryApi extends WorkerEntrypoint { chatAuthority(scope), chatContext(scope), { type: "own" }, - input + input, + // Again right before the save commits, after it was prepared. + async () => { + await admitEffect(this.env, scope, "memory.saveUser"); + } ); return { version: saved.currentVersion }; } catch (error) { diff --git a/apps/core/src/app-blueprints.ts b/apps/core/src/app-blueprints.ts index 0e29c07dd..95a2b546b 100644 --- a/apps/core/src/app-blueprints.ts +++ b/apps/core/src/app-blueprints.ts @@ -275,7 +275,9 @@ export const createFromBlueprint = async ( env: Env, by: Acting, id: unknown, - input: unknown + input: unknown, + /** Admits the write, called right before it is committed: throws to refuse it. */ + admit?: () => Promise ): Promise => { requireBuilder(by); requireNotStaff(by); @@ -399,6 +401,8 @@ export const createFromBlueprint = async ( ...requests.entries.map((entry) => outboxed(db, entry)), ] as const; try { + // Admitted last, with nothing slow left before the write. + await admit?.(); await auditedBatch(env, db, statements); } catch (error) { if (!(await blueprintRow(env, blueprintIdSchema.parse(blueprint.id)))) { diff --git a/apps/core/src/apps.ts b/apps/core/src/apps.ts index c02bb20e9..e493ff2f4 100644 --- a/apps/core/src/apps.ts +++ b/apps/core/src/apps.ts @@ -442,7 +442,9 @@ export const versionFiles = async ( export const createApp = async ( env: Env, by: Acting, - input: unknown + input: unknown, + /** Admits the write, called right before it is committed: throws to refuse it. */ + admit?: () => Promise ): Promise => { requireBuilder(by); const { name, description, blueprint } = appErrors.parse( @@ -462,6 +464,8 @@ export const createApp = async ( }; const app = toApp(row); const db = drizzle(env.DB); + // Admitted last, with nothing slow left before the write. + await admit?.(); await auditedBatch(env, db, [ db.insert(apps).values(row), outboxed( @@ -814,7 +818,9 @@ export const proposeDraft = async ( by: Acting, app: unknown, { parent, files }: DraftOverLatest, - message: unknown + message: unknown, + /** Admits the write, called right before it is committed: throws to refuse it. */ + admit?: () => Promise ): Promise => { const { id: appId } = await appFor(env, by, app, "builder"); const text = appErrors.parse("app.invalid", commitMessageSchema, message); @@ -848,6 +854,8 @@ export const proposeDraft = async ( }; const db = drizzle(env.DB); try { + // Admitted last, with nothing slow left before the write. + await admit?.(); await auditedBatch(env, db, [ db.insert(appVersions).values(row), outboxed( diff --git a/apps/core/src/chat-connections.ts b/apps/core/src/chat-connections.ts index c0f3e28ec..b0c25de80 100644 --- a/apps/core/src/chat-connections.ts +++ b/apps/core/src/chat-connections.ts @@ -302,7 +302,9 @@ const whyNotStored = async ( export const askInChat = async ( env: Env, scope: AgentScope, - input: unknown + input: unknown, + /** Admits the write, called right before it is committed: throws to refuse it. */ + admit?: () => Promise ): Promise => { const { reason, ...ask } = permissionErrors.parse( "permission.invalid", @@ -384,6 +386,8 @@ export const askInChat = async ( AND NOT EXISTS (SELECT 1 FROM ${permissions} WHERE subject_type = 'agent' AND subject_id = ${row.subjectId} AND binding = ${binding} AND chat_id IS NULL AND status <> 'revoked')` ); try { + // Admitted last, with nothing slow left before the write. + await admit?.(); await auditedBatch(env, db, [ insert, outboxedIfChanged( diff --git a/apps/core/src/knowledge/memory.ts b/apps/core/src/knowledge/memory.ts index 6018ac704..d38709d27 100644 --- a/apps/core/src/knowledge/memory.ts +++ b/apps/core/src/knowledge/memory.ts @@ -545,7 +545,9 @@ export const saveUserMemory = async ( authority: Authority, work: WorkContext, context: unknown, - input: unknown + input: unknown, + /** Admits the save, checked last, right before its batch: throws to refuse. */ + admit?: () => Promise ): Promise => { const { type } = knowledgeErrors.parse( "knowledge.invalid", @@ -581,6 +583,7 @@ export const saveUserMemory = async ( ifVersion, message: message === undefined || message === "" ? null : message, restoredFrom: null, + ...(admit === undefined ? {} : { lastCheck: admit }), } ); }; diff --git a/apps/core/src/packages/resolve.ts b/apps/core/src/packages/resolve.ts index d37a20b92..55bf21432 100644 --- a/apps/core/src/packages/resolve.ts +++ b/apps/core/src/packages/resolve.ts @@ -804,7 +804,12 @@ export interface Resolved { export const resolveDependencies = async ( env: Env, by: Acting, - input: unknown + input: unknown, + /** + * Admits the proposal, called once the graph is fetched and checked, + * right before it is stored: throws to refuse it. + */ + admit?: () => Promise ): Promise => { const intent = dependencyErrors.parse( "dependency.invalid", @@ -870,6 +875,8 @@ export const resolveDependencies = async ( const graph = graphOfLock(lock); // The lock is stored in the batch that stores the request: a request is // never without the lock its graph names, nor a lock without a request. + // Admitted after the slow fetch and checks, right before it is stored. + await admit?.(); const { request, lock: kept } = await proposeResolved( env, by, diff --git a/apps/core/src/permissions.ts b/apps/core/src/permissions.ts index 502b2b7ee..4a66600cf 100644 --- a/apps/core/src/permissions.ts +++ b/apps/core/src/permissions.ts @@ -536,7 +536,9 @@ export const requestPermission = async ( env: Env, by: Acting, input: unknown, - requireAppRole: (app: AppId, role: AppRole) => Promise + requireAppRole: (app: AppId, role: AppRole) => Promise, + /** Admits the write, called right before it is committed: throws to refuse it. */ + admit?: () => Promise ): Promise => { requireBuilder(by); if (by.staff) { @@ -600,6 +602,8 @@ export const requestPermission = async ( // cover one side only. const { subjectType, subjectId } = subjectColumns(subject); try { + // Admitted last, with nothing slow left before the write. + await admit?.(); await auditedBatch(env, db, [ insertWhere( db, diff --git a/apps/core/test/agent-builds.test.ts b/apps/core/test/agent-builds.test.ts index 2e67cd592..a35a90db3 100644 --- a/apps/core/test/agent-builds.test.ts +++ b/apps/core/test/agent-builds.test.ts @@ -159,21 +159,26 @@ const setUp = async ( }; /** - * Runs `during` with the object's code runs limited to a second, and every - * read of core's files two seconds slow: a call let in at once outlives - * its run while it waits on them. Set back however it ends. + * Runs `during` with the object's code runs limited to a second, and the + * calls to core's files `slowed` picks (every read, unless told otherwise) + * two seconds slow: a call let in at once outlives its run while it waits + * on them. Set back however it ends. */ const slowFilesDuring = async ( stub: WorkspaceStub, - during: () => Promise + during: () => Promise, + slowed: (method: string, key: unknown) => boolean = (method) => + method === "get" ): Promise => { const { FILES: files } = env; const slow = new Proxy(files, { get: (target, property) => { const value: unknown = Reflect.get(target, property); - if (property === "get" && typeof value === "function") { + if (typeof property === "string" && typeof value === "function") { return async (...args: unknown[]): Promise => { - await scheduler.wait(2000); + if (slowed(property, args[0])) { + await scheduler.wait(2000); + } return await Reflect.apply(value, target, args); }; } @@ -784,6 +789,47 @@ describe("building Apps from a chat", { timeout: 120_000 }, () => { expect(listed.find(({ id }) => id === existing)?.pendingVersion).toBeNull(); }); + it("commits no proposal whose run ran out of time while it stored the version's files, after its last check", async () => { + const { chat, builder, existing, grant } = await setUp([]); + await pointAtGateway( + chat.stub, + fakeGateway( + codeStep(`export default async (self, env) => { + await env.build.write(${JSON.stringify(existing)}, { "AGENTS.md": "# Ledger, once more\\n" }); + };`), + says("Written."), + codeStep(`export default async (self, env) => { + await env.build.propose(${JSON.stringify(existing)}, "Once more"); + };`), + says("It ran out of time.") + ) + ); + await grant(); + await chat.ask("Write it."); + + // Only storing the version's tree is slow: the check and the + // admission before it pass, and the run ends while the tree is stored. + await slowFilesDuring( + chat.stub, + async () => { + await chat.ask("Propose it."); + }, + (method, key) => + method === "put" && typeof key === "string" && key.includes("/trees/") + ); + + await vi.waitFor( + async () => { + await expect(endedRefusals(chat.chat.id)).resolves.toStrictEqual([ + "build.propose", + ]); + }, + { timeout: 30_000, interval: 200 } + ); + const listed = await builder.api.apps.list(); + expect(listed.find(({ id }) => id === existing)?.pendingVersion).toBeNull(); + }); + it("lands a draft write only over the revision it read", async () => { const { chat } = await setUp([]); const chatId = chatIdSchema.parse(chat.chat.id); diff --git a/apps/core/test/agent.test.ts b/apps/core/test/agent.test.ts index 8647603d5..800c4a08e 100644 --- a/apps/core/test/agent.test.ts +++ b/apps/core/test/agent.test.ts @@ -1533,6 +1533,24 @@ const withRevocationReads = async ( } }; +/** `statement`, answering two seconds late, as its bound copies do. */ +const lateStatement = (statement: D1PreparedStatement): D1PreparedStatement => + new Proxy(statement, { + get: (target, property) => { + const value: unknown = Reflect.get(target, property); + if (typeof value !== "function") { + return value; + } + if (property === "bind") { + return (...args: unknown[]) => lateStatement(target.bind(...args)); + } + return async (...args: unknown[]): Promise => { + await scheduler.wait(2000); + return await Reflect.apply(value, target, args); + }; + }, + }); + /** The chat's code run records, oldest first, as the object keeps them. */ const runRecords = async (stub: WorkspaceStub, chatId: string) => await runInDurableObject(stub, (_instance, state) => @@ -1888,6 +1906,61 @@ describe("chat agent code run leases", () => { ]); }); + it("saves no USER.md for a run that ran out of time while the save was prepared", async () => { + const { stub, chat, agent, ask } = await newChat( + codeStep( + "export default async (self, env) => await env.memory.saveUser({ text: 'Prefers short answers.', ifVersion: 0 });" + ), + says("It ran out of time.") + ); + // Knowledge answers two seconds late; the run has a second. The save + // is let in as it starts, then prepared past the run's end. + const { KNOWLEDGE: knowledge } = env; + const slow = new Proxy(knowledge, { + get: (target, property) => { + const value: unknown = Reflect.get(target, property); + if (property === "prepare") { + return (query: string) => lateStatement(target.prepare(query)); + } + return typeof value === "function" + ? (...args: unknown[]): unknown => Reflect.apply(value, target, args) + : value; + }, + }); + await setObjectEnv(stub, { CODE_RUN_TIMEOUT_MS: "1000" }); + env.KNOWLEDGE = slow; + try { + await ask("Remember it."); + } finally { + env.KNOWLEDGE = knowledge; + await setObjectEnv(stub, { CODE_RUN_TIMEOUT_MS: "" }); + } + + await vi.waitFor( + async () => { + await expect( + refusalsIn(chat.id, "agent.run_ended") + ).resolves.toStrictEqual([ + { + method: "memory.saveUser", + outcome: "refused", + reason: "agent.run_ended", + }, + ]); + }, + { timeout: 30_000, interval: 200 } + ); + const events = await allEvents(); + expect( + events.filter( + ({ action, actor }) => + action === "knowledge.document.saved" && + actor.type === "agent" && + actor.agentId === agent.agentId + ) + ).toStrictEqual([]); + }); + it("ends a run a restart cuts short: its lease closes, its record says interrupted, and nothing runs again", async () => { const { id, stub, chat, gateway, ask } = await newChat( codeStep( diff --git a/apps/core/test/dependencies.test.ts b/apps/core/test/dependencies.test.ts index 6d3134ada..4fe269b57 100644 --- a/apps/core/test/dependencies.test.ts +++ b/apps/core/test/dependencies.test.ts @@ -25,7 +25,13 @@ import { decideDependency, proposeForTests, } from "../src/dependencies/requests.ts"; -import { chatOf, codeResults, codeStep, says } from "./agent-chat.ts"; +import { + chatOf, + codeResults, + codeStep, + says, + setObjectEnv, +} from "./agent-chat.ts"; import { release, requestGranted } from "./apps.ts"; import { allEvents } from "./audit-events.ts"; import { actingFor, envOf } from "./contexts.ts"; @@ -1451,6 +1457,79 @@ describe("dependency approval", () => { ); }); + it("stores no request or lock for a chat's agent whose run ran out of time while the graph was fetched", async () => { + const admin = await personApi("admin"); + const builder = await personApi("builder"); + const { id: existing } = await builder.api.apps.create({ name: "Ledger" }); + await release(builder, existing, { "AGENTS.md": "# Ledger\n" }); + const { id: app } = await builder.api.apps.create({ + name: `App ${unique()}`, + }); + const dates = named("dates"); + await publish(plain(dates, "1.0.0")); + const intent = intentFor(app, { [dates]: "^1.0.0" }); + const chat = await chatOf( + builder.userId, + codeStep(`export default async (self, env) => { + await env.build.resolveDependencies(${JSON.stringify(app)}, ${JSON.stringify(intent)}); + };`), + says("It ran out of time.") + ); + await requestGranted(idp, admin, { + subject: chat.agent, + object: { type: "collection", collectionId: "apps" }, + actions: ["read", "write"], + binding: "APP_LIBRARY", + }); + // The registry answers two seconds late; the run has a second. + const { CONNECT: connect } = env; + const slow = new Proxy(connect, { + get: (target, property) => { + const value: unknown = Reflect.get(target, property); + if (property === "npmMetadata" && typeof value === "function") { + return async (...args: unknown[]): Promise => { + await scheduler.wait(2000); + return await Reflect.apply(value, target, args); + }; + } + return typeof value === "function" + ? (...args: unknown[]): unknown => Reflect.apply(value, target, args) + : value; + }, + }); + await setObjectEnv(chat.stub, { CODE_RUN_TIMEOUT_MS: "1000" }); + env.CONNECT = slow; + try { + await chat.ask("Add a date library"); + } finally { + env.CONNECT = connect; + await setObjectEnv(chat.stub, { CODE_RUN_TIMEOUT_MS: "" }); + } + + // The resolve goes on past the run's end; what it would store is + // refused as it commits: no request, no lock. + await vi.waitFor( + async () => { + const events = await allEvents(); + expect( + events + .filter( + ({ action, detail }) => + action === "agent.call" && + detail.chat === chat.chat.id && + detail.reason === "agent.run_ended" + ) + .map(({ detail }) => detail.method) + ).toStrictEqual(["build.resolveDependencies"]); + }, + { timeout: 30_000, interval: 200 } + ); + await expect(builder.api.dependencies.status(app)).resolves.toMatchObject({ + pending: null, + approved: null, + }); + }); + it("lets a chat's agent resolve, and never propose a graph of its own, approve, grant or ask for the permission", async () => { const admin = await personApi("admin"); const builder = await personApi("builder"); From fc75dddc30831cba721b146884605f32162b8d5e Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 11 Oct 2026 14:35:39 +0200 Subject: [PATCH 5/5] test(core): revoke only after the code's one call was answered, in the run-end revocation test --- apps/core/test/agent.test.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/core/test/agent.test.ts b/apps/core/test/agent.test.ts index 800c4a08e..4c66f81ad 100644 --- a/apps/core/test/agent.test.ts +++ b/apps/core/test/agent.test.ts @@ -1728,7 +1728,7 @@ describe("chat agent code run leases", () => { }); const { stub, chat, agent, ask } = await newChat( codeStep( - "export default async (self, env) => { const info = await env.chat.info(); await scheduler.wait(3_000); console.log('leaked', info.chatId); return 'read before the revoke'; };" + "export default async (self, env) => { const reports = await env.chat.attachments(); await scheduler.wait(3_000); console.log('leaked', reports.length); return 'read before the revoke'; };" ), says("Done.") ); @@ -1739,13 +1739,22 @@ describe("chat agent code run leases", () => { ); const reply = ask("Look once."); + // Revoked once the code's one call was answered, so it calls nothing + // after: the run notices the revocation as it ends, not at a call. await vi.waitFor( async () => { - await expect( - runRecords(stub, chat.id).then((records) => records.length) - ).resolves.toBe(1); + const events = await allEvents(); + expect( + events.some( + ({ action, detail }) => + action === "agent.call" && + detail.chat === chat.id && + detail.method === "chat.attachments" && + detail.outcome === "ok" + ) + ).toBeTruthy(); }, - { timeout: 10_000 } + { timeout: 10_000, interval: 50 } ); await admin.api.permissions.revoke(permission); await reply;