@drawcall/market 0.1.59 → 0.1.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,44 @@
1
- import { readFile } from 'node:fs/promises'
2
- import { extname } from 'node:path'
1
+ import { randomUUID } from 'node:crypto'
2
+ import { link, mkdir, open, readFile, rm } from 'node:fs/promises'
3
+ import { basename, dirname, extname, join } from 'node:path'
4
+ import { ORPCError } from '@orpc/client'
3
5
  import ora from 'ora'
4
6
  import { getCliClient } from '../cli-client.js'
5
- import { agentAndWait } from '../agent.js'
7
+ import { agentAndWait, AgentError, type AgentInput } from '../agent.js'
6
8
  import { assetVersionRef } from '../output.js'
7
- import type { AgentResult } from '../schemas.js'
9
+ import type { AgentControlResult, AgentResult, AgentRunStatus } from '../schemas.js'
10
+
11
+ export type AgentControlClient = {
12
+ asset: {
13
+ agent: {
14
+ startDeferred(input: DeferredAgentInput & { runId: string }): Promise<{ runId: string }>
15
+ status(input: { runId: string }): Promise<AgentRunStatus>
16
+ approve(input: { runId: string }): Promise<AgentControlResult>
17
+ cancel(input: { runId: string }): Promise<AgentControlResult>
18
+ }
19
+ }
20
+ }
21
+
22
+ type DeferredAgentInput = Pick<AgentInput, 'goal'>
23
+ type AgentDiagnostic = (line: string) => void
24
+
25
+ interface DeferredRunHandle {
26
+ version: 1
27
+ runId: string
28
+ input: DeferredAgentInput
29
+ }
8
30
 
9
31
  export interface AgentCommandOptions {
10
32
  images?: string[]
11
33
  json?: boolean
12
34
  baseUrl?: string
35
+ deferGeneration?: boolean
36
+ approvalFile?: string
37
+ runFile?: string
13
38
  }
14
39
 
15
40
  export async function agentCommand(goal: string, opts: AgentCommandOptions): Promise<void> {
41
+ validateDeferredOptions(opts)
16
42
  const { client } = await getCliClient({ baseUrl: opts.baseUrl, requireAuth: true })
17
43
  const images = await Promise.all((opts.images ?? []).map(toDataUrl))
18
44
 
@@ -23,7 +49,9 @@ export async function agentCommand(goal: string, opts: AgentCommandOptions): Pro
23
49
  }).start()
24
50
 
25
51
  try {
26
- const result = await agentAndWait(client, { goal, images: images.length ? images : undefined })
52
+ const result = opts.deferGeneration
53
+ ? await runDeferredAgent(client, { goal }, requiredApprovalFile(opts), requiredRunFile(opts))
54
+ : await agentAndWait(client, { goal, images: images.length ? images : undefined })
27
55
  spinner.stop()
28
56
  printResult(result, opts.json ?? false)
29
57
  } catch (err) {
@@ -32,6 +60,425 @@ export async function agentCommand(goal: string, opts: AgentCommandOptions): Pro
32
60
  }
33
61
  }
34
62
 
