@zq-silk/yui 2.11.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/artifacts/artifactCapability.js +14 -8
  2. package/dist/artifacts/artifactCommitLock.js +7 -5
  3. package/dist/artifacts/jobArtifact.js +93 -0
  4. package/dist/artifacts/jobArtifactTransport.js +13 -0
  5. package/dist/artifacts/taskArtifactRepository.js +58 -10
  6. package/dist/cli/commandCatalog.js +45 -14
  7. package/dist/cli/commandDiscovery.js +1 -1
  8. package/dist/commands/durableJobCommands.js +31 -6
  9. package/dist/commands/globalRoleCommands.js +5 -0
  10. package/dist/commands/scheduleCommands.js +14 -3
  11. package/dist/commands/taskCommands.js +5 -2
  12. package/dist/context/taskCatalog.js +6 -1
  13. package/dist/controlPlaneCli.js +7 -2
  14. package/dist/controller/agentJobControl.js +391 -0
  15. package/dist/controller/controller.js +24 -3
  16. package/dist/controller/fileSchedulerStoreAdapter.js +12 -1
  17. package/dist/controller/globalInputDelivery.js +8 -1
  18. package/dist/controller/jobArtifactControl.js +111 -0
  19. package/dist/controller/jobControl.js +4 -0
  20. package/dist/controller/jobDefinitionControl.js +191 -19
  21. package/dist/controller/jobSupervisor.js +83 -3
  22. package/dist/controller/jobTaskDelegationControl.js +193 -0
  23. package/dist/controller/runtime.js +7 -1
  24. package/dist/controller/scheduleControl.js +83 -20
  25. package/dist/integration/gitIntegrationService.js +2 -0
  26. package/dist/job/agentJob.js +131 -0
  27. package/dist/job/agentJobObservation.js +61 -0
  28. package/dist/job/agentRoleScope.js +33 -0
  29. package/dist/job/durableJob.js +63 -28
  30. package/dist/job/jobAssignmentScope.js +1 -0
  31. package/dist/job/jobBusinessResult.js +72 -0
  32. package/dist/job/jobCleanupInspection.js +4 -0
  33. package/dist/job/jobDefinition.js +26 -2
  34. package/dist/job/jobRunner.js +4 -3
  35. package/dist/job/projectRefreshRunner.js +54 -18
  36. package/dist/job/projectSync.js +162 -0
  37. package/dist/job/scheduleService.js +55 -0
  38. package/dist/job/taskDelegation.js +48 -0
  39. package/dist/message/messageContinuation.js +1 -1
  40. package/dist/resources/projectResourceService.js +1 -1
  41. package/dist/resources/resourceGc.js +1 -1
  42. package/dist/role/role.js +12 -0
  43. package/dist/schedule/schedule.js +65 -15
  44. package/dist/scheduler/wakeReason.js +4 -0
  45. package/dist/storage/agentJobSchema.js +10 -0
  46. package/dist/storage/recordValidation.js +2 -0
  47. package/dist/storage/sqliteSchema.js +77 -6
  48. package/dist/storage/sqliteStore.js +53 -0
  49. package/dist/storage/storageVersions.js +1 -1
  50. package/dist/storage/storeRpc.js +3 -0
  51. package/dist/storage/taskCatalog.js +10 -2
  52. package/dist/storage/upgrade/jobBusinessResultMigration.js +20 -0
  53. package/dist/storage/upgrade/scheduleDispatchMigration.js +15 -0
  54. package/dist/task/archivePreflight.js +15 -0
  55. package/dist/task/completionReadiness.js +7 -0
  56. package/dist/task/task.js +17 -1
  57. package/dist/verification/verificationGateService.js +4 -0
  58. package/dist/web/assets/client/app/catalog.js +16 -0
  59. package/dist/web/assets/client/i18n/en.js +29 -2
  60. package/dist/web/assets/client/i18n/zh-CN.js +29 -2
  61. package/dist/web/assets/client/layout/manageShell.js +9 -0
  62. package/dist/web/assets/client/layout/router.js +1 -0
  63. package/dist/web/assets/client/views/jobs.js +166 -37
  64. package/dist/web/assets/client/views/task/files.js +5 -4
  65. package/dist/web/assets/client/views/task/page.js +5 -1
  66. package/dist/web/taskPreviews.js +2 -2
  67. package/dist/web/webJobs.js +36 -11
  68. package/dist/web/webMaterials.js +21 -9
  69. package/dist/web/webServer.js +6 -5
  70. package/dist/web/webTaskSurface.js +1 -0
  71. package/docs/job-runs.md +392 -17
  72. package/docs/release-workflow.md +42 -0
  73. package/docs/release-workflow.zh-CN.md +31 -0
  74. package/docs/schedules.md +69 -15
  75. package/docs/storage-baseline.md +38 -1
  76. package/docs/storage-baseline.zh-CN.md +27 -1
  77. package/docs/testing/verification-levels.md +2 -2
  78. package/docs/testing/verification-levels.zh-CN.md +2 -2
  79. package/package.json +1 -1
