@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.
- package/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- 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
|
+
}
|