@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,688 @@
1
+ import { checkGrant, grantUseReservations, recordGrantUse } from "../grant/capabilityGrant.js";
2
+ import { isConcreteVersion } from "../domain/validation.js";
3
+ import { completeStep, confirmFailedStep, confirmStep, failStep, markStepUnknown, recoverRunningStep, rebindGrant, resumeCursor, startStep, workflowStatus } from "./releaseWorkflow.js";
4
+ import { acquireWorkflowFileLock } from "./workflowFileLock.js";
5
+ /**
6
+ * Step kinds whose external effect is inherently irreversible. A grant whose
7
+ * ceiling is below this rank cannot authorize the step even when the plan
8
+ * entry omits an explicit irreversibility.
9
+ */
10
+ const INHERENT_IRREVERSIBILITY = {
11
+ "npm-publish": "irreversible",
12
+ "version-tag": "irreversible",
13
+ "merge": "irreversible",
14
+ "controller-replace": "irreversible",
15
+ "project-migrate": "irreversible",
16
+ "cli-update": "irreversible",
17
+ // P1-5 (rr22): post-verify executes an arbitrary shell command
18
+ // (`sh -c params.command`). Its effect is inherently irreversible — the
19
+ // command can do anything — so a grant whose ceiling is below
20
+ // "irreversible" must never authorize it, regardless of what the plan
21
+ // entry declares.
22
+ "post-verify": "irreversible"
23
+ };
24
+ const IRREVERSIBILITY_RANK = {
25
+ none: 0,
26
+ reversible: 1,
27
+ irreversible: 2
28
+ };
29
+ /**
30
+ * Concurrency guard: prevents two runs of the same workflow from submitting
31
+ * duplicate external effects. Each CLI invocation is a fresh Node process with
32
+ * its own module instance, so an in-process map cannot coordinate across
33
+ * processes. When the store is a FileTaskStore (it exposes its root directory),
34
+ * the guard is a file lock under that root; otherwise (in-memory test stores)
35
+ * it falls back to an in-process map. The second run waits for the first to
36
+ * finish, then re-reads the (now-updated) workflow state.
37
+ */
38
+ const workflowLocks = new Map();
39
+ async function acquireInProcessLock(key) {
40
+ // Wait for any existing run of this workflow to finish.
41
+ while (workflowLocks.has(key)) {
42
+ await workflowLocks.get(key).promise;
43
+ }
44
+ let resolve;
45
+ const promise = new Promise((r) => { resolve = r; });
46
+ workflowLocks.set(key, { promise, resolve });
47
+ return () => {
48
+ workflowLocks.delete(key);
49
+ resolve();
50
+ };
51
+ }
52
+ async function acquireWorkflowLock(store, taskId, workflowId) {
53
+ if (typeof store.rootDirectory === "function") {
54
+ const root = store.rootDirectory();
55
+ if (typeof root === "string" && root.length > 0) {
56
+ return acquireWorkflowFileLock(root, taskId, workflowId);
57
+ }
58
+ }
59
+ return acquireInProcessLock(`${taskId}/${workflowId}`);
60
+ }
61
+ /**
62
+ * Runs the workflow from its resume cursor: the first step that is not
63
+ * terminal (succeeded/skipped). Every transition is persisted before the next
64
+ * external call, so a crash at any point resumes correctly:
65
+ *
66
+ * - running/unknown/failed steps that carry an externalIdentity are queried
67
+ * authoritatively first; "exists" confirms the step without a second
68
+ * submission, "unknown" stops (a failed step instead re-attempts), "absent"
69
+ * re-attempts.
70
+ * - a running irreversible step without an externalIdentity crashed before
71
+ * (or during) its first submission. Its grant reservation only proves the
72
+ * use was committed; it is never treated as evidence that the effect
73
+ * landed. The port is queried for the step's predeclared identity first:
74
+ * "exists" confirms without a second submission, "unknown" stops
75
+ * unconfirmed, and only "absent" re-attempts, charging the use once when
76
+ * the reservation was lost to the crash. A running reversible step
77
+ * re-attempts under the same idempotency key, which makes the retry safe.
78
+ * - every (re)submission re-checks the grant, including its repository,
79
+ * package, Project, and Home scope; denial fails closed and, for a pending
80
+ * step, records the denial on the step itself.
81
+ * - irreversible steps additionally require every prior step succeeded.
82
+ * - a timeout without an externalIdentity marks the step as unknown
83
+ * (unconfirmed); with one, the step becomes unknown and is never
84
+ * re-submitted blindly.
85
+ * - the grant use is recorded after the step's running state is persisted; a
86
+ * crash between the two is healed on resume as described above.
87
+ *
88
+ * Concurrent runs of the same workflow are serialized by a cross-process file
89
+ * lock when the store is a FileTaskStore (each CLI invocation is a fresh
90
+ * process), so a second run cannot submit a duplicate effect.
91
+ */
92
+ export async function runReleaseWorkflow(store, taskId, workflowId, ports, options = {}) {
93
+ // Prevent concurrent runs of the same workflow from submitting duplicate
94
+ // external effects. The second run waits for the first to finish, then
95
+ // re-reads the updated workflow state. The lock is acquired before reading
96
+ // the workflow and released after the run completes.
97
+ const releaseLock = await acquireWorkflowLock(store, taskId, workflowId);
98
+ try {
99
+ return await runReleaseWorkflowLocked(store, taskId, workflowId, ports, options);
100
+ }
101
+ finally {
102
+ releaseLock();
103
+ }
104
+ }
105
+ async function runReleaseWorkflowLocked(store, taskId, workflowId, ports, options) {
106
+ const clock = options.now ?? (() => new Date());
107
+ const maxSteps = options.maxSteps ?? 100;
108
+ const initial = store.getReleaseWorkflow(taskId, workflowId);
109
+ if (initial === null) {
110
+ throw new Error(`Release workflow not found: ${taskId}/${workflowId}.`);
111
+ }
112
+ let workflow = initial;
113
+ if (options.grantId !== undefined && options.grantId !== workflow.grantId) {
114
+ workflow = rebindGrant(workflow, options.grantId, clock());
115
+ store.saveReleaseWorkflow(taskId, workflow);
116
+ }
117
+ const attempted = [];
118
+ let budget = maxSteps;
119
+ while (budget > 0) {
120
+ budget -= 1;
121
+ const cursor = resumeCursor(workflow);
122
+ if (cursor === null) {
123
+ return finish(workflow, "succeeded", undefined, attempted);
124
+ }
125
+ const plan = requirePlan(workflow, cursor);
126
+ const record = workflow.steps[cursor];
127
+ const now = clock();
128
+ // Set when a persisted running step's grant use is recognized as already
129
+ // committed: the submit path below then re-attempts without charging again.
130
+ let recognizedPriorUse = false;
131
+ // In-flight or ambiguously failed steps: query authoritatively before any
132
+ // re-submission when we hold an external identity. A running step that
133
+ // crashed before recording a submission result has no identity; the
134
+ // idempotency key makes its re-attempt safe. A crash DURING executeStep
135
+ // is caught below and marks the step unknown (for irreversible steps)
136
+ // so it is never re-submitted blindly.
137
+ if (record.status === "unknown" || record.status === "running" || record.status === "failed") {
138
+ if (record.externalIdentity !== undefined) {
139
+ const identity = record.externalIdentity;
140
+ const query = await ports.queryStepEffect({ step: plan, source: workflow.source, externalIdentity: identity });
141
+ if (query.state === "exists") {
142
+ const logs = [`confirmed by authoritative query: ${identity.kind}=${identity.value}`];
143
+ workflow = record.status === "unknown"
144
+ ? confirmStep(workflow, cursor, { externalId: query.externalId, logs }, now)
145
+ : record.status === "failed"
146
+ ? confirmFailedStep(workflow, cursor, { externalId: query.externalId, logs }, now)
147
+ : completeStep(workflow, cursor, { externalId: query.externalId, logs }, now);
148
+ store.saveReleaseWorkflow(taskId, workflow);
149
+ continue;
150
+ }
151
+ if (query.state === "unknown") {
152
+ // An unknowable effect is never re-submitted blindly, regardless of
153
+ // the step's current status.
154
+ return finish(workflow, "unknown", `unknown:${cursor}`, attempted);
155
+ }
156
+ // The effect is confirmed absent: re-attempt. A running step records
157
+ // the recovery attempt; a failed step re-enters running via startStep below.
158
+ if (record.status === "running") {
159
+ workflow = recoverRunningStep(workflow, cursor, now);
160
+ store.saveReleaseWorkflow(taskId, workflow);
161
+ }
162
+ }
163
+ else if (record.status === "unknown") {
164
+ // An unknown step without an identity cannot be queried; fail closed.
165
+ return finish(workflow, "unconfirmed", `unconfirmed:${cursor}`, attempted);
166
+ }
167
+ else if (record.status === "running") {
168
+ // A persisted running step that crashed before recording a submission
169
+ // result (no external identity). For an irreversible step, query the
170
+ // port's effect (the adapter checks the durable idempotency store
171
+ // first) and re-execute only after an authoritative "absent" —
172
+ // regardless of whether a grant use was recognized, because an
173
+ // irreversible effect may have landed through a path the use
174
+ // accounting does not prove. A reversible step always falls through
175
+ // and re-attempts under the same idempotency key.
176
+ const resumeGrant = store.getCapabilityGrant(taskId, workflow.grantId);
177
+ const useRecognized = resumeGrant !== null
178
+ && grantUseRecognized(resumeGrant, workflowId, cursor, record.attempts);
179
+ if (effectiveStepIrreversibility(plan) === "irreversible") {
180
+ const disposition = await ports.queryStepEffect({ step: plan, source: workflow.source });
181
+ if (disposition.state === "exists") {
182
+ // The effect is proven to have landed; confirm the step without a
183
+ // second submission and move on.
184
+ const logs = [`confirmed by authoritative query: ${plan.kind}`];
185
+ workflow = completeStep(workflow, cursor, { externalId: disposition.externalId, logs }, now);
186
+ store.saveReleaseWorkflow(taskId, workflow);
187
+ continue;
188
+ }
189
+ if (disposition.state === "unknown") {
190
+ // The effect cannot be proven absent or present; never re-submit
191
+ // an irreversible effect on an unknowable disposition.
192
+ return finish(workflow, "unconfirmed", `unconfirmed:${cursor}`, attempted);
193
+ }
194
+ // state === "absent": the effect is proven not to have landed.
195
+ // Record the recovery attempt and fall through to re-submit
196
+ // exactly once. A recognized use makes the re-attempt free; an
197
+ // unrecognized one is charged once below.
198
+ workflow = recoverRunningStep(workflow, cursor, now);
199
+ store.saveReleaseWorkflow(taskId, workflow);
200
+ }
201
+ recognizedPriorUse = useRecognized;
202
+ }
203
+ // A failed step without an identity, or a running step whose use was
204
+ // recognized: re-attempt via the submit path below. The idempotency key
205
+ // prevents duplication.
206
+ }
207
+ // P1-1 (rr20): Re-sample the clock after any awaited authoritative query.
208
+ // A query that crosses the grant's expiry must not execute the external
209
+ // call with a stale timestamp — the authorization decision and use
210
+ // reservation must reflect the current time, not the pre-query time.
211
+ // A separate variable keeps `now` stable for state-transition timestamps
212
+ // (updatedAt) so the store's optimistic-concurrency check is unaffected.
213
+ const grantNow = clock();
214
+ // Authorization gate for every (re)submission.
215
+ //
216
+ // A running step WITHOUT an externalIdentity may have crashed before its
217
+ // grant use was committed, so its use is re-checked on resume: an
218
+ // allowance means the use is still owed (consumed below). A
219
+ // uses-exhausted denial is final — only the exact reservation recognized
220
+ // above makes the re-attempt free; inferring "already paid" from the
221
+ // denial itself would let a different Workflow's running attempt bypass
222
+ // maxUses after another Workflow consumed the last use. A running step
223
+ // WITH an identity already consumed its use (it reached executeStep); its
224
+ // re-attempt after an authoritative-absent query skips the use check.
225
+ const wasRunning = workflow.steps[cursor].status === "running";
226
+ const recheckGrantUses = wasRunning && record.externalIdentity === undefined;
227
+ const grant = store.getCapabilityGrant(taskId, workflow.grantId);
228
+ if (grant === null) {
229
+ return finish(workflow, "unauthorized", "unauthorized:grant-missing", attempted);
230
+ }
231
+ const params = resolveParams(workflow, plan);
232
+ const effectiveIrreversibility = effectiveStepIrreversibility(plan);
233
+ const decision = checkGrant(grant, {
234
+ action: grantAction(plan),
235
+ params,
236
+ irreversibility: effectiveIrreversibility
237
+ }, grantNow, { skipUsesCheck: wasRunning && !recheckGrantUses });
238
+ // A use is already paid for ONLY when the resume recognized this
239
+ // attempt's exact reservation. A uses-exhausted re-check does not prove
240
+ // this attempt paid: another Workflow may have consumed the last use,
241
+ // and treating the denial as "already paid" would bypass maxUses.
242
+ // The recognized reservation only exempts the uses-exhausted check;
243
+ // revoked, expired, action, parameter, and irreversibility denials
244
+ // must always stand.
245
+ const useAlreadyConsumed = recognizedPriorUse;
246
+ if (!decision.allowed && (decision.reason !== "grant-uses-exhausted" || !useAlreadyConsumed)) {
247
+ if (workflow.steps[cursor].status === "pending") {
248
+ workflow = startStep(workflow, cursor, now);
249
+ workflow = failStep(workflow, cursor, { logs: [`unauthorized: ${decision.reason}`] }, now);
250
+ store.saveReleaseWorkflow(taskId, workflow);
251
+ }
252
+ return finish(workflow, "unauthorized", `unauthorized:${decision.reason}`, attempted);
253
+ }
254
+ // The grant's repository scope must cover the workflow's exact source.
255
+ const scopeDenial = repositoryScopeDenial(grant, workflow);
256
+ if (scopeDenial !== undefined) {
257
+ if (workflow.steps[cursor].status === "pending") {
258
+ workflow = startStep(workflow, cursor, now);
259
+ workflow = failStep(workflow, cursor, { logs: [`unauthorized: ${scopeDenial}`] }, now);
260
+ store.saveReleaseWorkflow(taskId, workflow);
261
+ }
262
+ return finish(workflow, "unauthorized", `unauthorized:${scopeDenial}`, attempted);
263
+ }
264
+ // A repo-scoped grant binds a version-tag to the exact source checkout; an
265
+ // alternate path cannot be authenticated as that repository.
266
+ const checkoutDenial = versionTagCheckoutDenial(grant, plan, params);
267
+ if (checkoutDenial !== undefined) {
268
+ if (workflow.steps[cursor].status === "pending") {
269
+ workflow = startStep(workflow, cursor, now);
270
+ workflow = failStep(workflow, cursor, { logs: [`unauthorized: ${checkoutDenial}`] }, now);
271
+ store.saveReleaseWorkflow(taskId, workflow);
272
+ }
273
+ return finish(workflow, "unauthorized", `unauthorized:${checkoutDenial}`, attempted);
274
+ }
275
+ // The grant's package scope must cover the step's target package.
276
+ const packageDenial = packageScopeDenial(grant, plan, params, workflow);
277
+ if (packageDenial !== undefined) {
278
+ if (workflow.steps[cursor].status === "pending") {
279
+ workflow = startStep(workflow, cursor, now);
280
+ workflow = failStep(workflow, cursor, { logs: [`unauthorized: ${packageDenial}`] }, now);
281
+ store.saveReleaseWorkflow(taskId, workflow);
282
+ }
283
+ return finish(workflow, "unauthorized", `unauthorized:${packageDenial}`, attempted);
284
+ }
285
+ // Every npm-publish step must bind the tarball to a frozen,
286
+ // content-addressed source artifact; without it the adapter cannot prove
287
+ // the package is the granted one, and — because the workflow source is
288
+ // immutable — the step is unrecoverable: each resume would consume
289
+ // another grant use before the adapter failed. Reject before any use is
290
+ // deducted, regardless of grant scope.
291
+ const artifactDenial = publishArtifactDenial(plan, workflow);
292
+ if (artifactDenial !== undefined) {
293
+ if (workflow.steps[cursor].status === "pending") {
294
+ workflow = startStep(workflow, cursor, now);
295
+ workflow = failStep(workflow, cursor, { logs: [`unauthorized: ${artifactDenial}`] }, now);
296
+ store.saveReleaseWorkflow(taskId, workflow);
297
+ }
298
+ return finish(workflow, "unauthorized", `unauthorized:${artifactDenial}`, attempted);
299
+ }
300
+ // The grant's Project scope must cover a project-migrate step's target.
301
+ const projectDenial = projectScopeDenial(grant, plan, params);
302
+ if (projectDenial !== undefined) {
303
+ if (workflow.steps[cursor].status === "pending") {
304
+ workflow = startStep(workflow, cursor, now);
305
+ workflow = failStep(workflow, cursor, { logs: [`unauthorized: ${projectDenial}`] }, now);
306
+ store.saveReleaseWorkflow(taskId, workflow);
307
+ }
308
+ return finish(workflow, "unauthorized", `unauthorized:${projectDenial}`, attempted);
309
+ }
310
+ // A Home-scoped grant may only drive a cli-update against that exact Home.
311
+ const homeDenial = homeScopeDenial(grant, plan, ports);
312
+ if (homeDenial !== undefined) {
313
+ if (workflow.steps[cursor].status === "pending") {
314
+ workflow = startStep(workflow, cursor, now);
315
+ workflow = failStep(workflow, cursor, { logs: [`unauthorized: ${homeDenial}`] }, now);
316
+ store.saveReleaseWorkflow(taskId, workflow);
317
+ }
318
+ return finish(workflow, "unauthorized", `unauthorized:${homeDenial}`, attempted);
319
+ }
320
+ // A cli-update must be bound to a concrete frozen version before a grant
321
+ // use is consumed: an unversioned update would activate a moving `latest`.
322
+ const updateVersionDenial = cliUpdateVersionDenial(plan, params);
323
+ if (updateVersionDenial !== undefined) {
324
+ if (workflow.steps[cursor].status === "pending") {
325
+ workflow = startStep(workflow, cursor, now);
326
+ workflow = failStep(workflow, cursor, { logs: [`unauthorized: ${updateVersionDenial}`] }, now);
327
+ store.saveReleaseWorkflow(taskId, workflow);
328
+ }
329
+ return finish(workflow, "unauthorized", `unauthorized:${updateVersionDenial}`, attempted);
330
+ }
331
+ // Irreversible steps require a fully confirmed prefix.
332
+ if (effectiveIrreversibility === "irreversible") {
333
+ const index = workflow.plan.findIndex((entry) => entry.id === cursor);
334
+ const priorConfirmed = workflow.plan
335
+ .slice(0, index)
336
+ .every((entry) => workflow.steps[entry.id].status === "succeeded");
337
+ if (!priorConfirmed) {
338
+ if (workflow.steps[cursor].status === "pending") {
339
+ workflow = startStep(workflow, cursor, now);
340
+ workflow = failStep(workflow, cursor, { logs: ["prerequisite-not-confirmed"] }, now);
341
+ store.saveReleaseWorkflow(taskId, workflow);
342
+ }
343
+ return finish(workflow, "failed", "prerequisite-not-confirmed", attempted);
344
+ }
345
+ }
346
+ // Submit exactly once this pass. The running state is persisted before the
347
+ // grant use is recorded, so a crash between the two cannot strand a use
348
+ // without the matching attempt. A re-attempt of a running-without-identity
349
+ // step whose use was already committed (useAlreadyConsumed) is free; one
350
+ // whose use was lost to the crash consumes it now, healing the split.
351
+ if (!wasRunning) {
352
+ workflow = startStep(workflow, cursor, now);
353
+ store.saveReleaseWorkflow(taskId, workflow);
354
+ }
355
+ if ((!wasRunning || recheckGrantUses) && !useAlreadyConsumed) {
356
+ // One use per authorized (re)submission, recorded with a durable attempt
357
+ // identity so a resume recognizes it instead of charging again. The CAS
358
+ // retry means a concurrent consumer of the last slot makes this run fail
359
+ // closed (unauthorized) rather than overspending the grant.
360
+ const attempt = workflow.steps[cursor].attempts;
361
+ const reservationKey = attemptReservationKey(workflowId, cursor, attempt);
362
+ const { exhausted } = consumeGrantUse(store, taskId, grant, grantNow, reservationKey);
363
+ if (exhausted) {
364
+ workflow = failStep(workflow, cursor, { logs: ["unauthorized: grant-uses-exhausted"] }, now);
365
+ store.saveReleaseWorkflow(taskId, workflow);
366
+ return finish(workflow, "unauthorized", "unauthorized:grant-uses-exhausted", attempted);
367
+ }
368
+ }
369
+ attempted.push(cursor);
370
+ let effect;
371
+ try {
372
+ effect = await ports.executeStep({
373
+ step: plan,
374
+ idempotencyKey: plan.idempotencyKey,
375
+ source: workflow.source,
376
+ params
377
+ });
378
+ }
379
+ catch (error) {
380
+ // A crash during executeStep may have produced the external effect.
381
+ // For irreversible steps, mark as unknown and stop so the effect is
382
+ // never re-submitted blindly. For reversible steps, mark as failed.
383
+ // The exception is re-thrown so the caller sees the crash; the workflow
384
+ // state is already persisted for recovery on the next run.
385
+ const message = error instanceof Error ? error.message : String(error);
386
+ const crashNow = clock();
387
+ if (effectiveIrreversibility === "irreversible") {
388
+ workflow = markStepUnknown(workflow, cursor, {
389
+ logs: [`executeStep crashed: ${message}`]
390
+ }, crashNow);
391
+ store.saveReleaseWorkflow(taskId, workflow);
392
+ }
393
+ else {
394
+ workflow = failStep(workflow, cursor, {
395
+ logs: [`executeStep crashed: ${message}`]
396
+ }, crashNow);
397
+ store.saveReleaseWorkflow(taskId, workflow);
398
+ }
399
+ throw error;
400
+ }
401
+ const effectNow = clock();
402
+ if (effect.outcome === "succeeded") {
403
+ workflow = completeStep(workflow, cursor, {
404
+ externalId: effect.externalId,
405
+ logs: effect.logs
406
+ }, effectNow);
407
+ store.saveReleaseWorkflow(taskId, workflow);
408
+ continue;
409
+ }
410
+ if (effect.outcome === "failed") {
411
+ workflow = failStep(workflow, cursor, {
412
+ logs: [
413
+ ...(effect.logs ?? []),
414
+ ...(effect.error === undefined ? [] : [`error: ${effect.error}`])
415
+ ],
416
+ ...(effect.externalIdentity === undefined ? {} : { externalIdentity: effect.externalIdentity })
417
+ }, effectNow);
418
+ store.saveReleaseWorkflow(taskId, workflow);
419
+ return finish(workflow, "failed", `failed:${cursor}`, attempted);
420
+ }
421
+ // Timeout: the request may or may not have landed.
422
+ if (effect.externalIdentity === undefined) {
423
+ // Without an identity the effect cannot be re-queried authoritatively,
424
+ // but it may have landed. Mark the step as unknown (unconfirmed) rather
425
+ // than failed: a failed step is re-submitted on the next run, which
426
+ // could duplicate an effect that actually landed. An unknown step is
427
+ // queried (or left for operator resolution) instead of re-submitted.
428
+ workflow = markStepUnknown(workflow, cursor, {
429
+ logs: [...(effect.logs ?? []), "timeout without external identity"]
430
+ }, effectNow);
431
+ store.saveReleaseWorkflow(taskId, workflow);
432
+ return finish(workflow, "unknown", `unknown:${cursor}`, attempted);
433
+ }
434
+ workflow = markStepUnknown(workflow, cursor, {
435
+ externalIdentity: effect.externalIdentity,
436
+ logs: effect.logs
437
+ }, effectNow);
438
+ store.saveReleaseWorkflow(taskId, workflow);
439
+ return finish(workflow, "unknown", `unknown:${cursor}`, attempted);
440
+ }
441
+ // The budget ran out mid-workflow: success only if nothing remains.
442
+ const remaining = resumeCursor(workflow);
443
+ if (remaining === null) {
444
+ return finish(workflow, "succeeded", undefined, attempted);
445
+ }
446
+ return finish(workflow, "budget-exhausted", `budget-exhausted:${remaining}`, attempted);
447
+ }
448
+ /**
449
+ * The durable attempt identity for one step submission. The same key is
450
+ * recorded on the grant when the use is consumed and looked up on resume, so a
451
+ * crash between the two is recognized rather than charged twice.
452
+ */
453
+ function attemptReservationKey(workflowId, stepId, attempt) {
454
+ return `${workflowId}/${stepId}#${attempt}`;
455
+ }
456
+ /**
457
+ * Whether a persisted running step's grant use is recognized as already
458
+ * committed. Only the exact attempt reservation key (workflow/step/attempt)
459
+ * counts: a grant with uses from a different workflow, step, or attempt must
460
+ * not be treated as "this attempt already paid", or maxUses could be bypassed
461
+ * by starting a second workflow against the same grant.
462
+ */
463
+ function grantUseRecognized(grant, workflowId, stepId, attempt) {
464
+ const reservations = grant.useReservations;
465
+ if (Array.isArray(reservations)) {
466
+ return reservations.includes(attemptReservationKey(workflowId, stepId, attempt));
467
+ }
468
+ return false;
469
+ }
470
+ /**
471
+ * Records one grant use after a successful authorization decision. The engine
472
+ * calls this between checkGrant and the external submission; a grant bounded
473
+ * by maxUses therefore fails closed on the attempt that would exceed it.
474
+ *
475
+ * The save is a compare-and-swap: the store rejects a stale equal increment,
476
+ * so a concurrent consumer that read the grant earlier cannot also spend the
477
+ * last slot. On conflict the fresh grant is re-read and the use retried; if the
478
+ * fresh grant is exhausted (a concurrent consumer won the slot) the caller is
479
+ * told to fail closed rather than submit. If our own reservation landed before
480
+ * an error (a crash after the save), the error is surfaced without a retry or a
481
+ * second charge.
482
+ */
483
+ const GRANT_USE_CAS_ATTEMPTS = 8;
484
+ export function consumeGrantUse(store, taskId, grant, now, reservationKey) {
485
+ let current = grant;
486
+ for (let attempt = 0; attempt < GRANT_USE_CAS_ATTEMPTS; attempt += 1) {
487
+ let next;
488
+ try {
489
+ next = recordGrantUse(current, now, reservationKey);
490
+ }
491
+ catch {
492
+ // recordGrantUse fails closed on exhaustion. A concurrent consumer may
493
+ // have spent the last slot: report exhausted so the caller returns
494
+ // unauthorized instead of submitting.
495
+ return { exhausted: true };
496
+ }
497
+ try {
498
+ store.saveCapabilityGrant(taskId, next);
499
+ return { exhausted: false };
500
+ }
501
+ catch (error) {
502
+ const fresh = store.getCapabilityGrant(taskId, grant.id);
503
+ if (fresh === null)
504
+ throw error;
505
+ // Our own use landed before the error (a crash after the save): do not
506
+ // retry or double-charge; surface the original failure.
507
+ if (grantUseReservations(fresh).includes(reservationKey)) {
508
+ throw error;
509
+ }
510
+ // The grant did not move: a genuine failure, not a CAS race.
511
+ if (fresh.usesUsed === current.usesUsed) {
512
+ throw error;
513
+ }
514
+ // A concurrent consumer advanced the grant: retry against fresh state.
515
+ current = fresh;
516
+ }
517
+ }
518
+ throw new Error(`Grant use CAS retries exhausted for ${grant.id}.`);
519
+ }
520
+ /**
521
+ * Resolves `$externalId:<plan-id>` references in step params against the
522
+ * workflow's confirmed evidence. A merge step can thus consume the PR number
523
+ * a prior step produced without the operator knowing it in advance.
524
+ */
525
+ function resolveParams(workflow, plan) {
526
+ const params = { ...(plan.params ?? {}) };
527
+ for (const [name, value] of Object.entries(params)) {
528
+ const match = /^\$externalId:([A-Za-z0-9_-]+)$/.exec(value);
529
+ if (match === null)
530
+ continue;
531
+ const referenced = workflow.steps[match[1]]?.externalId;
532
+ if (referenced === undefined) {
533
+ throw new Error(`Release step ${plan.id} param ${name} references an unconfirmed step: ${match[1]}.`);
534
+ }
535
+ params[name] = referenced;
536
+ }
537
+ return Object.freeze(params);
538
+ }
539
+ function grantAction(plan) {
540
+ // The release step catalog IS the grant action catalog: a grant lists the
541
+ // step kinds it authorizes (e.g. --action npm-publish --action version-tag).
542
+ return plan.kind;
543
+ }
544
+ /**
545
+ * A grant scoped to specific repositories only authorizes a workflow whose
546
+ * exact source repository is one of them. A grant without a repository
547
+ * selector is unscoped at this layer (its Task binding is the boundary).
548
+ */
549
+ function repositoryScopeDenial(grant, workflow) {
550
+ const repositories = grant.scope.repositories;
551
+ if (repositories === undefined || repositories.length === 0)
552
+ return undefined;
553
+ const source = workflow.source.repository;
554
+ const covered = repositories.some((repo) => repo.owner === source.owner && repo.name === source.name);
555
+ return covered ? undefined : "grant-scope-repository-not-allowed";
556
+ }
557
+ function requirePlan(workflow, stepId) {
558
+ const plan = workflow.plan.find((entry) => entry.id === stepId);
559
+ if (plan === undefined) {
560
+ throw new Error(`Release workflow step is not in the plan: ${stepId}.`);
561
+ }
562
+ return plan;
563
+ }
564
+ /**
565
+ * The effective irreversibility of a step is the higher of the plan's
566
+ * explicit declaration and the kind's inherent irreversibility. A grant
567
+ * cannot authorize an inherently irreversible step with a lower ceiling.
568
+ */
569
+ function effectiveStepIrreversibility(plan) {
570
+ const inherent = INHERENT_IRREVERSIBILITY[plan.kind] ?? "none";
571
+ const declared = plan.irreversibility ?? "none";
572
+ return IRREVERSIBILITY_RANK[inherent] >= IRREVERSIBILITY_RANK[declared] ? inherent : declared;
573
+ }
574
+ /**
575
+ * A grant scoped to specific packages only authorizes a publish or smoke step
576
+ * whose target package is one of them. The canonical effect target is
577
+ * `params.package` when given, otherwise the package the adapter derives from
578
+ * the exact source repository (`@<owner>/<name>`). Omitting the package does
579
+ * not bypass the scope: it must be named or derivable, and it must be listed.
580
+ * A grant without a package selector is unscoped at this layer (its Task
581
+ * binding is the boundary).
582
+ */
583
+ function packageScopeDenial(grant, plan, params, workflow) {
584
+ const packages = grant.scope.packages;
585
+ if (packages === undefined || packages.length === 0)
586
+ return undefined;
587
+ if (plan.kind !== "npm-publish" && plan.kind !== "fresh-install-smoke")
588
+ return undefined;
589
+ const target = params.package
590
+ ?? `@${workflow.source.repository.owner}/${workflow.source.repository.name}`;
591
+ if (target === undefined)
592
+ return "grant-scope-package-required";
593
+ return packages.includes(target) ? undefined : "grant-scope-package-not-allowed";
594
+ }
595
+ /**
596
+ * A cli-update is an irreversible global binary/Home change. It must be bound
597
+ * to a concrete frozen version: without params.version the adapter would stage
598
+ * and activate whatever `latest` resolves to, which can move between the plan
599
+ * and the activation. The step is denied before the grant use is consumed.
600
+ */
601
+ function cliUpdateVersionDenial(plan, params) {
602
+ if (plan.kind !== "cli-update")
603
+ return undefined;
604
+ const version = params.version;
605
+ if (version === undefined || !isConcreteVersion(version)) {
606
+ return "cli-update-version-required";
607
+ }
608
+ return undefined;
609
+ }
610
+ /**
611
+ * A repo-scoped grant binds a version-tag to the workflow's exact source
612
+ * checkout. The step must name that checkout (params.repositoryPath); the
613
+ * adapter attests the path's origin remote against the bound source — host,
614
+ * owner, and repository — before any local tag or push. Rejecting the path
615
+ * outright would make a repo-scoped version-tag impossible, so the engine
616
+ * requires it instead and leaves the trust decision to the adapter's remote
617
+ * attestation. A grant without a repository selector is unscoped at this layer.
618
+ */
619
+ function versionTagCheckoutDenial(grant, plan, params) {
620
+ if (plan.kind !== "version-tag")
621
+ return undefined;
622
+ const repositories = grant.scope.repositories;
623
+ if (repositories === undefined || repositories.length === 0)
624
+ return undefined;
625
+ const checkoutPath = params.repositoryPath;
626
+ if (checkoutPath === undefined || checkoutPath.trim().length === 0) {
627
+ return "grant-scope-repository-path-required";
628
+ }
629
+ return undefined;
630
+ }
631
+ /**
632
+ * Every npm-publish step must bind the tarball to a frozen, content-addressed
633
+ * source artifact. Without it the adapter cannot prove the published package
634
+ * is the granted one, and — because the workflow source is immutable — the
635
+ * step is unrecoverable: each resume would consume another grant use before
636
+ * the adapter failed. Reject before any use is deducted, regardless of the
637
+ * grant's package scope.
638
+ */
639
+ function publishArtifactDenial(plan, workflow) {
640
+ if (plan.kind !== "npm-publish")
641
+ return undefined;
642
+ if (workflow.source.artifact === undefined)
643
+ return "artifact-required";
644
+ return undefined;
645
+ }
646
+ /**
647
+ * A grant scoped to specific Projects only authorizes a project-migrate step
648
+ * whose target Project is one of them. A grant without a Project selector is
649
+ * unscoped at this layer (its Task binding is the boundary).
650
+ */
651
+ function projectScopeDenial(grant, plan, params) {
652
+ const projectIds = grant.scope.projectIds;
653
+ if (projectIds === undefined || projectIds.length === 0)
654
+ return undefined;
655
+ if (plan.kind !== "project-migrate")
656
+ return undefined;
657
+ const target = params.project;
658
+ if (target === undefined)
659
+ return "grant-scope-project-required";
660
+ return projectIds.includes(target) ? undefined : "grant-scope-project-not-allowed";
661
+ }
662
+ /**
663
+ * A grant scoped to a specific Home only authorizes a cli-update or
664
+ * controller-replace step against that exact Home. The adapter Home is read
665
+ * from the ports; when it cannot be determined, the check fails closed rather
666
+ * than guessing. A grant without a Home selector is unscoped at this layer
667
+ * (its Task binding is the boundary).
668
+ */
669
+ function homeScopeDenial(grant, plan, ports) {
670
+ const homePath = grant.scope.homePath;
671
+ if (homePath === undefined)
672
+ return undefined;
673
+ if (plan.kind !== "cli-update" && plan.kind !== "controller-replace")
674
+ return undefined;
675
+ const adapterHome = ports.home;
676
+ if (adapterHome === undefined)
677
+ return "grant-scope-home-unverified";
678
+ return adapterHome === homePath ? undefined : "grant-scope-home-not-allowed";
679
+ }
680
+ function finish(workflow, outcome, stopReason, attempted) {
681
+ return {
682
+ workflow,
683
+ status: workflowStatus(workflow),
684
+ outcome,
685
+ ...(stopReason === undefined ? {} : { stopReason }),
686
+ attempted: Object.freeze([...attempted])
687
+ };
688
+ }