@zq-silk/yui 0.6.0 → 0.6.2

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 (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -0,0 +1,613 @@
1
+ /**
2
+ * Server-side DurableJob control: the only path through which a DurableJob
3
+ * record is created or cancel-requested. The Controller socket layer calls
4
+ * this port for `job.*` requests; nothing else writes queued jobs.
5
+ *
6
+ * Creation is idempotent per (owner, project, head, steps, workspace, env):
7
+ * a repeated `job.start` with the same inputs returns the existing job with
8
+ * `created: false`, so a Leader retry can never spawn duplicate runners.
9
+ */
10
+ import { execSync } from "node:child_process";
11
+ import { createHash } from "node:crypto";
12
+ import { resolve, sep } from "node:path";
13
+ import { acknowledgeUnknownDurableJob, createDurableJob, durableJobIdempotencyKey, isDurableJobTerminal, requestDurableJobCancel, retryDurableJobIdempotencyKey } from "../job/durableJob.js";
14
+ import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
15
+ export function createDurableJobControl(store) {
16
+ return {
17
+ startJob(params, now) {
18
+ // rr4/finding-3: The entire create path — validation, idempotency
19
+ // lookup, id allocation, and save — must be one transaction. A gap
20
+ // between the idempotency check and the save lets a concurrent
21
+ // startJob with the same key create a duplicate job.
22
+ return store.transaction((tx) => {
23
+ validateStartParams(tx, params);
24
+ const baseKey = durableJobIdempotencyKey({
25
+ owner: params.owner,
26
+ projectId: params.projectId,
27
+ head: params.head,
28
+ steps: params.steps,
29
+ workspace: params.workspace,
30
+ env: params.env
31
+ });
32
+ const key = params.retryOf === undefined
33
+ ? baseKey
34
+ : retryDurableJobIdempotencyKey(baseKey, params.retryOf);
35
+ const existing = tx.findDurableJobByIdempotencyKey(params.taskId, key);
36
+ if (existing !== null)
37
+ return { job: existing, created: false };
38
+ const id = tx.nextDurableJobId(params.taskId);
39
+ const job = createDurableJob({
40
+ id,
41
+ taskId: params.taskId,
42
+ owner: params.owner,
43
+ projectId: params.projectId,
44
+ head: params.head,
45
+ workspace: params.workspace,
46
+ env: params.env,
47
+ steps: params.steps,
48
+ artifactsLocator: `artifacts/jobs/${params.taskId}/${id}`,
49
+ ...(params.retryOf === undefined ? {} : { retryOf: params.retryOf })
50
+ }, now);
51
+ tx.saveDurableJob(params.taskId, job);
52
+ return { job, created: true };
53
+ });
54
+ },
55
+ getJob(taskId, jobId) {
56
+ return store.getDurableJob(taskId, jobId);
57
+ },
58
+ cancelJob(taskId, jobId, now, caller) {
59
+ return store.transaction((tx) => {
60
+ const current = tx.getDurableJob(taskId, jobId);
61
+ if (current === null)
62
+ return null;
63
+ // rr8: Bind the cancel request to the caller's managed identity. The
64
+ // same rules as job.start apply, checked against the job's owner.
65
+ assertCallerAuthorized(tx, caller, current.owner, taskId);
66
+ const next = requestDurableJobCancel(current, now);
67
+ if (next !== current)
68
+ tx.saveDurableJob(taskId, next);
69
+ return next;
70
+ });
71
+ },
72
+ acknowledgeJob(taskId, jobId, now, caller) {
73
+ return store.transaction((tx) => {
74
+ const current = tx.getDurableJob(taskId, jobId);
75
+ if (current === null)
76
+ return null;
77
+ // Acknowledge is a Leader-only recovery decision. Reuse the shared
78
+ // caller-key, active-Run, receipt, and Session checks, but explicitly
79
+ // require the Leader role; start/cancel's owner binding intentionally
80
+ // permits a Worker to operate its own Work Item.
81
+ assertCallerAuthorized(tx, caller, current.owner, taskId, { leaderOnly: true });
82
+ const next = acknowledgeUnknownDurableJob(current, now);
83
+ if (next !== current)
84
+ tx.saveDurableJob(taskId, next);
85
+ return next;
86
+ });
87
+ }
88
+ };
89
+ }
90
+ /**
91
+ * Persisted-boundary validation for `job.start`. The owner must resolve to a
92
+ * live Task record, the Task must be active, the workspace must be the exact
93
+ * managed workspace for that owner with write access to the Project, and the
94
+ * stable Project checkout is always rejected.
95
+ *
96
+ * f2: Previously this only checked that some records existed and that the
97
+ * workspace was not the stable checkout. Now it binds the job to the exact
98
+ * managed workspace, verifies write access, and requires an active Task.
99
+ */
100
+ function validateStartParams(store, params) {
101
+ // The Task must be active — a terminal Task cannot run jobs.
102
+ const task = store.getTask(params.taskId);
103
+ if (task === null) {
104
+ throw jobDomainError(`Task not found: ${params.taskId}.`);
105
+ }
106
+ if (task.status !== "active") {
107
+ throw jobDomainError(`DurableJob requires an active Task; ${params.taskId} is ${task.status}.`);
108
+ }
109
+ // The owner record must exist and must not be terminal. A terminal owner
110
+ // cannot run new jobs — its workspace is eligible for cleanup.
111
+ if (params.owner.kind === "work-item") {
112
+ const workItem = store.getWorkItem(params.taskId, params.owner.workItemId);
113
+ if (workItem === null) {
114
+ throw jobDomainError(`Work Item not found: ${params.taskId}/${params.owner.workItemId}.`);
115
+ }
116
+ if (isTerminalWorkItemStatus(workItem.status)) {
117
+ throw jobDomainError(`DurableJob owner Work Item is terminal: `
118
+ + `${params.taskId}/${params.owner.workItemId} is ${workItem.status}.`);
119
+ }
120
+ }
121
+ else if (params.owner.kind === "integration-attempt") {
122
+ const attempt = store.getIntegrationAttempt(params.taskId, params.owner.integrationAttemptId);
123
+ if (attempt === null) {
124
+ throw jobDomainError(`Integration Attempt not found: ${params.taskId}/${params.owner.integrationAttemptId}.`);
125
+ }
126
+ if (attempt.status === "committed" || attempt.status === "failed") {
127
+ throw jobDomainError(`DurableJob owner Integration Attempt is terminal: `
128
+ + `${params.taskId}/${params.owner.integrationAttemptId} is ${attempt.status}.`);
129
+ }
130
+ }
131
+ const project = store.getProject(params.projectId);
132
+ if (project === null) {
133
+ throw jobDomainError(`Project not found: ${params.projectId}.`);
134
+ }
135
+ // f2: The workspace must be the exact managed workspace for this owner.
136
+ const managedWorkspace = resolveManagedWorkspace(store, params);
137
+ if (managedWorkspace === null) {
138
+ throw jobDomainError(`DurableJob workspace is not a registered managed workspace for `
139
+ + `${params.owner.kind} ${params.taskId}: ${params.workspace}.`);
140
+ }
141
+ // The workspace path must match the managed workspace root.
142
+ const workspace = resolve(params.workspace);
143
+ if (workspace !== resolve(managedWorkspace.root)) {
144
+ throw jobDomainError(`DurableJob workspace must be the managed workspace root `
145
+ + `${managedWorkspace.root}; got ${params.workspace}.`);
146
+ }
147
+ // The Project must have write access in this workspace.
148
+ const entry = managedWorkspace.entries.find((e) => e.projectId === params.projectId);
149
+ if (entry === undefined) {
150
+ throw jobDomainError(`Project ${params.projectId} is not bound to workspace `
151
+ + `${managedWorkspace.root}.`);
152
+ }
153
+ if (entry.access !== "write") {
154
+ throw jobDomainError(`Project ${params.projectId} has read-only access in workspace `
155
+ + `${managedWorkspace.root}; DurableJob requires write access.`);
156
+ }
157
+ // The stable Project checkout is always rejected (belt and suspenders —
158
+ // a managed workspace should never be the checkout, but verify).
159
+ const checkout = resolve(project.path);
160
+ if (workspace === checkout || workspace.startsWith(`${checkout}${sep}`)) {
161
+ throw jobDomainError(`DurableJob workspace must be a managed workspace; the stable Project checkout is read-only: ${project.path}.`);
162
+ }
163
+ // rr4/finding-2: The declared head must match the repository's physical
164
+ // HEAD. A job that runs against a drifted workspace (checked out at a
165
+ // different commit than declared) produces evidence for the wrong code.
166
+ // This binds the job to the exact Git state of the managed workspace.
167
+ const repoHead = readGitHead(entry.path);
168
+ if (repoHead === null) {
169
+ throw jobDomainError(`DurableJob workspace project path is not a git repository: ${entry.path}.`);
170
+ }
171
+ if (repoHead !== params.head.toLowerCase()) {
172
+ throw jobDomainError(`DurableJob head ${params.head} does not match the workspace HEAD `
173
+ + `${repoHead} at ${entry.path}.`);
174
+ }
175
+ // f2: A retry must reference an existing terminal job in the same Task.
176
+ if (params.retryOf !== undefined) {
177
+ const original = store.getDurableJob(params.taskId, params.retryOf);
178
+ if (original === null) {
179
+ throw jobDomainError(`Retry original job not found: ${params.taskId}/${params.retryOf}.`);
180
+ }
181
+ if (!isDurableJobTerminal(original.status)) {
182
+ throw jobDomainError(`Retry original job must be terminal: ${params.retryOf} is ${original.status}.`);
183
+ }
184
+ }
185
+ // rr8: Bind the declared owner to the caller's managed identity. A
186
+ // Reviewer can never start jobs; a Worker can only start jobs owned by
187
+ // its own Work Item; a Leader or a plain user retains full access.
188
+ assertCallerAuthorized(store, params.caller, params.owner, params.taskId);
189
+ }
190
+ /**
191
+ * rr8/rr12: Bind the declared job owner to the caller's managed identity. The
192
+ * Controller validates the declared owner/workspace and verifies the caller
193
+ * against durable Run/Session state — a self-reported role or scope is never
194
+ * authority on its own. The rules:
195
+ *
196
+ * - `user` (non-managed): full access, but only when the request carries a
197
+ * `leaderAssertion` the Controller verifies against the active in-flight
198
+ * Leader Run. A managed Session that sheds its identity and declares
199
+ * `scope: "user"` without that proof is rejected.
200
+ * - `task` + mismatched taskId: rejected.
201
+ * - `task` + missing `runId`: rejected (a managed caller must bind to a Run).
202
+ * - `task` + Run not found / not active / `roleName !== role`: rejected — the
203
+ * claimed Role must be the real Role of an active Run.
204
+ * - `task` + Reviewer: always rejected.
205
+ * - `task` + Leader: must carry `receiptId` and pass the same
206
+ * active-Run + in-flight-receipt + Session check as `job.acknowledge`, so a
207
+ * borrowed or stale Leader Run cannot authorize the request.
208
+ * - `task` + any other Role (Worker etc.): the owner must be `work-item` and
209
+ * the caller's Run must be for that same Work Item.
210
+ */
211
+ /**
212
+ * rr13: `job.start`/`job.cancel` caller authorization at the Controller
213
+ * boundary. Every identity claim the Controller can verify from durable state
214
+ * (role, runId, receiptId, leaderAssertion) is replayable by any client in the
215
+ * same Home that reads state.json. The channel itself is therefore not
216
+ * authenticated by those claims alone. A non-replayable per-Session caller key
217
+ * closes the gap:
218
+ *
219
+ * - `user` scope: **rejected outright** for start/cancel. A bare shell or a
220
+ * managed Session that sheds its identity has no channel binding. The human
221
+ * operator acts through the Leader Session, which carries a caller key.
222
+ * - `task` scope: the caller must present `callerKey` — the
223
+ * `YUI_JOB_CALLER_KEY` injected at its native Session launch. The Controller
224
+ * hashes it (SHA-256) and compares against the durable `jobCallerKeyHashes`
225
+ * map for the caller's Role + Agent. No legacy fallback: an absent hash or a
226
+ * mismatched key is UNAUTHORIZED.
227
+ *
228
+ * The existing Run/role/WorkItem binding checks (rr8/rr12) run after the key
229
+ * check, so a forged identity that also lacks the key is rejected at the
230
+ * channel boundary before any durable-state lookup.
231
+ */
232
+ function assertCallerAuthorized(store, caller, owner, taskId, options = {}) {
233
+ if (caller.scope === "user") {
234
+ // rr13: A user-scope caller has no per-Session channel binding. Every
235
+ // durable-state claim it could carry (leaderAssertion etc.) is replayable
236
+ // by any client in the same Home. Reject outright (fail-closed).
237
+ throw jobControlError("UNAUTHORIZED", "job.start/job.cancel requires a managed Session caller key; user scope is rejected.");
238
+ }
239
+ if (caller.taskId !== taskId) {
240
+ throw jobControlError("UNAUTHORIZED", "A managed Task Session may not start or cancel Jobs for a different Task.");
241
+ }
242
+ if (caller.runId === undefined) {
243
+ throw jobControlError("UNAUTHORIZED", "A managed Task Session must bind its request to a Run.");
244
+ }
245
+ const run = store.getAgentRun(taskId, caller.runId);
246
+ if (run === null || run.status !== "active" || run.roleName !== caller.role) {
247
+ throw jobControlError("UNAUTHORIZED", "A managed Task Session's Role is not bound to an active Run.");
248
+ }
249
+ // rr13: Verify the non-replayable per-Session caller key. The key is injected
250
+ // at native Session launch and never persisted in plaintext; only its SHA-256
251
+ // hash is durable. A client that reads state.json can see the hash but cannot
252
+ // recover the key.
253
+ if (caller.callerKey === undefined) {
254
+ throw jobControlError("UNAUTHORIZED", "job.start/job.cancel requires a managed Session caller key.");
255
+ }
256
+ const expectedHash = store.getJobCallerKeyHash(taskId, caller.role ?? "", run.effective.agentId);
257
+ if (expectedHash === null) {
258
+ throw jobControlError("UNAUTHORIZED", "The managed Session has no durable caller key; it must be relaunched.");
259
+ }
260
+ const presentedHash = createHash("sha256").update(caller.callerKey).digest("hex");
261
+ if (presentedHash !== expectedHash) {
262
+ throw jobControlError("UNAUTHORIZED", "The managed Session caller key does not match the durable hash.");
263
+ }
264
+ if (options.leaderOnly && caller.role !== "leader") {
265
+ throw jobControlError("UNAUTHORIZED", "job.acknowledge requires the current Task Leader Session.");
266
+ }
267
+ if (caller.role === "reviewer") {
268
+ throw jobControlError("UNAUTHORIZED", "A Reviewer may not start or cancel DurableJobs.");
269
+ }
270
+ if (caller.role === "leader") {
271
+ // rr12: Verify the caller is THE current in-flight Task Leader, not a
272
+ // borrowed or stale Leader Run. This mirrors job.acknowledge.
273
+ if (caller.receiptId === undefined) {
274
+ throw jobControlError("UNAUTHORIZED", "A Leader must carry the current in-flight Turn receipt.");
275
+ }
276
+ assertLeaderActionRun(store, taskId, {
277
+ runId: run.id,
278
+ receiptId: caller.receiptId
279
+ });
280
+ return;
281
+ }
282
+ // Worker (or any non-leader, non-reviewer managed role): the owner must be
283
+ // the caller's own Work Item, verified through the caller's Run.
284
+ if (owner.kind !== "work-item" || run.workItemId !== owner.workItemId) {
285
+ throw jobControlError("UNAUTHORIZED", "A managed Worker may only start or cancel Jobs owned by its own Work Item.");
286
+ }
287
+ }
288
+ /**
289
+ * Resolve the managed workspace for the job's owner. Returns null if no
290
+ * managed workspace exists for this owner kind + id.
291
+ */
292
+ function resolveManagedWorkspace(store, params) {
293
+ if (params.owner.kind === "work-item") {
294
+ return store.getManagedWorkspace({
295
+ type: "work-item",
296
+ taskId: params.taskId,
297
+ workItemId: params.owner.workItemId
298
+ });
299
+ }
300
+ if (params.owner.kind === "integration-attempt") {
301
+ return store.getManagedWorkspace({
302
+ type: "integration-attempt",
303
+ taskId: params.taskId,
304
+ integrationAttemptId: params.owner.integrationAttemptId
305
+ });
306
+ }
307
+ // owner.kind === "task"
308
+ return store.getManagedWorkspace({
309
+ type: "task",
310
+ taskId: params.taskId
311
+ });
312
+ }
313
+ /**
314
+ * Read the physical Git HEAD of a repository path. Returns null if the path
315
+ * is not a git repository or the HEAD cannot be resolved.
316
+ */
317
+ function readGitHead(path) {
318
+ try {
319
+ const head = execSync("git rev-parse HEAD", {
320
+ cwd: path,
321
+ encoding: "utf8",
322
+ stdio: ["ignore", "pipe", "ignore"]
323
+ }).trim();
324
+ return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(head) ? head : null;
325
+ }
326
+ catch {
327
+ return null;
328
+ }
329
+ }
330
+ function isTerminalWorkItemStatus(status) {
331
+ return status === "completed" || status === "failed" || status === "retired";
332
+ }
333
+ /**
334
+ * rr5/f5: Verify that the caller is the current in-flight Task Leader. The
335
+ * runId must identify the active Leader Run and the receiptId must match the
336
+ * Run's current in-flight Turn receipt. This binds the assertion to the
337
+ * Leader identity/session the Controller already tracks — a bare flag or a
338
+ * stale Run/receipt pair is rejected.
339
+ */
340
+ function assertLeaderActionRun(store, taskId, assertion) {
341
+ const run = store.getActiveAgentRun(taskId, "leader");
342
+ if (run === null || run.status !== "active" || run.id !== assertion.runId) {
343
+ throw jobControlError("UNAUTHORIZED", "job.acknowledge requires the current active Task Leader Run.");
344
+ }
345
+ const expectedReceipt = formatAgentRunReceiptId(taskId, run.id);
346
+ if (assertion.receiptId !== expectedReceipt) {
347
+ throw jobControlError("UNAUTHORIZED", "job.acknowledge Leader receipt does not match the current Run.");
348
+ }
349
+ const sessions = store.getTaskRoleSessionSet(taskId, "leader");
350
+ if (sessions === null
351
+ || sessions.inFlight === null
352
+ || sessions.inFlight.runId !== run.id
353
+ || sessions.inFlight.receiptId !== expectedReceipt) {
354
+ throw jobControlError("UNAUTHORIZED", "job.acknowledge Leader Run is not in flight.");
355
+ }
356
+ }
357
+ /**
358
+ * Strict `job.start` params parsing. Throws CoreApplicationError-shaped errors
359
+ * so the socket layer reports INVALID_PARAMS without leaking internals.
360
+ */
361
+ export function parseDurableJobStartParams(value) {
362
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
363
+ throw jobControlError("INVALID_PARAMS", "job.start params are invalid.");
364
+ }
365
+ const record = value;
366
+ const allowed = new Set([
367
+ "taskId", "owner", "projectId", "head", "workspace", "env", "steps",
368
+ "retryOf", "caller"
369
+ ]);
370
+ for (const key of Object.keys(record)) {
371
+ if (!allowed.has(key)) {
372
+ throw jobControlError("INVALID_PARAMS", "job.start params are invalid.");
373
+ }
374
+ }
375
+ const taskId = requiredId(record.taskId, "job.start taskId");
376
+ const owner = parseJobOwner(record.owner);
377
+ const projectId = requiredId(record.projectId, "job.start projectId");
378
+ const head = requiredId(record.head, "job.start head");
379
+ const workspace = requiredId(record.workspace, "job.start workspace");
380
+ if (!workspace.startsWith("/")) {
381
+ throw jobControlError("INVALID_PARAMS", "job.start workspace must be an absolute path.");
382
+ }
383
+ const env = parseStringMap(record.env, "job.start env");
384
+ const steps = parseSteps(record.steps);
385
+ const retryOf = record.retryOf === undefined
386
+ ? undefined
387
+ : requiredId(record.retryOf, "job.start retryOf");
388
+ const caller = parseCaller(record.caller);
389
+ return {
390
+ taskId,
391
+ owner,
392
+ projectId,
393
+ head,
394
+ workspace,
395
+ env,
396
+ steps,
397
+ caller,
398
+ ...(retryOf === undefined ? {} : { retryOf })
399
+ };
400
+ }
401
+ export function parseDurableJobRefParams(value) {
402
+ if (typeof value !== "object" || value === null || Array.isArray(value)
403
+ || Object.keys(value).length !== 2) {
404
+ throw jobControlError("INVALID_PARAMS", "DurableJob ref params are invalid.");
405
+ }
406
+ const record = value;
407
+ return {
408
+ taskId: requiredId(record.taskId, "DurableJob taskId"),
409
+ jobId: requiredId(record.jobId, "DurableJob jobId")
410
+ };
411
+ }
412
+ /**
413
+ * rr8: `job.cancel` params carry the caller identity so the Controller can
414
+ * bind the cancel request to the caller's managed scope. Distinct from
415
+ * `parseDurableJobRefParams` (used by `job.get`) because cancel requires the
416
+ * third `caller` key.
417
+ */
418
+ export function parseDurableJobCancelParams(value) {
419
+ if (typeof value !== "object" || value === null || Array.isArray(value)
420
+ || Object.keys(value).length !== 3) {
421
+ throw jobControlError("INVALID_PARAMS", "DurableJob cancel params are invalid.");
422
+ }
423
+ const record = value;
424
+ return {
425
+ taskId: requiredId(record.taskId, "DurableJob taskId"),
426
+ jobId: requiredId(record.jobId, "DurableJob jobId"),
427
+ caller: parseCaller(record.caller)
428
+ };
429
+ }
430
+ /**
431
+ * rr26: `job.acknowledge` carries the same managed task caller as
432
+ * job.start/job.cancel. A replayable leaderAssertion without the ephemeral
433
+ * Session caller key is rejected by assertCallerAuthorized.
434
+ */
435
+ export function parseDurableJobAcknowledgeParams(value) {
436
+ if (typeof value !== "object" || value === null || Array.isArray(value)
437
+ || Object.keys(value).length !== 3) {
438
+ throw jobControlError("INVALID_PARAMS", "DurableJob acknowledge params are invalid.");
439
+ }
440
+ const record = value;
441
+ return {
442
+ taskId: requiredId(record.taskId, "DurableJob taskId"),
443
+ jobId: requiredId(record.jobId, "DurableJob jobId"),
444
+ caller: parseCaller(record.caller)
445
+ };
446
+ }
447
+ function parseJobOwner(value) {
448
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
449
+ throw jobControlError("INVALID_PARAMS", "job.start owner is invalid.");
450
+ }
451
+ const record = value;
452
+ if (record.kind === "task" && Object.keys(record).length === 1) {
453
+ return { kind: "task" };
454
+ }
455
+ if (record.kind === "work-item" && Object.keys(record).length === 2) {
456
+ return { kind: "work-item", workItemId: requiredId(record.workItemId, "job.start owner workItemId") };
457
+ }
458
+ if (record.kind === "integration-attempt" && Object.keys(record).length === 2) {
459
+ return {
460
+ kind: "integration-attempt",
461
+ integrationAttemptId: requiredId(record.integrationAttemptId, "job.start owner integrationAttemptId")
462
+ };
463
+ }
464
+ throw jobControlError("INVALID_PARAMS", "job.start owner is invalid.");
465
+ }
466
+ function parseSteps(value) {
467
+ if (!Array.isArray(value) || value.length === 0 || value.length > 64) {
468
+ throw jobControlError("INVALID_PARAMS", "job.start steps are invalid.");
469
+ }
470
+ const names = new Set();
471
+ const steps = [];
472
+ for (const entry of value) {
473
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
474
+ throw jobControlError("INVALID_PARAMS", "job.start steps are invalid.");
475
+ }
476
+ const record = entry;
477
+ const allowed = new Set(["name", "command", "timeoutMs"]);
478
+ for (const key of Object.keys(record)) {
479
+ if (!allowed.has(key)) {
480
+ throw jobControlError("INVALID_PARAMS", "job.start steps are invalid.");
481
+ }
482
+ }
483
+ const name = requiredId(record.name, "job.start step name");
484
+ const command = requiredId(record.command, "job.start step command");
485
+ if (names.has(name)) {
486
+ throw jobControlError("INVALID_PARAMS", `job.start step names must be unique: ${name}.`);
487
+ }
488
+ names.add(name);
489
+ if (record.timeoutMs !== undefined
490
+ && (typeof record.timeoutMs !== "number"
491
+ || !Number.isSafeInteger(record.timeoutMs)
492
+ || record.timeoutMs < 1)) {
493
+ throw jobControlError("INVALID_PARAMS", `job.start step timeoutMs is invalid: ${name}.`);
494
+ }
495
+ const step = {
496
+ name,
497
+ command,
498
+ ...(record.timeoutMs === undefined ? {} : { timeoutMs: record.timeoutMs })
499
+ };
500
+ steps.push(step);
501
+ }
502
+ return steps;
503
+ }
504
+ /**
505
+ * rr9/rr12: Parse the caller identity carried by `job.start`/`job.cancel`. The
506
+ * caller is REQUIRED: a request without one is rejected at the socket
507
+ * boundary (fail-closed). A non-managed caller must explicitly resolve to
508
+ * `{scope: "user"}`; the Controller never defaults a missing identity to
509
+ * user scope. A present caller must carry a valid `scope` and may carry
510
+ * optional `taskId`, `role`, `runId`, `receiptId`, and `leaderAssertion`
511
+ * fields. The identity is verified against durable Run state by
512
+ * `assertCallerAuthorized`; parsing only validates the JSON shape.
513
+ */
514
+ function parseCaller(value) {
515
+ if (value === undefined) {
516
+ throw jobControlError("INVALID_PARAMS", "job caller is required.");
517
+ }
518
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
519
+ throw jobControlError("INVALID_PARAMS", "job caller is invalid.");
520
+ }
521
+ const record = value;
522
+ const allowed = new Set([
523
+ "scope", "taskId", "role", "runId", "receiptId", "leaderAssertion", "callerKey"
524
+ ]);
525
+ for (const key of Object.keys(record)) {
526
+ if (!allowed.has(key)) {
527
+ throw jobControlError("INVALID_PARAMS", "job caller is invalid.");
528
+ }
529
+ }
530
+ if (record.scope !== "user" && record.scope !== "task") {
531
+ throw jobControlError("INVALID_PARAMS", "job caller scope is invalid.");
532
+ }
533
+ const optionalId = (key) => {
534
+ const entry = record[key];
535
+ if (entry === undefined)
536
+ return undefined;
537
+ return requiredId(entry, `job caller ${key}`);
538
+ };
539
+ const taskId = optionalId("taskId");
540
+ const role = optionalId("role");
541
+ const runId = optionalId("runId");
542
+ const receiptId = optionalId("receiptId");
543
+ const leaderAssertion = parseLeaderAssertion(record.leaderAssertion);
544
+ const callerKey = record.callerKey === undefined
545
+ ? undefined
546
+ : requiredId(record.callerKey, "job caller callerKey");
547
+ return {
548
+ scope: record.scope,
549
+ ...(taskId === undefined ? {} : { taskId }),
550
+ ...(role === undefined ? {} : { role }),
551
+ ...(runId === undefined ? {} : { runId }),
552
+ ...(receiptId === undefined ? {} : { receiptId }),
553
+ ...(leaderAssertion === undefined ? {} : { leaderAssertion }),
554
+ ...(callerKey === undefined ? {} : { callerKey })
555
+ };
556
+ }
557
+ /**
558
+ * rr12: Parse a `leaderAssertion` sub-object (`{runId, receiptId}`) carried by
559
+ * a user-scope caller. Returns undefined when absent; throws on a malformed
560
+ * shape.
561
+ */
562
+ function parseLeaderAssertion(value) {
563
+ if (value === undefined)
564
+ return undefined;
565
+ if (typeof value !== "object" || value === null || Array.isArray(value)
566
+ || Object.keys(value).length !== 2) {
567
+ throw jobControlError("INVALID_PARAMS", "job caller leaderAssertion must have runId and receiptId.");
568
+ }
569
+ const record = value;
570
+ return {
571
+ runId: requiredId(record.runId, "job caller leaderAssertion runId"),
572
+ receiptId: requiredId(record.receiptId, "job caller leaderAssertion receiptId")
573
+ };
574
+ }
575
+ function parseStringMap(value, label) {
576
+ if (value === undefined)
577
+ return {};
578
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
579
+ throw jobControlError("INVALID_PARAMS", `${label} is invalid.`);
580
+ }
581
+ const map = {};
582
+ for (const [key, entry] of Object.entries(value)) {
583
+ if (typeof entry !== "string") {
584
+ throw jobControlError("INVALID_PARAMS", `${label} is invalid.`);
585
+ }
586
+ map[key] = entry;
587
+ }
588
+ return map;
589
+ }
590
+ function requiredId(value, label) {
591
+ if (typeof value !== "string" || value.length === 0) {
592
+ throw jobControlError("INVALID_PARAMS", `${label} is required.`);
593
+ }
594
+ return value;
595
+ }
596
+ function jobControlError(code, message) {
597
+ const error = Object.assign(new Error(message), { code });
598
+ error.name = "CoreApplicationError";
599
+ return error;
600
+ }
601
+ /**
602
+ * rr7: Expected job-domain rejections from `validateStartParams` (unknown or
603
+ * terminal Task/owner, unmanaged or read-only workspace, stable checkout,
604
+ * HEAD mismatch, invalid retry) must cross the Controller socket as
605
+ * JOB_ERROR with their actionable message. The socket only passes
606
+ * CoreApplicationError/CoreServiceError/CoreJobError through; a plain Error
607
+ * collapses to INTERNAL_ERROR, losing the reason the caller needs to act on.
608
+ */
609
+ function jobDomainError(message) {
610
+ const error = new Error(message);
611
+ error.name = "CoreJobError";
612
+ return error;
613
+ }