@zq-silk/yui 2.11.0 → 2.14.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.
Files changed (89) hide show
  1. package/README.md +5 -0
  2. package/dist/artifacts/artifactCapability.js +14 -8
  3. package/dist/artifacts/artifactCommitLock.js +7 -5
  4. package/dist/artifacts/jobArtifact.js +93 -0
  5. package/dist/artifacts/jobArtifactTransport.js +13 -0
  6. package/dist/artifacts/taskArtifactRepository.js +58 -10
  7. package/dist/cli/commandCatalog.js +45 -14
  8. package/dist/cli/commandDiscovery.js +1 -1
  9. package/dist/commands/durableJobCommands.js +31 -6
  10. package/dist/commands/globalRoleCommands.js +5 -0
  11. package/dist/commands/scheduleCommands.js +14 -3
  12. package/dist/commands/taskCommands.js +5 -2
  13. package/dist/context/taskCatalog.js +6 -1
  14. package/dist/controlPlaneCli.js +7 -2
  15. package/dist/controller/agentJobControl.js +391 -0
  16. package/dist/controller/controller.js +24 -3
  17. package/dist/controller/fileSchedulerStoreAdapter.js +12 -1
  18. package/dist/controller/globalInputDelivery.js +8 -1
  19. package/dist/controller/jobArtifactControl.js +111 -0
  20. package/dist/controller/jobControl.js +4 -0
  21. package/dist/controller/jobDefinitionControl.js +191 -19
  22. package/dist/controller/jobSupervisor.js +83 -3
  23. package/dist/controller/jobTaskDelegationControl.js +193 -0
  24. package/dist/controller/runtime.js +9 -1
  25. package/dist/controller/scheduleControl.js +83 -20
  26. package/dist/integration/gitIntegrationService.js +2 -0
  27. package/dist/job/agentJob.js +131 -0
  28. package/dist/job/agentJobObservation.js +61 -0
  29. package/dist/job/agentRoleScope.js +33 -0
  30. package/dist/job/durableJob.js +63 -28
  31. package/dist/job/jobAssignmentScope.js +1 -0
  32. package/dist/job/jobBusinessResult.js +72 -0
  33. package/dist/job/jobCleanupInspection.js +4 -0
  34. package/dist/job/jobDefinition.js +26 -2
  35. package/dist/job/jobRunner.js +4 -3
  36. package/dist/job/projectRefreshRunner.js +54 -18
  37. package/dist/job/projectSync.js +162 -0
  38. package/dist/job/scheduleService.js +55 -0
  39. package/dist/job/taskDelegation.js +48 -0
  40. package/dist/message/messageContinuation.js +1 -1
  41. package/dist/resources/projectResourceService.js +1 -1
  42. package/dist/resources/resourceGc.js +1 -1
  43. package/dist/role/role.js +12 -0
  44. package/dist/schedule/schedule.js +65 -15
  45. package/dist/scheduler/wakeReason.js +4 -0
  46. package/dist/storage/agentJobSchema.js +10 -0
  47. package/dist/storage/recordValidation.js +2 -0
  48. package/dist/storage/sqliteSchema.js +77 -6
  49. package/dist/storage/sqliteStore.js +53 -0
  50. package/dist/storage/storageVersions.js +1 -1
  51. package/dist/storage/storeRpc.js +3 -0
  52. package/dist/storage/taskCatalog.js +10 -2
  53. package/dist/storage/upgrade/jobBusinessResultMigration.js +20 -0
  54. package/dist/storage/upgrade/scheduleDispatchMigration.js +15 -0
  55. package/dist/task/archivePreflight.js +15 -0
  56. package/dist/task/completionReadiness.js +7 -0
  57. package/dist/task/task.js +17 -1
  58. package/dist/verification/verificationGateService.js +4 -0
  59. package/dist/web/assets/client/app/catalog.js +16 -0
  60. package/dist/web/assets/client/app/settings.js +4 -0
  61. package/dist/web/assets/client/i18n/en.js +75 -2
  62. package/dist/web/assets/client/i18n/zh-CN.js +75 -2
  63. package/dist/web/assets/client/index.js +2 -0
  64. package/dist/web/assets/client/layout/manageShell.js +9 -0
  65. package/dist/web/assets/client/layout/router.js +1 -0
  66. package/dist/web/assets/client/views/jobs.js +166 -37
  67. package/dist/web/assets/client/views/permissions.js +129 -0
  68. package/dist/web/assets/client/views/task/files.js +5 -4
  69. package/dist/web/assets/client/views/task/page.js +5 -1
  70. package/dist/web/assets/shared/manageNav.js +1 -0
  71. package/dist/web/assets/shell/settingsContent.js +4 -0
  72. package/dist/web/assets/styles/views/settings.js +13 -0
  73. package/dist/web/taskPreviews.js +2 -2
  74. package/dist/web/webJobs.js +36 -11
  75. package/dist/web/webMaterials.js +21 -9
  76. package/dist/web/webPermissions.js +97 -0
  77. package/dist/web/webServer.js +44 -5
  78. package/dist/web/webTaskSurface.js +1 -0
  79. package/docs/authorization.md +35 -0
  80. package/docs/job-runs.md +392 -17
  81. package/docs/release-workflow.md +77 -0
  82. package/docs/release-workflow.zh-CN.md +57 -0
  83. package/docs/schedules.md +69 -15
  84. package/docs/storage-baseline.md +38 -1
  85. package/docs/storage-baseline.zh-CN.md +27 -1
  86. package/docs/testing/verification-levels.md +2 -2
  87. package/docs/testing/verification-levels.zh-CN.md +2 -2
  88. package/i18n/README.zh-CN.md +4 -0
  89. package/package.json +1 -1
