@deepblend/dsh-blender-tool 0.1.0

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.
@@ -0,0 +1,579 @@
1
+ /**
2
+ * M3 model-visible tools: the persistent delivery render (SPEC §11, §20 M3).
3
+ *
4
+ * Plane: Agent preset. Registers tools into the calling agent's scope and
5
+ * publishes NOTHING — it consumes `blenderStudio` from the Host composition,
6
+ * which is what makes it legal as a preset row (SPEC §4.4).
7
+ *
8
+ * WHY THESE FOUR, AND WHY THEY ARE THE LAST ONES
9
+ * ----------------------------------------------
10
+ * M0/M1/M2 registered ten tools and deliberately left five unregistered, on the
11
+ * rule that **a tool the model can see is a promise the runtime must keep**. Four
12
+ * of the five arrive here, because their host services now exist:
13
+ *
14
+ * blender_final_render start (or resume) a delivery render — §7.2 startFinalRender
15
+ * blender_export encode + publish + manifest — §7.2 exportProject
16
+ * blender_job_status read a durable job — §7.2 getJob
17
+ * blender_job_cancel stop it and prove the process is gone — §7.2 cancelJob
18
+ *
19
+ * `blender_asset_ingest` stays ABSENT: its host service and its approval boundary
20
+ * are M5 (SPEC §15.1), and registering it now would throw on every call.
21
+ *
22
+ * WHAT THE DESCRIPTIONS CARRY, AND WHY THEY ARE SO LONG
23
+ * -----------------------------------------------------
24
+ * A delivery render costs ~3.4 hours on the demo project and produces 424 MiB of
25
+ * frames. The two facts a model must not have to learn by experiment are (a) that
26
+ * `blender_final_render` RETURNS IMMEDIATELY and the work continues in the
27
+ * background, and (b) that an interrupted render is resumed by calling the SAME
28
+ * tool with `resumeJobId`, which renders only the frames that are missing. Both
29
+ * are in the description text because the parameter schema cannot say them.
30
+ *
31
+ * Owner: DeepBlend Studio — M3
32
+ */
33
+
34
+ import { defineTool } from '@deepseek-ai/dsh-tools'
35
+
36
+ import { BlenderErrorCode, HOST_API_VERSION, warning } from '@deepblend/dsh-blender-contracts'
37
+
38
+ import {
39
+ TOOL_OUTPUT,
40
+ canonicalCall,
41
+ definedFields,
42
+ renderFailure,
43
+ renderSuccess,
44
+ requestApproval,
45
+ resolveStudio,
46
+ } from './shared.js'
47
+
48
+ /**
49
+ * Register every M3 DeepBlend tool for the calling agent scope.
50
+ * @param {import('@deepseek-ai/cordis').Context} ctx
51
+ */
52
+ export function applyRenderTools(ctx) {
53
+ ctx.tools.register(finalRender(ctx))
54
+ ctx.tools.register(exportDelivery(ctx))
55
+ ctx.tools.register(jobStatus(ctx))
56
+ ctx.tools.register(jobCancel(ctx))
57
+ }
58
+
59
+
60
+ /**
61
+ * Refuse, in words, when the host half in this process is older than the tools.
62
+ *
63
+ * WHY THIS EXISTS, AND WHY IT IS NOT DEFENSIVE PADDING
64
+ * ----------------------------------------------------
65
+ * A host SERVICE instance lives in the process that constructed it, and Node's ESM
66
+ * module cache is process-level: updating the packages on disk does not update the
67
+ * `blenderStudio` that is already running. So there is a real deployment state where
68
+ * the tool plane is new and the host plane is not — and in that state every M3 tool
69
+ * would call a method that does not exist and fail with a `TypeError` wearing a
70
+ * `BLENDER_SCRIPT_ERROR` label. That is the worst kind of error here: a stable code
71
+ * that names the wrong problem.
72
+ *
73
+ * WHY IT ASKS FOR A VERSION INSTEAD OF LOOKING FOR METHODS
74
+ * --------------------------------------------------------
75
+ * The first version of this guard probed for the M3-only methods by `typeof`, and it
76
+ * was WRONG in a way worth recording. It caught `resumeRenderJob`, `listJobs` and
77
+ * `reconcileRenderJobs` — those are simply absent on the old host — and it MISSED
78
+ * `startFinalRender` and `exportProject`, because M1 implemented those as stubs and
79
+ * so `typeof` is `'function'` on both the old and the new host. The guard therefore
80
+ * covered four of six entry points and silently let through the two that would have
81
+ * failed worst. `deepblend/tests/contract/host-plane-staleness.test.mjs` now asserts
82
+ * all six, which is how the gap was found.
83
+ *
84
+ * The missing method is a property of the DEPLOYMENT, not of the request, so it is
85
+ * reported as one, with the fix.
86
+ *
87
+ * @param {object} studio the resolved host service
88
+ * @param {string[]} methods the M3 methods this tool needs, named in the diagnosis
89
+ * @returns {{text: string, data: object}|null} a refusal, or null when the host is current
90
+ */
91
+ function hostPlaneIsCurrent(studio, methods) {
92
+ const version = typeof studio.hostApiVersion === 'function' ? studio.hostApiVersion() : 0
93
+ if (Number.isFinite(version) && version >= HOST_API_VERSION) return null
94
+
95
+ // Named when they can be named, because "which methods are missing" is the useful
96
+ // half of the diagnosis for a human deciding whether to restart. Absence is not
97
+ // always detectable (see above), so this list is evidence, never the test.
98
+ const missing = methods.filter(name => typeof studio[name] !== 'function')
99
+ const versionText = Number.isFinite(version) && version > 0 ? `host API ${version}` : 'no host API version'
100
+ return {
101
+ text:
102
+ `DeepBlend host services are present but too OLD for this tool.\n` +
103
+ `errorCode: ${BlenderErrorCode.RUNTIME_UNAVAILABLE}\n` +
104
+ `message: this tool needs host API ${HOST_API_VERSION}, and the running blenderStudio service reports ` +
105
+ `${versionText}${missing.length > 0 ? ` and has no ${missing.join(', ')}` : ''}. The packages on disk are ` +
106
+ 'newer than the host service in this process, which is expected after an upgrade and before a profile ' +
107
+ 'restart: a Cordis service keeps the code it was constructed from.\n' +
108
+ 'fix: restart the profile (`dsh web`) and open a new session.',
109
+ data: {
110
+ ok: false,
111
+ errorCode: BlenderErrorCode.RUNTIME_UNAVAILABLE,
112
+ message:
113
+ `the running blenderStudio service is host API ${version || 'pre-M3'} and this tool needs ` +
114
+ `${HOST_API_VERSION}; restart the profile`,
115
+ detail: {
116
+ requiredHostApiVersion: HOST_API_VERSION,
117
+ reportedHostApiVersion: Number.isFinite(version) ? version : null,
118
+ missingMethods: missing,
119
+ fix: 'restart the dsh profile',
120
+ },
121
+ },
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Render the shared body of a job report as prose a model can act on.
127
+ *
128
+ * WHY IT IS EXPORTED: it is the block a model reads when it asks about a job, so it is where a
129
+ * recorded fact either reaches the reader or dies. The alternative to exporting it is a rule whose
130
+ * only exercise is a real render through a running Host — the same trade `frame-ledger.js` records
131
+ * for `sampleFrame`, and the same failure mode: a rule that can only fail in production is a rule
132
+ * nobody has checked. `contract/render-job.test.mjs` drives it directly, including the negative
133
+ * control (a job with no warnings prints no warning line).
134
+ *
135
+ * @param {object} job
136
+ * @returns {string[]}
137
+ */
138
+ export function describeJobLines(job) {
139
+ const lines = [
140
+ `job: ${job.jobId}${job.dshJobId !== null && job.dshJobId !== undefined ? ` (DSH job ${job.dshJobId})` : ''}`,
141
+ `project: ${job.projectId} @ ${job.revisionId}`,
142
+ `status: ${job.status}`,
143
+ `frames: ${job.completedFrames}/${job.expectedFrames} complete (${job.percent}%) in range ${job.frameStart}..${job.frameEnd}`,
144
+ ]
145
+ if (job.pid !== null && job.pid !== undefined) lines.push(`pid: ${job.pid}`)
146
+ if (job.meanMsPerFrame !== null && job.meanMsPerFrame !== undefined) {
147
+ const remaining = job.estimatedRemainingMs === null || job.estimatedRemainingMs === undefined
148
+ ? 'unknown'
149
+ : `${Math.round(job.estimatedRemainingMs / 1000)} s`
150
+ lines.push(`speed: ${(job.meanMsPerFrame / 1000).toFixed(1)} s/frame, ~${remaining} remaining`)
151
+ }
152
+ if (Array.isArray(job.corruptFrames) && job.corruptFrames.length > 0) {
153
+ lines.push(
154
+ `incomplete frames: ${job.corruptFrames
155
+ .slice(0, 8)
156
+ .map(entry => `${entry.frame} (${entry.reason})`)
157
+ .join(', ')}${job.corruptFrames.length > 8 ? `, +${job.corruptFrames.length - 8} more` : ''}`,
158
+ )
159
+ }
160
+ if (job.errorCode !== null && job.errorCode !== undefined) lines.push(`errorCode: ${job.errorCode}`)
161
+ if (job.message !== null && job.message !== undefined) lines.push(`message: ${job.message}`)
162
+ if (job.delivery !== null && job.delivery !== undefined) {
163
+ lines.push(`delivery: ${job.delivery.status}${job.delivery.videoPath ? ` -> ${job.delivery.videoPath}` : ''}`)
164
+ }
165
+ if (job.recovery !== null && job.recovery !== undefined) {
166
+ lines.push('recovery: this job was found unfinished when the host started; its ledger was rebuilt from the frames on disk')
167
+ for (const note of job.recovery.notes ?? []) lines.push(` - ${note}`)
168
+ }
169
+ // The record's own warnings, shown HERE because this list is the only place a human or a model reads
170
+ // a job's state. MEASURED defect: `JOB_PROJECTION_UNAVAILABLE` has been written onto job records
171
+ // since M3 and nothing ever displayed it — a fact recorded and never read is a fact not recorded,
172
+ // minus the disk space. Appended last, after the numbers, because a warning does not change what
173
+ // the job did.
174
+ if (Array.isArray(job.warnings) && job.warnings.length > 0) {
175
+ for (const entry of job.warnings) lines.push(`warning: [${entry.code}] ${entry.message}`)
176
+ }
177
+ return lines
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // blender_final_render
182
+ // ---------------------------------------------------------------------------
183
+
184
+ function finalRender(ctx) {
185
+ return defineTool({
186
+ name: 'blender_final_render',
187
+ description:
188
+ 'Start a DELIVERY render: every frame of the project\'s range (or a sub-range you name) at the ' +
189
+ 'SceneSpec\'s "final" render profile, then encode it to MP4 and publish the delivery package. ' +
190
+ 'This is the expensive operation — measured on this machine at 19.6-41.4 s PER FRAME for a ' +
191
+ '1920x1080 / Cycles / 256-sample delivery, so a 450-frame animation is about 3.4 hours. ' +
192
+ '\n\nIt RETURNS IMMEDIATELY with a jobId; the render continues in the background while you keep ' +
193
+ 'working. Poll it with blender_job_status, which also works after the harness has been restarted. ' +
194
+ '\n\nIF A RENDER WAS INTERRUPTED (harness restart, crash, cancellation), call this tool again with ' +
195
+ '`resumeJobId` set to that job. It reads which frames are actually complete on disk and renders ONLY ' +
196
+ 'the missing or incomplete ones, so resuming a 400-of-450 render costs 50 frames, not 450. Frames ' +
197
+ 'left half-written by a kill are detected and re-rendered rather than kept. ' +
198
+ '\n\nA project may have only ONE delivery render at a time: a second start is refused with ' +
199
+ 'RENDER_JOB_CONFLICT until the first is finished, cancelled, or resumed.',
200
+ parameters: {
201
+ projectId: { type: 'string', required: true, description: 'The project to render.' },
202
+ resumeJobId: {
203
+ type: 'string',
204
+ description:
205
+ 'Resume this interrupted render instead of starting a new one. The job must belong to the same ' +
206
+ 'project. Only the missing frames are rendered. Use blender_job_status to find unfinished jobs.',
207
+ },
208
+ revision: {
209
+ type: 'string',
210
+ description: 'Revision to render. Defaults to the project\'s current revision.',
211
+ },
212
+ frameStart: { type: 'number', description: 'First frame. Defaults to the project\'s own frameStart.' },
213
+ frameEnd: { type: 'number', description: 'Last frame (inclusive). Defaults to the project\'s own frameEnd.' },
214
+ frames: {
215
+ type: 'array',
216
+ items: { type: 'number' },
217
+ description:
218
+ 'An explicit list of frames instead of a contiguous range — useful for a spot check of a few ' +
219
+ 'expensive frames. Frames outside the project\'s own range are dropped with a warning.',
220
+ },
221
+ cameraId: { type: 'string', description: 'Camera to render from. Defaults to the camera with role "active-camera".' },
222
+ profileName: { type: 'string', description: 'SceneSpec render profile name. Defaults to "final".' },
223
+ samples: {
224
+ type: 'number',
225
+ description:
226
+ 'Override the profile\'s sample count. The profile\'s own maxSamplesBudget is the ceiling; the ' +
227
+ 'preview sample budget deliberately does not apply to a delivery render.',
228
+ },
229
+ },
230
+ output: TOOL_OUTPUT,
231
+ async execute(args, exec) {
232
+ const resolved = resolveStudio(ctx)
233
+ if (resolved.unavailable !== undefined) {
234
+ return { ok: false, text: `Delivery render unavailable.\n${resolved.unavailable.text}`, data: resolved.unavailable.data }
235
+ }
236
+ try {
237
+ if (typeof args?.resumeJobId === 'string' && args.resumeJobId.length > 0) {
238
+ const stale = hostPlaneIsCurrent(resolved.studio, ['resumeRenderJob', 'getJob'])
239
+ if (stale !== null) return { ok: false, ...stale }
240
+ const { data, canonicalWarnings } = await canonicalCall(
241
+ resolved.studio.resumeRenderJob(definedFields({
242
+ projectId: args.projectId,
243
+ jobId: args.resumeJobId,
244
+ signal: exec?.signal,
245
+ })),
246
+ warning,
247
+ )
248
+ const job = await resolved.studio.getJob({ projectId: data.projectId, jobId: data.jobId })
249
+ const notes = [
250
+ `already complete: ${data.alreadyComplete} frame(s)`,
251
+ `resuming: ${data.resumed} frame(s)${data.resumedFrames?.length ? ` -> ${summarizeFrames(data.resumedFrames)}` : ''}`,
252
+ data.corrupt?.length > 0
253
+ ? `re-rendering: ${data.corrupt.length} incomplete frame(s): ` +
254
+ data.corrupt.map(entry => `${entry.frame} (${entry.reason})`).join(', ')
255
+ : null,
256
+ '',
257
+ ...describeJobLines(job.renderJob),
258
+ ].filter(line => line !== null)
259
+ return {
260
+ ok: true,
261
+ text: renderSuccess(
262
+ `Resumed render job ${data.jobId} for ${data.projectId}/${data.revision}.`,
263
+ data,
264
+ { notes: [...notes, ...canonicalWarnings.map(entry => `[${entry.code}] ${entry.message}`)], warnings: data.warnings ?? [] },
265
+ ),
266
+ data: { ...data, job: job.renderJob },
267
+ }
268
+ }
269
+
270
+ const stale = hostPlaneIsCurrent(resolved.studio, ['startFinalRender', 'listJobs', 'getJob'])
271
+ if (stale !== null) return { ok: false, ...stale }
272
+
273
+ const startRequest = {
274
+ projectId: args.projectId,
275
+ revision: args.revision,
276
+ frameStart: args.frameStart,
277
+ frameEnd: args.frameEnd,
278
+ frames: args.frames,
279
+ cameraId: args.cameraId,
280
+ profileName: args.profileName,
281
+ samples: args.samples,
282
+ }
283
+
284
+ let started
285
+ try {
286
+ started = await canonicalCall(resolved.studio.startFinalRender(definedFields(startRequest)), warning)
287
+ } catch (cause) {
288
+ // ── the approval gate (SPEC §15.1, architecture-decisions Q7) ────────
289
+ //
290
+ // The HOST refuses an over-threshold render that arrives without a grant,
291
+ // which is where the enforcement has to live: the workbench starts renders
292
+ // too, and a control only one of two callers respects is not a control.
293
+ // What this plane adds is the means to ANSWER it — `ctx.approval.request`
294
+ // needs a live Agent and an open turn, and a tool call is the one place
295
+ // that has both.
296
+ if (cause?.code !== BlenderErrorCode.RENDER_APPROVAL_REQUIRED) throw cause
297
+
298
+ const approval = await requestApproval(ctx, exec, {
299
+ toolName: 'blender_final_render',
300
+ refusalCode: 'RENDER_APPROVAL_REFUSED',
301
+ // THE REVISION IS PART OF WHAT IS BEING APPROVED. The refusal computed its frame plan for one
302
+ // revision, and without this the prompt said "300 frames" while the human had no way to see WHICH
303
+ // scene those frames were of — and the re-issue below could resolve a newer revision, so the render
304
+ // they approved and the render that ran were not necessarily the same thing.
305
+ detail: {
306
+ frames: cause.detail?.frames ?? null,
307
+ threshold: cause.detail?.threshold ?? null,
308
+ revision: cause.detail?.revision ?? null,
309
+ },
310
+ reason:
311
+ `Start a DELIVERY render of ${cause.detail?.frames ?? 'an unknown number of'} frame(s)` +
312
+ `${cause.detail?.frameStart != null && cause.detail?.frameEnd != null
313
+ ? ` (${cause.detail.frameStart}..${cause.detail.frameEnd})`
314
+ : ''}${cause.detail?.revision == null ? '' : ` of revision ${cause.detail.revision}`}` +
315
+ `, above the configured approval threshold of ${cause.detail?.threshold ?? '?'}. ` +
316
+ 'Measured cost on the reference machine: 19.6-41.4 s per frame at 1920x1080 / Cycles / 256 ' +
317
+ 'samples, so this is hours of machine time.',
318
+ refusal:
319
+ `Nothing was started — no job, no frames. The request was for ${cause.detail?.frames ?? '?'} ` +
320
+ `frame(s), above the threshold of ${cause.detail?.threshold ?? '?'}. Options: ask the operator ` +
321
+ 'again, render a smaller range to check the scene first, or have the operator raise ' +
322
+ '`requireApprovalAboveFrames` for this deployment.',
323
+ })
324
+ if (approval.granted !== true) return { ok: false, ...approval.refusal }
325
+
326
+ // `approved` is set HERE and nowhere else. It is deliberately NOT a tool
327
+ // parameter: a model able to write `approved:true` would be approving its
328
+ // own spending, and the point of the threshold is that someone else
329
+ // decides. The only route to this line is an `'allowed-once'` outcome.
330
+ // PINNED TO THE REVISION THAT WAS APPROVED. `startRequest.revision` is often absent (the schema
331
+ // documents it as "defaults to the project's current revision"), so re-issuing the same request after
332
+ // an approval that took human time could render a revision nobody approved — a patch landing in
333
+ // between is enough. The refusal names the revision it measured, and that is the one this renders.
334
+ const approvedRevision = cause.detail?.revision ?? startRequest.revision
335
+ started = await canonicalCall(
336
+ resolved.studio.startFinalRender(definedFields({
337
+ ...startRequest,
338
+ ...approvedRevision === undefined ? {} : { revision: approvedRevision },
339
+ approved: true,
340
+ })),
341
+ warning,
342
+ )
343
+ }
344
+ const { data, canonicalWarnings } = started
345
+ return {
346
+ ok: true,
347
+ text: renderSuccess(
348
+ `Delivery render started: ${data.jobId} (${data.frames} frame(s), ${data.frameStart}..${data.frameEnd}).`,
349
+ data,
350
+ {
351
+ notes: [
352
+ 'This call has already returned; the frames are rendering in the background.',
353
+ `Next: blender_job_status {projectId: "${data.projectId}", jobId: "${data.jobId}"} to watch it.`,
354
+ `If it is interrupted: blender_final_render {projectId: "${data.projectId}", resumeJobId: "${data.jobId}"} ` +
355
+ 'renders only the frames that are still missing.',
356
+ ],
357
+ warnings: [...(data.warnings ?? []), ...canonicalWarnings],
358
+ },
359
+ ),
360
+ data,
361
+ }
362
+ } catch (cause) {
363
+ return { ok: false, ...renderFailure(cause, 'BLENDER_SCRIPT_ERROR') }
364
+ }
365
+ },
366
+ presentCall: args => ({
367
+ card: 'generic',
368
+ title: args?.resumeJobId !== undefined
369
+ ? `Resume delivery render ${args.resumeJobId}`
370
+ : `Render delivery for "${args?.projectId ?? ''}"`,
371
+ kind: 'edit',
372
+ }),
373
+ })
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // blender_export
378
+ // ---------------------------------------------------------------------------
379
+
380
+ function exportDelivery(ctx) {
381
+ return defineTool({
382
+ name: 'blender_export',
383
+ description:
384
+ 'Encode and publish the delivery package for a render whose frames already exist: H.264 MP4 into ' +
385
+ 'output/final.mp4, a verified delivery-manifest.json into output/, and the render manifest under ' +
386
+ 'renders/. It spends NO Blender time — use it to re-encode after a settings change, to re-publish a ' +
387
+ 'package whose manifest was lost, or to build the video for a render another session finished. ' +
388
+ '\n\nIt refuses with RENDER_FRAMES_INCOMPLETE when frames are missing or were left half-written by a ' +
389
+ 'kill, naming how many are owed; continue those with blender_final_render {resumeJobId}. ' +
390
+ '\n\nEvery property in the manifest is MEASURED with ffprobe after encoding and compared against what ' +
391
+ 'the job claimed. A mismatch fails the export with ENCODE_VERIFY_FAILED and publishes nothing, rather ' +
392
+ 'than shipping a video whose duration or frame count disagrees with its own manifest.',
393
+ parameters: {
394
+ projectId: { type: 'string', required: true, description: 'The project whose frames should be packaged.' },
395
+ jobId: {
396
+ type: 'string',
397
+ description: 'The render job whose frames to encode. Defaults to the newest job with a complete frame set.',
398
+ },
399
+ revision: { type: 'string', description: 'Restrict the default job search to this revision.' },
400
+ },
401
+ output: TOOL_OUTPUT,
402
+ async execute(args) {
403
+ const resolved = resolveStudio(ctx)
404
+ if (resolved.unavailable !== undefined) {
405
+ return { ok: false, text: `Delivery export unavailable.\n${resolved.unavailable.text}`, data: resolved.unavailable.data }
406
+ }
407
+ try {
408
+ const stale = hostPlaneIsCurrent(resolved.studio, ['exportProject', 'getJob'])
409
+ if (stale !== null) return { ok: false, ...stale }
410
+ const { data, canonicalWarnings } = await canonicalCall(
411
+ resolved.studio.exportProject(definedFields({
412
+ projectId: args?.projectId,
413
+ jobId: args?.jobId,
414
+ revision: args?.revision,
415
+ })),
416
+ warning,
417
+ )
418
+ return {
419
+ ok: data.verified === true,
420
+ text: data.verified === true
421
+ ? renderSuccess(`Delivery published for ${data.projectId} @ ${data.revision}.`, data, { warnings: canonicalWarnings })
422
+ : `Delivery encoded but NOT published: its properties disagree with the job's own claims.\n` +
423
+ `${JSON.stringify(data.problems, null, 2)}\n\nCanonical JSON:\n${JSON.stringify(data, null, 2)}`,
424
+ data,
425
+ }
426
+ } catch (cause) {
427
+ return { ok: false, ...renderFailure(cause, 'BLENDER_SCRIPT_ERROR') }
428
+ }
429
+ },
430
+ presentCall: args => ({
431
+ card: 'generic',
432
+ title: `Package the delivery for "${args?.projectId ?? ''}"`,
433
+ kind: 'edit',
434
+ }),
435
+ })
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // blender_job_status
440
+ // ---------------------------------------------------------------------------
441
+
442
+ function jobStatus(ctx) {
443
+ return defineTool({
444
+ name: 'blender_job_status',
445
+ description:
446
+ 'Read DeepBlend render jobs from the durable store. Without jobId it lists the project\'s jobs — ' +
447
+ 'newest last — and reports which are unfinished, which is how an interrupted render is found after a ' +
448
+ 'harness restart. With jobId it reports one job in detail: status, how many frames are complete out ' +
449
+ 'of how many, the measured seconds per frame and the estimated time remaining, the frames that are ' +
450
+ 'missing or were left incomplete, and the delivery package once one exists. ' +
451
+ '\n\nThis reads from DISK, not from the current process, so it answers correctly for a render started ' +
452
+ 'before the harness restarted. Progress is counted from the frame files themselves. ' +
453
+ '\n\nAny warnings recorded on the job are printed with it. They are NOT errors and none of them ' +
454
+ 'changes the numbers: JOURNAL_INCOMPLETE means an attempt was cut off mid-line in its event journal, ' +
455
+ 'which is why the progress above is counted from the frame files rather than from that journal.',
456
+ parameters: {
457
+ projectId: { type: 'string', required: true, description: 'The project to inspect.' },
458
+ jobId: { type: 'string', description: 'One render job id. Omit to list the project\'s jobs.' },
459
+ limit: { type: 'number', description: 'When listing, return at most this many of the newest jobs.' },
460
+ },
461
+ output: TOOL_OUTPUT,
462
+ async execute(args) {
463
+ const resolved = resolveStudio(ctx)
464
+ if (resolved.unavailable !== undefined) {
465
+ return { ok: false, text: `Job status unavailable.\n${resolved.unavailable.text}`, data: resolved.unavailable.data }
466
+ }
467
+ try {
468
+ if (typeof args?.jobId === 'string' && args.jobId.length > 0) {
469
+ const stale = hostPlaneIsCurrent(resolved.studio, ['getJob'])
470
+ if (stale !== null) return { ok: false, ...stale }
471
+ const { data, canonicalWarnings } = await canonicalCall(
472
+ resolved.studio.getJob({ projectId: args.projectId, jobId: args.jobId }),
473
+ warning,
474
+ )
475
+ const lines = data.renderJob !== undefined ? describeJobLines(data.renderJob) : [`job: ${data.jobId}`, `status: ${data.status}`]
476
+ return {
477
+ ok: true,
478
+ text: renderSuccess(`Job ${data.jobId}.`, data, { notes: lines, warnings: canonicalWarnings }),
479
+ data,
480
+ }
481
+ }
482
+ const stale = hostPlaneIsCurrent(resolved.studio, ['listJobs'])
483
+ if (stale !== null) return { ok: false, ...stale }
484
+ const { data, canonicalWarnings } = await canonicalCall(
485
+ resolved.studio.listJobs(definedFields({ projectId: args?.projectId, limit: args?.limit })),
486
+ warning,
487
+ )
488
+ const lines = data.jobs.length === 0
489
+ ? ['This project has no render jobs yet. Start one with blender_final_render.']
490
+ : data.jobs.flatMap(job => describeJobLines(job).map((line, index) => (index === 0 ? `- ${line}` : ` ${line}`)))
491
+ if (data.unfinished.length > 0) {
492
+ lines.push('')
493
+ lines.push(`Unfinished: ${data.unfinished.join(', ')} — continue with blender_final_render {resumeJobId}.`)
494
+ }
495
+ if (data.recoveryError !== null && data.recoveryError !== undefined) {
496
+ lines.push('')
497
+ lines.push(`Restart reconciliation reported an error: ${data.recoveryError}`)
498
+ }
499
+ return {
500
+ ok: true,
501
+ text: renderSuccess(`${data.jobs.length} render job(s) for ${data.projectId}.`, data, { notes: lines, warnings: canonicalWarnings }),
502
+ data,
503
+ }
504
+ } catch (cause) {
505
+ return { ok: false, ...renderFailure(cause, 'BLENDER_SCRIPT_ERROR') }
506
+ }
507
+ },
508
+ presentCall: args => ({
509
+ card: 'generic',
510
+ title: args?.jobId !== undefined ? `Read job ${args.jobId}` : `List jobs of "${args?.projectId ?? ''}"`,
511
+ kind: 'read',
512
+ }),
513
+ })
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // blender_job_cancel
518
+ // ---------------------------------------------------------------------------
519
+
520
+ function jobCancel(ctx) {
521
+ return defineTool({
522
+ name: 'blender_job_cancel',
523
+ description:
524
+ 'Cancel a running delivery render and STOP ITS BLENDER PROCESS, including a renderer left behind by a ' +
525
+ 'previous harness process. The result reports whether the process is actually GONE, measured after ' +
526
+ 'the signal rather than assumed from sending one: cancellation that leaves a renderer writing frames ' +
527
+ 'is not cancellation. ' +
528
+ '\n\nFrames already rendered are kept and the job is marked cancelled; if you later want the delivery, ' +
529
+ 'start with blender_final_render {resumeJobId} and only the missing frames will be rendered. ' +
530
+ 'Cancelling an already-finished job is a no-op that says so, not an error.',
531
+ parameters: {
532
+ projectId: { type: 'string', required: true, description: 'The project the job belongs to.' },
533
+ jobId: { type: 'string', required: true, description: 'The render job to cancel.' },
534
+ reason: { type: 'string', description: 'Recorded verbatim in the job record and the harness job log.' },
535
+ },
536
+ output: TOOL_OUTPUT,
537
+ async execute(args) {
538
+ const resolved = resolveStudio(ctx)
539
+ if (resolved.unavailable !== undefined) {
540
+ return { ok: false, text: `Job cancellation unavailable.\n${resolved.unavailable.text}`, data: resolved.unavailable.data }
541
+ }
542
+ try {
543
+ const stale = hostPlaneIsCurrent(resolved.studio, ['cancelJob'])
544
+ if (stale !== null) return { ok: false, ...stale }
545
+ const { data, canonicalWarnings } = await canonicalCall(
546
+ resolved.studio.cancelJob(definedFields({
547
+ projectId: args?.projectId,
548
+ jobId: args?.jobId,
549
+ reason: args?.reason,
550
+ })),
551
+ warning,
552
+ )
553
+ const notes = [
554
+ data.cancelled
555
+ ? `Cancel requested: ${data.reason}`
556
+ : `Nothing to cancel: ${data.reason}`,
557
+ `process gone: ${data.processGone === true ? 'yes (verified)' : 'NO — a renderer is still alive; do not start another render for this project'}`,
558
+ `frames kept: ${data.completedFrames ?? 0}`,
559
+ ]
560
+ if (data.process?.term !== undefined) notes.push(`signal: ${data.process.term}${data.process.kill ? ` then ${data.process.kill}` : ''}`)
561
+ return {
562
+ ok: data.processGone !== false,
563
+ text: renderSuccess(`Job ${data.jobId}: ${data.status}.`, data, { notes, warnings: canonicalWarnings }),
564
+ data,
565
+ }
566
+ } catch (cause) {
567
+ return { ok: false, ...renderFailure(cause, 'BLENDER_SCRIPT_ERROR') }
568
+ }
569
+ },
570
+ presentCall: args => ({ card: 'generic', title: `Cancel job ${args?.jobId ?? ''}`, kind: 'edit' }),
571
+ })
572
+ }
573
+
574
+ /** `[1, 2, 3, … 450]` -> `1-3, … 450`, so a 450-frame list does not flood a result. */
575
+ function summarizeFrames(frames) {
576
+ if (!Array.isArray(frames) || frames.length === 0) return ''
577
+ if (frames.length <= 8) return frames.join(', ')
578
+ return `${frames.slice(0, 6).join(', ')}, … +${frames.length - 6} more`
579
+ }