@zq-silk/yui 2.8.1 → 2.10.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 (119) hide show
  1. package/README.md +5 -3
  2. package/dist/cli/commandCatalog.js +56 -21
  3. package/dist/cli/helpRenderer.js +3 -1
  4. package/dist/cli/interactionPolicy.js +2 -2
  5. package/dist/commands/durableJobCommands.js +50 -0
  6. package/dist/commands/globalRoleCommands.js +30 -9
  7. package/dist/commands/inboxCommands.js +79 -22
  8. package/dist/commands/projectCommands.js +4 -33
  9. package/dist/commands/scheduleCommands.js +45 -0
  10. package/dist/commands/taskActivationCommands.js +19 -5
  11. package/dist/commands/taskCommands.js +8 -2
  12. package/dist/commands/taskFactCommands.js +24 -12
  13. package/dist/commands/taskProjectDeliveryCommand.js +56 -0
  14. package/dist/commands/taskPublicationCommands.js +6 -3
  15. package/dist/commands/taskPublicationVerifyCommand.js +74 -28
  16. package/dist/commands/taskRemoteDeliveryCommand.js +5 -3
  17. package/dist/context/runContextPack.js +25 -4
  18. package/dist/context/runInputContract.js +1 -0
  19. package/dist/context/taskContext.js +53 -16
  20. package/dist/context/taskPhase.js +99 -0
  21. package/dist/controlPlaneCli.js +38 -17
  22. package/dist/controller/controller.js +35 -0
  23. package/dist/controller/fileSchedulerStoreAdapter.js +75 -16
  24. package/dist/controller/globalInputDelivery.js +17 -7
  25. package/dist/controller/inboxTurnCheck.js +64 -0
  26. package/dist/controller/jobControl.js +42 -12
  27. package/dist/controller/jobDefinitionControl.js +304 -0
  28. package/dist/controller/jobSupervisor.js +47 -32
  29. package/dist/controller/providerRetryAdmission.js +2 -0
  30. package/dist/controller/runtime.js +11 -2
  31. package/dist/controller/scheduleControl.js +176 -0
  32. package/dist/coordination/workMailbox.js +3 -3
  33. package/dist/external/githubPublicationVerifier.js +14 -5
  34. package/dist/external/gitlabPublicationVerifier.js +12 -29
  35. package/dist/grant/taskAuthorization.js +7 -2
  36. package/dist/interaction/notificationPresentation.js +12 -3
  37. package/dist/interaction/operatorPresentation.js +5 -0
  38. package/dist/job/builtinJob.js +58 -0
  39. package/dist/job/durableJob.js +72 -49
  40. package/dist/job/jobDefinition.js +68 -0
  41. package/dist/job/jobRunner.js +2 -1
  42. package/dist/job/projectRefreshRunner.js +48 -0
  43. package/dist/message/globalInboxRecovery.js +44 -0
  44. package/dist/message/globalInterrupt.js +1 -1
  45. package/dist/message/inboxReceipt.js +14 -6
  46. package/dist/message/inboxTurn.js +86 -0
  47. package/dist/message/message.js +11 -0
  48. package/dist/message/taskInboxOwnership.js +20 -6
  49. package/dist/repository/projectRefresh.js +58 -0
  50. package/dist/repository/taskWorkspacePreparer.js +2 -1
  51. package/dist/repository/workspaceCleanupInspection.js +6 -1
  52. package/dist/schedule/schedule.js +108 -0
  53. package/dist/scheduler/operatorEvent.js +2 -0
  54. package/dist/scheduler/operatorInputNotificationProcessor.js +8 -0
  55. package/dist/storage/contextRecords.js +8 -0
  56. package/dist/storage/jobSchema.js +15 -0
  57. package/dist/storage/maintenanceCaller.js +1 -1
  58. package/dist/storage/recordValidation.js +5 -0
  59. package/dist/storage/scheduleSchema.js +12 -0
  60. package/dist/storage/sqliteSchema.js +95 -24
  61. package/dist/storage/sqliteStore.js +123 -10
  62. package/dist/storage/storageVersions.js +1 -1
  63. package/dist/storage/storeRpc.js +5 -0
  64. package/dist/storage/upgrade/jobDefinitionMigration.js +22 -0
  65. package/dist/task/archiveDiagnostics.js +1 -1
  66. package/dist/task/archivePreflight.js +1 -1
  67. package/dist/task/projectDeliveryScope.js +20 -0
  68. package/dist/task/publicationToolObservation.js +126 -0
  69. package/dist/task/remoteDelivery.js +19 -8
  70. package/dist/task/remoteDeliveryService.js +5 -4
  71. package/dist/task/taskActivationService.js +35 -0
  72. package/dist/web/assets/client/app/settings.js +7 -1
  73. package/dist/web/assets/client/i18n/en.js +65 -2
  74. package/dist/web/assets/client/i18n/zh-CN.js +65 -2
  75. package/dist/web/assets/client/index.js +2 -0
  76. package/dist/web/assets/client/views/jobs.js +222 -0
  77. package/dist/web/assets/client/views/task/deliveryResults.js +6 -2
  78. package/dist/web/assets/client/views/task/page.js +11 -1
  79. package/dist/web/assets/shared/manageNav.js +1 -0
  80. package/dist/web/assets/shared/taskTabs.js +2 -2
  81. package/dist/web/assets/shell/settingsContent.js +3 -0
  82. package/dist/web/assets/styles/index.js +2 -0
  83. package/dist/web/assets/styles/views/jobs.js +19 -0
  84. package/dist/web/controllerWeb.js +73 -21
  85. package/dist/web/taskPreviews.js +21 -13
  86. package/dist/web/webAccess.js +109 -0
  87. package/dist/web/webJobs.js +52 -0
  88. package/dist/web/webServer.js +76 -44
  89. package/docs/architecture/capabilities-and-resources.md +6 -4
  90. package/docs/architecture/capabilities-and-resources.zh-CN.md +5 -4
  91. package/docs/cli-information-contract.md +57 -4
  92. package/docs/cli-information-contract.zh-CN.md +41 -2
  93. package/docs/job-runs.md +224 -0
  94. package/docs/project-delivery-scope.md +59 -0
  95. package/docs/release-workflow.md +86 -0
  96. package/docs/release-workflow.zh-CN.md +68 -0
  97. package/docs/schedules.md +132 -0
  98. package/docs/storage-baseline.md +31 -1
  99. package/docs/storage-baseline.zh-CN.md +25 -1
  100. package/docs/task-delivery.md +74 -2
  101. package/docs/task-delivery.zh-CN.md +39 -1
  102. package/docs/testing/verification-levels.md +6 -2
  103. package/docs/testing/verification-levels.zh-CN.md +6 -3
  104. package/i18n/README.zh-CN.md +6 -3
  105. package/package.json +4 -3
  106. package/skills/yui-leader/SKILL.md +44 -1
  107. package/skills/yui-leader/VERSION +1 -1
  108. package/skills/yui-leader/references/planning.md +9 -1
  109. package/skills/yui-operator/SKILL.md +31 -0
  110. package/skills/yui-operator/VERSION +1 -1
  111. package/skills/yui-reviewer/SKILL.md +4 -0
  112. package/skills/yui-reviewer/VERSION +1 -1
  113. package/skills/yui-runtime/SKILL.md +67 -8
  114. package/skills/yui-runtime/VERSION +1 -1
  115. package/skills/yui-runtime/references/context-ordering.md +103 -0
  116. package/skills/yui-runtime/references/publication.md +44 -4
  117. package/skills/yui-runtime/references/recovery.md +21 -0
  118. package/skills/yui-worker/SKILL.md +5 -0
  119. package/skills/yui-worker/VERSION +1 -1
