@zq-silk/yui 2.8.0 → 2.9.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 (86) hide show
  1. package/dist/cli/commandCatalog.js +35 -12
  2. package/dist/cli/interactionPolicy.js +2 -2
  3. package/dist/cli/updatePorts.js +20 -0
  4. package/dist/commands/durableJobCommands.js +50 -0
  5. package/dist/commands/projectCommands.js +4 -33
  6. package/dist/commands/scheduleCommands.js +45 -0
  7. package/dist/commands/taskActivationCommands.js +19 -5
  8. package/dist/commands/taskCommands.js +2 -1
  9. package/dist/commands/taskFactCommands.js +24 -12
  10. package/dist/commands/taskPublicationCommands.js +6 -3
  11. package/dist/commands/taskPublicationVerifyCommand.js +74 -28
  12. package/dist/context/runContextPack.js +24 -4
  13. package/dist/context/taskContext.js +52 -15
  14. package/dist/context/taskPhase.js +99 -0
  15. package/dist/controlPlaneCli.js +24 -5
  16. package/dist/controller/controller.js +35 -0
  17. package/dist/controller/fileSchedulerStoreAdapter.js +16 -1
  18. package/dist/controller/jobControl.js +42 -12
  19. package/dist/controller/jobDefinitionControl.js +304 -0
  20. package/dist/controller/jobSupervisor.js +47 -32
  21. package/dist/controller/runtime.js +11 -2
  22. package/dist/controller/scheduleControl.js +176 -0
  23. package/dist/coordination/workMailbox.js +3 -3
  24. package/dist/external/githubPublicationVerifier.js +14 -5
  25. package/dist/external/gitlabPublicationVerifier.js +2 -1
  26. package/dist/interaction/notificationPresentation.js +3 -3
  27. package/dist/interaction/operatorPresentation.js +5 -0
  28. package/dist/job/builtinJob.js +58 -0
  29. package/dist/job/durableJob.js +72 -49
  30. package/dist/job/jobDefinition.js +68 -0
  31. package/dist/job/jobRunner.js +2 -1
  32. package/dist/job/projectRefreshRunner.js +48 -0
  33. package/dist/repository/projectRefresh.js +58 -0
  34. package/dist/repository/taskWorkspacePreparer.js +2 -1
  35. package/dist/schedule/schedule.js +108 -0
  36. package/dist/scheduler/operatorEvent.js +2 -0
  37. package/dist/scheduler/operatorInputNotificationProcessor.js +8 -0
  38. package/dist/storage/contextRecords.js +8 -0
  39. package/dist/storage/jobSchema.js +15 -0
  40. package/dist/storage/maintenanceCaller.js +69 -0
  41. package/dist/storage/recordValidation.js +5 -0
  42. package/dist/storage/scheduleSchema.js +12 -0
  43. package/dist/storage/sqliteSchema.js +75 -25
  44. package/dist/storage/sqliteStore.js +110 -9
  45. package/dist/storage/storageVersions.js +1 -1
  46. package/dist/storage/storeRpc.js +5 -0
  47. package/dist/storage/upgrade/jobDefinitionMigration.js +22 -0
  48. package/dist/task/publicationToolObservation.js +128 -0
  49. package/dist/task/taskActivationService.js +35 -0
  50. package/dist/web/assets/client/app/settings.js +7 -1
  51. package/dist/web/assets/client/i18n/en.js +61 -0
  52. package/dist/web/assets/client/i18n/zh-CN.js +61 -0
  53. package/dist/web/assets/client/index.js +2 -0
  54. package/dist/web/assets/client/views/jobs.js +222 -0
  55. package/dist/web/assets/client/views/task/page.js +11 -1
  56. package/dist/web/assets/shared/manageNav.js +1 -0
  57. package/dist/web/assets/shared/taskTabs.js +2 -2
  58. package/dist/web/assets/shell/settingsContent.js +3 -0
  59. package/dist/web/assets/styles/index.js +2 -0
  60. package/dist/web/assets/styles/views/jobs.js +19 -0
  61. package/dist/web/webJobs.js +52 -0
  62. package/dist/web/webServer.js +31 -0
  63. package/docs/cli-information-contract.md +43 -2
  64. package/docs/cli-information-contract.zh-CN.md +32 -1
  65. package/docs/job-runs.md +224 -0
  66. package/docs/release-workflow.md +65 -0
  67. package/docs/release-workflow.zh-CN.md +49 -0
  68. package/docs/schedules.md +132 -0
  69. package/docs/storage-baseline.md +18 -1
  70. package/docs/storage-baseline.zh-CN.md +15 -1
  71. package/docs/task-delivery.md +68 -2
  72. package/docs/task-delivery.zh-CN.md +33 -1
  73. package/docs/testing/verification-levels.md +6 -2
  74. package/docs/testing/verification-levels.zh-CN.md +6 -3
  75. package/package.json +4 -3
  76. package/skills/yui-leader/SKILL.md +18 -1
  77. package/skills/yui-leader/VERSION +1 -1
  78. package/skills/yui-leader/references/planning.md +9 -1
  79. package/skills/yui-reviewer/SKILL.md +4 -0
  80. package/skills/yui-reviewer/VERSION +1 -1
  81. package/skills/yui-runtime/SKILL.md +9 -3
  82. package/skills/yui-runtime/VERSION +1 -1
  83. package/skills/yui-runtime/references/context-ordering.md +103 -0
  84. package/skills/yui-runtime/references/publication.md +44 -4
  85. package/skills/yui-worker/SKILL.md +5 -0
  86. package/skills/yui-worker/VERSION +1 -1
