@zq-silk/yui 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/artifacts/artifactCapability.js +14 -8
  2. package/dist/artifacts/artifactCommitLock.js +7 -5
  3. package/dist/artifacts/jobArtifact.js +93 -0
  4. package/dist/artifacts/jobArtifactTransport.js +13 -0
  5. package/dist/artifacts/taskArtifactRepository.js +58 -10
  6. package/dist/cli/callerDiscovery.js +18 -0
  7. package/dist/cli/commandCatalog.js +45 -14
  8. package/dist/cli/commandDiscovery.js +2 -3
  9. package/dist/commands/durableJobCommands.js +31 -6
  10. package/dist/commands/globalRoleCommands.js +5 -0
  11. package/dist/commands/scheduleCommands.js +14 -3
  12. package/dist/commands/taskCommands.js +5 -2
  13. package/dist/context/taskCatalog.js +6 -1
  14. package/dist/controlPlaneCli.js +12 -12
  15. package/dist/controller/agentJobControl.js +391 -0
  16. package/dist/controller/controller.js +24 -3
  17. package/dist/controller/fileSchedulerStoreAdapter.js +12 -1
  18. package/dist/controller/globalInputDelivery.js +8 -1
  19. package/dist/controller/jobArtifactControl.js +111 -0
  20. package/dist/controller/jobControl.js +4 -0
  21. package/dist/controller/jobDefinitionControl.js +191 -19
  22. package/dist/controller/jobSupervisor.js +83 -3
  23. package/dist/controller/jobTaskDelegationControl.js +193 -0
  24. package/dist/controller/runtime.js +7 -1
  25. package/dist/controller/scheduleControl.js +83 -20
  26. package/dist/integration/gitIntegrationService.js +2 -0
  27. package/dist/job/agentJob.js +131 -0
  28. package/dist/job/agentJobObservation.js +61 -0
  29. package/dist/job/agentRoleScope.js +33 -0
  30. package/dist/job/durableJob.js +63 -28
  31. package/dist/job/jobAssignmentScope.js +1 -0
  32. package/dist/job/jobBusinessResult.js +72 -0
  33. package/dist/job/jobCleanupInspection.js +4 -0
  34. package/dist/job/jobDefinition.js +26 -2
  35. package/dist/job/jobRunner.js +4 -3
  36. package/dist/job/projectRefreshRunner.js +54 -18
  37. package/dist/job/projectSync.js +162 -0
  38. package/dist/job/scheduleService.js +55 -0
  39. package/dist/job/taskDelegation.js +48 -0
  40. package/dist/message/messageContinuation.js +1 -1
  41. package/dist/resources/projectResourceService.js +1 -1
  42. package/dist/resources/resourceGc.js +1 -1
  43. package/dist/role/role.js +22 -12
  44. package/dist/runtime/managedIdentity.js +5 -3
  45. package/dist/runtime/sessionCaller.js +9 -10
  46. package/dist/schedule/schedule.js +65 -15
  47. package/dist/scheduler/wakeReason.js +4 -0
  48. package/dist/storage/agentJobSchema.js +10 -0
  49. package/dist/storage/maintenanceCaller.js +17 -25
  50. package/dist/storage/nativeSessionLookup.js +23 -0
  51. package/dist/storage/recordValidation.js +2 -0
  52. package/dist/storage/sessionHistorySchema.js +17 -0
  53. package/dist/storage/sqliteSchema.js +96 -11
  54. package/dist/storage/sqliteStore.js +71 -4
  55. package/dist/storage/storageVersions.js +1 -1
  56. package/dist/storage/storeRpc.js +4 -0
  57. package/dist/storage/taskCatalog.js +10 -2
  58. package/dist/storage/upgrade/jobBusinessResultMigration.js +20 -0
  59. package/dist/storage/upgrade/scheduleDispatchMigration.js +15 -0
  60. package/dist/task/archivePreflight.js +15 -0
  61. package/dist/task/completionReadiness.js +7 -0
  62. package/dist/task/task.js +17 -1
  63. package/dist/verification/verificationGateService.js +4 -0
  64. package/dist/web/assets/client/app/catalog.js +16 -0
  65. package/dist/web/assets/client/i18n/en.js +29 -2
  66. package/dist/web/assets/client/i18n/zh-CN.js +29 -2
  67. package/dist/web/assets/client/layout/manageShell.js +9 -0
  68. package/dist/web/assets/client/layout/router.js +1 -0
  69. package/dist/web/assets/client/views/jobs.js +166 -37
  70. package/dist/web/assets/client/views/task/files.js +5 -4
  71. package/dist/web/assets/client/views/task/page.js +5 -1
  72. package/dist/web/taskPreviews.js +2 -2
  73. package/dist/web/webJobs.js +36 -11
  74. package/dist/web/webMaterials.js +21 -9
  75. package/dist/web/webServer.js +6 -5
  76. package/dist/web/webTaskSurface.js +1 -0
  77. package/docs/authorization.md +130 -0
  78. package/docs/job-runs.md +392 -17
  79. package/docs/managed-turn-and-session-runtime.md +10 -5
  80. package/docs/managed-turn-and-session-runtime.zh-CN.md +12 -5
  81. package/docs/release-workflow.md +79 -0
  82. package/docs/release-workflow.zh-CN.md +58 -0
  83. package/docs/schedules.md +69 -15
  84. package/docs/storage-baseline.md +45 -1
  85. package/docs/storage-baseline.zh-CN.md +33 -1
  86. package/docs/testing/verification-levels.md +2 -2
  87. package/docs/testing/verification-levels.zh-CN.md +2 -2
  88. package/package.json +1 -1
  89. package/skills/yui-runtime/SKILL.md +12 -5
  90. package/skills/yui-runtime/VERSION +1 -1