@@ -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
+ }
@@ -15,7 +15,7 @@ import { spawn } from "node:child_process";
15
15
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
- import { cancelQueuedDurableJob, completeDurableJob, isDurableJobTerminal, markDurableJobUnknown, markDurableJobWakeupNotified, rejectQueuedDurableJob, startDurableJob, touchDurableJobHeartbeat } from "../job/durableJob.js";
18
+ import { cancelQueuedDurableJob, completeDurableJob, isDurableJobTerminal, markDurableJobUnknown, markDurableJobWakeupNotified, rejectQueuedDurableJob, startDurableJob, touchDurableJobHeartbeat, executionNamespace } from "../job/durableJob.js";
19
19
  import { recordOperationEvidence } from "../kernel/operationFacts.js";
20
20
  import { wakeReason } from "../scheduler/wakeReason.js";
21
21
  import { writeTextFileAtomically } from "../storage/durableFile.js";
@@ -45,12 +45,23 @@ export class DurableJobSupervisor {
45
45
  }
46
46
  reconcile(now) {
47
47
  const jobs = this.#store.listActiveDurableJobs();
48
- for (const job of jobs) {
48
+ const refresh = (job) => !("taskId" in job) && job.action === "project.refresh";
49
+ const waiting = (job) => refresh(job) && job.status === "queued"
50
+ && job.operation.effect === "none" && !job.cancelRequestedAt;
51
+ // Harvest/recover existing attempts first. Network operations remain in
52
+ // detached runners, with at most four outstanding Project refreshes.
53
+ const ordered = [...jobs.filter(job => !waiting(job)), ...jobs.filter(waiting)];
54
+ for (const job of ordered) {
49
55
  try {
56
+ if (waiting(job)) {
57
+ const occupied = this.#store.listActiveDurableJobs().filter(candidate => refresh(candidate) && (candidate.status === "running" || candidate.operation.effect !== "none"));
58
+ if (occupied.length >= 4)
59
+ break;
60
+ }
50
61
  this.#reconcileJob(job, now);
51
62
  }
52
63
  catch (error) {
53
- this.#onError(new Error(`DurableJob supervision failed for ${job.taskId}/${job.id}: `
64
+ this.#onError(new Error(`DurableJob supervision failed for ${executionNamespace(job)}/${job.id}: `
54
65
  + `${error instanceof Error ? error.message : String(error)}. `
55
66
  + "Inspect retained process/artifact evidence before retry or cancellation; effects remain unconfirmed.", { cause: error }));
56
67
  }
@@ -68,14 +79,14 @@ export class DurableJobSupervisor {
68
79
  }
69
80
  /** Composite key for the SIGKILL deadline map. */
70
81
  #sigkillKey(job) {
71
- return `${job.taskId}/${job.id}`;
82
+ return `${executionNamespace(job)}/${job.id}`;
72
83
  }