@@ -9,7 +9,7 @@
9
9
  * preserves its admitted Job across authorized Session replacement.
10
10
  */
11
11
  import { execSync } from "node:child_process";
12
- import { createHash } from "node:crypto";
12
+ import { createHash, randomUUID } from "node:crypto";
13
13
  import { resolve, sep } from "node:path";
14
14
  import { activeLiveRoleAgentSession } from "../executor/agentExecutor.js";
15
15
  import { acknowledgeUnknownDurableJob, createDurableJob, durableJobIdempotencyKey, isDurableJobTerminal, requestDurableJobCancel, retryDurableJobIdempotencyKey } from "../job/durableJob.js";
@@ -21,6 +21,7 @@ import { assertJobAssignmentScope, JobAssignmentScopeError } from "../job/jobAss
21
21
  import { assertContextRecordReadable } from "../context/taskContext.js";
22
22
  import { assertTaskProjectWriteAuthority } from "../task/taskAuthority.js";
23
23
  import { validateHttpPreview } from "../job/httpPreview.js";
24
+ import { definitionFromTaskRun } from "../job/jobDefinition.js";
24
25
  export function createDurableJobControl(store) {
25
26
  const authority = createJobCallAuthority(store);
26
27
  return {
@@ -34,7 +35,7 @@ export function createDurableJobControl(store) {
34
35
  requireJobAssignment(tx, params, params.caller);
35
36
  assertNonSecretJobInput(params);
36
37
  validateHttpPreview(params.env, params.steps);
37
- const baseKey = durableJobIdempotencyKey({
38
+ const specificationKey = durableJobIdempotencyKey({
38
39
  owner: params.owner,
39
40
  projectId: params.projectId,
40
41
  head: params.head,
@@ -42,6 +43,12 @@ export function createDurableJobControl(store) {
42
43
  workspace: params.workspace,
43
44
  env: params.env
44
45
  });
46
+ const definition = params.definitionId === undefined ? null
47
+ : tx.getJobDefinition({ kind: "task", taskId: params.taskId }, params.definitionId);
48
+ if (params.definitionId !== undefined && definition === null)
49
+ throw jobDomainError("Job definition not found.");
50
+ const baseKey = definition === null ? specificationKey : createHash("sha256")
51
+ .update(JSON.stringify([specificationKey, definition.id, definition.revision, params.trigger])).digest("hex");
45
52
  const inputDigest = params.retryOf === undefined
46
53
  ? baseKey
47
54
  : retryDurableJobIdempotencyKey(baseKey, params.retryOf);
@@ -77,9 +84,29 @@ export function createDurableJobControl(store) {
77
84
  }
78
85
  authority.authorize(context, params.taskId);
79
86
  validateStartParams(tx, params);
87
+ const previous = params.retryOf === undefined ? null : tx.getDurableJob(params.taskId, params.retryOf);
88
+ if (previous !== null) {
89
+ requireJobAssignment(tx, previous, params.caller);
90
+ if (definition === null && durableJobIdempotencyKey(previous) !== specificationKey) {
91
+ throw jobDomainError("Legacy retry must preserve the original Run specification; update its Job definition for a changed configuration.");
92
+ }
93
+ }
94
+ const definitionId = definition?.id ?? previous?.definitionId;
95
+ if (definitionId !== undefined) {
96
+ const active = tx.listDurableJobs(params.taskId).find(run => run.definitionId === definitionId
97
+ && (run.status === "queued" || run.status === "running" || run.status === "unknown-needs-attention"));
98
+ if (active !== undefined)
99
+ throw jobDomainError(`Job is already occupied by Run ${active.id} (${active.status}); no new Run was admitted.`);
100
+ }
80
101
  const id = tx.nextDurableJobId(params.taskId);
81
102
  const job = createDurableJob({
82
103
  id,
104
+ definitionId: definitionId ?? `definition-${randomUUID()}`,
105
+ definitionRevision: definition?.revision ?? previous?.definitionRevision,
106
+ trigger: params.trigger ?? {
107
+ source: params.retryOf !== undefined ? "retry" : params.owner.kind === "integration-attempt" ? "integration" : "manual",
108
+ key: requestId
109
+ },
83
110
  taskId: params.taskId,
84
111
  owner: params.owner,
85
112
  projectId: params.projectId,
@@ -94,6 +121,9 @@ export function createDurableJobControl(store) {
94
121
  artifactsLocator: `artifacts/jobs/${params.taskId}/${id}`,
95
122
  ...(params.retryOf === undefined ? {} : { retryOf: params.retryOf })
96
123
  }, now);
124
+ if (tx.getJobDefinition({ kind: "task", taskId: params.taskId }, job.definitionId) === null) {
125
+ tx.saveJobDefinition(definitionFromTaskRun(job));
126
+ }
97
127
  tx.saveDurableJob(params.taskId, job);
98
128
  return { job, created: true };
99
129
  });
@@ -191,7 +221,7 @@ function assertLeaderJobWriteScope(store, target, caller) {
191
221
  throw jobControlError("UNAUTHORIZED", error instanceof Error ? error.message : String(error));
192
222
  }
193
223
  }
194
- function requireJobAssignment(store, target, caller) {
224
+ export function requireJobAssignment(store, target, caller) {
195
225
  try {
196
226
  assertJobAssignmentScope(store, target, caller);
197
227
  }
@@ -201,7 +231,7 @@ function requireJobAssignment(store, target, caller) {
201
231
  throw error;
202
232
  }
203
233
  }
204
- function jobAuthorityBinding(store, scope, roleName, taskId) {
234
+ export function jobAuthorityBinding(store, scope, roleName, taskId) {
205
235
  // A Host detach/reattach preserves the native Session and its queued work.
206
236
  // Authenticate the live launch at ingress, but bind accepted Jobs to the
207
237
  // caller's durable Session identity, not its disposable Host generation.
@@ -232,7 +262,7 @@ function jobAuthorityBinding(store, scope, roleName, taskId) {
232
262
  role.activeAgentId, session.agentId, session.nativeSessionId
233
263
  ])).digest("hex");
234
264
  }
235
- function assertNonSecretJobInput(params) {
265
+ export function assertNonSecretJobInput(params) {
236
266
  // Existing Jobs persist commands and environments. This boundary accepts
237
267
  // non-secret executable specifications only; credential resolution is not a
238
268
  // Job feature. Never hash a known secret then call the digest sanitized.
@@ -263,7 +293,7 @@ function assertNonSecretJobInput(params) {
263
293
  * workspace was not the stable checkout. Now it binds the job to the exact
264
294
  * managed workspace, verifies write access, and requires an active Task.
265
295
  */
266
- function validateStartParams(store, params) {
296
+ export function validateStartParams(store, params) {
267
297
  validateJobTarget(store, params);
268
298
  assertCallerAuthorized(store, params.caller, params.taskId);
269
299
  assertLeaderJobWriteScope(store, params, params.caller);
@@ -384,7 +414,7 @@ function validateJobTarget(store, params) {
384
414
  * identity contract does not defend against another process with the same
385
415
  * user's filesystem access. Bare user calls cannot start or cancel Jobs.
386
416
  */
387
- function assertCallerAuthorized(store, caller, taskId) {
417
+ export function assertCallerAuthorized(store, caller, taskId) {
388
418
  if (caller.scope === "user") {
389
419
  // rr13: A user-scope caller has no per-Session channel binding. Every
390
420
  // durable-state claim it could carry is replayable
@@ -508,7 +538,7 @@ export function parseDurableJobStartParams(value) {
508
538
  const retryOf = record.retryOf === undefined
509
539
  ? undefined
510
540
  : requiredId(record.retryOf, "job.start retryOf");
511
- const caller = parseCaller(record.caller);
541
+ const caller = parseDurableJobCaller(record.caller);
512
542
  return {
513
543
  taskId,
514
544
  owner,
@@ -531,7 +561,7 @@ export function parseDurableJobRefParams(value) {
531
561
  return {
532
562
  taskId: requiredId(record.taskId, "DurableJob taskId"),
533
563
  jobId: requiredId(record.jobId, "DurableJob jobId"),
534
- caller: parseCaller(record.caller)
564
+ caller: parseDurableJobCaller(record.caller)
535
565
  };
536
566
  }
537
567
  /**
@@ -548,7 +578,7 @@ export function parseDurableJobCancelParams(value) {
548
578
  return {
549
579
  taskId: requiredId(record.taskId, "DurableJob taskId"),
550
580
  jobId: requiredId(record.jobId, "DurableJob jobId"),
551
- caller: parseCaller(record.caller)
581
+ caller: parseDurableJobCaller(record.caller)
552
582
  };
553
583
  }
554
584
  /**
@@ -564,7 +594,7 @@ export function parseDurableJobAcknowledgeParams(value) {
564
594
  return {
565
595
  taskId: requiredId(record.taskId, "DurableJob taskId"),
566
596
  jobId: requiredId(record.jobId, "DurableJob jobId"),
567
- caller: parseCaller(record.caller)
597
+ caller: parseDurableJobCaller(record.caller)
568
598
  };
569
599
  }
570
600
  function parseJobOwner(value) {
@@ -645,7 +675,7 @@ function parseSteps(value) {
645
675
  * optional managed Session identity fields. The identity is verified against
646
676
  * durable state by `assertCallerAuthorized`; parsing only validates the JSON shape.
647
677
  */
648
- function parseCaller(value) {
678
+ export function parseDurableJobCaller(value) {
649
679
  if (value === undefined) {
650
680
  throw jobControlError("INVALID_PARAMS", "job caller is required.");
651
681
  }
@@ -0,0 +1,304 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { openSync, closeSync, fstatSync, readSync, realpathSync } from "node:fs";
3
+ import { join, resolve, sep } from "node:path";
4
+ import { requireKnownFields } from "../domain/validation.js";
5
+ import { createJobDefinition, reviseJobDefinition, jobScopeKey, validateJobDefinition } from "../job/jobDefinition.js";
6
+ import { acknowledgeUnknownDurableJob, requestDurableJobCancel, validateDurableJob, JOB_RUNNER_IMPLEMENTATION, executionNamespace } from "../job/durableJob.js";
7
+ import { prepareBuiltinJob, projectRefreshDigest } from "../job/builtinJob.js";
8
+ import { projectRefreshTargetDigest, requireRefreshProject } from "../repository/projectRefresh.js";
9
+ import { assertCallerAuthorized, assertNonSecretJobInput, createDurableJobControl, jobAuthorityBinding, validateStartParams, requireJobAssignment, parseDurableJobCaller } from "./jobControl.js";
10
+ import { createFileArtifactPort } from "./jobSupervisor.js";
11
+ export function authorizeScope(store, scope, caller) {
12
+ parseDurableJobCaller(caller);
13
+ jobScopeKey(scope);
14
+ // Match Task Context's Operator-only boundary for global callers as well
15
+ // as excluding Task callers from global Jobs. Session identity alone is not
16
+ // permission for a custom GlobalRole to read or manage Task definitions.
17
+ if ((scope.kind === "global" && caller.scope !== "global")
18
+ || (caller.scope === "global" && caller.role !== "operator")) {
19
+ throw Object.assign(new Error("Only the current Operator Session may access Jobs across global scope."), { name: "CoreApplicationError", code: "UNAUTHORIZED" });
20
+ }
21
+ assertCallerAuthorized(store, caller, scope.kind === "task" ? scope.taskId : "");
22
+ }
23
+ function taskParams(job, caller, requestId) {
24
+ if (job.scope.kind !== "task" || job.configuration.kind !== "shell")
25
+ throw new Error("Not a Task Job.");
26
+ return { ...job.configuration, taskId: job.scope.taskId, caller, requestId };
27
+ }
28
+ export function authorizeDefinition(store, job, caller, write) {
29
+ authorizeScope(store, job.scope, caller);
30
+ if (job.scope.kind === "task") {
31
+ const params = taskParams(job, caller, "definition-validation");
32
+ // A definition must not reveal another Assignment's commands/environment.
33
+ requireJobAssignment(store, params, caller);
34
+ if (write)
35
+ validateStartParams(store, params);
36
+ if (write)
37
+ assertNonSecretJobInput(params);
38
+ }
39
+ else if (write) {
40
+ if (job.configuration.kind !== "builtin")
41
+ throw new Error("Global Jobs require builtin actions.");
42
+ prepareBuiltinJob(job.configuration, "/");
43
+ if (job.configuration.action === "project.refresh") {
44
+ const project = requireRefreshProject(store, job.configuration.projectId, true);
45
+ if (projectRefreshDigest(job.configuration) !== projectRefreshTargetDigest(project)) {
46
+ throw new Error("Project refresh target changed; inspect the Project and explicitly update the Job.");
47
+ }
48
+ const duplicate = store.listJobDefinitions({ kind: "global" }).find(other => other.id !== job.id && other.configuration.kind === "builtin"
49
+ && other.configuration.action === "project.refresh" && other.configuration.projectId === project.id);
50
+ if (duplicate)
51
+ throw new Error(`Project refresh Job already exists: ${duplicate.id}.`);
52
+ }
53
+ }
54
+ }
55
+ export function listJobRuns(store, job) {
56
+ return (job.scope.kind === "task" ? store.listDurableJobs(job.scope.taskId) : store.listGlobalJobRuns())
57
+ .filter(run => run.definitionId === job.id);
58
+ }
59
+ /** Read-only presentation; attempts/results remain owned by exact JobRuns. */
60
+ function presentDefinition(store, job) {
61
+ if (job.configuration.kind !== "builtin" || job.configuration.action !== "project.refresh")
62
+ return job;
63
+ const projectId = job.configuration.projectId;
64
+ const targetDigest = projectRefreshDigest(job.configuration);
65
+ const runs = listJobRuns(store, job).filter(run => !("taskId" in run)
66
+ && run.action === "project.refresh" && run.projectId === projectId
67
+ && projectRefreshDigest({ kind: "builtin", action: run.action,
68
+ projectId: run.projectId, input: run.actionInput }) === targetDigest)
69
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
70
+ const summarize = (run) => run === undefined ? null : {
71
+ runId: run.id, definitionRevision: run.definitionRevision, trigger: run.trigger,
72
+ status: run.status, attemptedAt: run.createdAt, finishedAt: run.terminalAt ?? null
73
+ };
74
+ return { ...job, refresh: {
75
+ projectId, lastAttempt: summarize(runs[0]),
76
+ lastSuccessfulRefresh: summarize(runs.find(run => run.status === "succeeded"))
77
+ } };
78
+ }
79
+ function authorizeRunRead(store, run, caller) {
80
+ // A revised definition's Assignment does not grant access to earlier Runs.
81
+ if ("taskId" in run)
82
+ createDurableJobControl(store).getJob(run.taskId, run.id, caller);
83
+ }
84
+ /**
85
+ * The narrow Schedule seam: call inside the SAME TaskStore transaction as
86
+ * schedule advancement. No external effects occur here; rollback removes the
87
+ * admission as well. The caller must be freshly authenticated on every call.
88
+ */
89
+ export function admitJobRun(store, home, scope, definitionId, caller, trigger, now, retryOf) {
90
+ return store.transaction(tx => {
91
+ const job = tx.getJobDefinition(scope, definitionId);
92
+ if (job === null)
93
+ throw new Error("Job definition not found.");
94
+ authorizeDefinition(tx, job, caller, false);
95
+ if (!trigger.key.trim() || !["manual", "retry", "schedule", "integration"].includes(trigger.source)) {
96
+ throw new Error("Invalid JobRun trigger.");
97
+ }
98
+ const runs = listJobRuns(tx, job);
99
+ const same = runs.find(run => run.trigger.key === trigger.key && run.trigger.source === trigger.source);
100
+ if (same !== undefined) {
101
+ authorizeRunRead(tx, same, caller);
102
+ if (same.definitionRevision !== job.revision || same.retryOf !== retryOf) {
103
+ throw new Error(`JobRun trigger conflicts with its original snapshot: ${same.id}.`);
104
+ }
105
+ return { run: same, created: false };
106
+ }
107
+ authorizeDefinition(tx, job, caller, true);
108
+ const occupied = runs.find(run => run.status === "queued" || run.status === "running"
109
+ || run.status === "unknown-needs-attention");
110
+ if (occupied !== undefined) {
111
+ authorizeRunRead(tx, occupied, caller);
112
+ return { run: occupied, created: false, reason: "overlap" };
113
+ }
114
+ if (retryOf !== undefined && !runs.some(run => run.id === retryOf
115
+ && ["failed", "cancelled", "timed-out"].includes(run.status))) {
116
+ throw new Error("Retry requires an exact failed/cancelled/timed-out Run of this Job.");
117
+ }
118
+ const requestId = `job:${job.id}:${trigger.source}:${trigger.key}`;
119
+ if (scope.kind === "task") {
120
+ const result = createDurableJobControl(tx).startJob({
121
+ ...taskParams(job, caller, requestId), definitionId, trigger, retryOf
122
+ }, now);
123
+ return { run: result.job, created: result.created };
124
+ }
125
+ if (job.configuration.kind !== "builtin")
126
+ throw new Error("Global Jobs require builtin actions.");
127
+ const id = `job-${randomUUID()}`;
128
+ const locator = `artifacts/jobs/global/${id}`;
129
+ const spec = prepareBuiltinJob(job.configuration, join(home, locator), { home, runId: id });
130
+ const actorId = `global:${caller.role}`;
131
+ const run = validateDurableJob({
132
+ schemaVersion: 1, scope: "global", id, definitionId: job.id, definitionRevision: job.revision,
133
+ trigger: structuredClone(trigger), action: job.configuration.action,
134
+ actionInput: structuredClone(job.configuration.input),
135
+ ...(job.configuration.projectId === undefined ? {} : { projectId: job.configuration.projectId }),
136
+ ...spec, status: "queued", artifactsLocator: locator,
137
+ idempotencyKey: createHash("sha256").update(JSON.stringify([actorId, requestId])).digest("hex"),
138
+ operation: {
139
+ requestId, actorId, authorityRef: jobAuthorityBinding(tx, "global", caller.role, ""),
140
+ targetId: job.id, capability: "job.run", implementation: JOB_RUNNER_IMPLEMENTATION,
141
+ inputDigest: createHash("sha256").update(JSON.stringify([job, trigger, retryOf])).digest("hex"),
142
+ effect: "none", receiptRefs: [], partialResultRefs: []
143
+ },
144
+ ...(retryOf === undefined ? {} : { retryOf }),
145
+ createdAt: now.toISOString(), updatedAt: now.toISOString()
146
+ });
147
+ tx.saveGlobalJobRun(run);
148
+ return { run, created: true };
149
+ });
150
+ }
151
+ export function authorizeGlobalJobStart(store, run) {
152
+ if (run.operation.actorId !== "global:operator"
153
+ || jobAuthorityBinding(store, "global", "operator", "") !== run.operation.authorityRef) {
154
+ throw Object.assign(new Error("Global JobRun caller binding was revoked; no execution was started."), { name: "CoreJobError" });
155
+ }
156
+ // Storage failures are not domain refusals or proof of no execution.
157
+ const projects = run.action === "project.refresh" ? store.listProjects() : [];
158
+ const home = store.rootDirectory();
159
+ try {
160
+ const compiled = prepareBuiltinJob({
161
+ kind: "builtin", action: run.action, input: run.actionInput,
162
+ ...(run.projectId === undefined ? {} : { projectId: run.projectId })
163
+ }, run.workspace, { home, runId: run.id });
164
+ if (run.action === "project.refresh") {
165
+ const project = requireRefreshProject({ listProjects: () => projects, rootDirectory: () => home }, run.projectId, true);
166
+ if (projectRefreshTargetDigest(project) !== projectRefreshDigest({
167
+ kind: "builtin", action: run.action, projectId: run.projectId, input: run.actionInput
168
+ }))
169
+ throw new Error("Project refresh target changed before execution.");
170
+ if (run.workspace !== join(home, "artifacts/jobs/global", run.id)) {
171
+ throw new Error("Project refresh Run workspace does not match its artifact directory.");
172
+ }
173
+ }
174
+ if (JSON.stringify(compiled.steps) !== JSON.stringify(run.steps)
175
+ || JSON.stringify(compiled.env) !== JSON.stringify(run.env) || compiled.head !== run.head) {
176
+ throw new Error("Global JobRun does not match its controlled action.");
177
+ }
178
+ }
179
+ catch (error) {
180
+ throw Object.assign(new Error(error instanceof Error ? error.message : String(error), { cause: error }), { name: "CoreJobError" });
181
+ }
182
+ }
183
+ /** One bounded RPC surface; legacy job.start/get/cancel remain exact-run APIs. */
184
+ export function createJobDefinitionControl(store, home) {
185
+ return (value, now) => {
186
+ if (value === null || typeof value !== "object" || Array.isArray(value))
187
+ throw new Error("Invalid Job request.");
188
+ requireKnownFields(value, ["op", "scope", "caller", "id", "title", "configuration", "expectedRevision", "trigger", "retryOf", "runId"], "Job request");
189
+ const input = value;
190
+ authorizeScope(store, input.scope, input.caller);
191
+ return store.transaction(tx => {
192
+ if (input.op === "list") {
193
+ return tx.listJobDefinitions(input.scope).filter(job => {
194
+ try {
195
+ authorizeDefinition(tx, job, input.caller, false);
196
+ return true;
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ }).map(job => presentDefinition(tx, job));
202
+ }
203
+ if (typeof input.id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/u.test(input.id))
204
+ throw new Error("An explicit safe Job id is required.");
205
+ const existing = tx.getJobDefinition(input.scope, input.id);
206
+ if (input.op === "define" || input.op === "update") {
207
+ let configuration = input.configuration;
208
+ // Empty input is an explicit request to bind the current catalog target.
209
+ // A saved digest is never silently refreshed on run/retry.
210
+ if (input.scope.kind === "global" && configuration?.kind === "builtin"
211
+ && configuration.action === "project.refresh" && JSON.stringify(configuration.input) === "{}") {
212
+ const project = requireRefreshProject(tx, configuration.projectId, true);
213
+ configuration = { ...configuration, input: { targetDigest: projectRefreshTargetDigest(project) } };
214
+ }
215
+ const fields = { title: input.title, configuration };
216
+ if (input.op === "define") {
217
+ const next = createJobDefinition({ id: input.id, scope: input.scope, ...fields }, now);
218
+ authorizeDefinition(tx, next, input.caller, true);
219
+ if (existing !== null) {
220
+ if (JSON.stringify(existing.configuration) !== JSON.stringify(next.configuration) || existing.title !== next.title) {
221
+ throw new Error("Job id already exists with different configuration; use update with its revision.");
222
+ }
223
+ return existing;
224
+ }
225
+ tx.saveJobDefinition(next);
226
+ return next;
227
+ }
228
+ if (existing === null || existing.revision !== input.expectedRevision)
229
+ throw new Error("Job revision changed or Job missing.");
230
+ // The old snapshot may no longer be runnable (for example HEAD moved).
231
+ // Check its ownership before validating the replacement for execution.
232
+ authorizeDefinition(tx, existing, input.caller, false);
233
+ const next = reviseJobDefinition(existing, fields, now);
234
+ authorizeDefinition(tx, next, input.caller, true);
235
+ tx.saveJobDefinition(next);
236
+ return next;
237
+ }
238
+ if (existing === null)
239
+ throw new Error("Job definition not found.");
240
+ validateJobDefinition(existing);
241
+ authorizeDefinition(tx, existing, input.caller, false);
242
+ if (input.op === "show")
243
+ return presentDefinition(tx, existing);
244
+ if (input.op === "runs")
245
+ return listJobRuns(tx, existing).filter(run => {
246
+ try {
247
+ authorizeRunRead(tx, run, input.caller);
248
+ return true;
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ });
254
+ if (input.op === "run") {
255
+ if (input.trigger === undefined)
256
+ throw new Error("A trigger identity is required.");
257
+ // Schedule is an internal transactional caller, not a manual RPC claim.
258
+ if (!["manual", "retry"].includes(input.trigger.source))
259
+ throw new Error("RPC triggers must be manual or retry.");
260
+ return admitJobRun(tx, home, input.scope, input.id, input.caller, input.trigger, now, input.retryOf);
261
+ }
262
+ const run = listJobRuns(tx, existing).find(candidate => candidate.id === input.runId);
263
+ if (run === undefined)
264
+ throw new Error("Exact JobRun not found.");
265
+ authorizeRunRead(tx, run, input.caller);
266
+ if (input.op === "run-get")
267
+ return run;
268
+ if (input.op === "logs") {
269
+ const steps = run.result?.steps ?? createFileArtifactPort(home)
270
+ .readCheckpoint(executionNamespace(run), run.id)?.completedSteps ?? run.checkpoint?.completedSteps ?? [];
271
+ if (steps.length === 0)
272
+ return [];
273
+ const root = realpathSync(resolve(home, run.artifactsLocator, "logs"));
274
+ return steps.map(step => {
275
+ const path = realpathSync(resolve(root, step.logPath));
276
+ if (!path.startsWith(root + sep))
277
+ throw new Error("JobRun log escaped its artifact directory.");
278
+ const fd = openSync(path, "r");
279
+ try {
280
+ const size = fstatSync(fd).size;
281
+ const bytes = Buffer.alloc(Math.min(size, 65536));
282
+ const count = readSync(fd, bytes, 0, bytes.length, Math.max(0, size - bytes.length));
283
+ return { step: step.name, path, text: bytes.subarray(0, count).toString("utf8"), truncated: size > bytes.length };
284
+ }
285
+ finally {
286
+ closeSync(fd);
287
+ }
288
+ });
289
+ }
290
+ if (input.op === "run-cancel" || input.op === "run-acknowledge") {
291
+ if ("taskId" in run) {
292
+ const control = createDurableJobControl(tx);
293
+ return input.op === "run-cancel"
294
+ ? control.cancelJob(run.taskId, run.id, now, input.caller)
295
+ : control.acknowledgeJob(run.taskId, run.id, now, input.caller);
296
+ }
297
+ const next = input.op === "run-cancel" ? requestDurableJobCancel(run, now) : acknowledgeUnknownDurableJob(run, now);
298
+ tx.saveGlobalJobRun(next);
299
+ return next;
300
+ }
301
+ throw new Error(`Unknown Job operation: ${input.op}.`);
302
+ });
303
+ };
304
+ }