@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
@@ -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
  }
@@ -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 }),
@@ -0,0 +1,176 @@
1
+ import { requireKnownFields } from "../domain/validation.js";
2
+ import { nextScheduleDue, scheduleId, validateSchedule } from "../schedule/schedule.js";
3
+ import { admitJobRun, authorizeDefinition, authorizeScope, createJobDefinitionControl } from "./jobDefinitionControl.js";
4
+ import { jobAuthorityBinding } from "./jobControl.js";
5
+ import { createGlobalRoleMessage } from "../message/message.js";
6
+ import { createTaskEvent } from "../event/taskEvent.js";
7
+ import { enqueueSupervisorEvent } from "../scheduler/operatorEvent.js";
8
+ import { redactLaunchText } from "../runtime/launchDiagnostics.js";
9
+ function binding(store, caller, scope) {
10
+ return jobAuthorityBinding(store, caller.scope, caller.role, scope.kind === "task" ? scope.taskId : "");
11
+ }
12
+ function authorizeSchedule(store, schedule, caller, write) {
13
+ const job = store.getJobDefinition(schedule.scope, schedule.jobId);
14
+ if (job === null)
15
+ throw new Error("Schedule Job definition is missing.");
16
+ authorizeDefinition(store, job, caller, write);
17
+ return job;
18
+ }
19
+ function fence(schedule, now) {
20
+ return new Date(Math.max(now.getTime(), Date.parse(schedule?.processedThrough ?? "") || 0));
21
+ }
22
+ export function createScheduleControl(store, home) {
23
+ return (value, now) => {
24
+ if (value === null || typeof value !== "object" || Array.isArray(value))
25
+ throw new Error("Invalid Schedule request.");
26
+ requireKnownFields(value, ["op", "scope", "caller", "id", "jobId", "rule", "expectedRevision"], "Schedule request");
27
+ const input = value;
28
+ authorizeScope(store, input.scope, input.caller);
29
+ return store.transaction(tx => {
30
+ if (input.op === "list") {
31
+ return tx.listSchedules(input.scope).filter(s => {
32
+ try {
33
+ authorizeSchedule(tx, s, input.caller, false);
34
+ return true;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ });
40
+ }
41
+ scheduleId(input.id);
42
+ const previous = tx.getSchedule(input.scope, input.id);
43
+ if (input.op === "create") {
44
+ scheduleId(input.jobId);
45
+ const job = tx.getJobDefinition(input.scope, input.jobId);
46
+ if (job === null)
47
+ throw new Error("Schedule Job definition is missing.");
48
+ if (previous !== null) {
49
+ authorizeSchedule(tx, previous, input.caller, false);
50
+ if (previous.jobId !== input.jobId || JSON.stringify(previous.rule) !== JSON.stringify(input.rule)) {
51
+ throw new Error("Schedule id already exists; use update with its revision.");
52
+ }
53
+ return previous;
54
+ }
55
+ authorizeDefinition(tx, job, input.caller, true);
56
+ const due = nextScheduleDue(input.rule, now);
57
+ if (due === null)
58
+ throw new Error("A new once Schedule must be in the future.");
59
+ const schedule = validateSchedule({
60
+ schemaVersion: 1, id: input.id, scope: structuredClone(input.scope), jobId: input.jobId,
61
+ rule: structuredClone(input.rule), state: "enabled", revision: 1, generation: 1, nextDue: due,
62
+ authorization: { caller: structuredClone(input.caller), binding: binding(tx, input.caller, input.scope), jobRevision: job.revision },
63
+ createdAt: now.toISOString(), updatedAt: now.toISOString()
64
+ });
65
+ tx.saveSchedule(schedule);
66
+ return schedule;
67
+ }
68
+ if (previous === null)
69
+ throw new Error("Schedule not found.");
70
+ authorizeSchedule(tx, previous, input.caller, false);
71
+ if (input.op === "show") {
72
+ const runs = createJobDefinitionControl(tx, home)({
73
+ op: "runs", scope: input.scope, id: previous.jobId, caller: input.caller
74
+ }, now);
75
+ const ownRuns = runs.filter(r => r.trigger.source === "schedule" && r.trigger.key.startsWith(`${previous.id}:`));
76
+ const lastSuccess = ownRuns.filter(r => r.status === "succeeded")
77
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0] ?? null;
78
+ return { schedule: previous, lastSuccess };
79
+ }
80
+ if (previous.revision !== input.expectedRevision)
81
+ throw new Error("Schedule revision changed; read it before modifying.");
82
+ let next;
83
+ if (input.op === "disable") {
84
+ next = { ...previous, state: "disabled", reason: "Explicitly disabled; no future admission. Existing Runs are unchanged." };
85
+ }
86
+ else if (input.op === "enable" || input.op === "update") {
87
+ if (input.op === "enable" && ["completed", "retired"].includes(previous.state)) {
88
+ throw new Error("Completed/retired Schedule requires an explicit future rule update.");
89
+ }
90
+ const jobId = input.op === "update" ? input.jobId ?? previous.jobId : previous.jobId;
91
+ scheduleId(jobId);
92
+ const rule = input.op === "update" ? input.rule : previous.rule;
93
+ const job = authorizeSchedule(tx, { ...previous, jobId }, input.caller, true);
94
+ const after = fence(previous, now);
95
+ // Once overlap/disabled intent keeps its original due instant on enable.
96
+ const nextDue = input.op === "enable" && rule.kind === "once"
97
+ ? previous.nextDue : nextScheduleDue(rule, after);
98
+ if (nextDue === null)
99
+ throw new Error("Rule must have a future occurrence.");
100
+ next = {
101
+ ...previous, jobId, rule: structuredClone(rule), nextDue,
102
+ state: input.op === "enable" || previous.state === "enabled" ? "enabled" : "disabled",
103
+ generation: previous.generation + (input.op === "update" ? 1 : 0),
104
+ processedThrough: after.toISOString(),
105
+ authorization: { caller: structuredClone(input.caller), binding: binding(tx, input.caller, input.scope), jobRevision: job.revision }
106
+ };
107
+ const { reason: _reason, ...withoutReason } = next;
108
+ next = withoutReason;
109
+ }
110
+ else
111
+ throw new Error(`Unknown Schedule operation: ${input.op}.`);
112
+ next = { ...next, revision: previous.revision + 1, updatedAt: now.toISOString() };
113
+ tx.saveSchedule(next);
114
+ return next;
115
+ });
116
+ };
117
+ }
118
+ /** One bounded pass in the existing Controller. Transactions never spawn. */
119
+ export function reconcileSchedules(store, home, now, onError = () => { }, limit = 100) {
120
+ for (const candidate of store.listDueSchedules(now.toISOString(), limit)) {
121
+ try {
122
+ store.transaction(tx => {
123
+ const schedule = tx.getSchedule(candidate.scope, candidate.id);
124
+ if (schedule?.state !== "enabled" || schedule.nextDue === null || Date.parse(schedule.nextDue) > now.getTime())
125
+ return;
126
+ const dueAt = schedule.nextDue;
127
+ const after = fence(schedule, now);
128
+ if (schedule.scope.kind === "task") {
129
+ const task = tx.getTask(schedule.scope.taskId);
130
+ if (task === null || task.status !== "active" || task.executionGate.state !== "enabled") {
131
+ tx.saveSchedule({ ...schedule, state: "needs-attention", revision: schedule.revision + 1,
132
+ reason: "Task execution unavailable; pending intent retained.", updatedAt: now.toISOString() });
133
+ return;
134
+ }
135
+ }
136
+ const job = authorizeSchedule(tx, schedule, schedule.authorization.caller, true);
137
+ if (job.revision !== schedule.authorization.jobRevision
138
+ || binding(tx, schedule.authorization.caller, schedule.scope) !== schedule.authorization.binding) {
139
+ throw new Error("Schedule authority or Job revision changed; inspect and explicitly enable.");
140
+ }
141
+ const result = admitJobRun(tx, home, schedule.scope, schedule.jobId, schedule.authorization.caller, { source: "schedule", key: `${schedule.id}:${schedule.generation}:${dueAt}` }, now);
142
+ const overlap = result.reason === "overlap";
143
+ const nextDue = overlap && schedule.rule.kind === "once" ? dueAt : nextScheduleDue(schedule.rule, after);
144
+ tx.saveSchedule({
145
+ ...schedule, nextDue, state: nextDue === null ? "completed" : "enabled",
146
+ processedThrough: after.toISOString(), revision: schedule.revision + 1,
147
+ lastAttempt: { at: now.toISOString(), dueAt, outcome: overlap ? "overlap" : "admitted", runId: result.run.id },
148
+ updatedAt: now.toISOString()
149
+ });
150
+ });
151
+ }
152
+ catch (error) {
153
+ const reason = redactLaunchText(error instanceof Error ? error.message : String(error));
154
+ // Admission/advancement rolled back together. Do not retry an unknown error.
155
+ store.transaction(tx => {
156
+ const current = tx.getSchedule(candidate.scope, candidate.id);
157
+ if (current?.state !== "enabled" || current.revision !== candidate.revision)
158
+ return;
159
+ tx.saveSchedule({ ...current, state: "needs-attention", reason, revision: current.revision + 1,
160
+ lastAttempt: { at: now.toISOString(), dueAt: current.nextDue, outcome: "blocked", reason },
161
+ updatedAt: now.toISOString() });
162
+ if (current.scope.kind === "task") {
163
+ const event = createTaskEvent(tx.nextEventId(current.scope.taskId), current.scope.taskId, "schedule.needs-attention", { scheduleId: current.id, reason }, now);
164
+ tx.saveEvent(current.scope.taskId, event);
165
+ enqueueSupervisorEvent(tx, event, "leader", "schedule-needs-attention", now);
166
+ }
167
+ else {
168
+ tx.saveGlobalRoleMessage(createGlobalRoleMessage(tx.nextGlobalRoleMessageId(), "operator", `Yui-Summary: Schedule ${current.id} needs attention; no Run was admitted.\n`
169
+ + `Reason: ${reason}\nRead: yui schedule show --global --schedule ${current.id}\n`
170
+ + "Inspect the original intent before explicitly enabling; no new authority is implied.", "system", { type: "system" }, now, { inputControl: { action: "queue", requestId: `schedule-attention:${current.id}:${current.revision}` } }));
171
+ }
172
+ });
173
+ onError(new Error(`Schedule ${candidate.id} needs attention: ${reason}`, { cause: error }));
174
+ }
175
+ }
176
+ }
@@ -315,11 +315,11 @@ function parseTarget(value) {
315
315
  function parseRef(value, label) {
316
316
  const ref = record(value, label);
317
317
  const types = [
318
- "task", "run", "work-item", "input", "session", "message", "event"
318
+ "task", "run", "work-item", "input", "session", "message", "event", "global-job-run"
319
319
  ];
320
320
  if (!types.includes(ref.type))
321
321
  throw new Error(`${label} type is invalid`);
322
- if (ref.type === "task" || ref.type === "session") {
322
+ if (ref.type === "task" || ref.type === "session" || ref.type === "global-job-run") {
323
323
  exact(ref, ["type", "id"], label);
324
324
  return { type: ref.type, id: requireString(ref.id, `${label} id`) };
325
325
  }
@@ -360,7 +360,7 @@ function copyTarget(target) {
360
360
  }
361
361
  export function copyRef(ref) {
362
362
  if (!("taskId" in ref)) {
363
- if (ref.type !== "task" && ref.type !== "session") {
363
+ if (ref.type !== "task" && ref.type !== "session" && ref.type !== "global-job-run") {
364
364
  throw new Error(`entity reference taskId is required for ${ref.type}`);
365
365
  }
366
366
  return { type: ref.type, id: requireText(ref.id, "entity reference id") };
@@ -1,5 +1,6 @@
1
1
  import { createExecFileCommandRunner, createPinnedCommandRunner } from "./pinnedCommandRunner.js";
2
2
  import { redactLaunchText } from "../runtime/launchDiagnostics.js";
3
+ import { canonicalPublicationUrl } from "../task/publicationToolObservation.js";
3
4
  const GIT_COMMIT = /^[0-9a-f]{40}$/iu;
4
5
  const GITHUB_REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u;
5
6
  const MAX_FAILURE_DETAIL_CHARS = 4_000;
@@ -20,22 +21,24 @@ async function inspectGitHubPullRequest(run, input) {
20
21
  if (!PULL_REQUEST_NUMBER.test(input.externalId)) {
21
22
  throw new Error(`GitHub pull request id must be numeric: ${input.externalId}.`);
22
23
  }
24
+ const hostname = input.externalUrl === undefined
25
+ ? "github.com" : new URL(canonicalPublicationUrl(input.externalUrl)).host;
23
26
  const result = await run("gh", [
24
27
  "pr", "view", input.externalId,
25
- "--repo", input.repository,
26
- "--json", "number,state,headRefOid,mergeCommit,mergedAt,url"
28
+ "--repo", `${hostname}/${input.repository}`,
29
+ "--json", "number,state,headRefOid,mergeCommit,mergedAt,url,baseRefName"
27
30
  ]);
28
31
  if (result.code !== 0) {
29
32
  if (result.code === 127) {
30
- throw new Error("GitHub verification requires a trusted gh executable on PATH.");
33
+ throw new Error("The built-in GitHub adapter needs gh on PATH; alternatively record authorized tool evidence with task publication verify --observation.");
31
34
  }
32
35
  const detail = boundedFailureDetail(redactLaunchText(result.stderr || result.stdout).trim());
33
36
  throw new Error(`gh pr view failed for ${input.repository}#${input.externalId} `
34
37
  + `(exit ${result.code})${detail.length === 0 ? "" : `: ${detail}`}`);
35
38
  }
36
- return parseGitHubPullRequest(result.stdout, input.repository, input.externalId);
39
+ return parseGitHubPullRequest(result.stdout, input.repository, input.externalId, hostname);
37
40
  }
38
- export function parseGitHubPullRequest(stdout, repository, externalId) {
41
+ export function parseGitHubPullRequest(stdout, repository, externalId, expectedHost = "github.com") {
39
42
  let parsed;
40
43
  try {
41
44
  parsed = JSON.parse(stdout.trim());
@@ -60,6 +63,11 @@ export function parseGitHubPullRequest(stdout, repository, externalId) {
60
63
  throw new Error("Merged GitHub PR did not expose a merge commit.");
61
64
  }
62
65
  const externalUrl = optionalText(record.url);
66
+ if (externalUrl === undefined
67
+ || canonicalPublicationUrl(externalUrl, "github")
68
+ !== canonicalPublicationUrl(`https://${expectedHost}/${repository}/pull/${externalId}`, "github")) {
69
+ throw new Error("GitHub PR URL does not match the expected service, repository and PR.");
70
+ }
63
71
  const mergedAt = optionalText(record.mergedAt);
64
72
  return {
65
73
  provider: "github",
@@ -67,6 +75,7 @@ export function parseGitHubPullRequest(stdout, repository, externalId) {
67
75
  externalKind: "pull-request",
68
76
  externalId,
69
77
  ...(externalUrl === undefined ? {} : { externalUrl }),
78
+ ...(optionalText(record.baseRefName) === undefined ? {} : { targetBranch: optionalText(record.baseRefName) }),
70
79
  state,
71
80
  headCommit,
72
81
  ...(remoteCommit === undefined ? {} : { remoteCommit }),
@@ -30,7 +30,7 @@ async function inspectGitLabMergeRequest(run, input) {
30
30
  ]);
31
31
  if (result.code !== 0) {
32
32
  if (result.code === 127) {
33
- throw new Error("GitLab verification requires a trusted glab executable on PATH.");
33
+ throw new Error("The built-in GitLab adapter needs glab on PATH; alternatively record authorized tool evidence with task publication verify --observation.");
34
34
  }
35
35
  const detail = boundedFailureDetail(redactLaunchText(result.stderr || result.stdout).trim());
36
36
  throw new Error(`glab api failed for ${input.repository}!${input.externalId} `
@@ -74,6 +74,7 @@ export function parseGitLabMergeRequest(stdout, repository, externalId, expected
74
74
  externalKind: "merge-request",
75
75
  externalId,
76
76
  ...(externalUrl === undefined ? {} : { externalUrl }),
77
+ ...(optionalText(record.target_branch) === undefined ? {} : { targetBranch: optionalText(record.target_branch) }),
77
78
  state,
78
79
  headCommit,
79
80
  ...(remoteCommit === undefined ? {} : { remoteCommit }),
@@ -49,11 +49,11 @@ export function wakeChangeLabels(reasons) {
49
49
  }
50
50
  }))];
51
51
  }
52
- export function wakeMessages(wake, messages) {
52
+ export function wakeMessages(wake, messages, history = false) {
53
53
  const ids = new Set(wake.refs?.filter(ref => ref.type === "message" && ref.taskId === wake.taskId).map(ref => ref.id));
54
54
  const from = Date.parse(wake.fromCursor), to = Date.parse(wake.toCursor);
55
- return messages.filter(message => ids.has(message.id)
56
- || Date.parse(message.createdAt) > from && Date.parse(message.createdAt) <= to);
55
+ return messages.filter(message => message.taskId === wake.taskId && (ids.has(message.id)
56
+ || (history || wake.refs === undefined) && Date.parse(message.createdAt) > from && Date.parse(message.createdAt) <= to));
57
57
  }
58
58
  export function taskNotificationPresentation(wake, store) {
59
59
  const messages = wakeMessages(wake, store.listMessages(wake.taskId));
@@ -16,6 +16,11 @@ export function createOperatorBatchPresentation(batchId, items) {
16
16
  };
17
17
  }
18
18
  function renderItem(item) {
19
+ if (item.kind === "global-job-run")
20
+ return [
21
+ `- Global Job Run ${item.run.status}: ${item.run.definitionId}/${item.run.id}`,
22
+ ` Inspect: yui job run-get --global --job ${item.run.definitionId} --run ${item.run.id}`
23
+ ];
19
24
  if (item.kind === "input-request") {
20
25
  const { request } = item;
21
26
  return [