63
+ export interface AgentWaitCommandOptions {
64
+ json?: boolean
65
+ baseUrl?: string
66
+ approvalFile?: string
67
+ }
68
+
69
+ export async function waitAgentCommand(
70
+ runFile: string,
71
+ opts: AgentWaitCommandOptions,
72
+ ): Promise<void> {
73
+ const { client } = await getCliClient({ baseUrl: opts.baseUrl, requireAuth: true })
74
+ const handle = await readRunHandle(runFile)
75
+ await ensureDeferredStarted(client, handle)
76
+ const { runId } = handle
77
+ const interrupt = interruptSignal()
78
+ const spinner = ora({
79
+ text: `Waiting for Market agent run ${runId}`,
80
+ isEnabled: Boolean(process.stderr.isTTY),
81
+ isSilent: !process.stderr.isTTY,
82
+ }).start()
83
+
84
+ let settled = false
85
+ try {
86
+ const result = opts.approvalFile
87
+ ? await resumeExistingAgent(
88
+ client,
89
+ runId,
90
+ opts.approvalFile,
91
+ interrupt.signal,
92
+ writeAgentDiagnostic,
93
+ )
94
+ : await waitForExistingAgent(client, runId, interrupt.signal, writeAgentDiagnostic)
95
+ settled = true
96
+ spinner.stop()
97
+ printResult(result, opts.json ?? false)
98
+ } catch (error) {
99
+ spinner.stop()
100
+ throw error
101
+ } finally {
102
+ interrupt.dispose()
103
+ if (!settled && opts.approvalFile) {
104
+ await client.asset.agent.cancel({ runId }).catch(() => undefined)
105
+ }
106
+ }
107
+ }
108
+
109
+ function validateDeferredOptions(opts: AgentCommandOptions): void {
110
+ if (opts.deferGeneration && !opts.approvalFile) {
111
+ throw new Error('--defer-generation requires --approval-file <path>')
112
+ }
113
+ if (opts.deferGeneration && !opts.runFile) {
114
+ throw new Error('--defer-generation requires --run-file <path>')
115
+ }
116
+ if (opts.approvalFile && !opts.deferGeneration) {
117
+ throw new Error('--approval-file requires --defer-generation')
118
+ }
119
+ if (opts.runFile && !opts.deferGeneration) {
120
+ throw new Error('--run-file requires --defer-generation')
121
+ }
122
+ if (opts.deferGeneration && opts.images?.length) {
123
+ throw new Error('--image is not supported with --defer-generation')
124
+ }
125
+ }
126
+
127
+ function requiredRunFile(opts: AgentCommandOptions): string {
128
+ if (!opts.runFile) throw new Error('Run file is required')
129
+ return opts.runFile
130
+ }
131
+
132
+ function requiredApprovalFile(opts: AgentCommandOptions): string {
133
+ if (!opts.approvalFile) throw new Error('Approval file is required')
134
+ return opts.approvalFile
135
+ }
136
+
137
+ export async function runDeferredAgent(
138
+ client: AgentControlClient,
139
+ input: DeferredAgentInput,
140
+ approvalFile: string,
141
+ runFile: string,
142
+ emitDiagnostic: AgentDiagnostic = writeAgentDiagnostic,
143
+ ): Promise<AgentResult> {
144
+ const interrupt = interruptSignal()
145
+ const watch = new AbortController()
146
+ const stopWatch = () => watch.abort(interrupt.signal.reason)
147
+ interrupt.signal.addEventListener('abort', stopWatch, { once: true })
148
+
149
+ const runId = randomUUID()
150
+ const handle = { version: 1, runId, input } satisfies DeferredRunHandle
151
+ let runReachable = false
152
+ let settled = false
153
+ try {
154
+ // Persist the client-selected idempotency key and restart input before the
155
+ // RPC. A lost start response is recoverable without creating a second run.
156
+ await persistRunHandle(runFile, handle)
157
+ emitDiagnostic(runHandleDiagnosticLine(runId, runFile))
158
+ await ensureDeferredStarted(client, handle)
159
+ runReachable = true
160
+ const outcome = await resumeExistingAgent(
161
+ client,
162
+ runId,
163
+ approvalFile,
164
+ watch.signal,
165
+ emitDiagnostic,
166
+ )
167
+ settled = true
168
+ return outcome
169
+ } finally {
170
+ watch.abort()
171
+ interrupt.dispose()
172
+ if (!settled && runReachable) {
173
+ try {
174
+ await client.asset.agent.cancel({ runId })
175
+ } catch {
176
+ // Best-effort cleanup must not replace the actionable local error that
177
+ // brought us here. Before approval, the server run remains safely gated
178
+ // even if this request is lost.
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ export function runHandleDiagnosticLine(runId: string, runFile: string): string {
185
+ return JSON.stringify({ event: 'market.agent.run.persisted', runId, runFile })
186
+ }
187
+
188
+ function writeAgentDiagnostic(line: string): void {
189
+ process.stderr.write(`${line}\n`)
190
+ }
191
+
192
+ export function waitForExistingAgent(
193
+ client: AgentControlClient,
194
+ runId: string,
195
+ signal: AbortSignal = new AbortController().signal,
196
+ emitDiagnostic: AgentDiagnostic = ignoreDiagnostic,
197
+ ): Promise<AgentResult> {
198
+ return waitForResult(client, runId, signal, emitDiagnostic)
199
+ }
200
+
201
+ export async function resumeExistingAgent(
202
+ client: AgentControlClient,
203
+ runId: string,
204
+ approvalFile: string,
205
+ signal: AbortSignal = new AbortController().signal,
206
+ emitDiagnostic: AgentDiagnostic = ignoreDiagnostic,
207
+ ): Promise<AgentResult> {
208
+ const operation = new AbortController()
209
+ const abortOperation = () => operation.abort(signal.reason)
210
+ if (signal.aborted) abortOperation()
211
+ else signal.addEventListener('abort', abortOperation, { once: true })
212
+ try {
213
+ const result = waitForResult(client, runId, operation.signal, emitDiagnostic).then(
214
+ (value) => ({ kind: 'result' as const, value }),
215
+ (error: unknown) => ({ kind: 'error' as const, error }),
216
+ )
217
+ const decision = waitForApprovalFile(approvalFile, operation.signal).then(
218
+ (value) => ({ kind: 'decision' as const, value }),
219
+ (error: unknown) => ({ kind: 'error' as const, error }),
220
+ )
221
+ const first = await Promise.race([result, decision])
222
+ if (first.kind === 'error') throw first.error
223
+ if (first.kind === 'result') return first.value
224
+
225
+ if (operation.signal.aborted) throw abortReason(operation.signal)
226
+ const controlRequest =
227
+ first.value === 'approve'
228
+ ? client.asset.agent.approve({ runId })
229
+ : client.asset.agent.cancel({ runId })
230
+ const control = await raceAbort(controlRequest, operation.signal)
231
+ if (!control.accepted && control.state !== 'generation-started') {
232
+ if (isSettledControlState(control.state)) return unwrapResult(await result)
233
+ throw new AgentError(`Agent ${first.value} rejected: ${control.state}`)
234
+ }
235
+ if (first.value === 'cancel' && control.accepted) {
236
+ // Observe the durable terminal status before returning the cancellation
237
+ // error so callers receive generationStarted evidence for handle cleanup.
238
+ return unwrapResult(await result)
239
+ }
240
+ return unwrapResult(await result)
241
+ } finally {
242
+ operation.abort()
243
+ signal.removeEventListener('abort', abortOperation)
244
+ }
245
+ }
246
+
247
+ function unwrapResult(
248
+ outcome: { kind: 'result'; value: AgentResult } | { kind: 'error'; error: unknown },
249
+ ): AgentResult {
250
+ if (outcome.kind === 'error') throw outcome.error
251
+ return outcome.value
252
+ }
253
+
254
+ function isSettledControlState(state: AgentControlResult['state']): boolean {
255
+ return state === 'completed' || state === 'failed' || state === 'cancelled'
256
+ }
257
+
258
+ async function waitForResult(
259
+ client: AgentControlClient,
260
+ runId: string,
261
+ signal: AbortSignal,
262
+ emitDiagnostic: AgentDiagnostic,
263
+ ): Promise<AgentResult> {
264
+ for (;;) {
265
+ if (signal.aborted) throw abortReason(signal)
266
+ const status = await raceAbort(client.asset.agent.status({ runId }), signal)
267
+ const result = settledResult(runId, status, emitDiagnostic)
268
+ if (result) return result
269
+ if (
270
+ status.status === 'running' &&
271
+ (status.generation === 'deferred' || status.generation === 'awaiting-approval')
272
+ ) {
273
+ await abortableDelay(250, signal)
274
+ }
275
+ }
276
+ }
277
+
278
+ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
279
+ if (signal.aborted) return Promise.reject(abortReason(signal))
280
+ return new Promise<T>((resolve, reject) => {
281
+ signal.addEventListener('abort', aborted, { once: true })
282
+ promise.then(completed, failed)
283
+
284
+ function completed(value: T): void {
285
+ signal.removeEventListener('abort', aborted)
286
+ resolve(value)
287
+ }
288
+ function failed(error: unknown): void {
289
+ signal.removeEventListener('abort', aborted)
290
+ reject(error)
291
+ }
292
+ function aborted(): void {
293
+ reject(abortReason(signal))
294
+ }
295
+ })
296
+ }
297
+
298
+ function settledResult(
299
+ runId: string,
300
+ status: AgentRunStatus,
301
+ emitDiagnostic: AgentDiagnostic,
302
+ ): AgentResult | undefined {
303
+ if (status.status === 'running') return undefined
304
+ emitDiagnostic(agentTerminalDiagnosticLine(runId, status))
305
+ if (status.status === 'completed') return status.result
306
+ if (status.status === 'failed') throw new AgentError(status.error)
307
+ throw new AgentError('Agent run cancelled')
308
+ }
309
+
310
+ export function agentTerminalDiagnosticLine(
311
+ runId: string,
312
+ status: Exclude<AgentRunStatus, { status: 'running' }>,
313
+ ): string {
314
+ return JSON.stringify({
315
+ event: 'market.agent.run.terminal',
316
+ runId,
317
+ status: status.status,
318
+ generationStarted: status.generationStarted ?? null,
319
+ })
320
+ }
321
+
322
+ function ignoreDiagnostic(): void {}
323
+
324
+ async function waitForApprovalFile(
325
+ path: string,
326
+ signal: AbortSignal,
327
+ ): Promise<'approve' | 'cancel'> {
328
+ for (;;) {
329
+ if (signal.aborted) throw abortReason(signal)
330
+ try {
331
+ const value = (await readFile(path, 'utf8')).trim()
332
+ if (value === 'approve' || value === 'cancel') return value
333
+ if (value) {
334
+ throw new Error(`Approval file must contain exactly "approve" or "cancel": ${path}`)
335
+ }
336
+ } catch (error) {
337
+ if (!isMissingFile(error)) throw error
338
+ }
339
+ await abortableDelay(100, signal)
340
+ }
341
+ }
342
+
343
+ function interruptSignal(): { signal: AbortSignal; dispose(): void } {
344
+ const controller = new AbortController()
345
+ const onSigint = () => controller.abort(new AgentError('Interrupted by SIGINT'))
346
+ const onSigterm = () => controller.abort(new AgentError('Interrupted by SIGTERM'))
347
+ process.once('SIGINT', onSigint)
348
+ process.once('SIGTERM', onSigterm)
349
+ return {
350
+ signal: controller.signal,
351
+ dispose() {
352
+ process.off('SIGINT', onSigint)
353
+ process.off('SIGTERM', onSigterm)
354
+ },
355
+ }
356
+ }
357
+
358
+ function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
359
+ if (signal.aborted) return Promise.reject(abortReason(signal))
360
+ return new Promise((resolve, reject) => {
361
+ const timeout = setTimeout(done, ms)
362
+ signal.addEventListener('abort', aborted, { once: true })
363
+
364
+ function done(): void {
365
+ signal.removeEventListener('abort', aborted)
366
+ resolve()
367
+ }
368
+ function aborted(): void {
369
+ clearTimeout(timeout)
370
+ reject(abortReason(signal))
371
+ }
372
+ })
373
+ }
374
+
375
+ function abortReason(signal: AbortSignal): Error {
376
+ return signal.reason instanceof Error ? signal.reason : new AgentError('Agent command aborted')
377
+ }
378
+
379
+ function isMissingFile(error: unknown): boolean {
380
+ return hasErrorCode(error, 'ENOENT')
381
+ }
382
+
383
+ function hasErrorCode(error: unknown, code: string): boolean {
384
+ return error instanceof Error && 'code' in error && error.code === code
385
+ }
386
+
387
+ async function persistRunHandle(path: string, handle: DeferredRunHandle): Promise<void> {
388
+ const directory = dirname(path)
389
+ await mkdir(directory, { recursive: true })
390
+ const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`)
391
+ let file: Awaited<ReturnType<typeof open>> | undefined
392
+ try {
393
+ file = await open(temporary, 'wx', 0o600)
394
+ await file.writeFile(`${JSON.stringify(handle)}\n`, 'utf8')
395
+ await file.sync()
396
+ await file.close()
397
+ file = undefined
398
+
399
+ // A same-directory hard link is an atomic create-if-absent. Refusing to
400
+ // replace a previous handle prevents a new run from orphaning an older one.
401
+ try {
402
+ await link(temporary, path)
403
+ } catch (error) {
404
+ if (hasErrorCode(error, 'EEXIST')) {
405
+ throw new Error(`Run file already exists; recover or remove it first: ${path}`, {
406
+ cause: error,
407
+ })
408
+ }
409
+ throw error
410
+ }
411
+ await rm(temporary)
412
+ const directoryHandle = await open(directory, 'r')
413
+ try {
414
+ await directoryHandle.sync()
415
+ } finally {
416
+ await directoryHandle.close()
417
+ }
418
+ } finally {
419
+ await file?.close().catch(() => undefined)
420
+ await rm(temporary, { force: true }).catch(() => undefined)
421
+ }
422
+ }
423
+
424
+ async function readRunHandle(path: string): Promise<DeferredRunHandle> {
425
+ let value: unknown
426
+ try {
427
+ value = JSON.parse(await readFile(path, 'utf8'))
428
+ } catch (error) {
429
+ throw new Error(`Run file does not contain a valid Market run handle: ${path}`, {
430
+ cause: error,
431
+ })
432
+ }
433
+ if (!isDeferredRunHandle(value)) {
434
+ throw new Error(`Run file does not contain a valid Market run handle: ${path}`)
435
+ }
436
+ return value
437
+ }
438
+
439
+ function isDeferredRunHandle(value: unknown): value is DeferredRunHandle {
440
+ if (!value || typeof value !== 'object') return false
441
+ const record = value as Record<string, unknown>
442
+ const input = record.input
443
+ return (
444
+ record.version === 1 &&
445
+ typeof record.runId === 'string' &&
446
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(record.runId) &&
447
+ Boolean(input) &&
448
+ typeof input === 'object' &&
449
+ typeof (input as Record<string, unknown>).goal === 'string' &&
450
+ Object.keys(input as Record<string, unknown>).length === 1
451
+ )
452
+ }
453
+
454
+ async function ensureDeferredStarted(
455
+ client: AgentControlClient,
456
+ handle: DeferredRunHandle,
457
+ ): Promise<void> {
458
+ try {
459
+ const started = await client.asset.agent.startDeferred({
460
+ ...handle.input,
461
+ runId: handle.runId,
462
+ })
463
+ if (started.runId !== handle.runId) {
464
+ throw new Error('Market returned a different deferred run ID')
465
+ }
466
+ } catch (startError) {
467
+ if (isAuthoritativeStartRejection(startError)) throw startError
468
+ // The start response may be lost after the Durable Object committed. A
469
+ // successful owner-checked status proves that this exact run is reachable.
470
+ try {
471
+ await client.asset.agent.status({ runId: handle.runId })
472
+ } catch {
473
+ throw startError
474
+ }
475
+ }
476
+ }
477
+
478
+ function isAuthoritativeStartRejection(error: unknown): boolean {
479
+ return error instanceof ORPCError && error.status >= 400 && error.status < 500
480
+ }
481
+
35
482
  function printResult(result: AgentResult, asJson: boolean): void {
36
483
  if (asJson) {
37
484
  console.log(JSON.stringify(result))
package/src/contract.ts CHANGED
@@ -3,6 +3,8 @@ import { z } from 'zod'
3
3
  import type { AssetFileManifest } from './schemas.js'
4
4
  import {
5
5
  MAX_UPLOAD_ZIP_SIZE_BYTES,
6
+ agentControlResultSchema,
7
+ agentDeferredStartSchema,
6
8
  agentRunStatusSchema,
7
9
  agentStartSchema,
8
10
  agentStatusSchema,
@@ -143,10 +145,16 @@ export const contract = {
143
145
  // The agent plans, searches, judges and (where supported) generates a set of
144
146
  // assets from a goal. `start` kicks off an async run; `status` long-polls it
145
147
  // (blocks until the run settles or a heartbeat window elapses). The
146
- // `agentAndWait` client helper drives both.
148
+ // `agentAndWait` drives ordinary runs. A safety-critical deferred caller
149
+ // uses fail-closed `startDeferred`, observes `status`, then calls `approve`
150
+ // or `cancel`. Deferred semantics deliberately do not exist on `start`:
151
+ // an older worker could strip an unknown optional flag and spend immediately.
147
152
  agent: {
148
153
  start: oc.input(agentStartSchema).output(z.object({ runId: z.string() })),
154
+ startDeferred: oc.input(agentDeferredStartSchema).output(z.object({ runId: z.string() })),
149
155
  status: oc.input(agentStatusSchema).output(agentRunStatusSchema),
156
+ approve: oc.input(agentStatusSchema).output(agentControlResultSchema),
157
+ cancel: oc.input(agentStatusSchema).output(agentControlResultSchema),
150
158
  },
151
159
  },
152
160
 
package/src/index.ts CHANGED
@@ -67,7 +67,11 @@ export {
67
67
  updateProfileSchema,
68
68
  listAssetsSchema,
69
69
  agentStartSchema,
70
+ agentDeferredStartSchema,
70
71
  agentStatusSchema,
72
+ agentGenerationStateSchema,
73
+ agentControlStateSchema,
74
+ agentControlResultSchema,
71
75
  agentResultAssetSchema,
72
76
  agentResultSchema,
73
77
  agentRunStatusSchema,
@@ -83,6 +87,9 @@ export type {
83
87
  AgentResultAsset,
84
88
  AgentResult,
85
89
  AgentRunStatus,
90
+ AgentGenerationState,
91
+ AgentControlState,
92
+ AgentControlResult,
86
93
  GenerateResponse,
87
94
  GenerateJobStatus,
88
95
  } from './schemas.js'
package/src/schemas.ts CHANGED
@@ -144,21 +144,53 @@ export type GenerateJobStatus = z.infer<typeof generateJobStatusSchema>
144
144
  // The agent takes a natural-language goal (which carries the count and kinds,
145
145
  // e.g. "3 low poly stones" or "a template for a zombie game, none if no fit")
146
146
  // and optional reference images as base64 (raw or data URLs).
147
- export const agentStartSchema = z.object({
147
+ export const agentStartSchema = z.strictObject({
148
148
  goal: z.string().min(3).max(1000),
149
149
  images: z.array(z.string()).optional(),
150
150
  })
151
151
 
152
+ // A distinct procedure lets safety-critical callers fail closed against an
153
+ // older worker: an unknown `startDeferred` starts nothing. Deferred runs are
154
+ // goal-only because their restart input is persisted until the wall deadline;
155
+ // accepting unbounded base64 reference images would make that unsafe.
156
+ export const agentDeferredStartSchema = z.strictObject({
157
+ goal: agentStartSchema.shape.goal,
158
+ runId: z.string().uuid(),
159
+ })
160
+
152
161
  export const agentStatusSchema = z.object({
153
162
  runId: z.string().min(1),
154
163
  })
155
164
 
165
+ export const agentGenerationStateSchema = z.enum([
166
+ 'deferred',
167
+ 'awaiting-approval',
168
+ 'approved',
169
+ 'generation-started',
170
+ ])
171
+ export type AgentGenerationState = z.infer<typeof agentGenerationStateSchema>
172
+
173
+ export const agentControlStateSchema = z.enum([
174
+ ...agentGenerationStateSchema.options,
175
+ 'completed',
176
+ 'failed',
177
+ 'cancelled',
178
+ ])
179
+ export type AgentControlState = z.infer<typeof agentControlStateSchema>
180
+
181
+ export const agentControlResultSchema = z.object({
182
+ accepted: z.boolean(),
183
+ state: agentControlStateSchema,
184
+ })
185
+ export type AgentControlResult = z.infer<typeof agentControlResultSchema>
186
+
156
187
  // One chosen asset: a verified, installable `name@version` (the agent only
157
188
  // returns names the catalog resolved) plus a note on what it contributes.
158
189
  export const agentResultAssetSchema = z.object({
159
190
  name: assetNameSchema,
160
191
  version: semverSchema,
161
192
  notes: z.string(),
193
+ origin: z.enum(['catalog', 'generated']),
162
194
  })
163
195
  export type AgentResultAsset = z.infer<typeof agentResultAssetSchema>
164
196
 
@@ -171,12 +203,27 @@ export const agentResultSchema = z.object({
171
203
  })
172
204
  export type AgentResult = z.infer<typeof agentResultSchema>
173
205
 
174
- // An agent run is an async job: it is running, or it finished with a result, or
175
- // it failed. A discriminated union keeps "completed without a result" and
176
- // "failed without an error" unrepresentable.
206
+ // An agent run is an async job: it is running (with optional generation-gate
207
+ // progress), completed, failed, or explicitly cancelled. A discriminated union
208
+ // keeps "completed without a result" and "failed without an error"
209
+ // unrepresentable.
177
210
  export const agentRunStatusSchema = z.discriminatedUnion('status', [
178
- z.object({ status: z.literal('running') }),
179
- z.object({ status: z.literal('completed'), result: agentResultSchema }),
180
- z.object({ status: z.literal('failed'), error: z.string() }),
211
+ z.object({
212
+ status: z.literal('running'),
213
+ // Optional so a new client can still read a running response from an older
214
+ // worker during a rolling deploy.
215
+ generation: agentGenerationStateSchema.optional(),
216
+ }),
217
+ z.object({
218
+ status: z.literal('completed'),
219
+ result: agentResultSchema,
220
+ generationStarted: z.boolean().optional(),
221
+ }),
222
+ z.object({
223
+ status: z.literal('failed'),
224
+ error: z.string(),
225
+ generationStarted: z.boolean().optional(),
226
+ }),
227
+ z.object({ status: z.literal('cancelled'), generationStarted: z.boolean().optional() }),
181
228
  ])
182
229
  export type AgentRunStatus = z.infer<typeof agentRunStatusSchema>