@@ -0,0 +1,61 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { currentProviderConversation } from "../runtime/providerRuntimeIdentity.js";
3
+ export function jobAttemptOwnsInput(store, attempt, binding) {
4
+ const { recipient, messageId } = attempt;
5
+ const inputs = [binding.run?.input];
6
+ if (binding.run && binding.retry?.currentAttemptId === binding.run.attemptId)
7
+ inputs.push(binding.retry.input);
8
+ return inputs.some(input => {
9
+ if (recipient.scope === "global")
10
+ return input?.kind === "message"
11
+ && input.roleName === recipient.roleName && input.messageId === messageId;
12
+ if (input?.kind === "wake" && input.taskId === recipient.taskId)
13
+ return store.getTaskWake(recipient.taskId, input.wakeId)
14
+ ?.refs?.some(ref => ref.type === "message" && ref.id === messageId) === true;
15
+ return input?.kind === "run" && input.taskId === recipient.taskId
16
+ && store.listMessages(recipient.taskId).find(message => message.id === messageId)?.continuation?.runId === input.runId;
17
+ });
18
+ }
19
+ /** Collect existing Provider evidence at its owning transaction. No submission,
20
+ * retry, inferred business result, or transcript parsing occurs here. */
21
+ export function recordJobAgentObservation(store, recipient, binding, now) {
22
+ if (!binding?.run)
23
+ return;
24
+ for (const run of store.listAgentJobRunsForRole(recipient)) {
25
+ const last = run.attempts.at(-1);
26
+ if (!jobAttemptOwnsInput(store, last, binding))
27
+ continue;
28
+ const turn = binding.run;
29
+ const attempts = [...run.attempts];
30
+ let attempt = last;
31
+ if (turn.nativeTurnId && ["accepted", "completed", "failed", "cancelled"].includes(turn.status)) {
32
+ const execution = { nativeSessionId: currentProviderConversation(binding).conversationId,
33
+ attemptId: turn.attemptId, nativeTurnId: turn.nativeTurnId };
34
+ if (last.execution && !isDeepStrictEqual(last.execution, execution)) {
35
+ const retry = binding.retry;
36
+ // Only the existing bounded retry owner may prove a successor of the
37
+ // same original input. Session changes/other native Turns never do.
38
+ if (run.businessResult || retry?.currentAttemptId !== execution.attemptId
39
+ || retry.failureAttemptId !== last.execution.attemptId || retry.failedNativeTurnId !== last.execution.nativeTurnId
40
+ || retry.nativeSessionId !== last.execution.nativeSessionId)
41
+ continue;
42
+ attempts[attempts.length - 1] = { ...last, terminal: last.terminal ?? { status: "failed", observedAt: retry.updatedAt } };
43
+ attempt = { messageId: last.messageId, recipient: last.recipient, createdAt: turn.submittedAt, execution };
44
+ attempts.push(attempt);
45
+ }
46
+ else
47
+ attempt = { ...last, execution };
48
+ if (["completed", "failed", "cancelled"].includes(turn.status))
49
+ attempt = { ...attempt,
50
+ terminal: attempt.terminal ?? { status: turn.status, observedAt: now.toISOString() } };
51
+ attempts[attempts.length - 1] = attempt;
52
+ }
53
+ const status = run.businessResult ? run.status : turn.status === "delivery-unknown" ? "unknown-needs-attention"
54
+ : attempt.execution ? "running" : run.status;
55
+ if (status === run.status && isDeepStrictEqual(attempts, run.attempts))
56
+ continue;
57
+ const next = { ...run, status, attempts,
58
+ updatedAt: new Date(Math.max(now.getTime(), Date.parse(run.updatedAt))).toISOString() };
59
+ store.saveAgentJobRun(next);
60
+ }
61
+ }
@@ -0,0 +1,33 @@
1
+ /** A Job execution role is not a second Operator. Normal global Roles retain
2
+ * their contract; this narrows only explicitly Job-owned managed CLI callers.
3
+ * Job RPCs still authorize exact scope/Run and current native identity. */
4
+ export function assertJobAgentCommand(role, args) {
5
+ if (!role.jobOwner)
6
+ return;
7
+ const [command, action, operation, target] = args;
8
+ const allowed = command === "job" && ["show", "runs", "run-get", "run-result", "run-context",
9
+ "run-claim", "run-report", "run-evidence", "run-confirm", "artifact-save", "artifact-list",
10
+ "artifact-read", "artifact-preview", "artifact-register"].includes(action ?? "")
11
+ || command === "session" && (action === "skills" || action === "context" && operation === role.name)
12
+ || command === "role" && action === "message" && ["list", "show"].includes(operation ?? "") && target === role.name
13
+ || command === "inbox" && ["list", "show", "claim", "ack"].includes(action ?? "")
14
+ || ["help", "version"].includes(command ?? "");
15
+ if (!allowed)
16
+ throw new Error("Job Agent authority is limited to its own context, inbox and exact Job execution; management requires its owner.");
17
+ }
18
+ /** Global runtime transport does not give a Job's execution Role global authority. */
19
+ export function assertJobRoleRunnable(store, role) {
20
+ const owner = role.jobOwner;
21
+ if (!owner)
22
+ return;
23
+ const job = store.getJobDefinition(owner.scope, owner.jobId);
24
+ if (!job || job.configuration.kind !== "agent" || job.configuration.target.kind !== "job"
25
+ || job.configuration.target.agentId !== role.activeAgentId)
26
+ throw new Error("Job-owned Role configuration changed; inspect the original Job and pending input.");
27
+ if (owner.scope.kind === "task") {
28
+ const task = store.getTask(owner.scope.taskId);
29
+ if (task?.status !== "active" || task.executionGate.state !== "enabled") {
30
+ throw new Error("Job-owned Role cannot execute while its owning Task is stopped or terminal.");
31
+ }
32
+ }
33
+ }
@@ -1,5 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { requireIdentity, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
2
+ import { artifactsAppendOnly, validateRunArtifacts } from "../artifacts/jobArtifact.js";
3
+ import { processBusinessResult, runReference, validateBusinessResult } from "./jobBusinessResult.js";
4
+ import { validateTaskDelegation } from "./taskDelegation.js";
5
+ import { requireIdentity, requireKnownFields, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
3
6
  import { validateTaskRecordReference } from "../task/taskRecordReference.js";
4
7
  import { recordOperationEvidence, validateOperationFacts } from "../kernel/operationFacts.js";
5
8
  export const CURRENT_DURABLE_JOB_SCHEMA_VERSION = 1;
@@ -82,7 +85,7 @@ export function touchDurableJobHeartbeat(job, now) {
82
85
  updatedAt: now.toISOString()
83
86
  });
84
87
  }