@@ -0,0 +1,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));
@@ -0,0 +1,131 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { requireIdentity, requireKnownFields, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
4
+ import { jobScopeKey } from "./jobDefinition.js";
5
+ import { validateBusinessResult } from "./jobBusinessResult.js";
6
+ import { validateRunArtifacts, artifactsAppendOnly } from "../artifacts/jobArtifact.js";
7
+ export function validateAgentJobConfiguration(config) {
8
+ requireKnownFields(config, ["kind", "target", "input", "concurrency"], "Agent Job configuration");
9
+ if (config.kind !== "agent")
10
+ throw new Error("Invalid Agent Job executor.");
11
+ requireText(config.input, "Agent Job input");
12
+ if (Buffer.byteLength(config.input) > 65536)
13
+ throw new Error("Agent Job input exceeds 64 KiB.");
14
+ if (config.concurrency !== undefined && !["serial", "isolated"].includes(config.concurrency)) {
15
+ throw new Error("Invalid Agent Job concurrency.");
16
+ }
17
+ const target = config.target;
18
+ if (target.kind === "operator")
19
+ requireKnownFields(target, ["kind"], "Operator target");
20
+ else if (target.kind === "task") {
21
+ requireKnownFields(target, ["kind", "taskId", "roleName", "ownerRunId"], "Task Agent target");
22
+ jobScopeKey({ kind: "task", taskId: target.taskId });
23
+ requireIdentity(target.roleName, "Task Role");
24
+ if (target.roleName !== "leader" && !target.ownerRunId)
25
+ throw new Error("Task Agent target requires its exact Assignment ownerRunId.");
26
+ if (target.ownerRunId !== undefined)
27
+ requireIdentity(target.ownerRunId, "Assignment Run");
28
+ }
29
+ else if (target.kind === "job") {
30
+ requireKnownFields(target, ["kind", "agentId"], "Job Agent target");
31
+ requireIdentity(target.agentId, "Job Agent");
32
+ }
33
+ else
34
+ throw new Error("Unknown Agent Job target.");
35
+ if (config.concurrency === "isolated" && target.kind !== "job") {
36
+ throw new Error("Isolated concurrency requires a Job-owned Agent; shared roles are serial.");
37
+ }
38
+ }
39
+ export function jobAgentRoleName(scope, jobId, runId) {
40
+ return `job-${createHash("sha256").update(JSON.stringify([scope, jobId, runId ?? null])).digest("hex").slice(0, 32)}`;
41
+ }
42
+ export function validateAgentJobRun(run) {
43
+ requireKnownFields(run, ["schemaVersion", "executor", "id", "scope", "definitionId", "definitionRevision",
44
+ "configuration", "trigger", "retryOf", "status", "attempts", "businessResult", "cancelRequestedAt",
45
+ "createdAt", "updatedAt", "terminalAt", "artifacts"], "Agent JobRun");
46
+ if (run.schemaVersion !== 1 || run.executor !== "agent")
47
+ throw new Error("Invalid Agent JobRun.");
48
+ requireIdentity(run.id, "Agent JobRun id");
49
+ requireIdentity(run.definitionId, "Agent Job id");
50
+ requirePositiveInteger(run.definitionRevision, "Agent Job revision");
51
+ requireKnownFields(run.scope, run.scope.kind === "task" ? ["kind", "taskId"] : ["kind"], "Agent Job scope");
52
+ if (!["task", "global"].includes(run.scope.kind))
53
+ throw new Error("Invalid Agent Job scope.");
54
+ jobScopeKey(run.scope);
55
+ validateAgentJobConfiguration(run.configuration);
56
+ requireKnownFields(run.trigger, ["source", "key"], "Agent Job trigger");
57
+ if (!["manual", "retry", "schedule"].includes(run.trigger.source))
58
+ throw new Error("Invalid Agent Job trigger.");
59
+ requireText(run.trigger.key, "Agent Job trigger key");
60
+ if (!["queued", "running", "succeeded", "failed", "cancelled", "timed-out", "unknown-needs-attention"].includes(run.status)) {
61
+ throw new Error("Invalid Agent JobRun status.");
62
+ }
63
+ requireTimestamp(run.createdAt, "Agent JobRun createdAt");
64
+ requireTimestamp(run.updatedAt, "Agent JobRun updatedAt");
65
+ for (const value of [run.cancelRequestedAt, run.terminalAt])
66
+ if (value !== undefined)
67
+ requireTimestamp(value, "Agent JobRun timestamp");
68
+ if (!Array.isArray(run.attempts) || !run.attempts.length || run.attempts.length > 128)
69
+ throw new Error("Invalid Agent Job attempts.");
70
+ for (const attempt of run.attempts) {
71
+ requireKnownFields(attempt, ["messageId", "recipient", "createdAt", "execution", "terminal"], "Agent Job attempt");
72
+ requireIdentity(attempt.messageId, "Agent Job message");
73
+ requireTimestamp(attempt.createdAt, "Agent Job attempt timestamp");
74
+ const recipient = attempt.recipient;
75
+ requireKnownFields(recipient, recipient.scope === "task"
76
+ ? ["scope", "taskId", "roleName", "ownerRunId"] : ["scope", "roleName"], "Agent Job recipient");
77
+ if (!["task", "global"].includes(recipient.scope))
78
+ throw new Error("Invalid Agent Job recipient.");
79
+ requireIdentity(recipient.roleName, "Agent Job recipient Role");
80
+ if (recipient.scope === "task")
81
+ jobScopeKey({ kind: "task", taskId: recipient.taskId });
82
+ if (attempt.execution) {
83
+ requireKnownFields(attempt.execution, ["nativeSessionId", "attemptId", "nativeTurnId"], "Agent Job execution");
84
+ for (const value of Object.values(attempt.execution)) {
85
+ if (typeof value !== "string")
86
+ throw new Error("Invalid Agent execution identity.");
87
+ requireText(value, "Agent Job execution identity");
88
+ }
89
+ }
90
+ if (attempt.terminal) {
91
+ requireKnownFields(attempt.terminal, ["status", "observedAt"], "Agent Turn terminal");
92
+ if (!attempt.execution || !["completed", "failed", "cancelled"].includes(attempt.terminal.status))
93
+ throw new Error("Invalid Agent terminal evidence.");
94
+ requireTimestamp(attempt.terminal.observedAt, "Agent terminal observation");
95
+ }
96
+ }
97
+ if (run.businessResult) {
98
+ validateBusinessResult(run.businessResult, { scope: run.scope, runId: run.id });
99
+ if (run.status !== run.businessResult.outcome || !run.terminalAt)
100
+ throw new Error("Agent result and terminal disagree.");
101
+ }
102
+ else if (["succeeded", "failed", "cancelled", "timed-out"].includes(run.status)) {
103
+ throw new Error("Agent Job terminal requires an explicit business result.");
104
+ }
105
+ validateRunArtifacts(run);
106
+ return run;
107
+ }
108
+ export function assertAgentJobRunTransition(previous, next) {
109
+ const mutable = ["status", "attempts", "businessResult", "cancelRequestedAt", "updatedAt", "terminalAt", "artifacts"];
110
+ for (const key of new Set([...Object.keys(previous), ...Object.keys(next)])) {
111
+ if (!mutable.includes(key) && !isDeepStrictEqual(previous[key], next[key]))
112
+ throw new Error("Agent Job snapshot is immutable.");
113
+ }
114
+ if (previous.businessResult && (!isDeepStrictEqual(previous.businessResult, next.businessResult)
115
+ || previous.status !== next.status || previous.terminalAt !== next.terminalAt
116
+ || previous.attempts.length !== next.attempts.length))
117
+ throw new Error("Agent Job result is immutable.");
118
+ if (!artifactsAppendOnly(previous, next))
119
+ throw new Error("Agent Job artifacts are append-only.");
120
+ if (previous.cancelRequestedAt !== undefined && previous.cancelRequestedAt !== next.cancelRequestedAt)
121
+ throw new Error("Cancellation is immutable.");
122
+ if (Date.parse(next.updatedAt) < Date.parse(previous.updatedAt) || next.attempts.length < previous.attempts.length)
123
+ throw new Error("Agent Job history cannot move backwards.");
124
+ previous.attempts.forEach((attempt, index) => {
125
+ const updated = next.attempts[index];
126
+ if (!isDeepStrictEqual({ ...attempt, execution: undefined, terminal: undefined }, { ...updated, execution: undefined, terminal: undefined })
127
+ || attempt.execution && !isDeepStrictEqual(attempt.execution, updated.execution)
128
+ || attempt.terminal && !isDeepStrictEqual(attempt.terminal, updated.terminal))
129
+ throw new Error("Agent attempt evidence is immutable.");
130
+ });
131
+ }
@@ -0,0 +1,61 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { currentProviderConversation } from "../runtime/providerRuntimeIdentity.js";
3
+ export function jobAttemptOwnsInput(store, attempt, binding) {
4
+ const { recipient, messageId } = attempt;
5
+ const inputs = [binding.run?.input];
6
+ if (binding.run && binding.retry?.currentAttemptId === binding.run.attemptId)
7
+ inputs.push(binding.retry.input);
8
+ return inputs.some(input => {
9
+ if (recipient.scope === "global")
10
+ return input?.kind === "message"
11
+ && input.roleName === recipient.roleName && input.messageId === messageId;
12
+ if (input?.kind === "wake" && input.taskId === recipient.taskId)
13
+ return store.getTaskWake(recipient.taskId, input.wakeId)
14
+ ?.refs?.some(ref => ref.type === "message" && ref.id === messageId) === true;
15
+ return input?.kind === "run" && input.taskId === recipient.taskId
16
+ && store.listMessages(recipient.taskId).find(message => message.id === messageId)?.continuation?.runId === input.runId;
17
+ });
18
+ }
19
+ /** Collect existing Provider evidence at its owning transaction. No submission,
20
+ * retry, inferred business result, or transcript parsing occurs here. */
21
+ export function recordJobAgentObservation(store, recipient, binding, now) {
22
+ if (!binding?.run)
23
+ return;
24
+ for (const run of store.listAgentJobRunsForRole(recipient)) {
25
+ const last = run.attempts.at(-1);
26
+ if (!jobAttemptOwnsInput(store, last, binding))
27
+ continue;
28
+ const turn = binding.run;
29
+ const attempts = [...run.attempts];
30
+ let attempt = last;
31
+ if (turn.nativeTurnId && ["accepted", "completed", "failed", "cancelled"].includes(turn.status)) {
32
+ const execution = { nativeSessionId: currentProviderConversation(binding).conversationId,
33
+ attemptId: turn.attemptId, nativeTurnId: turn.nativeTurnId };
34
+ if (last.execution && !isDeepStrictEqual(last.execution, execution)) {
35
+ const retry = binding.retry;
36
+ // Only the existing bounded retry owner may prove a successor of the
37
+ // same original input. Session changes/other native Turns never do.
38
+ if (run.businessResult || retry?.currentAttemptId !== execution.attemptId
39
+ || retry.failureAttemptId !== last.execution.attemptId || retry.failedNativeTurnId !== last.execution.nativeTurnId
40
+ || retry.nativeSessionId !== last.execution.nativeSessionId)
41
+ continue;
42
+ attempts[attempts.length - 1] = { ...last, terminal: last.terminal ?? { status: "failed", observedAt: retry.updatedAt } };
43
+ attempt = { messageId: last.messageId, recipient: last.recipient, createdAt: turn.submittedAt, execution };
44
+ attempts.push(attempt);
45
+ }
46
+ else
47
+ attempt = { ...last, execution };
48
+ if (["completed", "failed", "cancelled"].includes(turn.status))
49
+ attempt = { ...attempt,
50
+ terminal: attempt.terminal ?? { status: turn.status, observedAt: now.toISOString() } };
51
+ attempts[attempts.length - 1] = attempt;
52
+ }
53
+ const status = run.businessResult ? run.status : turn.status === "delivery-unknown" ? "unknown-needs-attention"
54
+ : attempt.execution ? "running" : run.status;
55
+ if (status === run.status && isDeepStrictEqual(attempts, run.attempts))
56
+ continue;
57
+ const next = { ...run, status, attempts,
58
+ updatedAt: new Date(Math.max(now.getTime(), Date.parse(run.updatedAt))).toISOString() };
59
+ store.saveAgentJobRun(next);
60
+ }
61
+ }