@@ -20,6 +20,7 @@ import { recordOperationEvidence } from "../kernel/operationFacts.js";
20
20
  import { wakeReason } from "../scheduler/wakeReason.js";
21
21
  import { writeTextFileAtomically } from "../storage/durableFile.js";
22
22
  import { readProcessStartIdentity } from "../core/fileLockOwner.js";
23
+ import { processBusinessResult, resultDigest, runReference, validateBusinessResult } from "../job/jobBusinessResult.js";
23
24
  const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
24
25
  const HEARTBEAT_STALE_MS = 2 * 60_000;
25
26
  const SIGKILL_GRACE_MS = 30_000;
@@ -31,6 +32,7 @@ export class DurableJobSupervisor {
31
32
  #wake;
32
33
  #onError;
33
34
  #authorizeStart;
35
+ #reconcileDelegation;
34
36
  // f5: Composite key (taskId/jobId) because job IDs are Task-local — every
35
37
  // Task has a job-1, so a Task-local key would cross-kill healthy runners.
36
38
  #sigkillAt = new Map();
@@ -42,6 +44,7 @@ export class DurableJobSupervisor {
42
44
  this.#wake = options.wake ?? (() => undefined);
43
45
  this.#onError = options.onError ?? (() => undefined);
44
46
  this.#authorizeStart = options.authorizeStart;
47
+ this.#reconcileDelegation = options.reconcileDelegation;
45
48
  }
46
49
  reconcile(now) {
47
50
  const jobs = this.#store.listActiveDurableJobs();
@@ -67,7 +70,38 @@ export class DurableJobSupervisor {
67
70
  }
68
71
  }
69
72
  }
