diff --git a/apps/core/src/agent-builds.ts b/apps/core/src/agent-builds.ts index e046c413b..8b6a548a4 100644 --- a/apps/core/src/agent-builds.ts +++ b/apps/core/src/agent-builds.ts @@ -39,10 +39,10 @@ import { draftOverLatest, latestVersion, proposeDraft, - proposedByJob, } from "./apps.ts"; import type { Acting, Member } from "./auth/identity.ts"; -import { jobView } from "./build-job-records.ts"; +import { reconcileReceipt } from "./build-job-receipts.ts"; +import { brokenOff, jobView } from "./build-job-records.ts"; import type { BuildJobView } from "./build-job-records.ts"; import { workspace } from "./durable-objects.ts"; import { appsCollectionId } from "./knowledge/app-entries.ts"; @@ -457,15 +457,6 @@ const orphanedJobMs = 60_000; /** Where the engine has an instance that has ended. */ const endedInstances = new Set(["complete", "errored", "terminated"]); -/** How a job that never ran, or whose instance went, ends. */ -const jobNotStarted = { - status: "failed", - error: JSON.stringify({ - code: "internal.unexpected", - message: "The build job couldn't run. Submit it again.", - }), -} as const; - /** * Fails the chat's jobs whose record says they run while the engine has * ended their instance (terminated, errored or complete without their last @@ -500,7 +491,9 @@ const failDeadJobs = async ( ? Date.now() - createdAt.getTime() > orphanedJobMs : endedInstances.has(instance.status); if (dead) { - await chats.endBuildJob(chatId, id, jobNotStarted); + // As one that committed, if it did before it went. + const version = await reconcileReceipt(env, workspaceId, chatId, id); + await chats.endBuildJob(chatId, id, brokenOff(version ?? null)); } }) ); @@ -1077,7 +1070,7 @@ export class BuildApi extends WorkerEntrypoint { ...errorFields(error), }); await chats - .endBuildJob(chatId, opened.id, jobNotStarted) + .endBuildJob(chatId, opened.id, brokenOff(null)) .catch(logCountFailure); throw error; } @@ -1111,17 +1104,7 @@ export class BuildApi extends WorkerEntrypoint { } await this.#buildable(by, found.appId); await failDeadJobs(this.env, workspaceId, chatId); - if (found.version === null && found.intendedTree !== null) { - const version = await proposedByJob( - this.env, - found.appId, - { parent: found.intendedParent, tree: found.intendedTree }, - { chatId, jobId: found.id } - ); - if (version !== undefined) { - await chats.recordBuildReceipt(chatId, found.id, version); - } - } + await reconcileReceipt(this.env, workspaceId, chatId, found.id); const now = await chats.buildJob(chatId, found.id); return jobView(now ?? found); }, diff --git a/apps/core/src/build-job-receipts.ts b/apps/core/src/build-job-receipts.ts new file mode 100644 index 000000000..1d42f7b2a --- /dev/null +++ b/apps/core/src/build-job-receipts.ts @@ -0,0 +1,44 @@ +import type { ChatId, WorkspaceId } from "@grasp-os/shared/ids"; + +import { proposedByJob } from "./apps.ts"; +import { workspace } from "./durable-objects.ts"; + +// A build job's receipt (build-jobs.ts): the version it committed. The job +// records the tree it is about to commit before it commits; a version over +// that parent with that tree, proposed by this very job, is found here and +// recorded as its receipt, however the job went on: cut off, refused or +// ended behind its record's back. + +/** + * The version the chat's job `jobId` committed, recorded as its receipt + * once found: the one its record names, or the one it recorded it was + * about to commit and did. None when it committed nothing. + */ +export const reconcileReceipt = async ( + env: Env, + workspaceId: WorkspaceId, + chatId: ChatId, + jobId: string +): Promise => { + const chats = workspace(env, workspaceId); + const job = await chats.buildJob(chatId, jobId); + if (job === undefined) { + return undefined; + } + if (job.version !== null) { + return job.version; + } + if (job.intendedTree === null) { + return undefined; + } + const version = await proposedByJob( + env, + job.appId, + { parent: job.intendedParent, tree: job.intendedTree }, + { chatId: job.chatId, jobId: job.id } + ); + if (version !== undefined) { + await chats.recordBuildReceipt(job.chatId, job.id, version); + } + return version; +}; diff --git a/apps/core/src/build-job-records.ts b/apps/core/src/build-job-records.ts index f10c2b5fb..7c4bfeabb 100644 --- a/apps/core/src/build-job-records.ts +++ b/apps/core/src/build-job-records.ts @@ -14,7 +14,7 @@ import { chatBuildJobs } from "./db/workspace/schema.ts"; // interleaved with another call. /** Most build jobs a chat may have running at once. */ -export const maxRunningJobs = 3; +const maxRunningJobs = 3; /** * Most build job records a chat keeps, newest first: what its agent may @@ -23,6 +23,22 @@ export const maxRunningJobs = 3; */ export const jobRecordsKept = 100; +/** + * How a job that broke off ends, failed: as one that committed version + * `version` when it did, so the agent doesn't propose it again, or as one + * that didn't run to its end, to submit again. + */ +export const brokenOff = (version: number | null): BuildJobEnd => ({ + status: "failed", + error: JSON.stringify({ + code: "internal.unexpected", + message: + version === null + ? "The build job couldn't run to its end. Submit it again." + : `The build job ended unexpectedly after committing version ${version}, which is proposed for review. Don't propose it again.`, + }), +}); + /** A build job's row. */ export type BuildJobRow = typeof chatBuildJobs.$inferSelect; diff --git a/apps/core/src/build-jobs.ts b/apps/core/src/build-jobs.ts index c0853812b..9d84e73e6 100644 --- a/apps/core/src/build-jobs.ts +++ b/apps/core/src/build-jobs.ts @@ -1,6 +1,6 @@ import { agentErrors } from "@grasp-os/shared/agent"; import { appErrors } from "@grasp-os/shared/apps"; -import { internalErrors, isExpectedError } from "@grasp-os/shared/errors"; +import { isExpectedError } from "@grasp-os/shared/errors"; import { chatIdSchema, workspaceIdSchema } from "@grasp-os/shared/ids"; import type { AppId } from "@grasp-os/shared/ids"; import { permissionErrors } from "@grasp-os/shared/permissions"; @@ -29,11 +29,12 @@ import { draftFiles, draftOverLatest, proposeDraft, - proposedByJob, versionTree, } from "./apps.ts"; import type { Acting } from "./auth/identity.ts"; import { memberOf } from "./auth/identity.ts"; +import { reconcileReceipt } from "./build-job-receipts.ts"; +import { brokenOff } from "./build-job-records.ts"; import type { BuildJobEnd } from "./build-job-records.ts"; import { workspace } from "./durable-objects.ts"; import { grantedPermissions } from "./permissions.ts"; @@ -69,6 +70,10 @@ import type { BuildJob, Draft } from "./workspace.ts"; // it, granted when that run opened. Each step, and each write right // before it commits, reads revocations since then (`admitJob`): one // found ends the job as revoked, and nothing it does after is admitted. +// Any revoked permission of the agent ends it, not only those a build +// uses (decided for GRA-443): a revocation is an admin narrowing the +// agent's authority, which a job mustn't outlive on a guess at which +// grants its work rests on, and a job is cheap to submit again. // A revoked job hands on no result. What it committed before keeps its // receipt: it stays, proposed for review, and is never said to be undone. // - Cancellation: cancelling the turn that submitted it, or deleting the @@ -103,7 +108,7 @@ import type { BuildJob, Draft } from "./workspace.ts"; // App's run. /** What the dispatcher hands a job (`env.build.submit`). */ -export const buildJobInputSchema = z.strictObject({ +const buildJobInputSchema = z.strictObject({ workspaceId: workspaceIdSchema, chatId: chatIdSchema, jobId: z.uuid(), @@ -143,10 +148,11 @@ const scopeOf = ( * Admits a step or write of the job `jobId`, right before it: refused once * the job has ended (cancelled with its turn or chat, or revoked), and * when a permission of its agent was revoked since its authority was - * granted, which ends it as revoked. The slow read of revocations comes - * first, the job's own check last. + * granted, which ends it as revoked. The job's own check comes first, a + * cheap refusal, and again last, after the slow read of revocations, so + * nothing ended meanwhile is admitted. */ -export const admitJob = async ( +const admitJob = async ( env: Env, scope: Omit, jobId: string @@ -258,7 +264,8 @@ const asJob = async ( const value = await auditedCall( env, { ...scope, runId: job.runId }, - { method, detail: { job: job.id, app: job.appId } }, + // The run that submitted it, recorded with each of its calls. + { method, detail: { job: job.id, app: job.appId, run: job.runId } }, async () => { const { by, app } = await authorize(env, scope, job); return await run({ job, scope, app, by }); @@ -334,36 +341,6 @@ const checkStep = async (env: Env, step: JobStep): Promise => { }; }; -/** - * Records, as the job's receipt, the version it recorded it was about to - * commit and did commit, if it did, before anything else of a commit run - * again: whether or not the job may still act, as what it committed stays. - * The version, if there is one. - */ -const reconcileReceipt = async ( - env: Env, - input: JobInput -): Promise => { - const chats = workspace(env, input.workspaceId); - const job = await chats.buildJob(input.chatId, input.jobId); - if (job?.version !== null) { - return job?.version; - } - if (job.intendedTree === null) { - return undefined; - } - const version = await proposedByJob( - env, - job.appId, - { parent: job.intendedParent, tree: job.intendedTree }, - { chatId: job.chatId, jobId: job.id } - ); - if (version !== undefined) { - await chats.recordBuildReceipt(job.chatId, job.id, version); - } - return version; -}; - /** * Commits the job's draft as the App's next version, pending review, as * `env.build.propose` does once its check passed: admitted right before @@ -448,15 +425,6 @@ const completed = (result: DraftCheck | Proposal): BuildJobEnd => ({ result: JSON.stringify(result), }); -/** The job's end for a step that failed for good: internal, unexpected. */ -const unexpected = (): BuildJobEnd => { - const error = internalErrors.create("internal.unexpected"); - return { - status: "failed", - error: JSON.stringify({ code: error.code, message: error.message }), - }; -}; - /** * Runs `run` as the engine's step `name` (`asJob`), journaled: run again * after a restart only when it never finished. @@ -506,7 +474,8 @@ const jobSteps = async ( }); } const proposed = await jobStep(step, "commit", async () => { - await reconcileReceipt(env, input); + // Before it asks to be admitted: what it committed stays its receipt. + await reconcileReceipt(env, input.workspaceId, input.chatId, input.jobId); return await asJob( env, input, @@ -529,18 +498,29 @@ export const runBuildJob = async ( step: RunStep ): Promise => { const input = buildJobInputSchema.parse(payload); - let end: BuildJobEnd; + // Null when a step failed for good, past its retries. Should the engine + // have stopped this execution instead, the next step throws as well, + // and the engine runs the job again from here. + let end: BuildJobEnd | null; try { end = await jobSteps(env, input, step); } catch { - // A step failed for good, past its retries. Should the engine have - // stopped this execution instead, the next step throws as well, and - // the engine runs the job again from here. - end = unexpected(); + end = null; } await step.do("finish", stepConfig, async () => { const chats = workspace(env, input.workspaceId); - let ending = end; + // Broken off: as one that committed, if it did, never as one to submit + // again. + let ending = + end ?? + brokenOff( + (await reconcileReceipt( + env, + input.workspaceId, + input.chatId, + input.jobId + )) ?? null + ); if (ending.status === "completed") { const job = await chats.buildJob(input.chatId, input.jobId); try { diff --git a/apps/core/src/workspace.ts b/apps/core/src/workspace.ts index c3a3ad6a5..34abde724 100644 --- a/apps/core/src/workspace.ts +++ b/apps/core/src/workspace.ts @@ -115,12 +115,17 @@ export type Chat = typeof chats.$inferSelect; * A chat's turn while it runs: what cancels it, its ID (random, and only * this turn's), and what the chat's build jobs it submitted said of how * they ended (`endBuildJob`), waiting for its agent to read before its - * next model request. + * next model request. Those notices are the turn's alone: its model + * requests carry them from then on, and they are never stored with the + * chat (`notices` names them, for `keep` to pass over), so they leave the + * prompt with their turn. A later turn reads how a job ended with + * `env.build.job`, for as long as the chat keeps its record. */ interface LiveTurn { cancel: AbortController; id: string; events: Message[]; + notices: Set; } /** A build job, with the chat's agent and person it acts for. */ @@ -914,7 +919,12 @@ export class Workspace extends DurableObject { // Taken before the first await, so a second question waits its turn // and the chat isn't deleted under it (`deleteChat`). const cancel = new AbortController(); - const live: LiveTurn = { cancel, id: crypto.randomUUID(), events: [] }; + const live: LiveTurn = { + cancel, + id: crypto.randomUUID(), + events: [], + notices: new Set(), + }; this.#turns.set(chat.id, live); // Marked in storage as long as it is in `#turns`, however it ends. this.ctx.storage.kv.put( @@ -983,6 +993,14 @@ export class Workspace extends DurableObject { signal: cancel.signal, events: () => live.events.splice(0), keep: (message) => { + // A job's notice is this turn's only (`LiveTurn`). + if ( + message.role === "system" && + typeof message.content === "string" && + live.notices.has(message.content) + ) { + return; + } if (message.role === "assistant") { this.#partials.delete(chat.id); } @@ -1588,7 +1606,8 @@ export class Workspace extends DurableObject { */ admitFromBuildJob(chatId: unknown, id: string): BuildJobAdmission { const chat = chatIdSchema.safeParse(chatId); - return chat.success + // A chat being deleted admits nothing of its jobs (`deleteChat`). + return chat.success && !this.#deleting.has(chat.data) ? this.#jobs.admission(chat.data, id) : { call: "cancelled" }; } @@ -1596,9 +1615,10 @@ export class Workspace extends DurableObject { /** * Ends the chat's running build job `id` as `end` says, once: `false` * when it had ended already. Its turn's agent is told how it ended, if - * that turn still runs, before its next model request; no other turn - * ever is, and nothing of its result goes with it: the agent reads that - * with `env.build.job`. + * that turn still runs, before its next model request, in a notice that + * is never stored with the chat (`LiveTurn`); no other turn ever is, and + * nothing of its result goes with it: the agent reads that with + * `env.build.job`. */ endBuildJob(chatId: unknown, id: string, end: BuildJobEnd): boolean { const chat = chatIdSchema.safeParse(chatId); @@ -1619,11 +1639,9 @@ export class Workspace extends DurableObject { : end; this.#jobs.end(job.chatId, id, settled); if (turn?.id === job.turnId) { - turn.events.push({ - role: "system", - content: `Build job ${id} (${job.kind} of App ${job.appId}, submitted by code run ${job.runId}) ${jobEnded(job, end)}. Read how it ended with \`env.build.job("${id}")\`.`, - timestamp: Date.now(), - }); + const content = `Build job ${id} (${job.kind} of App ${job.appId}, submitted by code run ${job.runId}) ${jobEnded(job, end)}. Read how it ended with \`env.build.job("${id}")\`.`; + turn.notices.add(content); + turn.events.push({ role: "system", content, timestamp: Date.now() }); } return true; } @@ -1821,6 +1839,9 @@ export class Workspace extends DurableObject { throw agentErrors.create("agent.busy"); } this.#deleting.add(id); + // Its build jobs end before anything here awaits, so none commits while + // it is deleted; they stay ended should the delete then fail. + this.#terminateJobs(this.#jobs.cancelRunning(id)); try { const workspaceId = workspaceIdSchema.parse(this.ctx.id.name); const declined = await this.env.CONNECT.declineChatActions({ @@ -1842,8 +1863,7 @@ export class Workspace extends DurableObject { .from(chatDrafts) .where(eq(chatDrafts.chatId, id)) .all(); - // Its build jobs end with it, and their rows go below. - this.#terminateJobs(this.#jobs.cancelRunning(id)); + // Their rows go with it below. this.ctx.storage.transactionSync(() => { this.#jobs.deleteChat(id); this.#db.delete(chatMessages).where(eq(chatMessages.chatId, id)).run(); diff --git a/apps/core/test/build-jobs.test.ts b/apps/core/test/build-jobs.test.ts index 08dc1ce21..0389cc0bb 100644 --- a/apps/core/test/build-jobs.test.ts +++ b/apps/core/test/build-jobs.test.ts @@ -9,6 +9,8 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { z } from "zod"; import { BuildJobRecords, jobRecordsKept } from "../src/build-job-records.ts"; +import { personOf } from "../src/connections.ts"; +import { workspace } from "../src/durable-objects.ts"; import { chatOf, codeResults, @@ -186,6 +188,17 @@ const slowTestResults = (ms: number): typeof env.FILES => { return files; }; +/** A Workspace object's own env, to change a binding of it in a test. */ +const envOf = (instance: object): Pick => { + const objectEnv: unknown = Reflect.get(instance, "env"); + if (typeof objectEnv !== "object" || objectEnv === null) { + throw new TypeError("The Workspace object has no env"); + } + // SAFETY: a Workspace object's env is core's Env, which has CONNECT. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- see SAFETY + return objectEnv as Pick; +}; + /** A commit storing a version's files: no code run commits. */ const storedTrees = (method: string, key: unknown): boolean => method === "put" && typeof key === "string" && key.includes("/trees/"); @@ -289,7 +302,7 @@ const jobEvents = async (stub: WorkspaceStub, chatId: string, id: string) => { }; describe("build jobs", { timeout: 180_000 }, () => { - it("finishes a proposal after the run that submitted it ended, and tells that turn how it ended", async () => { + it("finishes a proposal after the run that submitted it ended, tells that turn how it ended, and no later turn", async () => { const waiting = pausedReply(says("I'll say once the build is done."), 3); const { builder, chat } = await setUp([ codeStep(submitProposal), @@ -331,25 +344,25 @@ describe("build jobs", { timeout: 180_000 }, () => { waiting.release(); const answer = await answering; const messages = await transcript(chat.stub, chat.chat.id); - const waited = messages.findIndex( - (message) => - message.role === "assistant" && - message.content.some( - (part) => part.type === "text" && part.text.startsWith("I'll say") - ) - ); const { app, versions } = await createdApp(builder); - const told = await jobEvents(chat.stub, chat.chat.id, job.id); + const lastRequest = JSON.stringify(chat.gateway.requests.at(-1)?.body); + // A later turn of the chat. + const later = fakeGateway(says("Anything else?")); + await pointAtGateway(chat.stub, later); + await chat.ask("Thanks."); expect({ answer, - // Told once, right after the answer it gave while the job ran. - events: told.map(({ index, content }) => ({ - index, - byItsRun: content.includes("submitted by code run"), - })), + // The model was sent the notice with its last request of the turn, + // naming the run that submitted it. + sent: lastRequest.includes(`Build job ${job.id}`), + byItsRun: lastRequest.includes("submitted by code run"), + // It isn't stored with the chat, so no later request carries it. + stored: await jobEvents(chat.stub, chat.chat.id, job.id), + // (Its ID is still there, in what the code that submitted it returned.) + laterSent: JSON.stringify(later.requests[0]?.body).includes( + `Build job ${job.id}` + ), last: messages.at(-1)?.role, - // The model was sent it with its last request. - sent: JSON.stringify(chat.gateway.requests.at(-1)?.body).includes(job.id), // It proposed the draft once, as the chat's agent, for review. app: { pending: app.pendingVersion, current: app.currentVersion }, versions: versions.map(({ version, proposedBy }) => ({ @@ -362,14 +375,11 @@ describe("build jobs", { timeout: 180_000 }, () => { outcome: "answered", answer: "The invoice desk is proposed for review.", }, - events: [ - { - index: waited + 1, - byItsRun: true, - }, - ], - last: "assistant", sent: true, + byItsRun: true, + stored: [], + laterSent: false, + last: "assistant", app: { pending: 1, current: null }, versions: [{ version: 1, chat: chat.chat.id, job: job.id }], }); @@ -993,6 +1003,217 @@ describe("build jobs", { timeout: 180_000 }, () => { }).toStrictEqual({ status: "failed", code: "app.conflict", versions: [] }); }); + it("admits nothing of a job while its chat is being deleted", async () => { + const { builder, chat } = await setUp([ + codeStep(submitProposal), + says("Submitted."), + ]); + const asConnect = await personOf(env, await builder.api.whoami()); + const by = { type: "person" as const, userId: builder.userId }; + const storing = holdFiles(storedTrees); + const declining = { reached: false, released: false }; + let deleting: Promise | undefined; + let ended: Awaited> | undefined; + let originalConnect: Env["CONNECT"] | undefined; + try { + await chat.ask("Build an invoice desk for invoices@"); + const job = await submittedJob(chat.stub, chat.chat.id); + await storing.reached(); + // Deleting the chat waits on connect, as it declines its writes: + // the object's own binding, wrapped, every other method as it was. + await runInDurableObject(chat.stub, (instance) => { + const objectEnv = envOf(instance); + const connect = objectEnv.CONNECT; + originalConnect = connect; + objectEnv.CONNECT = new Proxy(connect, { + get: (target, property) => { + const value: unknown = Reflect.get(target, property); + if ( + property !== "declineChatActions" || + typeof value !== "function" + ) { + return typeof value === "function" + ? (...args: unknown[]): unknown => + Reflect.apply(value, target, args) + : value; + } + return async (...args: unknown[]): Promise => { + declining.reached = true; + while (!declining.released) { + // oxlint-disable-next-line no-await-in-loop -- until the test releases it + await scheduler.wait(20); + } + return await Reflect.apply(value, target, args); + }; + }, + }); + }); + deleting = runInDurableObject( + chat.stub, + async (instance) => + await instance.deleteChat(chat.chat.id, builder.userId, asConnect, by) + ); + await vi.waitFor( + () => { + expect(declining.reached).toBeTruthy(); + }, + { timeout: 30_000, interval: 20 } + ); + // The job goes on to its commit while the chat is being deleted. + storing.restore(); + ended = await endedJob(chat.stub, chat.chat.id, job.id); + } finally { + storing.restore(); + declining.released = true; + await deleting; + // The object's env may be other tests' too: its binding goes back. + const restored = originalConnect; + if (restored !== undefined) { + await runInDurableObject(chat.stub, (instance) => { + envOf(instance).CONNECT = restored; + }); + } + } + const { versions } = await createdApp(builder); + expect({ + status: ended.status, + version: ended.version, + versions, + }).toStrictEqual({ + status: "cancelled", + version: null, + versions: [], + }); + }); + + it("says a job that broke off after its commit committed, and never to submit it again", async () => { + const { builder, chat } = await setUp([ + codeStep(submitProposal), + says("Submitted."), + ]); + // The commit's step is still keeping test results when its instance + // ends, its receipt unrecorded. + const files = slowTestResults(20_000); + let job: z.infer; + try { + await chat.ask("Build an invoice desk for invoices@"); + job = await submittedJob(chat.stub, chat.chat.id); + await vi.waitFor( + async () => { + const { versions } = await createdApp(builder); + expect(versions).toHaveLength(1); + }, + { timeout: 60_000, interval: 50 } + ); + const instance = await env.WORKFLOWS.get(job.id); + await instance.terminate(); + await pointAtGateway( + chat.stub, + fakeGateway( + codeStep(`export default async (self, env) => { + const { status, version, error } = await env.build.job(${JSON.stringify(job.id)}); + return { status, version, error }; + };`), + says("It was proposed.") + ) + ); + await chat.ask("How did it go?"); + } finally { + env.FILES = files; + } + const results = await codeResults(chat.stub, chat.chat.id); + const read = z + .object({ + status: z.string(), + version: z.number().nullable(), + error: z.object({ code: z.string(), message: z.string() }), + }) + .parse(returned(results.at(-1)?.text)); + expect({ + status: read.status, + version: read.version, + committed: read.error.message.includes("after committing version 1"), + submitAgain: read.error.message.includes("Submit it again"), + }).toStrictEqual({ + status: "failed", + version: 1, + committed: true, + submitAgain: false, + }); + }); + + it("goes on through a restart of its chat's object, and reaches no turn", async () => { + const { builder, chat } = await setUp([ + codeStep(submitProposal), + says("Submitted."), + ]); + const checking = holdFiles(screenBuilds); + let job: z.infer; + let { stub } = chat; + try { + await chat.ask("Build an invoice desk for invoices@"); + job = await submittedJob(chat.stub, chat.chat.id); + await checking.reached(); + await runInDurableObject(chat.stub, (_instance, state) => { + state.abort("Restarted by the test"); + }).catch(() => { + // Aborting fails the call that aborted: that is the restart. + }); + stub = workspace(env, chat.id); + } finally { + checking.restore(); + } + const ended = await endedJob(stub, chat.chat.id, job.id); + const { versions } = await createdApp(builder); + expect({ + status: ended.status, + version: ended.version, + versions: versions.length, + stored: await jobEvents(stub, chat.chat.id, job.id), + }).toStrictEqual({ + status: "completed", + version: 1, + versions: 1, + stored: [], + }); + }); + + it("hands on nothing of a check whose agent lost a permission as it ended", async () => { + const { admin, chat } = await setUp([ + codeStep(`export default async (self, env) => { + const app = await env.build.create({ name: ${JSON.stringify(appName)} }); + await env.build.write(app.id, ${JSON.stringify({ "screens/desk.tsx": desk })}); + return await env.build.submit(app.id, { kind: "check" }); + };`), + says("Submitted."), + ]); + const { collectionId } = await collectionWithNote(admin.api, { + name: `Handbook ${crypto.randomUUID()}`, + access: "everyone", + }); + const handbook = await requestGranted( + idp, + admin, + readCollection(chat.agent, collectionId, "HANDBOOK") + ); + const checking = holdFiles(screenBuilds); + let job: z.infer; + try { + await chat.ask("Check an invoice desk"); + job = await submittedJob(chat.stub, chat.chat.id); + // After its only step was admitted: its last step finds it. + await checking.reached(); + await admin.api.permissions.revoke(handbook); + } finally { + checking.restore(); + } + const ended = await endedJob(chat.stub, chat.chat.id, job.id); + expect({ status: ended.status, result: ended.result }).toStrictEqual({ + status: "revoked", + result: null, + }); + }); + it("reads as none to another chat's code", async () => { const { chat } = await setUp([ codeStep(submitProposal),