@drawcall/market 0.1.60 → 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.
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +2 -0
- package/dist/agent.js.map +1 -1
- package/dist/cli.js +30 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/agent.d.ts +41 -0
- package/dist/commands/agent.d.ts.map +1 -1
- package/dist/commands/agent.js +359 -4
- package/dist/commands/agent.js.map +1 -1
- package/dist/contract.d.ts +46 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +8 -2
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/schemas.d.ts +46 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +46 -7
- package/dist/schemas.js.map +1 -1
- package/package.json +1 -1
- package/src/agent.ts +1 -0
- package/src/cli.ts +47 -6
- package/src/commands/agent.ts +452 -5
- package/src/contract.ts +9 -1
- package/src/index.ts +7 -0
- package/src/schemas.ts +53 -7
package/src/commands/agent.ts
CHANGED
|
@@ -1,18 +1,44 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 =
|
|
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`
|
|
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,15 +144,46 @@ 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.
|
|
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({
|
|
@@ -172,12 +203,27 @@ export const agentResultSchema = z.object({
|
|
|
172
203
|
})
|
|
173
204
|
export type AgentResult = z.infer<typeof agentResultSchema>
|
|
174
205
|
|
|
175
|
-
// An agent run is an async job: it is running
|
|
176
|
-
//
|
|
177
|
-
// "failed without an error"
|
|
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.
|
|
178
210
|
export const agentRunStatusSchema = z.discriminatedUnion('status', [
|
|
179
|
-
z.object({
|
|
180
|
-
|
|
181
|
-
|
|
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() }),
|
|
182
228
|
])
|
|
183
229
|
export type AgentRunStatus = z.infer<typeof agentRunStatusSchema>
|