@zq-silk/yui 2.11.0 → 2.12.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 (79) hide show
  1. package/dist/artifacts/artifactCapability.js +14 -8
  2. package/dist/artifacts/artifactCommitLock.js +7 -5
  3. package/dist/artifacts/jobArtifact.js +93 -0
  4. package/dist/artifacts/jobArtifactTransport.js +13 -0
  5. package/dist/artifacts/taskArtifactRepository.js +58 -10
  6. package/dist/cli/commandCatalog.js +45 -14
  7. package/dist/cli/commandDiscovery.js +1 -1
  8. package/dist/commands/durableJobCommands.js +31 -6
  9. package/dist/commands/globalRoleCommands.js +5 -0
  10. package/dist/commands/scheduleCommands.js +14 -3
  11. package/dist/commands/taskCommands.js +5 -2
  12. package/dist/context/taskCatalog.js +6 -1
  13. package/dist/controlPlaneCli.js +7 -2
  14. package/dist/controller/agentJobControl.js +391 -0
  15. package/dist/controller/controller.js +24 -3
  16. package/dist/controller/fileSchedulerStoreAdapter.js +12 -1
  17. package/dist/controller/globalInputDelivery.js +8 -1
  18. package/dist/controller/jobArtifactControl.js +111 -0
  19. package/dist/controller/jobControl.js +4 -0
  20. package/dist/controller/jobDefinitionControl.js +191 -19
  21. package/dist/controller/jobSupervisor.js +83 -3
  22. package/dist/controller/jobTaskDelegationControl.js +193 -0
  23. package/dist/controller/runtime.js +7 -1
  24. package/dist/controller/scheduleControl.js +83 -20
  25. package/dist/integration/gitIntegrationService.js +2 -0
  26. package/dist/job/agentJob.js +131 -0
  27. package/dist/job/agentJobObservation.js +61 -0
  28. package/dist/job/agentRoleScope.js +33 -0
  29. package/dist/job/durableJob.js +63 -28
  30. package/dist/job/jobAssignmentScope.js +1 -0
  31. package/dist/job/jobBusinessResult.js +72 -0
  32. package/dist/job/jobCleanupInspection.js +4 -0
  33. package/dist/job/jobDefinition.js +26 -2
  34. package/dist/job/jobRunner.js +4 -3
  35. package/dist/job/projectRefreshRunner.js +54 -18
  36. package/dist/job/projectSync.js +162 -0
  37. package/dist/job/scheduleService.js +55 -0
  38. package/dist/job/taskDelegation.js +48 -0
  39. package/dist/message/messageContinuation.js +1 -1
  40. package/dist/resources/projectResourceService.js +1 -1
  41. package/dist/resources/resourceGc.js +1 -1
  42. package/dist/role/role.js +12 -0
  43. package/dist/schedule/schedule.js +65 -15
  44. package/dist/scheduler/wakeReason.js +4 -0
  45. package/dist/storage/agentJobSchema.js +10 -0
  46. package/dist/storage/recordValidation.js +2 -0
  47. package/dist/storage/sqliteSchema.js +77 -6
  48. package/dist/storage/sqliteStore.js +53 -0
  49. package/dist/storage/storageVersions.js +1 -1
  50. package/dist/storage/storeRpc.js +3 -0
  51. package/dist/storage/taskCatalog.js +10 -2
  52. package/dist/storage/upgrade/jobBusinessResultMigration.js +20 -0
  53. package/dist/storage/upgrade/scheduleDispatchMigration.js +15 -0
  54. package/dist/task/archivePreflight.js +15 -0
  55. package/dist/task/completionReadiness.js +7 -0
  56. package/dist/task/task.js +17 -1
  57. package/dist/verification/verificationGateService.js +4 -0
  58. package/dist/web/assets/client/app/catalog.js +16 -0
  59. package/dist/web/assets/client/i18n/en.js +29 -2
  60. package/dist/web/assets/client/i18n/zh-CN.js +29 -2
  61. package/dist/web/assets/client/layout/manageShell.js +9 -0
  62. package/dist/web/assets/client/layout/router.js +1 -0
  63. package/dist/web/assets/client/views/jobs.js +166 -37
  64. package/dist/web/assets/client/views/task/files.js +5 -4
  65. package/dist/web/assets/client/views/task/page.js +5 -1
  66. package/dist/web/taskPreviews.js +2 -2
  67. package/dist/web/webJobs.js +36 -11
  68. package/dist/web/webMaterials.js +21 -9
  69. package/dist/web/webServer.js +6 -5
  70. package/dist/web/webTaskSurface.js +1 -0
  71. package/docs/job-runs.md +392 -17
  72. package/docs/release-workflow.md +42 -0
  73. package/docs/release-workflow.zh-CN.md +31 -0
  74. package/docs/schedules.md +69 -15
  75. package/docs/storage-baseline.md +38 -1
  76. package/docs/storage-baseline.zh-CN.md +27 -1
  77. package/docs/testing/verification-levels.md +2 -2
  78. package/docs/testing/verification-levels.zh-CN.md +2 -2
  79. package/package.json +1 -1