73
+ /** Explicit collection only. Never admits, spawns, cancels or retries work. */
74
+ confirmResult(namespace, runId, now) {
75
+ const run = this.#store.listActiveDurableJobs()
76
+ .find(run => executionNamespace(run) === namespace && run.id === runId);
77
+ if (!run)
78
+ return;
79
+ if (run.delegation) {
80
+ this.#reconcileDelegation?.(run, now);
81
+ return;
82
+ }
83
+ if (run.status === "queued") {
84
+ const spawned = this.#spawnedProcessFromEvidence(run, this.#artifacts.readStartMarker(namespace, runId));
85
+ if (spawned)
86
+ this.#adoptAndContinue(run, spawned, now);
87
+ }
88
+ else {
89
+ const exit = this.#artifacts.readExitJson(namespace, runId);
90
+ if (exit)
91
+ this.#harvestExit(run, exit, now);
92
+ else if (run.process) {
93
+ const identity = this.#process.processStartIdentity(run.process.pid);
94
+ if (identity === undefined || identity !== run.process.startIdentity
95
+ || !this.#process.isProcessAlive(run.process.pid))
96
+ this.#handleDeadProcess(run, now);
97
+ }
98
+ }
99
+ }
70
100
  #reconcileJob(job, now) {
101
+ if (job.delegation) {
102
+ this.#reconcileDelegation?.(job, now);
103
+ return;
104
+ }
71
105
  if (job.status === "queued") {
72
106
  this.#reconcileQueued(job, now);
73
107
  return;
@@ -155,6 +189,8 @@ export class DurableJobSupervisor {
155
189
  }
156
190
  }
157
191
  #startJob(job, now) {
192
+ if (job.delegation)
193
+ throw new Error("Task delegation cannot start a process.");
158
194
  try {
159
195
  this.#authorizeStart(job);
160
196
  }
@@ -298,6 +334,7 @@ export class DurableJobSupervisor {
298
334
  evidenceSource: "exit-artifact",
299
335
  steps: exit.steps
300
336
  };
337
+ const business = this.#businessResult(job, result);
301
338
  // rr4/finding-7: The terminal transition and the Leader wakeup must be
302
339
  // atomic. Compose complete + wakeupNotified and pass the wakeup param so
303
340
  // the adapter enqueues the Leader mailbox entry in the same transaction.
@@ -306,11 +343,11 @@ export class DurableJobSupervisor {
306
343
  const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => {
307
344
  let completed;
308
345
  try {
309
- completed = completeDurableJob(current, result, now);
346
+ completed = completeDurableJob(current, result, now, business);
310
347
  }
311
348
  catch {
312
349
  completed = completeDurableJob(current, {
313
- outcome: "failed", exitCode: null, signal: null,
350
+ outcome: "unknown-needs-attention", exitCode: null, signal: null,
314
351
  unknownReason: "runner output is invalid; original receipt is retained",
315
352
  steps: []
316
353
  }, now);
@@ -329,7 +366,7 @@ export class DurableJobSupervisor {
329
366
  // The runner died without exit.json, but its step checkpoint covered
330
367
  // every planned step: the outcome is still provable from durable
331
368
  // evidence. No false pass — the checkpoint is the runner's own record.
332
- const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, { ...proven, evidenceSource: "checkpoint" }, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
369
+ const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, { ...proven, evidenceSource: "checkpoint" }, now, this.#businessResult(current, { ...proven, evidenceSource: "checkpoint" })), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
333
370
  this.#deliverTerminalEvent(terminal);
334
371
  }
335
372
  else {
@@ -339,6 +376,34 @@ export class DurableJobSupervisor {
339
376
  }
340
377
  this.#sigkillAt.delete(this.#sigkillKey(job));
341
378
  }
379
+ #businessResult(job, result) {
380
+ const fallback = processBusinessResult(job, result);
381
+ if ("taskId" in job)
382
+ return fallback;
383
+ // The runner records this only when its cancel fence is seen before the
384
+ // first step. No domain action ran, so no business receipt is expected.
385
+ if (result.outcome === "cancelled" && result.steps.length === 0)
386
+ return fallback;
387
+ if (job.action === "system.noop")
388
+ return { ...fallback, data: { action: "system.noop" } };
389
+ const raw = this.#artifacts.readBusinessResult?.(executionNamespace(job), job.id);
390
+ if (raw !== null && raw !== undefined) {
391
+ try {
392
+ const business = validateBusinessResult(JSON.parse(raw), runReference(job));
393
+ const sourceRun = runReference(job);
394
+ return { ...business, evidence: [...business.evidence, {
395
+ kind: "execution-evidence", owner: sourceRun, sourceRun,
396
+ version: resultDigest(raw), locator: `${job.artifactsLocator}/business-result.json`
397
+ }, ...fallback.evidence] };
398
+ }
399
+ catch {
400
+ return { ...fallback, outcome: "unknown-needs-attention",
401
+ summary: "Invalid business result receipt; inspect retained execution evidence. Do not replay the action." };
402
+ }
403
+ }
404
+ return { ...fallback, outcome: "unknown-needs-attention",
405
+ summary: "Business result receipt is missing; process completion does not prove the business outcome. Do not replay the action." };
406
+ }
342
407
  /**
343
408
  * f7/rr5: Deliver a terminal notice to the runtime inbox channel when a
344
409
  * Job reaches a terminal state. The notice is best-effort: the terminal
@@ -375,6 +440,8 @@ function wakeupRefs(job) {
375
440
  * checkpoint covers every planned step exactly, by name.
376
441
  */
377
442
  function proveOutcomeFromCheckpoint(job, checkpoint) {
443
+ if (job.delegation)
444
+ return null;
378
445
  const byName = new Map(checkpoint.completedSteps.map((step) => [step.name, step]));
379
446
  if (byName.size !== checkpoint.completedSteps.length)
380
447
  return null;
@@ -501,6 +568,19 @@ export function createFileArtifactPort(home) {
501
568
  },
502
569
  readReadyFile(taskId, jobId) {
503
570
  return readJsonFile(join(home, "artifacts", "jobs", taskId, jobId, "ready.json"));
571
+ },
572
+ readBusinessResult(taskId, jobId) {
573
+ const path = join(home, "artifacts", "jobs", taskId, jobId, "business-result.json");
574
+ try {
575
+ if (statSync(path).size > 256 * 1024)
576
+ return "oversized business result";
577
+ return readFileSync(path, "utf8");
578
+ }
579
+ catch (error) {
580
+ if (error.code === "ENOENT")
581
+ return null;
582
+ throw error; // A transient collection failure is not absence or permission to rerun.
583
+ }
504
584
  }
505
585
  };
506
586
  }
@@ -0,0 +1,193 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createTaskAggregate } from "../commands/taskCommands.js";
3
+ import { isDurableJobTerminal, validateDurableJob } from "../job/durableJob.js";
4
+ import { resultDigest, runReference } from "../job/jobBusinessResult.js";
5
+ import { validateTaskJobConfiguration, workItemDefinitionDigest } from "../job/taskDelegation.js";
6
+ import { assertCallerAuthorized } from "./jobControl.js";
7
+ import { enqueueWork } from "../coordination/workMailboxQueue.js";
8
+ import { isDeepStrictEqual } from "node:util";
9
+ /** Scope authorization is performed by the Job ingress; provenance adds none. */
10
+ export function authorizeTaskJob(store, job, caller, write) {
11
+ if (job.configuration.kind !== "task")
12
+ throw new Error("Not a Task delegation.");
13
+ validateTaskJobConfiguration(job.configuration);
14
+ if (caller.scope === "task") {
15
+ if (!write && caller.role === "leader")
16
+ return;
17
+ // Creating another Task is a cross-Task effect, not inherited authority.
18
+ if (caller.role !== "leader" || job.configuration.target.kind === "new"
19
+ || job.configuration.target.taskId !== caller.taskId) {
20
+ throw new Error("Task delegation requires exact target authority; source Job scope grants no cross-Task permission.");
21
+ }
22
+ assertCallerAuthorized(store, caller, caller.taskId);
23
+ if (write) {
24
+ const session = store.getTaskRoleSessionSet(caller.taskId, "leader");
25
+ const effective = session?.sessions[session.activeAgentId]?.effective;
26
+ if (effective?.executionAuthority !== "delivery" || effective.profileAccess !== "write") {
27
+ throw new Error("Task delegation requires a current writable delivery Session.");
28
+ }
29
+ }
30
+ }
31
+ if (!write)
32
+ return;
33
+ if (job.scope.kind === "task") {
34
+ const source = store.getTask(job.scope.taskId);
35
+ if (!source || source.status !== "active" || source.executionGate.state !== "enabled")
36
+ throw new Error("Source Task is not executable.");
37
+ }
38
+ const target = job.configuration.target;
39
+ if (target.kind === "new") {
40
+ for (const binding of target.projectBindings) {
41
+ const project = store.listProjects().find(project => project.id === binding.projectId);
42
+ if (!project || project.status !== "active")
43
+ throw new Error("Target Project is not active.");
44
+ }
45
+ }
46
+ else {
47
+ const task = store.getTask(target.taskId);
48
+ const work = store.getWorkItem(target.taskId, target.workItemId);
49
+ if (!task || task.status !== "active" || task.executionGate.state !== "enabled"
50
+ || !work || work.status !== "open" || work.revision !== target.revision) {
51
+ throw new Error("An exact open WorkItem revision in an executable Task is required.");
52
+ }
53
+ }
54
+ }
55
+ /** Inside Run admission's transaction: Task/Leader + Run + association commit together. */
56
+ export function admitTaskJob(store, job, caller, trigger, requestId, now, retryOf) {
57
+ const config = job.configuration;
58
+ const id = job.scope.kind === "global" ? `job-${randomUUID()}` : store.nextDurableJobId(job.scope.taskId);
59
+ const sourceRun = { scope: job.scope, runId: id };
60
+ const target = config.target;
61
+ const taskId = target.kind === "new" ? createTaskAggregate(store, target.title, {
62
+ description: target.description, projectBindings: target.projectBindings,
63
+ ...(target.type === undefined ? {} : { type: target.type })
64
+ }, now, [], { kind: "job-run", jobId: job.id, run: sourceRun }).task.id : target.taskId;
65
+ const delegation = { taskId, goal: config.goal,
66
+ ...(target.kind === "work-item" ? { workItemId: target.workItemId, workItemRevision: target.revision,
67
+ workItemDefinitionDigest: workItemDefinitionDigest(store.getWorkItem(taskId, target.workItemId)) } : {}) };
68
+ const actorId = caller.scope === "user" ? "user" : `${caller.scope}:${caller.role}`;
69
+ const inputDigest = resultDigest(JSON.stringify([job, trigger, retryOf]));
70
+ const locator = `artifacts/jobs/${job.scope.kind === "global" ? "global" : job.scope.taskId}/${id}`;
71
+ const run = validateDurableJob({
72
+ schemaVersion: 1,
73
+ ...(job.scope.kind === "global" ? { scope: "global" }
74
+ : { taskId: job.scope.taskId, owner: { kind: "task" } }),
75
+ id, definitionId: job.id, definitionRevision: job.revision, trigger,
76
+ delegation, artifactsLocator: locator,
77
+ idempotencyKey: createHash("sha256").update(JSON.stringify([actorId, requestId])).digest("hex"),
78
+ operation: { requestId, actorId, inputDigest, authorityRef: inputDigest,
79
+ targetId: taskId, capability: "job.run", implementation: { id: "yui:task-delegation", generation: "1" },
80
+ effect: "confirmed", receiptRefs: [`tasks/${taskId}`], partialResultRefs: [] },
81
+ status: "running", startedAt: now.toISOString(),
82
+ ...(retryOf === undefined ? {} : { retryOf }),
83
+ createdAt: now.toISOString(), updatedAt: now.toISOString()
84
+ });
85
+ saveRun(store, run);
86
+ if (config.goal === "task-created")
87
+ return reconcileTaskDelegation(store, run, now);
88
+ return run;
89
+ }
90
+ function saveRun(store, run) {
91
+ if ("taskId" in run)
92
+ store.saveDurableJob(run.taskId, run);
93
+ else
94
+ store.saveGlobalJobRun(run);
95
+ }
96
+ /** Collection is transactional, repeatable, and never executes/activates/stops a Task. */
97
+ export function reconcileTaskDelegation(store, observed, now) {
98
+ return store.transaction(tx => {
99
+ const run = "taskId" in observed ? tx.getDurableJob(observed.taskId, observed.id) : tx.getGlobalJobRun(observed.id);
100
+ if (!run || !run.delegation || isDurableJobTerminal(run.status))
101
+ return run ?? observed;
102
+ const link = run.delegation;
103
+ const task = tx.getTask(link.taskId);
104
+ if (!task)
105
+ throw new Error("Delegated Task is missing; preserve the exact Run for inspection.");
106
+ if (link.goal !== "work-item" && (task.source?.jobId !== run.definitionId
107
+ || !isDeepStrictEqual(task.source.run, runReference(run)))) {
108
+ throw new Error("Delegated Task creation source does not match the exact Run.");
109
+ }
110
+ let outcome;
111
+ let summary = "";
112
+ let evidence = [];
113
+ if (link.goal === "task-created") {
114
+ outcome = "succeeded";
115
+ summary = `Explicit creation goal met: ${task.id}; no delivery completion is claimed.`;
116
+ const event = tx.listEventsByType(task.id, ["task.created"])[0];
117
+ if (!event)
118
+ throw new Error("Delegated Task creation evidence is missing.");
119
+ evidence = [{ kind: "task-result", owner: { kind: "task", taskId: task.id },
120
+ sourceRun: runReference(run), version: resultDigest(JSON.stringify(event)), locator: `events/${event.id}` }];
121
+ }
122
+ else if (link.goal === "work-item") {
123
+ const work = tx.getWorkItem(task.id, link.workItemId);
124
+ if (!work || workItemDefinitionDigest(work) !== link.workItemDefinitionDigest) {
125
+ outcome = "unknown-needs-attention";
126
+ summary = "Delegated WorkItem requirements changed or are missing; inspect the exact scope.";
127
+ }
128
+ else if (work.status === "retired") {
129
+ outcome = "failed";
130
+ summary = `Delegated WorkItem retired: ${work.id}.`;
131
+ }
132
+ else if (work.status === "accepted") {
133
+ const candidate = work.candidates.find(candidate => candidate.id === work.acceptedCandidateId
134
+ && candidate.workItemRevision >= link.workItemRevision);
135
+ if (!candidate)
136
+ throw new Error("Accepted delegation has no exact Candidate; preserve evidence.");
137
+ outcome = "succeeded";
138
+ summary = work.outcome ?? candidate.summary;
139
+ evidence = [{ kind: "work-item-result", owner: { kind: "task", taskId: task.id },
140
+ sourceRun: runReference(run), version: resultDigest(JSON.stringify(candidate)),
141
+ locator: `work-items/${work.id}/candidates/${candidate.id}` }];
142
+ }
143
+ }
144
+ else {
145
+ // The never-rewritten completion event identifies the exact lifecycle,
146
+ // even if the Task is reopened before this collector runs.
147
+ const event = tx.listEventsByType(task.id, ["task.completed"]).find(event => event.createdAt >= run.createdAt);
148
+ if (event) {
149
+ outcome = "succeeded";
150
+ summary = String(event.payload.summary);
151
+ evidence = [{ kind: "task-result", owner: { kind: "task", taskId: task.id },
152
+ sourceRun: runReference(run), version: resultDigest(JSON.stringify(event)), locator: `events/${event.id}` }];
153
+ }
154
+ else if (task.status === "cancelled" || (task.status === "archived" && task.completedAt === undefined)) {
155
+ outcome = "failed";
156
+ summary = `Delegated Task ended without the requested delivery: ${task.id}.`;
157
+ }
158
+ else if (task.completedAt !== undefined) {
159
+ outcome = "unknown-needs-attention";
160
+ summary = "Task reports completion without immutable completion-event evidence; inspect the target.";
161
+ }
162
+ }
163
+ // Like process collection, already-proven delivery wins over a late cancel.
164
+ // Cancelling an unsettled wait never fabricates target resource quiescence.
165
+ if (!outcome && run.cancelRequestedAt) {
166
+ outcome = "cancelled";
167
+ summary = "This Run's delegation was cancelled; the target Task/Leader/WorkItem was not stopped. Inspect its own resources separately.";
168
+ }
169
+ if (!outcome)
170
+ return run;
171
+ const businessResult = { schemaVersion: 1, sourceRun: runReference(run), outcome,
172
+ summary, evidence };
173
+ const next = validateDurableJob({ ...run, status: outcome, businessResult, wakeupNotified: true,
174
+ terminalAt: now.toISOString(), updatedAt: now.toISOString() });
175
+ saveRun(tx, next);
176
+ if ("taskId" in run)
177
+ enqueueWork(tx, { kind: "role", taskId: run.taskId, roleName: "leader" }, "job-finished", now, [{ type: "task", id: run.taskId }]);
178
+ else if (run.operation.actorId !== "user")
179
+ enqueueWork(tx, { kind: "operator" }, "global-job-finished", now, [{ type: "global-job-run", id: run.id }], { source: "global-job", dedupeKey: `operator-global-job-run:${run.id}` });
180
+ return next;
181
+ });
182
+ }
183
+ /** Bounded projection only; source permissions never open the target's Context. */
184
+ export function taskDelegationView(store, run) {
185
+ if (!run.delegation)
186
+ return null;
187
+ const task = store.getTask(run.delegation.taskId);
188
+ return { ...run.delegation, taskStatus: task?.status ?? null,
189
+ executionGate: task?.executionGate.state ?? null,
190
+ openInputs: store.listInputRequests(run.delegation.taskId).filter(input => input.status === "open").length,
191
+ result: run.businessResult ?? null,
192
+ cancellation: run.cancelRequestedAt ? "requested; target resources not stopped" : null };
193
+ }
@@ -57,7 +57,9 @@ import { AgentHostProviderSessionBusyError, AgentHostProviderTurnFenceError, Fil
57
57
  import { deliverGlobalInputs } from "./globalInputDelivery.js";
58
58
  import { authorizeJobStart } from "./jobControl.js";
59
59
  import { authorizeGlobalJobStart, createJobDefinitionControl } from "./jobDefinitionControl.js";
60
+ import { reconcileTaskDelegation } from "./jobTaskDelegationControl.js";
60
61
  import { createScheduleControl, reconcileSchedules } from "./scheduleControl.js";
62
+ import { reconcileProjectSync } from "../job/projectSync.js";
61
63
  import { createFileArtifactPort, createLinuxProcessPort, DurableJobSupervisor } from "./jobSupervisor.js";
62
64
  import { createProviderRetryHooks } from "./providerRetryDelivery.js";
63
65
  import { createRuntimeResourceActivityTracker } from "./resourceInventory.js";
@@ -488,6 +490,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
488
490
  process: kernel.runner,
489
491
  artifacts: createFileArtifactPort(home),
490
492
  authorizeStart: (job) => "taskId" in job ? authorizeJobStart(store, job) : authorizeGlobalJobStart(store, job),
493
+ reconcileDelegation: (job, now) => { reconcileTaskDelegation(store, job, now); },
491
494
  // rr6/f1: Bounded supervision wake. The supervisor signals the Controller
492
495
  // after spawning a runner (queued→running adoption) and when a runner
493
496
  // exits (terminal harvest), so a quick job converges without waiting for
@@ -575,7 +578,10 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
575
578
  jobDefinitions: createJobDefinitionControl(store, home),
576
579
  schedules: {
577
580
  control: createScheduleControl(store, home),
578
- reconcile: now => reconcileSchedules(store, home, now, options.onError)
581
+ reconcile: now => {
582
+ reconcileProjectSync(store, now, options.onError);
583
+ reconcileSchedules(store, home, now, options.onError);
584
+ }
579
585
  },
580
586
  capabilityDispatcher: createCapabilityDispatcher(kernel.capabilities),
581
587
  ...(continuationReconciler === undefined ? {} : { continuationReconciler }),
@@ -1,11 +1,14 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { requireKnownFields } from "../domain/validation.js";
2
- import { nextScheduleDue, scheduleId, validateSchedule } from "../schedule/schedule.js";
3
- import { admitJobRun, authorizeDefinition, authorizeScope, createJobDefinitionControl } from "./jobDefinitionControl.js";
3
+ import { nextScheduleDue, scheduleId, validateSchedule, validateScheduleTarget } from "../schedule/schedule.js";
4
+ import { admitJobRun, instantiateJobRun, authorizeDefinition, authorizeScope, authorizeRunRead } from "./jobDefinitionControl.js";
4
5
  import { jobAuthorityBinding } from "./jobControl.js";
5
6
  import { createGlobalRoleMessage } from "../message/message.js";
6
7
  import { createTaskEvent } from "../event/taskEvent.js";
7
8
  import { enqueueSupervisorEvent } from "../scheduler/operatorEvent.js";
8
9
  import { redactLaunchText } from "../runtime/launchDiagnostics.js";
10
+ import { requireScheduleService, scheduleServiceBinding } from "../job/scheduleService.js";
11
+ import { updateProjectSync } from "../job/projectSync.js";
9
12
  function binding(store, caller, scope) {
10
13
  return jobAuthorityBinding(store, caller.scope, caller.role, scope.kind === "task" ? scope.taskId : "");
11
14
  }
@@ -19,13 +22,35 @@ function authorizeSchedule(store, schedule, caller, write) {
19
22
  function fence(schedule, now) {
20
23
  return new Date(Math.max(now.getTime(), Date.parse(schedule?.processedThrough ?? "") || 0));
21
24
  }
22
- export function createScheduleControl(store, home) {
25
+ function authorization(store, plan, job, caller, now, requested, previous) {
26
+ if (requested !== undefined && requested !== "session" && requested !== "service")
27
+ throw new Error("Invalid Schedule authorization choice.");
28
+ if (plan.target.kind === "new-job" && plan.target.definitionRevision !== job.revision) {
29
+ throw new Error("Source Job revision changed; select its exact available revision.");
30
+ }
31
+ if (requested === "service") {
32
+ const grantId = randomUUID();
33
+ return { kind: "service", service: "schedule", grantId, revoked: false, jobRevision: job.revision,
34
+ grantedBy: structuredClone(caller), grantedAt: now.toISOString(),
35
+ binding: scheduleServiceBinding(store, plan, job, grantId) };
36
+ }
37
+ if (requested === undefined && previous?.kind === "service") {
38
+ if (previous.revoked || previous.binding !== scheduleServiceBinding(store, plan, job, previous.grantId)) {
39
+ throw new Error("Changed or revoked service authority requires explicit authorization=service.");
40
+ }
41
+ return previous;
42
+ }
43
+ if (caller.scope === "user")
44
+ throw new Error("Public Schedule creation/rebinding requires explicit authorization=service.");
45
+ return { kind: "session", caller: structuredClone(caller), binding: binding(store, caller, plan.scope), jobRevision: job.revision };
46
+ }
47
+ export function createScheduleControl(store, _home) {
23
48
  return (value, now) => {
24
49
  if (value === null || typeof value !== "object" || Array.isArray(value))
25
50
  throw new Error("Invalid Schedule request.");
26
- requireKnownFields(value, ["op", "scope", "caller", "id", "jobId", "rule", "expectedRevision"], "Schedule request");
51
+ requireKnownFields(value, ["op", "scope", "caller", "id", "jobId", "target", "authorization", "rule", "expectedRevision"], "Schedule request");
27
52
  const input = value;
28
- authorizeScope(store, input.scope, input.caller);
53
+ authorizeScope(store, input.scope, input.caller, true);
29
54
  return store.transaction(tx => {
30
55
  if (input.op === "list") {
31
56
  return tx.listSchedules(input.scope).filter(s => {
@@ -45,9 +70,13 @@ export function createScheduleControl(store, home) {
45
70
  const job = tx.getJobDefinition(input.scope, input.jobId);
46
71
  if (job === null)
47
72
  throw new Error("Schedule Job definition is missing.");
73
+ if (job.builtin)
74
+ throw new Error("Builtin Job has one installed Schedule; manage that Schedule instead.");
48
75
  if (previous !== null) {
49
76
  authorizeSchedule(tx, previous, input.caller, false);
50
- if (previous.jobId !== input.jobId || JSON.stringify(previous.rule) !== JSON.stringify(input.rule)) {
77
+ if (previous.jobId !== input.jobId || JSON.stringify(previous.rule) !== JSON.stringify(input.rule)
78
+ || JSON.stringify(previous.target) !== JSON.stringify(input.target ?? { kind: "existing-job" })
79
+ || (input.authorization !== undefined && previous.authorization.kind !== input.authorization)) {
51
80
  throw new Error("Schedule id already exists; use update with its revision.");
52
81
  }
53
82
  return previous;
@@ -56,10 +85,12 @@ export function createScheduleControl(store, home) {
56
85
  const due = nextScheduleDue(input.rule, now);
57
86
  if (due === null)
58
87
  throw new Error("A new once Schedule must be in the future.");
88
+ const target = validateScheduleTarget(input.target ?? { kind: "existing-job" });
59
89
  const schedule = validateSchedule({
60
90
  schemaVersion: 1, id: input.id, scope: structuredClone(input.scope), jobId: input.jobId,
91
+ target,
61
92
  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 },
93
+ authorization: authorization(tx, { id: input.id, scope: input.scope, jobId: input.jobId, target }, job, input.caller, now, input.authorization),
63
94
  createdAt: now.toISOString(), updatedAt: now.toISOString()
64
95
  });
65
96
  tx.saveSchedule(schedule);
@@ -69,20 +100,40 @@ export function createScheduleControl(store, home) {
69
100
  throw new Error("Schedule not found.");
70
101
  authorizeSchedule(tx, previous, input.caller, false);
71
102
  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}:`));
103
+ const runs = input.scope.kind === "task" ? tx.listDurableJobs(input.scope.taskId) : tx.listGlobalJobRuns();
104
+ const ownRuns = runs.filter(run => {
105
+ if (run.trigger.source !== "schedule" || !run.trigger.key.startsWith(`${previous.id}:`))
106
+ return false;
107
+ try {
108
+ authorizeRunRead(tx, run, input.caller);
109
+ return true;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ });
76
115
  const lastSuccess = ownRuns.filter(r => r.status === "succeeded")
77
116
  .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0] ?? null;
78
- return { schedule: previous, lastSuccess };
117
+ return { schedule: previous, lastSuccess, runs: ownRuns };
79
118
  }
80
119
  if (previous.revision !== input.expectedRevision)
81
120
  throw new Error("Schedule revision changed; read it before modifying.");
121
+ if (previous.builtin) {
122
+ if (input.jobId !== undefined || input.target !== undefined || input.authorization !== undefined) {
123
+ throw new Error("Builtin Schedule target and service registration cannot be overridden.");
124
+ }
125
+ return updateProjectSync(tx, previous, input.op, input.rule, now);
126
+ }
82
127
  let next;
83
128
  if (input.op === "disable") {
84
129
  next = { ...previous, state: "disabled", reason: "Explicitly disabled; no future admission. Existing Runs are unchanged." };
85
130
  }
131
+ else if (input.op === "revoke") {
132
+ if (previous.authorization.kind !== "service")
133
+ throw new Error("Only a service grant can be revoked; disable Session plans.");
134
+ next = { ...previous, state: "disabled", authorization: { ...previous.authorization, revoked: true },
135
+ reason: "Service authorization explicitly revoked; future admission and not-yet-started service Runs are denied." };
136
+ }
86
137
  else if (input.op === "enable" || input.op === "update") {
87
138
  if (input.op === "enable" && ["completed", "retired"].includes(previous.state)) {
88
139
  throw new Error("Completed/retired Schedule requires an explicit future rule update.");
@@ -90,7 +141,10 @@ export function createScheduleControl(store, home) {
90
141
  const jobId = input.op === "update" ? input.jobId ?? previous.jobId : previous.jobId;
91
142
  scheduleId(jobId);
92
143
  const rule = input.op === "update" ? input.rule : previous.rule;
144
+ const target = validateScheduleTarget(input.op === "update" ? input.target ?? previous.target : previous.target);
93
145
  const job = authorizeSchedule(tx, { ...previous, jobId }, input.caller, true);
146
+ if (job.builtin)
147
+ throw new Error("Builtin Job has one installed Schedule.");
94
148
  const after = fence(previous, now);
95
149
  // Once overlap/disabled intent keeps its original due instant on enable.
96
150
  const nextDue = input.op === "enable" && rule.kind === "once"
@@ -98,11 +152,11 @@ export function createScheduleControl(store, home) {
98
152
  if (nextDue === null)
99
153
  throw new Error("Rule must have a future occurrence.");
100
154
  next = {
101
- ...previous, jobId, rule: structuredClone(rule), nextDue,
155
+ ...previous, jobId, target, rule: structuredClone(rule), nextDue,
102
156
  state: input.op === "enable" || previous.state === "enabled" ? "enabled" : "disabled",
103
157
  generation: previous.generation + (input.op === "update" ? 1 : 0),
104
158
  processedThrough: after.toISOString(),
105
- authorization: { caller: structuredClone(input.caller), binding: binding(tx, input.caller, input.scope), jobRevision: job.revision }
159
+ authorization: authorization(tx, { ...previous, jobId, target }, job, input.caller, now, input.authorization, previous.authorization)
106
160
  };
107
161
  const { reason: _reason, ...withoutReason } = next;
108
162
  next = withoutReason;
@@ -133,18 +187,27 @@ export function reconcileSchedules(store, home, now, onError = () => { }, limit
133
187
  return;
134
188
  }
135
189
  }
136
- const job = authorizeSchedule(tx, schedule, schedule.authorization.caller, true);
190
+ const auth = schedule.authorization;
191
+ const service = auth.kind === "service" ? { scheduleId: schedule.id, grantId: auth.grantId } : undefined;
192
+ if (service)
193
+ requireScheduleService(tx, service);
194
+ const caller = auth.kind === "session" ? auth.caller : { scope: "user" };
195
+ const job = authorizeSchedule(tx, schedule, caller, true);
137
196
  if (job.revision !== schedule.authorization.jobRevision
138
- || binding(tx, schedule.authorization.caller, schedule.scope) !== schedule.authorization.binding) {
197
+ || (auth.kind === "session" && binding(tx, caller, schedule.scope) !== auth.binding)) {
139
198
  throw new Error("Schedule authority or Job revision changed; inspect and explicitly enable.");
140
199
  }
141
- const result = admitJobRun(tx, home, schedule.scope, schedule.jobId, schedule.authorization.caller, { source: "schedule", key: `${schedule.id}:${schedule.generation}:${dueAt}` }, now);
200
+ const trigger = { source: "schedule", key: `${schedule.id}:${schedule.generation}:${dueAt}` };
201
+ const result = schedule.target.kind === "existing-job"
202
+ ? admitJobRun(tx, home, schedule.scope, schedule.jobId, caller, trigger, now, undefined, service)
203
+ : instantiateJobRun(tx, home, schedule.scope, schedule.jobId, schedule.target.definitionRevision, caller, trigger, now, service);
142
204
  const overlap = result.reason === "overlap";
143
205
  const nextDue = overlap && schedule.rule.kind === "once" ? dueAt : nextScheduleDue(schedule.rule, after);
144
206
  tx.saveSchedule({
145
207
  ...schedule, nextDue, state: nextDue === null ? "completed" : "enabled",
146
208
  processedThrough: after.toISOString(), revision: schedule.revision + 1,
147
- lastAttempt: { at: now.toISOString(), dueAt, outcome: overlap ? "overlap" : "admitted", runId: result.run.id },
209
+ lastAttempt: { at: now.toISOString(), dueAt, generation: schedule.generation,
210
+ outcome: overlap ? "overlap" : "admitted", jobId: result.run.definitionId, runId: result.run.id },
148
211
  updatedAt: now.toISOString()
149
212
  });
150
213
  });
@@ -157,14 +220,14 @@ export function reconcileSchedules(store, home, now, onError = () => { }, limit
157
220
  if (current?.state !== "enabled" || current.revision !== candidate.revision)
158
221
  return;
159
222
  tx.saveSchedule({ ...current, state: "needs-attention", reason, revision: current.revision + 1,
160
- lastAttempt: { at: now.toISOString(), dueAt: current.nextDue, outcome: "blocked", reason },
223
+ lastAttempt: { at: now.toISOString(), dueAt: current.nextDue, generation: current.generation, outcome: "blocked", reason },
161
224
  updatedAt: now.toISOString() });
162
225
  if (current.scope.kind === "task") {
163
226
  const event = createTaskEvent(tx.nextEventId(current.scope.taskId), current.scope.taskId, "schedule.needs-attention", { scheduleId: current.id, reason }, now);
164
227
  tx.saveEvent(current.scope.taskId, event);
165
228
  enqueueSupervisorEvent(tx, event, "leader", "schedule-needs-attention", now);
166
229
  }
167
- else {
230
+ else if (tx.getGlobalRole("operator") !== null) {
168
231
  tx.saveGlobalRoleMessage(createGlobalRoleMessage(tx.nextGlobalRoleMessageId(), "operator", `Yui-Summary: Schedule ${current.id} needs attention; no Run was admitted.\n`
169
232
  + `Reason: ${reason}\nRead: yui schedule show --global --schedule ${current.id}\n`
170
233
  + "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}` } }));
@@ -460,6 +460,8 @@ export class GitIntegrationService {
460
460
  */
461
461
  async #resumeCheckJob(attempt, workspace, path, repositoryPath, gate) {
462
462
  const job = await this.jobPort.getJob(attempt.taskId, attempt.jobId);
463
+ if (job.delegation)
464
+ throw new Error("Task delegation is not an Integration check Job.");
463
465
  assertCheckJobIdentity(attempt, job, path);
464
466
  await assertIntegrationCandidate(path, job.head, workspace.branch);
465
467
  await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.beforeCommit, this.#targetCheckout(attempt));