73
84
  /** Adopt observed execution, preserve unknown, or start an unattempted request. */
74
85
  #reconcileQueued(job, now) {
75
- const marker = this.#artifacts.readStartMarker(job.taskId, job.id);
86
+ const marker = this.#artifacts.readStartMarker(executionNamespace(job), job.id);
76
87
  const spawned = this.#spawnedProcessFromEvidence(job, marker);
77
88
  if (job.cancelRequestedAt !== undefined) {
78
- this.#artifacts.writeCancelFence(job.taskId, job.id);
89
+ this.#artifacts.writeCancelFence(executionNamespace(job), job.id);
79
90
  if (spawned !== null) {
80
91
  this.#process.signalIfOwned(spawned.pid, spawned.startIdentity, "SIGTERM");
81
92
  }
@@ -87,13 +98,13 @@ export class DurableJobSupervisor {
87
98
  if (job.operation.effect !== "none") {
88
99
  // A send intent was committed but no acceptance can be proved. Absence
89
100
  // of a file is not proof of no external effect. Never respawn unknown.
90
- const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(markDurableJobUnknown(current, "runner acceptance is unknown; inspect the original request", current.checkpoint?.completedSteps ?? [], now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
101
+ const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(markDurableJobUnknown(current, "runner acceptance is unknown; inspect the original request", current.checkpoint?.completedSteps ?? [], now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
91
102
  this.#deliverTerminalEvent(terminal);
92
103
  return;
93
104
  }
94
105
  if (job.cancelRequestedAt !== undefined) {
95
106
  // No effect attempted. Cancellation and its wake commit atomically.
96
- const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(cancelQueuedDurableJob(current, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
107
+ const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(cancelQueuedDurableJob(current, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
97
108
  this.#deliverTerminalEvent(terminal);
98
109
  return;
99
110
  }
@@ -112,7 +123,7 @@ export class DurableJobSupervisor {
112
123
  // around the spawn), while ready.json is written by the runner itself
113
124
  // before any side effect. A disagreement (PID reuse, spawn retry, stale
114
125
  // /proc read) is resolved in favor of ready.json.
115
- const ready = this.#artifacts.readReadyFile(job.taskId, job.id);
126
+ const ready = this.#artifacts.readReadyFile(executionNamespace(job), job.id);
116
127
  if (ready !== null) {
117
128
  return { pid: ready.pid, startIdentity: ready.startIdentity };
118
129
  }
@@ -128,9 +139,9 @@ export class DurableJobSupervisor {
128
139
  * process. A request with unobserved acceptance instead stays unknown.
129
140
  */
130
141
  #adoptAndContinue(job, process, now) {
131
- this.#store.transitionDurableJob(job.taskId, job.id, (current) => startDurableJob(current, process, now), now);
142
+ this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => startDurableJob(current, process, now), now);
132
143
  // The runner may already have finished while the Controller was down.
133
- const exit = this.#artifacts.readExitJson(job.taskId, job.id);
144
+ const exit = this.#artifacts.readExitJson(executionNamespace(job), job.id);
134
145
  if (exit !== null) {
135
146
  this.#harvestExit(job, exit, now);
136
147
  return;
@@ -153,18 +164,18 @@ export class DurableJobSupervisor {
153
164
  }
154
165
  const spec = {
155
166
  jobId: job.id,
156
- taskId: job.taskId,
167
+ ...("taskId" in job ? { taskId: job.taskId } : { scope: "global" }),
157
168
  workspace: job.workspace,
158
169
  env: job.env,
159
170
  steps: job.steps,
160
171
  defaultStepTimeoutMs: DEFAULT_STEP_TIMEOUT_MS,
161
- artifactDir: this.#artifacts.artifactDir(job.taskId, job.id),
172
+ artifactDir: this.#artifacts.artifactDir(executionNamespace(job), job.id),
162
173
  head: job.head
163
174
  };
164
- const specPath = this.#artifacts.writeSpec(job.taskId, job.id, spec);
175
+ const specPath = this.#artifacts.writeSpec(executionNamespace(job), job.id, spec);
165
176
  let attempted;
166
177
  try {
167
- attempted = this.#store.transitionDurableJob(job.taskId, job.id, (current) => {
178
+ attempted = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => {
168
179
  if (current.status !== "queued" || current.operation.effect !== "none") {
169
180
  throw new Error("Job already attempted; inspect the original request.");
170
181
  }
@@ -184,7 +195,7 @@ export class DurableJobSupervisor {
184
195
  return;
185
196
  // Both the request and possible-effect boundary precede spawn. A missing
186
197
  // acceptance after this point is unknown, never permission to respawn.
187
- this.#artifacts.writeStartMarker(job.taskId, job.id, {
198
+ this.#artifacts.writeStartMarker(executionNamespace(job), job.id, {
188
199
  pid: 0,
189
200
  startIdentity: "pending",
190
201
  spawnedAt: now.toISOString()
@@ -194,7 +205,7 @@ export class DurableJobSupervisor {
194
205
  // the runner's own ready.json is the fallback: the next reconcile reads
195
206
  // ready and adopts. The runner checks ready with O_EXCL and exits if it
196
207
  // already exists, preventing duplicate execution.
197
- this.#artifacts.writeStartMarker(job.taskId, job.id, {
208
+ this.#artifacts.writeStartMarker(executionNamespace(job), job.id, {
198
209
  pid: spawned.pid,
199
210
  startIdentity: spawned.startIdentity ?? String(spawned.pid),
200
211
  spawnedAt: now.toISOString()
@@ -206,27 +217,27 @@ export class DurableJobSupervisor {
206
217
  // terminalized or no longer exists; the recovery interval stays the
207
218
  // cross-restart fallback. No new timer is pinned while the Controller is
208
219
  // idle — each wake is a single event-driven signal.
209
- spawned.onExit?.(() => this.#wake(job.taskId));
210
- this.#wake(job.taskId);
220
+ spawned.onExit?.(() => this.#wake(executionNamespace(job)));
221
+ this.#wake(executionNamespace(job));
211
222
  }
212
223
  #rejectStart(job, error, now) {
213
224
  // Only an explicit domain refusal is a known failure. Storage/CAS/runtime
214
225
  // errors still propagate; never infer rejection from an unavailable read.
215
226
  if (!(error instanceof Error) || error.name !== "CoreJobError")
216
227
  throw error;
217
- const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(rejectQueuedDurableJob(current, error.message, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
228
+ const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(rejectQueuedDurableJob(current, error.message, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
218
229
  this.#deliverTerminalEvent(terminal);
219
230
  }
220
231
  #superviseRunning(job, now) {
221
232
  // 1. Harvest exit.json if present.
222
- const exit = this.#artifacts.readExitJson(job.taskId, job.id);
233
+ const exit = this.#artifacts.readExitJson(executionNamespace(job), job.id);
223
234
  if (exit !== null) {
224
235
  this.#harvestExit(job, exit, now);
225
236
  return;
226
237
  }
227
238
  // 2. Cancel requested: write fence + SIGTERM.
228
239
  if (job.cancelRequestedAt !== undefined && job.process !== undefined) {
229
- this.#artifacts.writeCancelFence(job.taskId, job.id);
240
+ this.#artifacts.writeCancelFence(executionNamespace(job), job.id);
230
241
  this.#process.signalIfOwned(job.process.pid, job.process.startIdentity, "SIGTERM");
231
242
  }
232
243
  // 3. Process liveness via pid + startIdentity.
@@ -241,7 +252,7 @@ export class DurableJobSupervisor {
241
252
  }
242
253
  }
243
254
  // 4. Heartbeat freshness.
244
- const heartbeatMtime = this.#artifacts.heartbeatMtime(job.taskId, job.id);
255
+ const heartbeatMtime = this.#artifacts.heartbeatMtime(executionNamespace(job), job.id);
245
256
  if (heartbeatMtime !== null) {
246
257
  const staleMs = now.getTime() - heartbeatMtime;
247
258
  if (staleMs > HEARTBEAT_STALE_MS && job.process !== undefined) {
@@ -255,7 +266,7 @@ export class DurableJobSupervisor {
255
266
  ? Date.parse(job.heartbeatAt)
256
267
  : 0;
257
268
  if (heartbeatMtime > recordHeartbeat) {
258
- this.#store.transitionDurableJob(job.taskId, job.id, (current) => touchDurableJobHeartbeat(current, new Date(heartbeatMtime)), now);
269
+ this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => touchDurableJobHeartbeat(current, new Date(heartbeatMtime)), now);
259
270
  }
260
271
  }
261
272
  // 5. SIGKILL escalation.
@@ -268,7 +279,7 @@ export class DurableJobSupervisor {
268
279
  #harvestExit(job, exit, now) {
269
280
  // Persist the original receipt locator before interpreting its output.
270
281
  // A bad schema cannot erase evidence of an already executed runner.
271
- this.#store.transitionDurableJob(job.taskId, job.id, (current) => ({
282
+ this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => ({
272
283
  ...current,
273
284
  operation: recordOperationEvidence(current.operation, {
274
285
  effect: "confirmed",
@@ -292,7 +303,7 @@ export class DurableJobSupervisor {
292
303
  // the adapter enqueues the Leader mailbox entry in the same transaction.
293
304
  // A separate #notifyWakeup pass would lose the wakeup if the Controller
294
305
  // died between the terminal write and the flag flip.
295
- const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => {
306
+ const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => {
296
307
  let completed;
297
308
  try {
298
309
  completed = completeDurableJob(current, result, now);
@@ -310,7 +321,7 @@ export class DurableJobSupervisor {
310
321
  this.#sigkillAt.delete(this.#sigkillKey(job));
311
322
  }
312
323
  #handleDeadProcess(job, now) {
313
- const checkpoint = this.#artifacts.readCheckpoint(job.taskId, job.id);
324
+ const checkpoint = this.#artifacts.readCheckpoint(executionNamespace(job), job.id);
314
325
  const proven = checkpoint === null ? null : proveOutcomeFromCheckpoint(job, checkpoint);
315
326
  // rr4/finding-7: Same atomic wakeup composition as #harvestExit — the
316
327
  // terminal transition and the Leader wakeup commit together.
@@ -318,12 +329,12 @@ export class DurableJobSupervisor {
318
329
  // The runner died without exit.json, but its step checkpoint covered
319
330
  // every planned step: the outcome is still provable from durable
320
331
  // evidence. No false pass — the checkpoint is the runner's own record.
321
- const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, { ...proven, evidenceSource: "checkpoint" }, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
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) });
322
333
  this.#deliverTerminalEvent(terminal);
323
334
  }
324
335
  else {
325
336
  const completedSteps = checkpoint?.completedSteps ?? [];
326
- const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(markDurableJobUnknown(current, "runner process exited without writing exit.json", completedSteps, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
337
+ const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(markDurableJobUnknown(current, "runner process exited without writing exit.json", completedSteps, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
327
338
  this.#deliverTerminalEvent(terminal);
328
339
  }
329
340
  this.#sigkillAt.delete(this.#sigkillKey(job));
@@ -339,8 +350,10 @@ export class DurableJobSupervisor {
339
350
  #deliverTerminalEvent(job) {
340
351
  if (job === null || !isDurableJobTerminal(job.status))
341
352
  return;
353
+ if (!("taskId" in job))
354
+ return; // The durable Operator mailbox owns global notices.
342
355
  this.#terminalEvents?.deliverTerminalEvent({
343
- taskId: job.taskId,
356
+ taskId: executionNamespace(job),
344
357
  jobId: job.id,
345
358
  status: job.status,
346
359
  outcome: job.result?.outcome ?? job.status
@@ -348,9 +361,11 @@ export class DurableJobSupervisor {
348
361
  }
349
362
  }
350
363
  function wakeupRefs(job) {
351
- const refs = [{ type: "task", id: job.taskId }];
364
+ if (!("taskId" in job))
365
+ return [];
366
+ const refs = [{ type: "task", id: executionNamespace(job) }];
352
367
  if (job.owner.kind === "work-item") {
353
- refs.push({ type: "work-item", taskId: job.taskId, id: job.owner.workItemId });
368
+ refs.push({ type: "work-item", taskId: executionNamespace(job), id: job.owner.workItemId });
354
369
  }
355
370
  return refs;
356
371
  }
@@ -2,6 +2,7 @@ import { admitProviderRetry, cancelProviderRetry, providerRetryAttemptId, provid
2
2
  import { blockingProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
3
3
  import { validateExactRunReviewRound } from "../lifecycle/exactRunTerminalization.js";
4
4
  import { isDeepStrictEqual } from "node:util";
5
+ import { isHandlingReminder } from "../message/inboxTurn.js";
5
6
  export function ownedProviderInput(input) {
6
7
  if (input.runId !== undefined && input.taskId !== undefined) {
7
8
  return { kind: "run", taskId: input.taskId, runId: input.runId };
@@ -26,6 +27,7 @@ export function retryIntentBlocker(store, input, retry) {
26
27
  return "role-configuration-changed";
27
28
  if (store.listGlobalRoleMessages(input.roleName).some(message => (message.kind === "user" || message.kind === "operator"
28
29
  || message.inputControl !== undefined || message.interruptThen !== undefined)
30
+ && !isHandlingReminder(message)
29
31
  && Date.parse(message.createdAt) > Date.parse(retry.intentAt)
30
32
  && message.delivery === undefined && message.notDelivered === undefined))
31
33
  return "new-user-input";
@@ -45,6 +45,7 @@ import { archiveOrdinaryTask } from "../task/ordinaryArchive.js";
45
45
  import { createWebSettings } from "../web/webSettings.js";
46
46
  import { createModelManagement } from "../models/modelManagement.js";
47
47
  import { createWebSkills } from "../web/webSkills.js";
48
+ import { createWebJobs } from "../web/webJobs.js";
48
49
  import { isTaskOwnedWorkspace, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
49
50
  import { AgentRuntimeObserver } from "./agentRuntimeObserver.js";
50
51
  import { createCapabilityDispatcher } from "./capabilityBridge.js";
@@ -55,6 +56,8 @@ import { createEphemeralResourceReaper } from "./ephemeralResourceReaper.js";
55
56
  import { AgentHostProviderSessionBusyError, AgentHostProviderTurnFenceError, FileSchedulerStoreAdapter } from "./fileSchedulerStoreAdapter.js";
56
57
  import { deliverGlobalInputs } from "./globalInputDelivery.js";
57
58
  import { authorizeJobStart } from "./jobControl.js";
59
+ import { authorizeGlobalJobStart, createJobDefinitionControl } from "./jobDefinitionControl.js";
60
+ import { createScheduleControl, reconcileSchedules } from "./scheduleControl.js";
58
61
  import { createFileArtifactPort, createLinuxProcessPort, DurableJobSupervisor } from "./jobSupervisor.js";
59
62
  import { createProviderRetryHooks } from "./providerRetryDelivery.js";
60
63
  import { createRuntimeResourceActivityTracker } from "./resourceInventory.js";
@@ -412,6 +415,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
412
415
  const web = createControllerWeb(store, {
413
416
  previews: () => createTaskPreviews(store, home),
414
417
  skills: createWebSkills(store),
418
+ jobs: createWebJobs(store, home),
415
419
  models: createModelManagement(store),
416
420
  settings: createWebSettings(store, {
417
421
  environment: options.environment ?? process.env, catalogs,
@@ -483,7 +487,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
483
487
  store: schedulerStore,
484
488
  process: kernel.runner,
485
489
  artifacts: createFileArtifactPort(home),
486
- authorizeStart: (job) => authorizeJobStart(store, job),
490
+ authorizeStart: (job) => "taskId" in job ? authorizeJobStart(store, job) : authorizeGlobalJobStart(store, job),
487
491
  // rr6/f1: Bounded supervision wake. The supervisor signals the Controller
488
492
  // after spawning a runner (queued→running adoption) and when a runner
489
493
  // exits (terminal harvest), so a quick job converges without waiting for
@@ -492,7 +496,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
492
496
  // no-op. The recovery interval stays the cross-restart fallback.
493
497
  wake: (taskId) => {
494
498
  try {
495
- runningRuntime?.signal(`task:${taskId}`);
499
+ runningRuntime?.signal(taskId === "global" ? "operator" : `task:${taskId}`);
496
500
  }
497
501
  catch {
498
502
  // Controller stopped; the recovery interval remains the fallback.
@@ -568,6 +572,11 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
568
572
  await lifecycleDispatcher("runtime.ensure-role-session", { scope: "global", roleName });
569
573
  }, options.onError ?? (() => undefined), deliveryOptions),
570
574
  jobControl,
575
+ jobDefinitions: createJobDefinitionControl(store, home),
576
+ schedules: {
577
+ control: createScheduleControl(store, home),
578
+ reconcile: now => reconcileSchedules(store, home, now, options.onError)
579
+ },
571
580
  capabilityDispatcher: createCapabilityDispatcher(kernel.capabilities),
572
581
  ...(continuationReconciler === undefined ? {} : { continuationReconciler }),
573
582
  ...(resourceReaper === undefined ? {} : { resourceReaper }),