@@ -0,0 +1,111 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { requireKnownFields } from "../domain/validation.js";
3
+ import { runReference } from "../job/jobBusinessResult.js";
4
+ import { createJobDefinitionControl } from "./jobDefinitionControl.js";
5
+ import { assertCallerAuthorized } from "./jobControl.js";
6
+ import { artifactInputBytes, presentArtifactContent } from "../artifacts/artifactCapability.js";
7
+ import { previewArtifactContent } from "../web/webMaterials.js";
8
+ import { boundedJobArtifactPayload, MAX_JOB_ARTIFACT_RESPONSE_BYTES } from "../artifacts/jobArtifactTransport.js";
9
+ import { jobArtifactOwner, openJobArtifactRepository, readJobArtifactReference, validateJobArtifactReference } from "../artifacts/jobArtifact.js";
10
+ /** Git commits precede database publication. A failed publication retains the exact
11
+ * commit and returns its reference for explicit registration, never business replay.
12
+ * Never hold a synchronous SQLite transaction across awaited Git operations. */
13
+ export async function controlJobArtifact(store, home, value, now) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value))
15
+ throw new Error("Invalid Job artifact request.");
16
+ requireKnownFields(value, ["op", "scope", "caller", "id", "runId", "artifact", "reference", "offset"], "Job artifact request");
17
+ const input = value;
18
+ const current = () => createJobDefinitionControl(store, home)({
19
+ op: "run-get", scope: input.scope, caller: input.caller, id: input.id, runId: input.runId
20
+ }, now);
21
+ const run = current(); // Both definition and historical Assignment authorization.
22
+ const repo = openJobArtifactRepository(home, jobArtifactOwner(run), runReference(run));
23
+ if (input.op === "artifact-list") {
24
+ const head = await repo.head();
25
+ const files = head === null ? [] : await repo.list(head);
26
+ if (files.length > 256)
27
+ throw new Error("Artifact repository exceeds the bounded 256-file listing.");
28
+ const saved = [];
29
+ if (head !== null)
30
+ for (const file of files) {
31
+ // One bounded file at a time: a valid 256-file repository must not spawn
32
+ // hundreds of Git children or materialize all 8 MiB blobs concurrently.
33
+ const content = await repo.read(file.relativePath, head);
34
+ saved.push({ kind: "artifact", owner: jobArtifactOwner(run), sourceRun: runReference(run),
35
+ version: head, relativePath: file.relativePath, digest: content.digest });
36
+ }
37
+ current();
38
+ return { sourceRun: runReference(run), artifacts: run.artifacts ?? [],
39
+ repository: { head, saved, workingChanges: await repo.workingChanges() } };
40
+ }
41
+ if (input.op === "artifact-read" || input.op === "artifact-preview") {
42
+ const ref = validateJobArtifactReference(input.reference, run);
43
+ // This grants only a deliberately selected, published object, never HEAD or
44
+ // all material in its Task. An arbitrary caller-supplied Task ref is not a grant.
45
+ if (!(run.artifacts ?? []).some(item => isDeepStrictEqual(item, ref))) {
46
+ throw new Error("Artifact reference is not registered for this exact Run.");
47
+ }
48
+ const content = await readJobArtifactReference(home, ref);
49
+ current(); // A replaced Session during I/O must not receive the material.
50
+ return boundedJobArtifactPayload({ reference: ref, ...(input.op === "artifact-preview"
51
+ ? previewArtifactContent(content, input.offset) : presentArtifactContent(content)) }, "Artifact response", MAX_JOB_ARTIFACT_RESPONSE_BYTES);
52
+ }
53
+ if (!["artifact-save", "artifact-register"].includes(input.op))
54
+ throw new Error("Unknown Job artifact operation.");
55
+ let ref;
56
+ if (input.op === "artifact-save") {
57
+ const artifact = input.artifact;
58
+ requireKnownFields(artifact, ["relativePath", "content", "encoding", "message", "expectedHead"], "Job artifact save");
59
+ boundedJobArtifactPayload(artifact, "Artifact save");
60
+ if (typeof artifact.relativePath !== "string" || artifact.relativePath.length > 512)
61
+ throw new Error("Artifact path exceeds 512 characters.");
62
+ if ((run.artifacts?.length ?? 0) >= 256)
63
+ throw new Error("Run artifacts exceed the bounded reference limit.");
64
+ const bytes = artifactInputBytes(artifact);
65
+ const saved = await repo.save({ files: [{ relativePath: artifact.relativePath, bytes }],
66
+ message: artifact.message ?? `Save ${artifact.relativePath}`, expectedHead: artifact.expectedHead });
67
+ const content = await repo.read(artifact.relativePath, saved.commit);
68
+ ref = { kind: "artifact", owner: jobArtifactOwner(run), sourceRun: runReference(run),
69
+ version: saved.commit, relativePath: content.relativePath, digest: content.digest };
70
+ }
71
+ else {
72
+ ref = validateJobArtifactReference(input.reference, run);
73
+ if (ref.owner.kind === "task") {
74
+ // Only a Task Leader or Operator with current access may select Task material.
75
+ // Assignment readers cannot manufacture a cross-entity grant.
76
+ if (!(input.caller.scope === "global" && input.caller.role === "operator")
77
+ && !(input.caller.scope === "task" && input.caller.role === "leader")) {
78
+ throw new Error("Selecting Task artifacts requires its authorized Leader or Operator.");
79
+ }
80
+ assertCallerAuthorized(store, input.caller, ref.owner.taskId);
81
+ }
82
+ await readJobArtifactReference(home, ref);
83
+ }
84
+ try {
85
+ return store.transaction(tx => {
86
+ const fresh = current();
87
+ if (ref.owner.kind === "task")
88
+ assertCallerAuthorized(tx, input.caller, ref.owner.taskId);
89
+ const previous = fresh.artifacts ?? [];
90
+ if (previous.some(item => isDeepStrictEqual(item, ref)))
91
+ return { status: "registered", reference: ref };
92
+ const next = { ...fresh, artifacts: [...previous, ref],
93
+ updatedAt: new Date(Math.max(now.getTime(), Date.parse(fresh.updatedAt))).toISOString() };
94
+ if ("executor" in next)
95
+ tx.saveAgentJobRun(next);
96
+ else if ("taskId" in next)
97
+ tx.saveDurableJob(next.taskId, next);
98
+ else
99
+ tx.saveGlobalJobRun(next);
100
+ return { status: "registered", reference: ref };
101
+ });
102
+ }
103
+ catch (cause) {
104
+ // An explicit partial receipt survives the RPC's bounded exception text.
105
+ // It is NOT a published result. Never replay the business action or silently
106
+ // retry the database write to conceal this boundary.
107
+ return { status: "saved-unregistered", reference: ref,
108
+ error: (cause instanceof Error ? cause.message : String(cause)).slice(0, 4096),
109
+ recovery: "Inspect artifact-list and register this exact reference; do not rerun business work." };
110
+ }
111
+ }
@@ -86,6 +86,8 @@ export function createDurableJobControl(store) {
86
86
  validateStartParams(tx, params);
87
87
  const previous = params.retryOf === undefined ? null : tx.getDurableJob(params.taskId, params.retryOf);
88
88
  if (previous !== null) {
89
+ if (previous.delegation)
90
+ throw jobDomainError("Use Job run retry for a Task delegation, not a process specification.");
89
91
  requireJobAssignment(tx, previous, params.caller);
90
92
  if (definition === null && durableJobIdempotencyKey(previous) !== specificationKey) {
91
93
  throw jobDomainError("Legacy retry must preserve the original Run specification; update its Job definition for a changed configuration.");
@@ -190,6 +192,8 @@ export function createJobCallAuthority(store) {
190
192
  * Job even when its management binding is revoked. Only a new spawn checks it.
191
193
  */
192
194
  export function authorizeJobStart(store, job) {
195
+ if (job.delegation)
196
+ throw jobDomainError("Task delegation cannot start a process.");
193
197
  const taskPrefix = `task:${job.taskId}/role:`;
194
198
  const actor = job.operation.actorId;
195
199
  const scope = actor.startsWith(taskPrefix) ? "task" : actor.startsWith("global:") ? "global" : undefined;
@@ -8,9 +8,26 @@ import { prepareBuiltinJob, projectRefreshDigest } from "../job/builtinJob.js";
8
8
  import { projectRefreshTargetDigest, requireRefreshProject } from "../repository/projectRefresh.js";
9
9
  import { assertCallerAuthorized, assertNonSecretJobInput, createDurableJobControl, jobAuthorityBinding, validateStartParams, requireJobAssignment, parseDurableJobCaller } from "./jobControl.js";
10
10
  import { createFileArtifactPort } from "./jobSupervisor.js";
11
- export function authorizeScope(store, scope, caller) {
11
+ import { jobRunSnapshot, runReference } from "../job/jobBusinessResult.js";
12
+ import { controlJobArtifact } from "./jobArtifactControl.js";
13
+ import { ArtifactHeadConflictError, ArtifactManagedConfigViolationError, ArtifactRemoteViolationError } from "../artifacts/taskArtifactRepository.js";
14
+ import { ArtifactCommitLockedError } from "../artifacts/artifactCommitLock.js";
15
+ import { ManagedGitError } from "../artifacts/managedGit.js";
16
+ import { authorizeServiceJob, requireScheduleService, serviceRunBinding } from "../job/scheduleService.js";
17
+ import { authorizeAgentDefinition, controlAgentJob, summarizeAgentRuns } from "./agentJobControl.js";
18
+ import { admitTaskJob, authorizeTaskJob, reconcileTaskDelegation, taskDelegationView } from "./jobTaskDelegationControl.js";
19
+ import { requireProjectSyncTarget } from "../job/projectSync.js";
20
+ export function authorizeScope(store, scope, caller, allowPublic = false) {
12
21
  parseDurableJobCaller(caller);
13
22
  jobScopeKey(scope);
23
+ // Public local ingress is not a fabricated Operator. It grants only immediate
24
+ // controlled global management/actions. Schedule grants are explicit and
25
+ // Task-shell authority is unchanged.
26
+ if (allowPublic && scope.kind === "global" && caller.scope === "user") {
27
+ if (Object.keys(caller).some(key => key !== "scope"))
28
+ throw new Error("Public user Job caller must not claim a managed identity.");
29
+ return;
30
+ }
14
31
  // Match Task Context's Operator-only boundary for global callers as well
15
32
  // as excluding Task callers from global Jobs. Session identity alone is not
16
33
  // permission for a custom GlobalRole to read or manage Task definitions.
@@ -26,7 +43,13 @@ function taskParams(job, caller, requestId) {
26
43
  return { ...job.configuration, taskId: job.scope.taskId, caller, requestId };
27
44
  }
28
45
  export function authorizeDefinition(store, job, caller, write) {
29
- authorizeScope(store, job.scope, caller);
46
+ if (job.configuration.kind === "agent")
47
+ return authorizeAgentDefinition(store, job, caller, write);
48
+ authorizeScope(store, job.scope, caller, true);
49
+ if (job.configuration.kind === "task") {
50
+ authorizeTaskJob(store, job, caller, write);
51
+ return;
52
+ }
30
53
  if (job.scope.kind === "task") {
31
54
  const params = taskParams(job, caller, "definition-validation");
32
55
  // A definition must not reveal another Assignment's commands/environment.
@@ -41,11 +64,13 @@ export function authorizeDefinition(store, job, caller, write) {
41
64
  throw new Error("Global Jobs require builtin actions.");
42
65
  prepareBuiltinJob(job.configuration, "/");
43
66
  if (job.configuration.action === "project.refresh") {
67
+ if (job.builtin)
68
+ requireProjectSyncTarget(store, job);
44
69
  const project = requireRefreshProject(store, job.configuration.projectId, true);
45
70
  if (projectRefreshDigest(job.configuration) !== projectRefreshTargetDigest(project)) {
46
71
  throw new Error("Project refresh target changed; inspect the Project and explicitly update the Job.");
47
72
  }
48
- const duplicate = store.listJobDefinitions({ kind: "global" }).find(other => other.id !== job.id && other.configuration.kind === "builtin"
73
+ const duplicate = !job.instantiation && store.listJobDefinitions({ kind: "global" }).find(other => !other.instantiation && !other.builtin && other.id !== job.id && other.configuration.kind === "builtin"
49
74
  && other.configuration.action === "project.refresh" && other.configuration.projectId === project.id);
50
75
  if (duplicate)
51
76
  throw new Error(`Project refresh Job already exists: ${duplicate.id}.`);
@@ -57,7 +82,29 @@ export function listJobRuns(store, job) {
57
82
  .filter(run => run.definitionId === job.id);
58
83
  }
59
84
  /** Read-only presentation; attempts/results remain owned by exact JobRuns. */
60
- function presentDefinition(store, job) {
85
+ function presentDefinition(store, definition, caller) {
86
+ if (definition.configuration.kind === "agent")
87
+ return {
88
+ ...definition, results: summarizeAgentRuns(store.listAgentJobRuns(definition.scope, definition.id))
89
+ };
90
+ const history = listJobRuns(store, definition).filter(run => {
91
+ try {
92
+ authorizeRunRead(store, run, caller);
93
+ return true;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }).sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
99
+ const result = (run) => run ? {
100
+ runId: run.id, sourceRun: runReference(run), status: run.status,
101
+ outcome: run.businessResult?.outcome ?? null,
102
+ summary: run.businessResult?.summary.slice(0, 512) ?? null,
103
+ artifactCount: run.artifacts?.length ?? 0, delegation: taskDelegationView(store, run)
104
+ } : null;
105
+ const job = { ...definition, results: {
106
+ latest: result(history[0]), latestSuccessful: result(history.find(run => run.businessResult?.outcome === "succeeded"))
107
+ } };
61
108
  if (job.configuration.kind !== "builtin" || job.configuration.action !== "project.refresh")
62
109
  return job;
63
110
  const projectId = job.configuration.projectId;
@@ -69,14 +116,21 @@ function presentDefinition(store, job) {
69
116
  .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
70
117
  const summarize = (run) => run === undefined ? null : {
71
118
  runId: run.id, definitionRevision: run.definitionRevision, trigger: run.trigger,
72
- status: run.status, attemptedAt: run.createdAt, finishedAt: run.terminalAt ?? null
119
+ status: run.status, attemptedAt: run.createdAt, finishedAt: run.terminalAt ?? null,
120
+ summary: run.businessResult?.summary.slice(0, 512) ?? null
73
121
  };
74
122
  return { ...job, refresh: {
75
123
  projectId, lastAttempt: summarize(runs[0]),
76
124
  lastSuccessfulRefresh: summarize(runs.find(run => run.status === "succeeded"))
77
125
  } };
78
126
  }
79
- function authorizeRunRead(store, run, caller) {
127
+ export function authorizeRunRead(store, run, caller) {
128
+ if (run.delegation) {
129
+ authorizeScope(store, runReference(run).scope, caller, true);
130
+ if (caller.scope === "task" && caller.role !== "leader")
131
+ throw new Error("Task delegation is not an Assignment grant.");
132
+ return;
133
+ }
80
134
  // A revised definition's Assignment does not grant access to earlier Runs.
81
135
  if ("taskId" in run)
82
136
  createDurableJobControl(store).getJob(run.taskId, run.id, caller);
@@ -86,12 +140,21 @@ function authorizeRunRead(store, run, caller) {
86
140
  * schedule advancement. No external effects occur here; rollback removes the
87
141
  * admission as well. The caller must be freshly authenticated on every call.
88
142
  */
89
- export function admitJobRun(store, home, scope, definitionId, caller, trigger, now, retryOf) {
143
+ export function admitJobRun(store, home, scope, definitionId, caller, trigger, now, retryOf, service) {
90
144
  return store.transaction(tx => {
91
145
  const job = tx.getJobDefinition(scope, definitionId);
92
146
  if (job === null)
93
147
  throw new Error("Job definition not found.");
148
+ if (job.configuration.kind === "agent")
149
+ throw new Error("Agent Jobs use their explicit Role message admission; Schedule service does not grant Agent execution.");
94
150
  authorizeDefinition(tx, job, caller, false);
151
+ const serviceSchedule = service === undefined ? undefined : authorizeServiceJob(tx, job, service, trigger.key);
152
+ if (service && (scope.kind !== "global" || trigger.source !== "schedule" || retryOf !== undefined)) {
153
+ throw new Error("Invalid Schedule service admission.");
154
+ }
155
+ if (!service && caller.scope === "user" && !["manual", "retry"].includes(trigger.source)) {
156
+ throw new Error("Public Job admission only authorizes an immediate manual execution or explicit retry.");
157
+ }
95
158
  if (!trigger.key.trim() || !["manual", "retry", "schedule", "integration"].includes(trigger.source)) {
96
159
  throw new Error("Invalid JobRun trigger.");
97
160
  }
@@ -116,6 +179,11 @@ export function admitJobRun(store, home, scope, definitionId, caller, trigger, n
116
179
  throw new Error("Retry requires an exact failed/cancelled/timed-out Run of this Job.");
117
180
  }
118
181
  const requestId = `job:${job.id}:${trigger.source}:${trigger.key}`;
182
+ if (job.configuration.kind === "task") {
183
+ if (service)
184
+ throw new Error("Schedule service does not authorize Task delegation.");
185
+ return { run: admitTaskJob(tx, { ...job, configuration: job.configuration }, caller, trigger, requestId, now, retryOf), created: true };
186
+ }
119
187
  if (scope.kind === "task") {
120
188
  const result = createDurableJobControl(tx).startJob({
121
189
  ...taskParams(job, caller, requestId), definitionId, trigger, retryOf
@@ -127,30 +195,85 @@ export function admitJobRun(store, home, scope, definitionId, caller, trigger, n
127
195
  const id = `job-${randomUUID()}`;
128
196
  const locator = `artifacts/jobs/global/${id}`;
129
197
  const spec = prepareBuiltinJob(job.configuration, join(home, locator), { home, runId: id });
130
- const actorId = `global:${caller.role}`;
131
- const run = validateDurableJob({
198
+ const actorId = service ? "service:schedule" : caller.scope === "user" ? "user" : `global:${caller.role}`;
199
+ let run = {
132
200
  schemaVersion: 1, scope: "global", id, definitionId: job.id, definitionRevision: job.revision,
133
201
  trigger: structuredClone(trigger), action: job.configuration.action,
202
+ ...(service === undefined ? {} : { serviceAuthorization: structuredClone(service) }),
134
203
  actionInput: structuredClone(job.configuration.input),
135
204
  ...(job.configuration.projectId === undefined ? {} : { projectId: job.configuration.projectId }),
136
205
  ...spec, status: "queued", artifactsLocator: locator,
137
206
  idempotencyKey: createHash("sha256").update(JSON.stringify([actorId, requestId])).digest("hex"),
138
207
  operation: {
139
- requestId, actorId, authorityRef: jobAuthorityBinding(tx, "global", caller.role, ""),
208
+ requestId, actorId, authorityRef: service || caller.scope === "user" ? "pending" : jobAuthorityBinding(tx, "global", caller.role, ""),
140
209
  targetId: job.id, capability: "job.run", implementation: JOB_RUNNER_IMPLEMENTATION,
141
210
  inputDigest: createHash("sha256").update(JSON.stringify([job, trigger, retryOf])).digest("hex"),
142
211
  effect: "none", receiptRefs: [], partialResultRefs: []
143
212
  },
144
213
  ...(retryOf === undefined ? {} : { retryOf }),
145
214
  createdAt: now.toISOString(), updatedAt: now.toISOString()
146
- });
215
+ };
216
+ if (serviceSchedule)
217
+ run = { ...run, operation: { ...run.operation, authorityRef: serviceRunBinding(tx, run, serviceSchedule) } };
218
+ else if (caller.scope === "user")
219
+ run = { ...run, operation: { ...run.operation, authorityRef: publicJobBinding(tx, run) } };
220
+ validateDurableJob(run);
147
221
  tx.saveGlobalJobRun(run);
148
222
  return { run, created: true };
149
223
  });
150
224
  }
225
+ /** Job owns instantiation; Schedule supplies only the exact version and occurrence. */
226
+ export function instantiateJobRun(store, home, scope, definitionId, revision, caller, trigger, now, service) {
227
+ return store.transaction(tx => {
228
+ const source = tx.getJobDefinition(scope, definitionId);
229
+ if (!source || source.revision !== revision)
230
+ throw new Error("Source Job revision changed; explicitly select an available version.");
231
+ authorizeDefinition(tx, source, caller, true);
232
+ const id = `instance-${createHash("sha256").update(JSON.stringify([source.scope, source.id, trigger])).digest("hex")}`;
233
+ const instantiation = { sourceId: source.id, sourceRevision: revision, key: trigger.key };
234
+ let job = tx.getJobDefinition(source.scope, id);
235
+ if (job) {
236
+ if (JSON.stringify(job.instantiation) !== JSON.stringify(instantiation)
237
+ || JSON.stringify(job.configuration) !== JSON.stringify(source.configuration)) {
238
+ throw new Error("Job instance identity conflicts with its original source.");
239
+ }
240
+ }
241
+ else {
242
+ job = validateJobDefinition({ ...createJobDefinition({ id, scope: source.scope,
243
+ title: source.title, configuration: source.configuration }, now), instantiation });
244
+ tx.saveJobDefinition(job);
245
+ }
246
+ return admitJobRun(tx, home, source.scope, job.id, caller, trigger, now, undefined, service);
247
+ });
248
+ }
249
+ function publicJobBinding(store, run) {
250
+ return createHash("sha256").update(JSON.stringify([
251
+ "public-immediate-job/v1", store.getHomeIdentity().homeId, run.id, run.definitionId, run.definitionRevision,
252
+ run.action, run.projectId, run.actionInput, run.trigger, run.operation.requestId
253
+ ])).digest("hex");
254
+ }
151
255
  export function authorizeGlobalJobStart(store, run) {
152
- if (run.operation.actorId !== "global:operator"
153
- || jobAuthorityBinding(store, "global", "operator", "") !== run.operation.authorityRef) {
256
+ if (run.delegation)
257
+ throw new Error("Task delegation cannot start a builtin process.");
258
+ let serviceAdmission = false;
259
+ if (run.operation.actorId === "service:schedule" && run.serviceAuthorization && run.trigger.source === "schedule") {
260
+ try {
261
+ const schedule = requireScheduleService(store, run.serviceAuthorization);
262
+ const job = store.getJobDefinition({ kind: "global" }, run.definitionId);
263
+ if (!job || job.revision !== run.definitionRevision)
264
+ throw new Error("Schedule Run Job revision changed.");
265
+ authorizeServiceJob(store, job, run.serviceAuthorization, run.trigger.key);
266
+ serviceAdmission = serviceRunBinding(store, run, schedule) === run.operation.authorityRef;
267
+ }
268
+ catch (error) {
269
+ throw Object.assign(new Error(error instanceof Error ? error.message : String(error), { cause: error }), { name: "CoreJobError" });
270
+ }
271
+ }
272
+ const publicAdmission = run.operation.actorId === "user"
273
+ && ["manual", "retry"].includes(run.trigger.source)
274
+ && publicJobBinding(store, run) === run.operation.authorityRef;
275
+ if (!serviceAdmission && !publicAdmission && (run.operation.actorId !== "global:operator"
276
+ || jobAuthorityBinding(store, "global", "operator", "") !== run.operation.authorityRef)) {
154
277
  throw Object.assign(new Error("Global JobRun caller binding was revoked; no execution was started."), { name: "CoreJobError" });
155
278
  }
156
279
  // Storage failures are not domain refusals or proof of no execution.
@@ -162,6 +285,9 @@ export function authorizeGlobalJobStart(store, run) {
162
285
  ...(run.projectId === undefined ? {} : { projectId: run.projectId })
163
286
  }, run.workspace, { home, runId: run.id });
164
287
  if (run.action === "project.refresh") {
288
+ const definition = store.getJobDefinition({ kind: "global" }, run.definitionId);
289
+ if (definition?.builtin)
290
+ requireProjectSyncTarget(store, definition);
165
291
  const project = requireRefreshProject({ listProjects: () => projects, rootDirectory: () => home }, run.projectId, true);
166
292
  if (projectRefreshTargetDigest(project) !== projectRefreshDigest({
167
293
  kind: "builtin", action: run.action, projectId: run.projectId, input: run.actionInput
@@ -185,11 +311,28 @@ export function createJobDefinitionControl(store, home) {
185
311
  return (value, now) => {
186
312
  if (value === null || typeof value !== "object" || Array.isArray(value))
187
313
  throw new Error("Invalid Job request.");
188
- requireKnownFields(value, ["op", "scope", "caller", "id", "title", "configuration", "expectedRevision", "trigger", "retryOf", "runId"], "Job request");
314
+ const op = value.op;
315
+ if (typeof op === "string" && op.startsWith("artifact-")) {
316
+ return controlJobArtifact(store, home, value, now).catch(error => {
317
+ // Preserve bounded domain diagnostics through RPC; authentication errors
318
+ // retain their existing CoreApplicationError identity and code.
319
+ if (error instanceof ArtifactHeadConflictError || error instanceof ArtifactManagedConfigViolationError
320
+ || error instanceof ArtifactRemoteViolationError || error instanceof ArtifactCommitLockedError
321
+ || error instanceof ManagedGitError)
322
+ error.name = "CoreJobError";
323
+ throw error;
324
+ });
325
+ }
326
+ requireKnownFields(value, ["op", "scope", "caller", "id", "title", "configuration", "expectedRevision", "trigger", "retryOf", "runId", "result", "expectedSessionId", "expectedAttemptId"], "Job request");
189
327
  const input = value;
190
- authorizeScope(store, input.scope, input.caller);
328
+ // A Job execution reader is authorized against its exact definition below,
329
+ // not promoted to Operator or to a Task manager.
330
+ if (!(input.caller.scope === "global" && input.caller.role !== "operator")
331
+ && !(input.caller.scope === "task" && input.scope.kind === "global"))
332
+ authorizeScope(store, input.scope, input.caller, true);
191
333
  return store.transaction(tx => {
192
334
  if (input.op === "list") {
335
+ authorizeScope(tx, input.scope, input.caller, true);
193
336
  return tx.listJobDefinitions(input.scope).filter(job => {
194
337
  try {
195
338
  authorizeDefinition(tx, job, input.caller, false);
@@ -198,12 +341,15 @@ export function createJobDefinitionControl(store, home) {
198
341
  catch {
199
342
  return false;
200
343
  }
201
- }).map(job => presentDefinition(tx, job));
344
+ }).map(job => presentDefinition(tx, job, input.caller));
202
345
  }
203
346
  if (typeof input.id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/u.test(input.id))
204
347
  throw new Error("An explicit safe Job id is required.");
205
348
  const existing = tx.getJobDefinition(input.scope, input.id);
206
349
  if (input.op === "define" || input.op === "update") {
350
+ if (existing?.builtin || (!existing && input.scope.kind === "global" && input.id.startsWith("project-sync-"))) {
351
+ throw new Error("Builtin Project sync definitions are installer-owned; edit their Schedule.");
352
+ }
207
353
  let configuration = input.configuration;
208
354
  // Empty input is an explicit request to bind the current catalog target.
209
355
  // A saved digest is never silently refreshed on run/retry.
@@ -238,9 +384,11 @@ export function createJobDefinitionControl(store, home) {
238
384
  if (existing === null)
239
385
  throw new Error("Job definition not found.");
240
386
  validateJobDefinition(existing);
387
+ if (existing.configuration.kind === "agent")
388
+ return controlAgentJob(tx, home, existing, input, now);
241
389
  authorizeDefinition(tx, existing, input.caller, false);
242
390
  if (input.op === "show")
243
- return presentDefinition(tx, existing);
391
+ return presentDefinition(tx, existing, input.caller);
244
392
  if (input.op === "runs")
245
393
  return listJobRuns(tx, existing).filter(run => {
246
394
  try {
@@ -263,8 +411,24 @@ export function createJobDefinitionControl(store, home) {
263
411
  if (run === undefined)
264
412
  throw new Error("Exact JobRun not found.");
265
413
  authorizeRunRead(tx, run, input.caller);
266
- if (input.op === "run-get")
267
- return run;
414
+ if (input.op === "run-delegation")
415
+ return taskDelegationView(tx, run);
416
+ if (input.op === "run-get" || input.op === "run-confirm")
417
+ return input.op === "run-confirm" && run.delegation
418
+ ? reconcileTaskDelegation(tx, run, now) : run;
419
+ if (input.op === "run-result")
420
+ return {
421
+ snapshot: jobRunSnapshot(run),
422
+ acceptedAt: run.createdAt, startedAt: run.startedAt ?? null,
423
+ confirmedAt: run.businessResult === undefined ? null : run.terminalAt ?? null,
424
+ status: run.status, result: run.businessResult ?? null, artifacts: run.artifacts ?? []
425
+ };
426
+ if (input.op === "run-evidence")
427
+ return {
428
+ sourceRun: jobRunSnapshot(run), operation: run.operation,
429
+ process: run.process ?? null, checkpoint: run.checkpoint ?? null,
430
+ processResult: run.result ?? null, artifactsLocator: run.artifactsLocator
431
+ };
268
432
  if (input.op === "logs") {
269
433
  const steps = run.result?.steps ?? createFileArtifactPort(home)
270
434
  .readCheckpoint(executionNamespace(run), run.id)?.completedSteps ?? run.checkpoint?.completedSteps ?? [];
@@ -288,6 +452,14 @@ export function createJobDefinitionControl(store, home) {
288
452
  });
289
453
  }
290
454
  if (input.op === "run-cancel" || input.op === "run-acknowledge") {
455
+ if (run.delegation) {
456
+ const next = input.op === "run-cancel" ? requestDurableJobCancel(run, now) : acknowledgeUnknownDurableJob(run, now);
457
+ if ("taskId" in next)
458
+ tx.saveDurableJob(next.taskId, next);
459
+ else
460
+ tx.saveGlobalJobRun(next);
461
+ return next;
462
+ }
291
463
  if ("taskId" in run) {
292
464
  const control = createDurableJobControl(tx);
293
465
  return input.op === "run-cancel"
@@ -20,6 +20,7 @@ import { recordOperationEvidence } from "../kernel/operationFacts.js";
20
20
  import { wakeReason } from "../scheduler/wakeReason.js";
21
21
  import { writeTextFileAtomically } from "../storage/durableFile.js";
22
22
  import { readProcessStartIdentity } from "../core/fileLockOwner.js";
23
+ import { processBusinessResult, resultDigest, runReference, validateBusinessResult } from "../job/jobBusinessResult.js";
23
24
  const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
24
25
  const HEARTBEAT_STALE_MS = 2 * 60_000;
25
26
  const SIGKILL_GRACE_MS = 30_000;
@@ -31,6 +32,7 @@ export class DurableJobSupervisor {
31
32
  #wake;
32
33
  #onError;
33
34
  #authorizeStart;
35
+ #reconcileDelegation;
34
36
  // f5: Composite key (taskId/jobId) because job IDs are Task-local — every
35
37
  // Task has a job-1, so a Task-local key would cross-kill healthy runners.
36
38
  #sigkillAt = new Map();
@@ -42,6 +44,7 @@ export class DurableJobSupervisor {
42
44
  this.#wake = options.wake ?? (() => undefined);
43
45
  this.#onError = options.onError ?? (() => undefined);
44
46
  this.#authorizeStart = options.authorizeStart;
47
+ this.#reconcileDelegation = options.reconcileDelegation;
45
48
  }
46
49
  reconcile(now) {
47
50
  const jobs = this.#store.listActiveDurableJobs();
@@ -67,7 +70,38 @@ export class DurableJobSupervisor {
67
70
  }
68
71
  }
69
72
  }
73
+ /** Explicit collection only. Never admits, spawns, cancels or retries work. */
74
+ confirmResult(namespace, runId, now) {
75
+ const run = this.#store.listActiveDurableJobs()
76
+ .find(run => executionNamespace(run) === namespace && run.id === runId);
77
+ if (!run)
78
+ return;
79
+ if (run.delegation) {
80
+ this.#reconcileDelegation?.(run, now);
81
+ return;
82
+ }
83
+ if (run.status === "queued") {
84
+ const spawned = this.#spawnedProcessFromEvidence(run, this.#artifacts.readStartMarker(namespace, runId));
85
+ if (spawned)
86
+ this.#adoptAndContinue(run, spawned, now);
87
+ }
88
+ else {
89
+ const exit = this.#artifacts.readExitJson(namespace, runId);
90
+ if (exit)
91
+ this.#harvestExit(run, exit, now);
92
+ else if (run.process) {
93
+ const identity = this.#process.processStartIdentity(run.process.pid);
94
+ if (identity === undefined || identity !== run.process.startIdentity
95
+ || !this.#process.isProcessAlive(run.process.pid))
96
+ this.#handleDeadProcess(run, now);
97
+ }
98
+ }
99
+ }
70
100
  #reconcileJob(job, now) {
101
+ if (job.delegation) {
102
+ this.#reconcileDelegation?.(job, now);
103
+ return;
104
+ }
71
105
  if (job.status === "queued") {
72
106
  this.#reconcileQueued(job, now);
73
107
  return;
@@ -155,6 +189,8 @@ export class DurableJobSupervisor {
155
189
  }
156
190
  }
157
191
  #startJob(job, now) {
192
+ if (job.delegation)
193
+ throw new Error("Task delegation cannot start a process.");
158
194
  try {
159
195
  this.#authorizeStart(job);
160
196
  }
@@ -298,6 +334,7 @@ export class DurableJobSupervisor {
298
334
  evidenceSource: "exit-artifact",
299
335
  steps: exit.steps
300
336
  };
337
+ const business = this.#businessResult(job, result);
301
338
  // rr4/finding-7: The terminal transition and the Leader wakeup must be
302
339
  // atomic. Compose complete + wakeupNotified and pass the wakeup param so
303
340
  // the adapter enqueues the Leader mailbox entry in the same transaction.
@@ -306,11 +343,11 @@ export class DurableJobSupervisor {
306
343
  const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => {
307
344
  let completed;
308
345
  try {
309
- completed = completeDurableJob(current, result, now);
346
+ completed = completeDurableJob(current, result, now, business);
310
347
  }
311
348
  catch {
312
349
  completed = completeDurableJob(current, {
313
- outcome: "failed", exitCode: null, signal: null,
350
+ outcome: "unknown-needs-attention", exitCode: null, signal: null,
314
351
  unknownReason: "runner output is invalid; original receipt is retained",
315
352
  steps: []
316
353
  }, now);
@@ -329,7 +366,7 @@ export class DurableJobSupervisor {
329
366
  // The runner died without exit.json, but its step checkpoint covered
330
367
  // every planned step: the outcome is still provable from durable
331
368
  // evidence. No false pass — the checkpoint is the runner's own record.
332
- const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, { ...proven, evidenceSource: "checkpoint" }, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
369
+ const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, { ...proven, evidenceSource: "checkpoint" }, now, this.#businessResult(current, { ...proven, evidenceSource: "checkpoint" })), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
333
370
  this.#deliverTerminalEvent(terminal);
334
371
  }
335
372
  else {
@@ -339,6 +376,34 @@ export class DurableJobSupervisor {
339
376
  }
340
377
  this.#sigkillAt.delete(this.#sigkillKey(job));
341
378
  }
379
+ #businessResult(job, result) {
380
+ const fallback = processBusinessResult(job, result);
381
+ if ("taskId" in job)
382
+ return fallback;
383
+ // The runner records this only when its cancel fence is seen before the
384
+ // first step. No domain action ran, so no business receipt is expected.
385
+ if (result.outcome === "cancelled" && result.steps.length === 0)
386
+ return fallback;
387
+ if (job.action === "system.noop")
388
+ return { ...fallback, data: { action: "system.noop" } };
389
+ const raw = this.#artifacts.readBusinessResult?.(executionNamespace(job), job.id);
390
+ if (raw !== null && raw !== undefined) {
391
+ try {
392
+ const business = validateBusinessResult(JSON.parse(raw), runReference(job));
393
+ const sourceRun = runReference(job);
394
+ return { ...business, evidence: [...business.evidence, {
395
+ kind: "execution-evidence", owner: sourceRun, sourceRun,
396
+ version: resultDigest(raw), locator: `${job.artifactsLocator}/business-result.json`
397
+ }, ...fallback.evidence] };
398
+ }
399
+ catch {
400
+ return { ...fallback, outcome: "unknown-needs-attention",
401
+ summary: "Invalid business result receipt; inspect retained execution evidence. Do not replay the action." };
402
+ }
403
+ }
404
+ return { ...fallback, outcome: "unknown-needs-attention",
405
+ summary: "Business result receipt is missing; process completion does not prove the business outcome. Do not replay the action." };
406
+ }
342
407
  /**
343
408
  * f7/rr5: Deliver a terminal notice to the runtime inbox channel when a
344
409
  * Job reaches a terminal state. The notice is best-effort: the terminal
@@ -375,6 +440,8 @@ function wakeupRefs(job) {
375
440
  * checkpoint covers every planned step exactly, by name.
376
441
  */
377
442
  function proveOutcomeFromCheckpoint(job, checkpoint) {
443
+ if (job.delegation)
444
+ return null;
378
445
  const byName = new Map(checkpoint.completedSteps.map((step) => [step.name, step]));
379
446
  if (byName.size !== checkpoint.completedSteps.length)
380
447
  return null;
@@ -501,6 +568,19 @@ export function createFileArtifactPort(home) {
501
568
  },
502
569
  readReadyFile(taskId, jobId) {
503
570
  return readJsonFile(join(home, "artifacts", "jobs", taskId, jobId, "ready.json"));
571
+ },
572
+ readBusinessResult(taskId, jobId) {
573
+ const path = join(home, "artifacts", "jobs", taskId, jobId, "business-result.json");
574
+ try {
575
+ if (statSync(path).size > 256 * 1024)
576
+ return "oversized business result";
577
+ return readFileSync(path, "utf8");
578
+ }
579
+ catch (error) {
580
+ if (error.code === "ENOENT")
581
+ return null;
582
+ throw error; // A transient collection failure is not absence or permission to rerun.
583
+ }
504
584
  }
505
585
  };
506
586
  }