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..06f6b399f 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"; @@ -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, @@ -536,10 +556,13 @@ export class BuildApi extends WorkerEntrypoint { * nothing. */ async #creating(create: () => Promise): Promise { - const { workspaceId, chatId } = this.ctx.props; + 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 + maxCreatesPerTurn, + runId ); if (!taken) { throw appErrors.create("app.creates_exhausted"); @@ -637,6 +660,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 @@ -646,7 +670,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 +698,11 @@ 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 admitEffect(this.env, this.ctx.props); + await workspace(this.env, workspaceId).dropDraft(chatId, id, { + runId, + }); }, () => ({ app: typeof app === "string" ? app : null }) ); @@ -750,7 +778,16 @@ export class BuildApi extends WorkerEntrypoint { reviewUnavailable: false, }; } - const proposed = await proposeDraft(this.env, by, id, over, message); + // 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, + 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. @@ -764,7 +801,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,13 +832,15 @@ 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, 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) => ({ @@ -825,12 +864,14 @@ 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, 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 d318b9769..298ca6c3c 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,12 @@ 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, async () => { + await admitEffect(this.env, scope); + }); + } ); } catch (error) { throw forSandbox(error); @@ -280,6 +286,9 @@ export class ConnectionsApi extends WorkerEntrypoint { ); let result: ConnectResult; try { + // 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 f160e04a7..7846c558d 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,12 +25,18 @@ export class MemoryApi extends WorkerEntrypoint { const scope = this.ctx.props; await requireOpenRun(this.env, scope, "memory.saveUser"); try { + // Recorded here when refused: knowledge records only a save. + await admitEffect(this.env, scope, "memory.saveUser"); const saved = await saveUserMemory( this.env, 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/agent-scope.ts b/apps/core/src/agent-scope.ts index 7e5143340..4ce56c26a 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, gte, isNotNull, isNull, or } 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,32 @@ 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" }; + +/** 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 @@ -125,8 +146,8 @@ export const recordSources = async ( runId, sources ); - if (!recorded) { - throw agentErrors.create("agent.run_ended"); + if (recorded !== "open") { + throw runRefusal(recorded); } }; @@ -171,11 +192,76 @@ export const auditAgentCall = async ( }); }; +/** + * 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`). + */ +export const revokedSince = async ( + env: Env, + { agentId, chatId }: Pick, + 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"), + // 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. 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)) + ) + ) + .limit(1); + return revoked !== undefined; +}; + +/** + * 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 revocationOf = async ( + env: Env, + scope: AgentScope, + openedAt: number +): Promise<"open" | "closed" | "closedBefore"> => { + if (!(await revokedSince(env, scope, openedAt))) { + return "open"; + } + const closed = await workspace(env, scope.workspaceId).revokeCodeRun( + scope.chatId, + scope.runId + ); + return closed ? "closed" : "closedBefore"; +}; + /** * 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. 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, @@ -183,26 +269,75 @@ 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 revocation = + counted.call === "open" + ? await revocationOf(env, scope, counted.openedAt) + : "open"; + if (counted.call === "open" && revocation === "open") { return; } - const refusal = agentErrors.create( - call === "spent" ? "agent.run_calls_spent" : "agent.run_ended" + const refusal = runRefusal( + counted.call === "open" ? "revoked" : counted.call ); - // Once per run: code that goes on calling can't flood the audit log. - if (first) { + if ((counted.call !== "open" && counted.first) || revocation === "closed") { + await auditAgentCall( + env, + scope, + { method }, + { outcome: "refused", reason: refusal.code } + ); + } + 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. 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 (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, + scope: AgentScope, + method?: string +): Promise => { + 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 (refused === undefined) { + return; + } + const refusal = runRefusal(refused); + if (method !== undefined && (record || refused === "ended")) { await auditAgentCall( env, scope, { method }, - { - outcome: "refused", - reason: refusal.code, - } + { outcome: "refused", reason: refusal.code } ); } throw refusal; diff --git a/apps/core/src/agent.ts b/apps/core/src/agent.ts index 2a53d96b2..f34e026b4 100644 --- a/apps/core/src/agent.ts +++ b/apps/core/src/agent.ts @@ -14,9 +14,11 @@ 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 type { CodeRun, CodeRunStatus } from "./code-mode.ts"; +import { describeRun, runCode, sourceHash } 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"; // 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 +211,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) => Promise; } -/** 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 +237,38 @@ 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 = await runs.close(runId, ran); } + // 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: [], + error: agentErrors.create("agent.run_revoked").message, + } + : run; }; /** The one tool: runs code against the chat's APIs in a locked isolate. */ @@ -259,9 +289,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/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/code-mode.ts b/apps/core/src/code-mode.ts index f56e79da6..049b5230c 100644 --- a/apps/core/src/code-mode.ts +++ b/apps/core/src/code-mode.ts @@ -1,7 +1,10 @@ +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"; +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 @@ -48,27 +51,38 @@ export const codeLimits = { } 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), 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. + * 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, + * 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 type CodeRunStatus = - | "completed" - | "threw" - | "refused" - | "load_failed" - | "cpu_exceeded" - | "crashed" - | "wall_expired" - | "cancelled"; +export const codeRuntime = (env: Pick) => { + const cloudflare = env.RUNTIME_PROFILE === "cloudflare"; + return { + agent: `${piAgentCore.name}@${piAgentCore.version}`, + compatibilityDate, + runtime: cloudflare ? ("cloudflare" as const) : ("workerd" as const), + cpuLimit: cloudflare + ? ("enforced" as const) + : ("cpu_limit_unsupported" 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/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 new file mode 100644 index 000000000..591831fdd --- /dev/null +++ b/apps/core/src/db/workspace/migrations/0008_chat_code_runs.sql @@ -0,0 +1,20 @@ +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, + 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`);--> 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 new file mode 100644 index 000000000..d9da6f223 --- /dev/null +++ b/apps/core/src/db/workspace/migrations/meta/0008_snapshot.json @@ -0,0 +1,549 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6ff8b1a6-ebe3-4f73-a2c7-318833ab78d1", + "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 + }, + "chat_code_runs_running": { + "name": "chat_code_runs_running", + "columns": [ + "status" + ], + "isUnique": false, + "where": "status = 'running'" + } + }, + "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_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", + "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..f13c0780b 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": 1791687051646, + "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..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", { @@ -155,3 +159,59 @@ 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. + */ +/** Most characters of a tool call's ID a run's record keeps. */ +export const maxToolCallIdChars = 200; + +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({ 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), + // 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 491d5c55c..72cb2ece2 100644 --- a/apps/core/src/env.d.ts +++ b/apps/core/src/env.d.ts @@ -30,6 +30,13 @@ interface __BaseEnv_Env { AUDIT_ARCHIVE_RETENTION_DAYS?: unknown; /** Days an ended workflow run keeps its details (src/workflows/retention.ts). */ RUN_RETENTION_DAYS?: unknown; + /** + * `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). */ DURABLE_OBJECT_JURISDICTION?: string; /** 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/src/workspace.ts b/apps/core/src/workspace.ts index bf6a8cea4..b05013faa 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 { 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"; @@ -62,13 +67,16 @@ 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-run-statuses.ts"; import { migrateOnWake } from "./db/migrate.ts"; import migrations from "./db/workspace/migrations/migrations.js"; import { auditOutbox, chatAttachments, + chatCodeRuns, chatDraftFiles, + maxToolCallIdChars, chatDrafts, chatMessages, chatSources, @@ -173,6 +181,29 @@ 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 + * 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 +327,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 +402,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 +810,9 @@ 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: async (runId, status) => + await this.#endCodeRun(scope, runId, status), }, loader: this.env.LOADER, codeTimeoutMs: codeTimeoutMs(this.env), @@ -920,20 +960,192 @@ 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. - this.#codeRuns.delete(key); - this.#codeRuns.set(key, "ended"); - const ended = [...this.#codeRuns.keys()].filter( - (run) => typeof this.#codeRuns.get(run) !== "number" + /** + * 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.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; + } + + /** + * 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; + } + + /** + * 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, + runId: string, + status: CodeRunStatus + ): Promise { + const admission = this.admitFromCodeRun(scope.chatId, runId); + 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; + } + 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"; + } + + /** + * 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, { 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,49 +1159,54 @@ 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 }; } /** * 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 (typeof this.#codeRuns.get(`${chatId}/${runId}`) !== "number") { - 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; } /** @@ -1112,6 +1329,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 +1593,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 +1602,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 +1671,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 +1956,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..a35a90db3 100644 --- a/apps/core/test/agent-builds.test.ts +++ b/apps/core/test/agent-builds.test.ts @@ -9,7 +9,17 @@ 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, + 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"; @@ -148,6 +158,58 @@ const setUp = async ( return { admin, builder, person, existing, chat, grant }; }; +/** + * 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, + 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 (typeof property === "string" && typeof value === "function") { + return async (...args: unknown[]): Promise => { + if (slowed(property, args[0])) { + 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"] @@ -660,17 +722,128 @@ describe("building Apps from a chat", { timeout: 120_000 }, () => { }); }); + 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(); + + // 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 () => { + 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), 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("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); - 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 +861,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 +897,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-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 373839a14..4c66f81ad 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, codeRuntime, 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,583 @@ 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(); +}; + +/** 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); + } +}; + +/** `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) => + 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("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 reports = await env.chat.attachments(); await scheduler.wait(3_000); console.log('leaked', reports.length); return 'read before the revoke'; };" + ), + says("Done.") + ); + const permission = await requestGranted( + idp, + admin, + readCollection(agent, collectionId, "HANDBOOK") + ); + + 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 () => { + 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, interval: 50 } + ); + 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("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("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( + "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/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"); 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/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", 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).