85
- export function completeDurableJob(job, result, now) {
88
+ export function completeDurableJob(job, result, now, businessResult = processBusinessResult(job, result)) {
86
89
  validateDurableJob(job);
87
90
  if (job.status !== "running") {
88
91
  throw new Error(`DurableJob can only complete from running: ${job.status}.`);
@@ -93,13 +96,14 @@ export function completeDurableJob(job, result, now) {
93
96
  const timestamp = now.toISOString();
94
97
  return validateDurableJob({
95
98
  ...job,
96
- status: result.outcome,
99
+ status: businessResult.outcome,
97
100
  operation: recordOperationEvidence(job.operation, {
98
101
  partialResultRefs: result.steps.map((step) => step.logPath),
99
102
  receiptRefs: result.evidenceSource === undefined ? []
100
103
  : [`${job.artifactsLocator}/${result.evidenceSource === "checkpoint" ? "checkpoint.json" : "exit.json"}`]
101
104
  }),
102
105
  result: normalizeDurableJobResult(result),
106
+ businessResult: validateBusinessResult(businessResult, runReference(job)),
103
107
  terminalAt: timestamp,
104
108
  updatedAt: timestamp
105
109
  });
@@ -111,13 +115,14 @@ export function rejectQueuedDurableJob(job, reason, now) {
111
115
  throw new Error("Only an unattempted queued Job can be rejected.");
112
116
  }
113
117
  const timestamp = now.toISOString();
118
+ const result = {
119
+ outcome: "failed", exitCode: null, signal: null,
120
+ unknownReason: requireText(reason, "DurableJob rejection reason"), steps: []
121
+ };
114
122
  return validateDurableJob({
115
123
  ...job,
116
124
  status: "failed",
117
- result: {
118
- outcome: "failed", exitCode: null, signal: null,
119
- unknownReason: requireText(reason, "DurableJob rejection reason"), steps: []
120
- },
125
+ result, businessResult: processBusinessResult(job, result),
121
126
  terminalAt: timestamp,
122
127
  updatedAt: timestamp
123
128
  });
@@ -128,19 +133,18 @@ export function markDurableJobUnknown(job, unknownReason, completedSteps, now) {
128
133
  throw new Error(`DurableJob can only be marked unknown after an attempted effect: ${job.status}.`);
129
134
  }
130
135
  const timestamp = now.toISOString();
136
+ const result = {
137
+ outcome: "unknown-needs-attention", exitCode: null, signal: null,
138
+ unknownReason: requireText(unknownReason, "DurableJob unknown reason"),
139
+ steps: completedSteps.map(step => ({ ...step }))
140
+ };
131
141
  return validateDurableJob({
132
142
  ...job,
133
143
  status: "unknown-needs-attention",
134
144
  operation: recordOperationEvidence(job.operation, {
135
145
  partialResultRefs: completedSteps.map((step) => step.logPath)
136
146
  }),
137
- result: {
138
- outcome: "unknown-needs-attention",
139
- exitCode: null,
140
- signal: null,
141
- unknownReason: requireText(unknownReason, "DurableJob unknown reason"),
142
- steps: completedSteps.map((step) => ({ ...step }))
143
- },
147
+ result, businessResult: processBusinessResult(job, result),
144
148
  terminalAt: timestamp,
145
149
  updatedAt: timestamp
146
150
  });
@@ -173,15 +177,11 @@ export function cancelQueuedDurableJob(job, now) {
173
177
  throw new Error(`DurableJob can only cancel-queued from queued: ${job.status}.`);
174
178
  }
175
179
  const timestamp = now.toISOString();
180
+ const result = { outcome: "cancelled", exitCode: null, signal: null, steps: [] };
176
181
  return validateDurableJob({
177
182
  ...job,
178
183
  status: "cancelled",
179
- result: {
180
- outcome: "cancelled",
181
- exitCode: null,
182
- signal: null,
183
- steps: []
184
- },
184
+ result, businessResult: processBusinessResult(job, result),
185
185
  terminalAt: timestamp,
186
186
  updatedAt: timestamp
187
187
  });
@@ -235,6 +235,23 @@ export function retryDurableJobIdempotencyKey(previousKey, previousJobId) {
235
235
  .digest("hex");
236
236
  }
237
237
  export function validateDurableJob(job) {
238
+ if (job.delegation !== undefined) {
239
+ validateTaskDelegation(job.delegation);
240
+ if (job.process !== undefined || job.result !== undefined || job.status === "queued") {
241
+ throw new Error("Task delegation cannot carry process execution/result evidence.");
242
+ }
243
+ if (job.steps !== undefined || job.env !== undefined || job.workspace !== undefined
244
+ || job.head !== undefined || job.projectId !== undefined || job.checkpoint !== undefined
245
+ || ("action" in job && job.action !== undefined)
246
+ || ("actionInput" in job && job.actionInput !== undefined)
247
+ || ("serviceAuthorization" in job && job.serviceAuthorization !== undefined)) {
248
+ throw new Error("Task delegation cannot contain a process specification.");
249
+ }
250
+ if (job.businessResult?.outcome === "succeeded" && !job.businessResult.evidence.some(ref => (ref.kind === "task-result" || ref.kind === "work-item-result") && ref.owner.taskId === job.delegation.taskId)) {
251
+ throw new Error("Successful delegation requires exact target delivery evidence.");
252
+ }
253
+ }
254
+ validateRunArtifacts(job);
238
255
  if (job.schemaVersion !== CURRENT_DURABLE_JOB_SCHEMA_VERSION) {
239
256
  throw new Error(`DurableJob must use schemaVersion ${CURRENT_DURABLE_JOB_SCHEMA_VERSION}.`);
240
257
  }
@@ -248,17 +265,25 @@ export function validateDurableJob(job) {
248
265
  if ("taskId" in job) {
249
266
  validateTaskRecordReference({ taskId: job.taskId, localId: job.id }, "durableJob");
250
267
  validateDurableJobOwner(job.owner, job.taskId);
251
- requireIdentity(job.projectId, "DurableJob project id");
268
+ if (!job.delegation)
269
+ requireIdentity(job.projectId, "DurableJob project id");
252
270
  }
253
271
  else {
254
272
  if (job.scope !== "global")
255
273
  throw new Error("Invalid global JobRun scope.");
256
274
  requireIdentity(job.id, "JobRun id");
257
- requireText(job.action, "JobRun builtin action");
275
+ if (!job.delegation)
276
+ requireText(job.action, "JobRun builtin action");
277
+ if (job.serviceAuthorization !== undefined) {
278
+ requireKnownFields(job.serviceAuthorization, ["scheduleId", "grantId"], "Job Schedule service reference");
279
+ requireText(job.serviceAuthorization.scheduleId, "Schedule id");
280
+ requireText(job.serviceAuthorization.grantId, "Schedule grant id");
281
+ }
258
282
  if (job.projectId !== undefined)
259
283
  requireIdentity(job.projectId, "JobRun Project id");
260
284
  }
261
- validateJobExecutionConfiguration(job);
285
+ if (!job.delegation)
286
+ validateJobExecutionConfiguration(job);
262
287
  requireText(job.idempotencyKey, "DurableJob idempotency key");
263
288
  if (!/^[a-f0-9]{64}$/u.test(job.idempotencyKey)) {
264
289
  throw new Error("DurableJob idempotency key is invalid.");
@@ -302,6 +327,11 @@ export function validateDurableJob(job) {
302
327
  if (job.result !== undefined) {
303
328
  normalizeDurableJobResult(job.result);
304
329
  }
330
+ if (job.businessResult !== undefined) {
331
+ validateBusinessResult(job.businessResult, runReference(job));
332
+ if (job.status !== job.businessResult.outcome)
333
+ throw new Error("Job status must match its business outcome.");
334
+ }
305
335
  if (job.retryOf !== undefined) {
306
336
  if ("taskId" in job)
307
337
  validateTaskRecordReference({ taskId: job.taskId, localId: job.retryOf }, "durableJob");
@@ -329,7 +359,10 @@ export function validateDurableJob(job) {
329
359
  return job;
330
360
  }
331
361
  export function validDurableJobTransition(before, after) {
332
- if (before.id !== after.id
362
+ if (!isDeepStrictEqual(before.delegation, after.delegation)
363
+ ||
364
+ !artifactsAppendOnly(before, after)
365
+ || before.id !== after.id
333
366
  || before.definitionId !== after.definitionId
334
367
  || before.definitionRevision !== after.definitionRevision
335
368
  || !isDeepStrictEqual(before.trigger, after.trigger)
@@ -341,7 +374,7 @@ export function validDurableJobTransition(before, after) {
341
374
  || before.idempotencyKey !== after.idempotencyKey
342
375
  || before.artifactsLocator !== after.artifactsLocator
343
376
  || before.createdAt !== after.createdAt
344
- || !isDeepStrictEqual("owner" in before ? before.owner : [before.action, before.actionInput], "owner" in after ? after.owner : [after.action, after.actionInput])
377
+ || !isDeepStrictEqual("owner" in before ? before.owner : [before.action, before.actionInput, before.serviceAuthorization], "owner" in after ? after.owner : [after.action, after.actionInput, after.serviceAuthorization])
345
378
  || !isDeepStrictEqual(before.env, after.env)
346
379
  || !isDeepStrictEqual(before.steps, after.steps)
347
380
  || before.operation.requestId !== after.operation.requestId
@@ -354,7 +387,9 @@ export function validDurableJobTransition(before, after) {
354
387
  || !isDeepStrictEqual(recordOperationEvidence(before.operation, after.operation), after.operation))
355
388
  return false;
356
389
  if (isDurableJobTerminal(before.status)) {
357
- // A terminal job is immutable except for two one-way flags:
390
+ // A terminal job's outcome is immutable. Artifact references can only append,
391
+ // permitting exact post-execution collection without rerunning business work.
392
+ // The two existing one-way flags remain:
358
393
  // wakeupNotified (set by the supervisor after enqueuing the Leader
359
394
  // wakeup) and acknowledgedAt (set by the Leader on an unknown job so
360
395
  // Task lifecycle gates can proceed). Without these exceptions the
@@ -363,8 +398,8 @@ export function validDurableJobTransition(before, after) {
363
398
  const wakeupFlip = before.wakeupNotified !== true && after.wakeupNotified === true;
364
399
  const acknowledgeFlip = before.acknowledgedAt === undefined
365
400
  && after.acknowledgedAt !== undefined;
366
- const { wakeupNotified: _beforeWake, acknowledgedAt: _beforeAck, updatedAt: _beforeTime, ...beforeFacts } = before;
367
- const { wakeupNotified: _afterWake, acknowledgedAt: _afterAck, updatedAt: _afterTime, ...afterFacts } = after;
401
+ const { artifacts: _beforeArtifacts, wakeupNotified: _beforeWake, acknowledgedAt: _beforeAck, updatedAt: _beforeTime, ...beforeFacts } = before;
402
+ const { artifacts: _afterArtifacts, wakeupNotified: _afterWake, acknowledgedAt: _afterAck, updatedAt: _afterTime, ...afterFacts } = after;
368
403
  return isDeepStrictEqual(beforeFacts, afterFacts)
369
404
  && before.status === after.status
370
405
  && isDeepStrictEqual(before.result, after.result)
@@ -13,6 +13,7 @@ export function assertJobAssignmentScope(store, target, caller) {
13
13
  if (run?.status !== "active" || run.reviewRoundId !== undefined
14
14
  || target.owner.kind !== "work-item" || target.owner.workItemId !== run.workItemId
15
15
  || item?.status !== "open" || item.assignee !== caller.role
16
+ || target.projectId === undefined || target.workspace === undefined
16
17
  || !item.writeProjectIds.includes(target.projectId)
17
18
  || !run.effective.writeProjectIds.includes(target.projectId)
18
19
  || workspace === undefined || resolve(workspace.root) !== resolve(target.workspace)
@@ -0,0 +1,72 @@
1
+ import { createHash } from "node:crypto";
2
+ import { requireKnownFields, requireText } from "../domain/validation.js";
3
+ export function runReference(run) {
4
+ if ("executor" in run)
5
+ return { scope: run.scope, runId: run.id };
6
+ return { scope: "taskId" in run ? { kind: "task", taskId: run.taskId } : { kind: "global" }, runId: run.id };
7
+ }
8
+ export function resultDigest(value) {
9
+ return createHash("sha256").update(value).digest("hex");
10
+ }
11
+ export function validateBusinessResult(result, run) {
12
+ requireKnownFields(result, ["schemaVersion", "sourceRun", "outcome", "summary", "data", "evidence"], "Job business result");
13
+ const matches = (ref) => {
14
+ requireKnownFields(ref, ["scope", "runId"], "Job result Run reference");
15
+ requireKnownFields(ref.scope, ref.scope?.kind === "task" ? ["kind", "taskId"] : ["kind"], "Job result scope");
16
+ return ref.runId === run.runId && ref.scope.kind === run.scope.kind
17
+ && (run.scope.kind !== "task" || (ref.scope.kind === "task" && ref.scope.taskId === run.scope.taskId));
18
+ };
19
+ if (result.schemaVersion !== 1 || !matches(result.sourceRun))
20
+ throw new Error("Job result source Run mismatch.");
21
+ if (!["succeeded", "failed", "cancelled", "timed-out", "unknown-needs-attention"].includes(result.outcome)) {
22
+ throw new Error("Invalid Job business outcome.");
23
+ }
24
+ requireText(result.summary, "Job result summary");
25
+ if (Buffer.byteLength(JSON.stringify(result)) > 256 * 1024)
26
+ throw new Error("Job business result exceeds 256 KiB.");
27
+ if (!Array.isArray(result.evidence))
28
+ throw new Error("Job result evidence must be an array.");
29
+ for (const ref of result.evidence) {
30
+ requireKnownFields(ref, ["kind", "owner", "sourceRun", "version", "locator"], "Job result reference");
31
+ const taskEvidence = ref.kind === "task-result" || ref.kind === "work-item-result";
32
+ const ownerMatches = taskEvidence
33
+ ? (requireKnownFields(ref.owner, ["kind", "taskId"], "Task result owner"),
34
+ ref.owner.kind === "task" && /^task-\d+$/u.test(ref.owner.taskId))
35
+ : matches(ref.owner);
36
+ if (!["run-record", "execution-evidence", "task-result", "work-item-result"].includes(ref.kind) || !ownerMatches || !matches(ref.sourceRun)
37
+ || !/^[a-f0-9]{64}$/u.test(ref.version))
38
+ throw new Error("Invalid Job result evidence identity/version.");
39
+ requireText(ref.locator, "Job result evidence locator");
40
+ if (ref.locator.startsWith("/") || ref.locator.split("/").includes(".."))
41
+ throw new Error("Invalid Job evidence locator.");
42
+ }
43
+ return result;
44
+ }
45
+ /** Shell and historical results describe only what the saved process evidence proves. */
46
+ export function processBusinessResult(run, result) {
47
+ const sourceRun = runReference(run);
48
+ const summary = result.unknownReason ?? `Execution ${result.outcome}${result.failedStep ? ` at ${result.failedStep}` : ""}.`;
49
+ return {
50
+ schemaVersion: 1, sourceRun, outcome: result.outcome,
51
+ summary: summary.length <= 4096 ? summary : `${summary.slice(0, 4096)}… (see original execution evidence)`,
52
+ evidence: [{
53
+ kind: "run-record", owner: sourceRun, sourceRun,
54
+ version: resultDigest(JSON.stringify(result)),
55
+ locator: `job-runs/${"taskId" in run ? run.taskId : "global"}/${run.id}/result`
56
+ }]
57
+ };
58
+ }
59
+ /** Derived only from the admitted Run; never read a mutable definition here. */
60
+ export function jobRunSnapshot(run) {
61
+ return {
62
+ ...runReference(run), definitionId: run.definitionId, definitionRevision: run.definitionRevision,
63
+ owner: "owner" in run ? run.owner : null,
64
+ trigger: run.trigger, retryOf: run.retryOf ?? null,
65
+ executor: run.delegation ? { kind: "task", delegation: run.delegation } : "taskId" in run ? { kind: "shell" } : {
66
+ kind: "builtin", action: run.action, input: run.actionInput
67
+ },
68
+ target: run.delegation ? { taskId: run.delegation.taskId, workItemId: run.delegation.workItemId ?? null }
69
+ : { projectId: run.projectId ?? null, workspace: run.workspace, head: run.head },
70
+ execution: run.delegation ? null : { env: run.env, steps: run.steps, implementation: run.operation.implementation }
71
+ };
72
+ }
@@ -12,6 +12,10 @@ export function jobCleanupBlocker(job, home, phase = "before-removal") {
12
12
  if (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined) {
13
13
  return "Unknown Job result requires explicit acknowledgement.";
14
14
  }
15
+ // A settled delegation owns no runner/workspace. Its target Task retains
16
+ // independent resource ownership and must pass its own cleanup inspection.
17
+ if (job.delegation)
18
+ return undefined;
15
19
  if (job.status !== "unknown-needs-attention" && job.startedAt === undefined
16
20
  && job.process === undefined && job.operation.effect === "none")
17
21
  return undefined;
@@ -1,5 +1,7 @@
1
1
  import { requireIdentity, requireKnownFields, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
2
2
  import { validateJobExecutionConfiguration, validateDurableJobOwner } from "./durableJob.js";
3
+ import { validateAgentJobConfiguration } from "./agentJob.js";
4
+ import { validateTaskJobConfiguration } from "./taskDelegation.js";
3
5
  export function jobScopeKey(scope) {
4
6
  if (scope.kind === "global")
5
7
  return "global";
@@ -9,7 +11,7 @@ export function jobScopeKey(scope) {
9
11
  return scope.taskId;
10
12
  }
11
13
  export function validateJobDefinition(job) {
12
- requireKnownFields(job, ["schemaVersion", "id", "scope", "title", "revision", "configuration", "createdAt", "updatedAt"], "Job");
14
+ requireKnownFields(job, ["schemaVersion", "id", "scope", "title", "revision", "configuration", "builtin", "instantiation", "createdAt", "updatedAt"], "Job");
13
15
  if (job.schemaVersion !== 1)
14
16
  throw new Error("Job schemaVersion must be 1.");
15
17
  requireIdentity(job.id, "Job id");
@@ -19,12 +21,29 @@ export function validateJobDefinition(job) {
19
21
  requirePositiveInteger(job.revision, "Job revision");
20
22
  requireTimestamp(job.createdAt, "Job createdAt");
21
23
  requireTimestamp(job.updatedAt, "Job updatedAt");
24
+ if (job.instantiation !== undefined) {
25
+ requireKnownFields(job.instantiation, ["sourceId", "sourceRevision", "key"], "Job instantiation");
26
+ requireIdentity(job.instantiation.sourceId, "Job source id");
27
+ requirePositiveInteger(job.instantiation.sourceRevision, "Job source revision");
28
+ requireText(job.instantiation.key, "Job instantiation key");
29
+ }
22
30
  if (job.scope.kind !== "task" && job.scope.kind !== "global")
23
31
  throw new Error("Invalid Job scope.");
24
32
  requireKnownFields(job.scope, job.scope.kind === "task" ? ["kind", "taskId"] : ["kind"], "Job scope");
25
33
  jobScopeKey(job.scope);
26
34
  const config = job.configuration;
27
- if (job.scope.kind === "global") {
35
+ if (job.builtin !== undefined && (job.builtin !== "project-sync" || job.scope.kind !== "global"
36
+ || config.kind !== "builtin" || config.action !== "project.refresh"
37
+ || job.id !== `project-sync-${config.projectId}` || job.instantiation !== undefined)) {
38
+ throw new Error("Invalid builtin Project sync definition.");
39
+ }
40
+ if (config.kind === "agent") {
41
+ validateAgentJobConfiguration(config);
42
+ }
43
+ else if (config.kind === "task") {
44
+ validateTaskJobConfiguration(config);
45
+ }
46
+ else if (job.scope.kind === "global") {
28
47
  if (config.kind !== "builtin")
29
48
  throw new Error("Global Jobs require a controlled builtin action.");
30
49
  requireKnownFields(config, ["kind", "action", "projectId", "input"], "Builtin Job");
@@ -51,6 +70,9 @@ export function createJobDefinition(input, now) {
51
70
  });
52
71
  }
53
72
  export function reviseJobDefinition(job, input, now) {
73
+ if (job.configuration.kind !== input.configuration.kind) {
74
+ throw new Error("Changing Job executor kind requires a new Job id; preserve the original execution history.");
75
+ }
54
76
  return validateJobDefinition({
55
77
  ...job, title: input.title, configuration: structuredClone(input.configuration),
56
78
  revision: job.revision + 1, updatedAt: now.toISOString()
@@ -58,6 +80,8 @@ export function reviseJobDefinition(job, input, now) {
58
80
  }
59
81
  /** Old execution IDs are preserved one-to-one; command equality never merges history. */
60
82
  export function definitionFromTaskRun(run) {
83
+ if (run.delegation)
84
+ throw new Error("Task delegation already requires a Job definition.");
61
85
  return createJobDefinition({
62
86
  id: run.definitionId, scope: { kind: "task", taskId: run.taskId }, title: run.steps[0].name,
63
87
  configuration: {
@@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url";
14
14
  import { jobStepDirectory } from "./stepDirectory.js";
15
15
  import { prepareHttpPreview, isHttpPreview } from "./httpPreview.js";
16
16
  import { writeTextFileAtomically } from "../storage/durableFile.js";
17
- import { readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
17
+ import { currentProcessStartIdentity } from "../core/fileLockOwner.js";
18
18
  const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
19
19
  const HEARTBEAT_INTERVAL_MS = 5_000;
20
20
  const SIGKILL_GRACE_MS = 2_000;
@@ -46,14 +46,15 @@ export async function runDurableJobRunner(specPath) {
46
46
  // reads this file to adopt a queued job after a Controller crash.
47
47
  //
48
48
  // rr4/finding-4: The runner writes its own OS startIdentity so the
49
- // supervisor can adopt without re-reading /proc (which could return a
49
+ // supervisor can adopt without re-reading the OS (which could return a
50
50
  // reused PID's identity after the original runner exited).
51
51
  const readyPath = join(artifactDir, "ready.json");
52
+ const startIdentity = currentProcessStartIdentity();
52
53
  try {
53
54
  const readyFd = openSync(readyPath, "wx", 0o600);
54
55
  writeFileSync(readyFd, JSON.stringify({
55
56
  pid: process.pid,
56
- startIdentity: readLinuxProcessStartIdentity(process.pid) ?? String(process.pid),
57
+ startIdentity,
57
58
  startedAt: new Date().toISOString()
58
59
  }, null, 2) + "\n");
59
60
  closeSync(readyFd);
@@ -2,38 +2,74 @@
2
2
  * Controlled Project domain action invoked by the artifact-only Job runner.
3
3
  * Its catalog connection is read-only; only the shared domain service writes Git.
4
4
  */
5
- import { resolve } from "node:path";
5
+ import { join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { SqliteTaskStore } from "../storage/sqliteStore.js";
8
8
  import { authorizeGlobalJobStart } from "../controller/jobDefinitionControl.js";
9
9
  import { refreshRegisteredProject } from "../repository/projectRefresh.js";
10
10
  import { projectRefreshDigest } from "./builtinJob.js";
11
11
  import { CliError } from "../errors/cliError.js";
12
+ import { writeTextFileAtomically } from "../storage/durableFile.js";
13
+ import { runReference } from "./jobBusinessResult.js";
14
+ import { requireProjectSyncCheckout } from "./projectSync.js";
12
15
  export async function runProjectRefresh(home, runId) {
13
16
  const store = new SqliteTaskStore(home, { readonly: true });
14
17
  try {
15
18
  const run = store.getGlobalJobRun(runId);
16
- if (!run || run.action !== "project.refresh" || !["queued", "running"].includes(run.status)
17
- || run.operation.effect !== "possible" || run.cancelRequestedAt) {
19
+ if (!run || run.action !== "project.refresh"
20
+ || !((run.status === "queued" && run.operation.effect === "possible")
21
+ || (run.status === "running" && run.operation.effect === "confirmed"))
22
+ || run.cancelRequestedAt) {
18
23
  throw new Error("No active admitted project.refresh Run.");
19
24
  }
20
- authorizeGlobalJobStart(store, run);
21
- const result = await refreshRegisteredProject(store, run.projectId, {
22
- automatic: true,
23
- expectedDigest: projectRefreshDigest({
24
- kind: "builtin", action: run.action, projectId: run.projectId, input: run.actionInput
25
- }),
26
- beforeRefresh: () => {
27
- const current = store.getGlobalJobRun(runId);
28
- if (!current || !["queued", "running"].includes(current.status) || current.cancelRequestedAt) {
29
- throw new Error("Project refresh Run stopped while waiting for maintenance.");
25
+ let result;
26
+ try {
27
+ // A revoked/stale grant before Git is a known refusal. Persist its
28
+ // business receipt just like a refusal after waiting for maintenance.
29
+ authorizeGlobalJobStart(store, run);
30
+ result = await refreshRegisteredProject(store, run.projectId, {
31
+ automatic: true,
32
+ expectedDigest: projectRefreshDigest({
33
+ kind: "builtin", action: run.action, projectId: run.projectId, input: run.actionInput
34
+ }),
35
+ beforeRefresh: () => {
36
+ const current = store.getGlobalJobRun(runId);
37
+ if (!current || !["queued", "running"].includes(current.status) || current.cancelRequestedAt) {
38
+ throw new Error("Project refresh Run stopped while waiting for maintenance.");
39
+ }
40
+ authorizeGlobalJobStart(store, current);
41
+ const job = store.getJobDefinition({ kind: "global" }, current.definitionId);
42
+ if (job?.builtin)
43
+ requireProjectSyncCheckout(store, job);
30
44
  }
31
- authorizeGlobalJobStart(store, current);
32
- }
33
- });
34
- process.stdout.write(JSON.stringify({ action: run.action, projectId: run.projectId,
45
+ });
46
+ }
47
+ catch (error) {
48
+ const details = error instanceof CliError ? error.details : {};
49
+ const partial = details.refresh;
50
+ const business = {
51
+ schemaVersion: 1, sourceRun: runReference(run),
52
+ outcome: partial?.changed === null ? "unknown-needs-attention" : "failed",
53
+ summary: error instanceof Error ? error.message : String(error),
54
+ data: JSON.parse(JSON.stringify({ action: run.action, projectId: run.projectId,
55
+ target: run.actionInput, observedAt: new Date().toISOString(), details })),
56
+ evidence: []
57
+ };
58
+ writeTextFileAtomically(join(home, run.artifactsLocator, "business-result.json"), JSON.stringify(business) + "\n");
59
+ throw error;
60
+ }
61
+ const data = { action: run.action, projectId: run.projectId,
35
62
  trigger: run.trigger, outcome: "succeeded", observedAt: new Date().toISOString(),
36
- remoteCheck: { status: "verified", commit: result.toCommit }, refresh: result }) + "\n");
63
+ remoteCheck: { status: "verified", commit: result.toCommit }, refresh: result };
64
+ const business = {
65
+ schemaVersion: 1, sourceRun: runReference(run), outcome: "succeeded",
66
+ summary: `Project ${run.projectId} refreshed to ${result.toCommit}.`,
67
+ data: JSON.parse(JSON.stringify(data)), evidence: []
68
+ };
69
+ // Persist separately from stdout. If this write fails after Git effects,
70
+ // the collector reports unknown, never a safely retryable failed action.
71
+ writeTextFileAtomically(join(home, run.artifactsLocator, "business-result.json"), JSON.stringify(business) + "\n");
72
+ process.stdout.write(JSON.stringify(data) + "\n");
37
73
  }
38
74
  finally {
39
75
  store.close();