release-skill 0.1.1
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/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- package/src/snapshot/scan.mjs +419 -0
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile command: idempotent recovery from partial publish.
|
|
3
|
+
*
|
|
4
|
+
* Reads the source run (a publish or prior reconcile run) and the frozen
|
|
5
|
+
* release plan. For each checkpoint in the source run:
|
|
6
|
+
* - SUCCEEDED: re-observe to verify remote state is still consistent.
|
|
7
|
+
* - FAILED/PENDING: observe remote state; if consistent skip, if missing
|
|
8
|
+
* add to retry list, if conflicting => REMOTE_CONFLICT.
|
|
9
|
+
*
|
|
10
|
+
* Retry actions are validated against the approval record, then preflighted
|
|
11
|
+
* globally before any execute. Each retry execute is followed by observe.
|
|
12
|
+
*
|
|
13
|
+
* Invariants:
|
|
14
|
+
* - SUCCEEDED actions are never re-executed (only re-observed)
|
|
15
|
+
* - Remote state conflict => REMOTE_CONFLICT (never blindly overwrite)
|
|
16
|
+
* - All retry preflight must pass before first retry execute
|
|
17
|
+
* - Every retry execute is followed by observe
|
|
18
|
+
* - PARTIAL => PUBLISHED when all external actions are consistent
|
|
19
|
+
* - A separate verify run performs fresh npm/plugin consumer installs and is
|
|
20
|
+
* the only command that may promote PUBLISHED to VERIFIED
|
|
21
|
+
* - New run includes sourceRunId; source run and plan are never modified
|
|
22
|
+
*
|
|
23
|
+
* @module commands/reconcile
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFile, mkdir } from 'node:fs/promises';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { assertImmutablePlanAuthority, computePlanDigest, validatePlan, validatePlanActionCompleteness } from '../core/plan.mjs';
|
|
30
|
+
import {
|
|
31
|
+
assertImmutableApprovalAuthority,
|
|
32
|
+
computeApprovalDigest,
|
|
33
|
+
validateApproval,
|
|
34
|
+
validateApprovalRecordSchema,
|
|
35
|
+
} from '../core/approval.mjs';
|
|
36
|
+
import { captureBaseline, WORKSPACE_DIGEST_ALGORITHM } from '../core/baseline.mjs';
|
|
37
|
+
import {
|
|
38
|
+
assertPreviousPublicBaselineTarget,
|
|
39
|
+
reObservePreviousPublicBaseline,
|
|
40
|
+
} from '../core/previous-public-baseline.mjs';
|
|
41
|
+
import { createEvidenceWriter } from '../core/evidence.mjs';
|
|
42
|
+
import {
|
|
43
|
+
loadRun,
|
|
44
|
+
validateRunPlanDigest,
|
|
45
|
+
validateRunCheckpointMapping,
|
|
46
|
+
writeRunAtomic,
|
|
47
|
+
appendRunState,
|
|
48
|
+
computeRunDigest,
|
|
49
|
+
createProductionRunDir,
|
|
50
|
+
validateRunLineage,
|
|
51
|
+
resolveDefaultRunDir,
|
|
52
|
+
} from '../core/run.mjs';
|
|
53
|
+
import {
|
|
54
|
+
ReleaseError,
|
|
55
|
+
GATE_FAILED,
|
|
56
|
+
BASELINE_CHANGED,
|
|
57
|
+
REMOTE_CONFLICT,
|
|
58
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
59
|
+
} from '../core/errors.mjs';
|
|
60
|
+
import { assertTransition, PARTIAL, PUBLISHED, BLOCKED } from '../core/state-machine.mjs';
|
|
61
|
+
import { matchObservation } from '../adapters/contract.mjs';
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Constants
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Checkpoint order for the reconcile saga.
|
|
69
|
+
* Must match publish.mjs.
|
|
70
|
+
*/
|
|
71
|
+
const CHECKPOINT_ORDER = [
|
|
72
|
+
'push-commit',
|
|
73
|
+
'push-snapshot',
|
|
74
|
+
'create-tag',
|
|
75
|
+
'npm-publish',
|
|
76
|
+
'github-release',
|
|
77
|
+
'claude-marketplace-install',
|
|
78
|
+
'codex-marketplace-install',
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Map plan action type to adapter ActionType.
|
|
83
|
+
* Must match publish.mjs.
|
|
84
|
+
*/
|
|
85
|
+
const ADAPTER_ACTION_TYPE_MAP = {
|
|
86
|
+
'push-commit': 'git-push',
|
|
87
|
+
'push-snapshot': 'push-snapshot',
|
|
88
|
+
'create-tag': 'git-tag',
|
|
89
|
+
'npm-publish': 'npm-publish',
|
|
90
|
+
'github-release': 'github-release',
|
|
91
|
+
'claude-marketplace-install': 'claude-marketplace-install',
|
|
92
|
+
'codex-marketplace-install': 'codex-marketplace-install',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Marketplace action types that are reconstructable isolated consumer checks,
|
|
97
|
+
* not permanent remote writes. These use isolatedConsumerWritesAuthorized
|
|
98
|
+
* and don't require externalWritesAuthorized even during retry.
|
|
99
|
+
*/
|
|
100
|
+
const MARKETPLACE_TYPES = new Set([
|
|
101
|
+
'claude-marketplace-install',
|
|
102
|
+
'codex-marketplace-install',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Helpers
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
function defaultClock() {
|
|
110
|
+
return new Date().toISOString();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Public API
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Reconcile a release from a source run.
|
|
119
|
+
*
|
|
120
|
+
* @param {Object} options
|
|
121
|
+
* @param {string} options.planPath - Absolute path to the frozen release plan.
|
|
122
|
+
* @param {string} options.sourceRunPath - Absolute path to the source run (publish or prior reconcile).
|
|
123
|
+
* @param {string} [options.approvalPath] - Path to the approval record (required if any action needs retry).
|
|
124
|
+
* @param {string} [options.productionConfirmation] - Exact plan digest required before retrying production writes.
|
|
125
|
+
* @param {Object} options.adapterRegistry - Adapter registry for action execution.
|
|
126
|
+
* @param {string} [options.runDir] - Evidence directory. Defaults to `<planDir>/runs/reconcile-<ts>`.
|
|
127
|
+
* @param {string} [options.root] - Project root for baseline capture.
|
|
128
|
+
* @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
|
|
129
|
+
* @param {(root: string) => Promise<Object>} [options.captureBaselineFn] - Injectable baseline capture.
|
|
130
|
+
*
|
|
131
|
+
* @returns {Promise<{ planPath: string, runPath: string, status: string, checkpoints: Object[] }>}
|
|
132
|
+
*
|
|
133
|
+
* @throws {ReleaseError} GATE_FAILED on safety gate failures.
|
|
134
|
+
* @throws {ReleaseError} BASELINE_CHANGED if the baseline has changed since freeze.
|
|
135
|
+
* @throws {ReleaseError} REMOTE_CONFLICT if remote state is inconsistent with the plan.
|
|
136
|
+
*/
|
|
137
|
+
export async function reconcileRelease(options) {
|
|
138
|
+
const {
|
|
139
|
+
planPath,
|
|
140
|
+
sourceRunPath,
|
|
141
|
+
approvalPath,
|
|
142
|
+
adapterRegistry,
|
|
143
|
+
runDir: runDirOpt,
|
|
144
|
+
root = process.cwd(),
|
|
145
|
+
clock: clockOpt,
|
|
146
|
+
captureBaselineFn,
|
|
147
|
+
productionConfirmation,
|
|
148
|
+
observePreviousPublicBaselineFn,
|
|
149
|
+
} = options ?? {};
|
|
150
|
+
|
|
151
|
+
const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
|
|
152
|
+
const captureBaselineActual =
|
|
153
|
+
typeof captureBaselineFn === 'function' ? captureBaselineFn : captureBaseline;
|
|
154
|
+
|
|
155
|
+
// --- Gate: sourceRunPath is required ---
|
|
156
|
+
if (!sourceRunPath) {
|
|
157
|
+
throw new ReleaseError(
|
|
158
|
+
GATE_FAILED,
|
|
159
|
+
'reconcile requires a source run path (--run)',
|
|
160
|
+
{ parameter: 'sourceRunPath' },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Load the plan before selecting a production evidence authority. An unsafe
|
|
165
|
+
// runDir must fail before this command writes through it or retries a remote
|
|
166
|
+
// action.
|
|
167
|
+
let planRaw;
|
|
168
|
+
try {
|
|
169
|
+
planRaw = await readFile(planPath, 'utf8');
|
|
170
|
+
} catch (err) {
|
|
171
|
+
throw new ReleaseError(GATE_FAILED, `cannot read release plan: ${err.message}`, { planPath, cause: err.code });
|
|
172
|
+
}
|
|
173
|
+
let plan;
|
|
174
|
+
try {
|
|
175
|
+
plan = JSON.parse(planRaw);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
throw new ReleaseError(GATE_FAILED, `release plan is not valid JSON: ${err.message}`, { planPath });
|
|
178
|
+
}
|
|
179
|
+
validatePlan(plan);
|
|
180
|
+
assertImmutablePlanAuthority(planPath, plan);
|
|
181
|
+
const isProductionPlan = plan.production?.mode === 'github-npm-v1';
|
|
182
|
+
|
|
183
|
+
// --- Set up directories ---
|
|
184
|
+
const runId = `reconcile-${Date.now()}`;
|
|
185
|
+
let runDir = runDirOpt ?? resolveDefaultRunDir(planPath, 'reconcile', runId);
|
|
186
|
+
if (isProductionPlan) {
|
|
187
|
+
runDir = await createProductionRunDir(runDir, planPath);
|
|
188
|
+
} else {
|
|
189
|
+
await mkdir(runDir, { recursive: true });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const evidence = createEvidenceWriter({ runDir, command: 'reconcile', clock: clockFn });
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
// =======================================================================
|
|
196
|
+
// Safety Gate 1: Load and validate plan schema
|
|
197
|
+
// =======================================================================
|
|
198
|
+
await evidence.append({ phase: 'safety-gate', gate: 'plan-load', status: 'started' });
|
|
199
|
+
|
|
200
|
+
await evidence.append({ phase: 'safety-gate', gate: 'plan-schema', status: 'passed' });
|
|
201
|
+
|
|
202
|
+
// =======================================================================
|
|
203
|
+
// Safety Gate 2: Verify plan digest
|
|
204
|
+
// =======================================================================
|
|
205
|
+
await evidence.append({ phase: 'safety-gate', gate: 'plan-digest', status: 'started' });
|
|
206
|
+
|
|
207
|
+
const actualDigest = computePlanDigest(plan);
|
|
208
|
+
if (plan.digest && plan.digest !== actualDigest) {
|
|
209
|
+
throw new ReleaseError(
|
|
210
|
+
GATE_FAILED,
|
|
211
|
+
`plan digest mismatch: expected ${plan.digest.slice(0, 16)}..., computed ${actualDigest.slice(0, 16)}...`,
|
|
212
|
+
{ expected: plan.digest, actual: actualDigest },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await evidence.append({ phase: 'safety-gate', gate: 'plan-digest', status: 'passed' });
|
|
217
|
+
|
|
218
|
+
// =======================================================================
|
|
219
|
+
// Safety Gate 2b: Validate plan action completeness
|
|
220
|
+
// =======================================================================
|
|
221
|
+
await evidence.append({ phase: 'safety-gate', gate: 'action-completeness', status: 'started' });
|
|
222
|
+
|
|
223
|
+
const completenessResult = validatePlanActionCompleteness(plan);
|
|
224
|
+
if (!completenessResult.passed) {
|
|
225
|
+
await evidence.append({
|
|
226
|
+
phase: 'safety-gate',
|
|
227
|
+
gate: 'action-completeness',
|
|
228
|
+
status: 'failed',
|
|
229
|
+
failures: completenessResult.details.failures,
|
|
230
|
+
});
|
|
231
|
+
throw new ReleaseError(
|
|
232
|
+
GATE_FAILED,
|
|
233
|
+
`plan action completeness gate failed: ${completenessResult.details.failures.join('; ')}`,
|
|
234
|
+
{ failures: completenessResult.details.failures },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await evidence.append({ phase: 'safety-gate', gate: 'action-completeness', status: 'passed' });
|
|
239
|
+
|
|
240
|
+
// =======================================================================
|
|
241
|
+
// Safety Gate 3: Load and validate source run
|
|
242
|
+
// =======================================================================
|
|
243
|
+
await evidence.append({ phase: 'safety-gate', gate: 'source-run-load', status: 'started' });
|
|
244
|
+
|
|
245
|
+
const sourceRun = await loadRun(sourceRunPath, {
|
|
246
|
+
requireDigest: Boolean(plan.production),
|
|
247
|
+
...(plan.production ? { authorityPlanPath: planPath } : {}),
|
|
248
|
+
});
|
|
249
|
+
await validateRunLineage(sourceRun, {
|
|
250
|
+
plan,
|
|
251
|
+
planPath,
|
|
252
|
+
runPath: sourceRunPath,
|
|
253
|
+
production: Boolean(plan.production),
|
|
254
|
+
});
|
|
255
|
+
const sourceAuthorityDigest = sourceRun.runDigest ?? computeRunDigest(sourceRun);
|
|
256
|
+
validateRunCheckpointMapping(sourceRun, plan.externalActions ?? []);
|
|
257
|
+
|
|
258
|
+
if (!['publish', 'reconcile'].includes(sourceRun.command)) {
|
|
259
|
+
throw new ReleaseError(
|
|
260
|
+
GATE_FAILED,
|
|
261
|
+
`reconcile source command must be publish or reconcile, got "${sourceRun.command}"`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let consumedApprovalPath = sourceRun.approvalPath;
|
|
266
|
+
let consumedApprovalDigest = sourceRun.approvalDigest;
|
|
267
|
+
if (plan.production) {
|
|
268
|
+
if (!consumedApprovalPath || !consumedApprovalDigest) {
|
|
269
|
+
throw new ReleaseError(
|
|
270
|
+
GATE_FAILED,
|
|
271
|
+
'production source run is missing immutable approvalPath/approvalDigest authority',
|
|
272
|
+
{ sourceRunId: sourceRun.runId },
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
const sourceApprovalRaw = await readFile(consumedApprovalPath, 'utf8').catch((error) => {
|
|
276
|
+
throw new ReleaseError(GATE_FAILED, 'source run approval authority is unavailable', {
|
|
277
|
+
sourceRunId: sourceRun.runId,
|
|
278
|
+
cause: error.code,
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
const observedApprovalDigest = assertImmutableApprovalAuthority(
|
|
282
|
+
consumedApprovalPath,
|
|
283
|
+
plan,
|
|
284
|
+
sourceApprovalRaw,
|
|
285
|
+
);
|
|
286
|
+
if (observedApprovalDigest !== consumedApprovalDigest) {
|
|
287
|
+
throw new ReleaseError(
|
|
288
|
+
GATE_FAILED,
|
|
289
|
+
'source run approvalDigest does not match immutable approval bytes',
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
let sourceApproval;
|
|
293
|
+
try {
|
|
294
|
+
sourceApproval = JSON.parse(sourceApprovalRaw);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
throw new ReleaseError(GATE_FAILED, `source run approval is not valid JSON: ${error.message}`);
|
|
297
|
+
}
|
|
298
|
+
validateApprovalRecordSchema(sourceApproval);
|
|
299
|
+
validateApproval(plan, sourceApproval, { clock: clockFn, requireUnexpired: false });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (sourceRun.status !== PARTIAL) {
|
|
303
|
+
throw new ReleaseError(
|
|
304
|
+
GATE_FAILED,
|
|
305
|
+
`reconcile only accepts PARTIAL runs; source status is "${sourceRun.status}". ` +
|
|
306
|
+
'For BLOCKED with no durable writes, fix the gate and rerun publish; VERIFIED is terminal.',
|
|
307
|
+
{ sourceRunId: sourceRun.runId, sourceRunStatus: sourceRun.status },
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// A production retry authority must be confirmed before any remote
|
|
312
|
+
// observation can influence whether a permanent write will be retried.
|
|
313
|
+
// Isolated marketplace consumer checks remain outside this requirement.
|
|
314
|
+
const planActionsById = new Map(
|
|
315
|
+
(plan.externalActions ?? []).map((action) => [action.id, action]),
|
|
316
|
+
);
|
|
317
|
+
const hasNonMarketplaceRetryCandidate = sourceRun.checkpoints.some((checkpoint) => {
|
|
318
|
+
if (checkpoint.status === 'succeeded') return false;
|
|
319
|
+
const action = planActionsById.get(checkpoint.actionId);
|
|
320
|
+
return action && !MARKETPLACE_TYPES.has(action.type);
|
|
321
|
+
});
|
|
322
|
+
if (
|
|
323
|
+
plan.production?.mode === 'github-npm-v1' &&
|
|
324
|
+
hasNonMarketplaceRetryCandidate &&
|
|
325
|
+
productionConfirmation !== actualDigest
|
|
326
|
+
) {
|
|
327
|
+
throw new ReleaseError(
|
|
328
|
+
GATE_FAILED,
|
|
329
|
+
'production reconcile confirmation must exactly match the current plan digest before retry',
|
|
330
|
+
{ planDigest: actualDigest },
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
await evidence.append({
|
|
335
|
+
phase: 'safety-gate',
|
|
336
|
+
gate: 'source-run-load',
|
|
337
|
+
status: 'passed',
|
|
338
|
+
sourceRunId: sourceRun.runId,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// =======================================================================
|
|
342
|
+
// Safety Gate 4: Baseline comparison
|
|
343
|
+
// =======================================================================
|
|
344
|
+
await evidence.append({ phase: 'safety-gate', gate: 'baseline-check', status: 'started' });
|
|
345
|
+
|
|
346
|
+
if (
|
|
347
|
+
plan.production?.mode === 'github-npm-v1' &&
|
|
348
|
+
plan.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM
|
|
349
|
+
) {
|
|
350
|
+
throw new ReleaseError(
|
|
351
|
+
GATE_FAILED,
|
|
352
|
+
`production plan workspace digest algorithm is missing or obsolete; re-run prepare (expected ${WORKSPACE_DIGEST_ALGORITHM})`,
|
|
353
|
+
{ expected: WORKSPACE_DIGEST_ALGORITHM, actual: plan.baseline?.workspaceDigestAlgorithm ?? null },
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const currentBaseline = await captureBaselineActual(root);
|
|
358
|
+
|
|
359
|
+
if (currentBaseline.gitTreeHash !== plan.baseline.gitTreeHash) {
|
|
360
|
+
await evidence.append({
|
|
361
|
+
phase: 'safety-gate',
|
|
362
|
+
gate: 'baseline-check',
|
|
363
|
+
status: 'failed',
|
|
364
|
+
planTreeHash: plan.baseline.gitTreeHash,
|
|
365
|
+
currentTreeHash: currentBaseline.gitTreeHash,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
throw new ReleaseError(
|
|
369
|
+
BASELINE_CHANGED,
|
|
370
|
+
`baseline has changed since plan freeze: plan=${plan.baseline.gitTreeHash}, current=${currentBaseline.gitTreeHash}`,
|
|
371
|
+
{ planTreeHash: plan.baseline.gitTreeHash, currentTreeHash: currentBaseline.gitTreeHash },
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (
|
|
376
|
+
plan.baseline.workspaceDigest &&
|
|
377
|
+
currentBaseline.workspaceDigest !== plan.baseline.workspaceDigest
|
|
378
|
+
) {
|
|
379
|
+
throw new ReleaseError(
|
|
380
|
+
BASELINE_CHANGED,
|
|
381
|
+
`workspace digest has changed since plan freeze: plan=${plan.baseline.workspaceDigest}, current=${currentBaseline.workspaceDigest}`,
|
|
382
|
+
{ planWorkspaceDigest: plan.baseline.workspaceDigest, currentWorkspaceDigest: currentBaseline.workspaceDigest },
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await evidence.append({
|
|
387
|
+
phase: 'safety-gate',
|
|
388
|
+
gate: 'baseline-check',
|
|
389
|
+
status: 'passed',
|
|
390
|
+
gitTreeHash: currentBaseline.gitTreeHash,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Re-observe every unit's frozen public baseline before any adapter
|
|
394
|
+
// observation, preflight, or execute call.
|
|
395
|
+
const isProductionPlan = plan.production?.mode === 'github-npm-v1';
|
|
396
|
+
const defaultPpbObserveFn = async (repo, ref, expectedCommit, { githubHost = 'github.com' } = {}) => {
|
|
397
|
+
try {
|
|
398
|
+
const { execFile } = await import('node:child_process');
|
|
399
|
+
const { promisify } = await import('node:util');
|
|
400
|
+
const { stdout } = await promisify(execFile)(
|
|
401
|
+
'git',
|
|
402
|
+
['ls-remote', `https://${githubHost}/${repo}.git`, ref],
|
|
403
|
+
{ shell: false, encoding: 'utf8', timeout: 30000 },
|
|
404
|
+
);
|
|
405
|
+
const [line] = stdout.trim().split('\n').filter(Boolean);
|
|
406
|
+
if (!line) return { status: 'drifted', actual: null, diff: 'ref not found on remote' };
|
|
407
|
+
const [actual] = line.split('\t');
|
|
408
|
+
return actual === expectedCommit
|
|
409
|
+
? { status: 'consistent', actual }
|
|
410
|
+
: { status: 'drifted', actual, diff: `expected ${expectedCommit}, got ${actual}` };
|
|
411
|
+
} catch (error) {
|
|
412
|
+
return { status: 'unknown', error: error.message };
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
const ppbObserveFn = observePreviousPublicBaselineFn ?? defaultPpbObserveFn;
|
|
416
|
+
|
|
417
|
+
for (const unit of plan.units ?? []) {
|
|
418
|
+
const baseline = unit.previousPublicBaseline;
|
|
419
|
+
if (!baseline) {
|
|
420
|
+
if (isProductionPlan) {
|
|
421
|
+
throw new ReleaseError(
|
|
422
|
+
GATE_FAILED,
|
|
423
|
+
`unit "${unit.id}" missing previousPublicBaseline in production plan`,
|
|
424
|
+
{ gate: 'previous-public-baseline', unitId: unit.id },
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const githubHost = unit.productionConfig?.githubHost ?? 'github.com';
|
|
430
|
+
assertPreviousPublicBaselineTarget({
|
|
431
|
+
baseline,
|
|
432
|
+
githubHost,
|
|
433
|
+
publicRepo: unit.publicRepo,
|
|
434
|
+
requireHost: isProductionPlan,
|
|
435
|
+
});
|
|
436
|
+
if (baseline.mode === 'bound' && baseline.status !== 'consistent') {
|
|
437
|
+
throw new ReleaseError(
|
|
438
|
+
GATE_FAILED,
|
|
439
|
+
`unit "${unit.id}" previous public baseline is not frozen as consistent`,
|
|
440
|
+
{ gate: 'previous-public-baseline', unitId: unit.id, status: baseline.status },
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
const observed = await reObservePreviousPublicBaseline({
|
|
444
|
+
baseline,
|
|
445
|
+
observeFn: ppbObserveFn,
|
|
446
|
+
evidence,
|
|
447
|
+
});
|
|
448
|
+
if (!observed.consistent) {
|
|
449
|
+
throw new ReleaseError(
|
|
450
|
+
GATE_FAILED,
|
|
451
|
+
`unit "${unit.id}": ${observed.error}`,
|
|
452
|
+
{ gate: 'previous-public-baseline', unitId: unit.id, ...observed.detail },
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// =======================================================================
|
|
458
|
+
// Load approval if provided (needed for retrying actions)
|
|
459
|
+
// =======================================================================
|
|
460
|
+
let approval = null;
|
|
461
|
+
if (approvalPath) {
|
|
462
|
+
let approvalRaw;
|
|
463
|
+
try {
|
|
464
|
+
approvalRaw = await readFile(approvalPath, 'utf8');
|
|
465
|
+
} catch (err) {
|
|
466
|
+
throw new ReleaseError(
|
|
467
|
+
GATE_FAILED,
|
|
468
|
+
`cannot read approval record: ${err.message}`,
|
|
469
|
+
{ approvalPath, cause: err.code },
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
approval = JSON.parse(approvalRaw);
|
|
475
|
+
} catch (err) {
|
|
476
|
+
throw new ReleaseError(
|
|
477
|
+
GATE_FAILED,
|
|
478
|
+
`approval record is not valid JSON: ${err.message}`,
|
|
479
|
+
{ approvalPath },
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
validateApprovalRecordSchema(approval);
|
|
483
|
+
consumedApprovalDigest = assertImmutableApprovalAuthority(approvalPath, plan, approvalRaw)
|
|
484
|
+
?? computeApprovalDigest(approvalRaw);
|
|
485
|
+
consumedApprovalPath = approvalPath;
|
|
486
|
+
// Any supplied approval that may replace the source lineage must be
|
|
487
|
+
// fully bound to this plan, even when observation later proves that no
|
|
488
|
+
// retry is necessary.
|
|
489
|
+
validateApproval(plan, approval, { clock: clockFn });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// =======================================================================
|
|
493
|
+
// Safety Gate 5: Adapter availability for all plan action types
|
|
494
|
+
// =======================================================================
|
|
495
|
+
await evidence.append({ phase: 'safety-gate', gate: 'adapter-availability', status: 'started' });
|
|
496
|
+
|
|
497
|
+
for (const action of plan.externalActions ?? []) {
|
|
498
|
+
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
499
|
+
if (!adapterActionType) continue;
|
|
500
|
+
try {
|
|
501
|
+
adapterRegistry.getAdapter(adapterActionType);
|
|
502
|
+
} catch {
|
|
503
|
+
throw new ReleaseError(
|
|
504
|
+
GATE_FAILED,
|
|
505
|
+
`no adapter registered for action type "${adapterActionType}" (plan action "${action.id}")`,
|
|
506
|
+
{ actionId: action.id, adapterActionType },
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
await evidence.append({ phase: 'safety-gate', gate: 'adapter-availability', status: 'passed' });
|
|
512
|
+
|
|
513
|
+
// =======================================================================
|
|
514
|
+
// Map source run checkpoints to plan actions
|
|
515
|
+
// =======================================================================
|
|
516
|
+
const planActions = (plan.externalActions ?? []).slice().sort((a, b) => {
|
|
517
|
+
const ai = CHECKPOINT_ORDER.indexOf(a.type);
|
|
518
|
+
const bi = CHECKPOINT_ORDER.indexOf(b.type);
|
|
519
|
+
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// Build a map from actionId -> source run checkpoint
|
|
523
|
+
const sourceCpMap = new Map();
|
|
524
|
+
for (const cp of sourceRun.checkpoints) {
|
|
525
|
+
sourceCpMap.set(cp.actionId, cp);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
await evidence.append({
|
|
529
|
+
phase: 'reconcile',
|
|
530
|
+
status: 'started',
|
|
531
|
+
actionCount: planActions.length,
|
|
532
|
+
sourceRunId: sourceRun.runId,
|
|
533
|
+
sourceRunDigest: sourceRun.runDigest,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const context = {
|
|
537
|
+
externalWritesAuthorized: false,
|
|
538
|
+
plan,
|
|
539
|
+
baseline: plan.baseline,
|
|
540
|
+
root,
|
|
541
|
+
runDir,
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
// --- Phase 1: Process each plan action using source run checkpoint ---
|
|
545
|
+
//
|
|
546
|
+
// Marketplace actions are reconstructable isolated consumer checks, not
|
|
547
|
+
// permanent remote writes. They are handled with a fresh
|
|
548
|
+
// preflight -> execute -> verify cycle in an isolated directory, using
|
|
549
|
+
// isolatedConsumerWritesAuthorized instead of externalWritesAuthorized.
|
|
550
|
+
//
|
|
551
|
+
// Non-marketplace actions use observe-based consistency checks.
|
|
552
|
+
// =======================================================================
|
|
553
|
+
const actionsToRetry = [];
|
|
554
|
+
const actionResults = new Map(); // actionId -> final status
|
|
555
|
+
let retryFailed = false;
|
|
556
|
+
|
|
557
|
+
for (const action of planActions) {
|
|
558
|
+
const sourceCp = sourceCpMap.get(action.id);
|
|
559
|
+
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
560
|
+
|
|
561
|
+
if (!adapterActionType) {
|
|
562
|
+
// Meta-checkpoint, skip
|
|
563
|
+
actionResults.set(action.id, 'skipped');
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const adapter = adapterRegistry.getAdapter(adapterActionType);
|
|
568
|
+
|
|
569
|
+
// -------------------------------------------------------------------
|
|
570
|
+
// Marketplace actions: isolated consumer verification
|
|
571
|
+
// -------------------------------------------------------------------
|
|
572
|
+
if (MARKETPLACE_TYPES.has(action.type)) {
|
|
573
|
+
const marketplaceContext = {
|
|
574
|
+
externalWritesAuthorized: false,
|
|
575
|
+
isolatedConsumerWritesAuthorized: true,
|
|
576
|
+
plan,
|
|
577
|
+
baseline: plan.baseline,
|
|
578
|
+
root,
|
|
579
|
+
runDir,
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const actionInput = {
|
|
583
|
+
actionType: adapterActionType,
|
|
584
|
+
...action.parameters,
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
// Preflight (always runs — validates frozen snapshot)
|
|
588
|
+
const preflightResult = await adapter.preflight(actionInput, marketplaceContext);
|
|
589
|
+
if (preflightResult.status === 'PREFLIGHT_FAILED') {
|
|
590
|
+
actionResults.set(action.id, 'failed');
|
|
591
|
+
await evidence.append({
|
|
592
|
+
phase: 'reconcile-marketplace',
|
|
593
|
+
actionId: action.id,
|
|
594
|
+
actionType: action.type,
|
|
595
|
+
decision: 'preflight-failed',
|
|
596
|
+
error: preflightResult.error,
|
|
597
|
+
});
|
|
598
|
+
throw new ReleaseError(
|
|
599
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
600
|
+
`marketplace preflight failed for action "${action.id}": ${preflightResult.error}`,
|
|
601
|
+
{ actionId: action.id },
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Execute (install to isolated consumer directory)
|
|
606
|
+
const executeResult = await adapter.execute(actionInput, marketplaceContext);
|
|
607
|
+
if (executeResult.status !== 'EXECUTED') {
|
|
608
|
+
actionResults.set(action.id, 'failed');
|
|
609
|
+
await evidence.append({
|
|
610
|
+
phase: 'reconcile-marketplace',
|
|
611
|
+
actionId: action.id,
|
|
612
|
+
actionType: action.type,
|
|
613
|
+
decision: 'execute-failed',
|
|
614
|
+
error: executeResult.error,
|
|
615
|
+
});
|
|
616
|
+
// Execute failed — if source was succeeded this is a conflict;
|
|
617
|
+
// if source was failed/pending, add to retry list
|
|
618
|
+
if (sourceCp.status === 'succeeded') {
|
|
619
|
+
throw new ReleaseError(
|
|
620
|
+
REMOTE_CONFLICT,
|
|
621
|
+
`marketplace execute failed for SUCCEEDED action "${action.id}": ${executeResult.error}`,
|
|
622
|
+
{ actionId: action.id },
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
actionsToRetry.push(action);
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Verify (observe + match against plan expected state)
|
|
630
|
+
const verifyResult = await adapter.verify(
|
|
631
|
+
{ ...actionInput, expected: action.expected },
|
|
632
|
+
marketplaceContext,
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
if (verifyResult.status === 'VERIFIED') {
|
|
636
|
+
// Consistent: source succeeded => skipped, source failed => succeeded (recovered)
|
|
637
|
+
const resultStatus = sourceCp.status === 'succeeded' ? 'skipped' : 'succeeded';
|
|
638
|
+
actionResults.set(action.id, resultStatus);
|
|
639
|
+
await evidence.append({
|
|
640
|
+
phase: 'reconcile-marketplace',
|
|
641
|
+
actionId: action.id,
|
|
642
|
+
actionType: action.type,
|
|
643
|
+
decision: sourceCp.status === 'succeeded' ? 'skip-source-succeeded-consistent' : 'recovered',
|
|
644
|
+
sourceStatus: sourceCp.status,
|
|
645
|
+
});
|
|
646
|
+
} else {
|
|
647
|
+
// Verify mismatch
|
|
648
|
+
await evidence.append({
|
|
649
|
+
phase: 'reconcile-marketplace',
|
|
650
|
+
actionId: action.id,
|
|
651
|
+
actionType: action.type,
|
|
652
|
+
decision: 'verify-mismatch',
|
|
653
|
+
error: verifyResult.error,
|
|
654
|
+
});
|
|
655
|
+
if (sourceCp.status === 'succeeded') {
|
|
656
|
+
throw new ReleaseError(
|
|
657
|
+
REMOTE_CONFLICT,
|
|
658
|
+
`marketplace verify mismatch for SUCCEEDED action "${action.id}": ${verifyResult.error}`,
|
|
659
|
+
{ actionId: action.id },
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
// Source was failed/pending and verify mismatched after retry => BLOCKED
|
|
663
|
+
actionResults.set(action.id, 'failed');
|
|
664
|
+
retryFailed = true;
|
|
665
|
+
// Don't add to retry — this is a verify mismatch, not a missing state
|
|
666
|
+
}
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// -------------------------------------------------------------------
|
|
671
|
+
// Non-marketplace actions: observe-based consistency checks
|
|
672
|
+
// -------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
if (sourceCp.status === 'succeeded') {
|
|
675
|
+
// SUCCEEDED: re-observe to verify remote state is still consistent.
|
|
676
|
+
// Item 20 invariant: if the source checkpoint already succeeded, any
|
|
677
|
+
// observe failure (empty observation, auth error, network error, or
|
|
678
|
+
// uncertain state) must fail closed and require human intervention.
|
|
679
|
+
// We never add a SUCCEEDED action to actionsToRetry or re-execute it.
|
|
680
|
+
const observeResult = await adapter.observe(
|
|
681
|
+
{ actionType: adapterActionType, ...action.parameters },
|
|
682
|
+
context,
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
if (action.expected) {
|
|
686
|
+
if (
|
|
687
|
+
!observeResult.observation ||
|
|
688
|
+
(observeResult.error && Object.keys(observeResult.observation).length === 0)
|
|
689
|
+
) {
|
|
690
|
+
// SUCCEEDED action but observe failed: fail closed. The remote
|
|
691
|
+
// state is uncertain — we cannot confirm consistency, so we
|
|
692
|
+
// cannot proceed. Requires human intervention.
|
|
693
|
+
await evidence.append({
|
|
694
|
+
phase: 'reconcile-observe',
|
|
695
|
+
actionId: action.id,
|
|
696
|
+
actionType: action.type,
|
|
697
|
+
decision: 'succeeded-observe-failed-fail-closed',
|
|
698
|
+
error: observeResult.error,
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
throw new ReleaseError(
|
|
702
|
+
REMOTE_CONFLICT,
|
|
703
|
+
`SUCCEEDED action "${action.id}" observe returned empty/error: ${observeResult.error ?? 'empty observation'}. Remote state uncertain; manual verification required.`,
|
|
704
|
+
{ actionId: action.id, observeError: observeResult.error },
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const { matches, mismatches } = matchObservation(
|
|
709
|
+
action.expected,
|
|
710
|
+
observeResult.observation,
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
if (!matches) {
|
|
714
|
+
await evidence.append({
|
|
715
|
+
phase: 'reconcile-observe',
|
|
716
|
+
actionId: action.id,
|
|
717
|
+
actionType: action.type,
|
|
718
|
+
decision: 'remote-conflict',
|
|
719
|
+
mismatches,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
throw new ReleaseError(
|
|
723
|
+
REMOTE_CONFLICT,
|
|
724
|
+
`Remote state conflict for SUCCEEDED action "${action.id}": ${mismatches.join('; ')}`,
|
|
725
|
+
{ actionId: action.id, mismatches },
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
actionResults.set(action.id, 'succeeded');
|
|
731
|
+
await evidence.append({
|
|
732
|
+
phase: 'reconcile-observe',
|
|
733
|
+
actionId: action.id,
|
|
734
|
+
actionType: action.type,
|
|
735
|
+
decision: 'skip-succeeded-verified',
|
|
736
|
+
});
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// FAILED or PENDING: observe remote state
|
|
741
|
+
const observeResult = await adapter.observe(
|
|
742
|
+
{ actionType: adapterActionType, ...action.parameters },
|
|
743
|
+
context,
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
if (
|
|
747
|
+
!observeResult.observation ||
|
|
748
|
+
(observeResult.error && Object.keys(observeResult.observation).length === 0)
|
|
749
|
+
) {
|
|
750
|
+
await evidence.append({
|
|
751
|
+
phase: 'reconcile-observe',
|
|
752
|
+
actionId: action.id,
|
|
753
|
+
actionType: action.type,
|
|
754
|
+
decision: 'uncertain-observation-fail-closed',
|
|
755
|
+
error: observeResult.error ?? null,
|
|
756
|
+
});
|
|
757
|
+
throw new ReleaseError(
|
|
758
|
+
REMOTE_CONFLICT,
|
|
759
|
+
`action "${action.id}" cannot be retried because remote state is unobservable`,
|
|
760
|
+
{ actionId: action.id, observeError: observeResult.error },
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const explicitlyMissing = observeResult.observation.exists === false
|
|
765
|
+
|| observeResult.observation.remoteCommit === ''
|
|
766
|
+
|| observeResult.observation.commit === ''
|
|
767
|
+
|| observeResult.observation.published === false;
|
|
768
|
+
if (explicitlyMissing) {
|
|
769
|
+
actionsToRetry.push(action);
|
|
770
|
+
await evidence.append({
|
|
771
|
+
phase: 'reconcile-observe',
|
|
772
|
+
actionId: action.id,
|
|
773
|
+
actionType: action.type,
|
|
774
|
+
decision: 'retry-explicitly-missing',
|
|
775
|
+
});
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Check if remote already has the expected state
|
|
780
|
+
if (observeResult.observation && action.expected) {
|
|
781
|
+
const { matches, mismatches } = matchObservation(
|
|
782
|
+
action.expected,
|
|
783
|
+
observeResult.observation,
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
if (matches) {
|
|
787
|
+
actionResults.set(action.id, 'skipped');
|
|
788
|
+
await evidence.append({
|
|
789
|
+
phase: 'reconcile-observe',
|
|
790
|
+
actionId: action.id,
|
|
791
|
+
actionType: action.type,
|
|
792
|
+
decision: 'skip-remote-consistent',
|
|
793
|
+
});
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Remote state exists but doesn't match: REMOTE_CONFLICT
|
|
798
|
+
await evidence.append({
|
|
799
|
+
phase: 'reconcile-observe',
|
|
800
|
+
actionId: action.id,
|
|
801
|
+
actionType: action.type,
|
|
802
|
+
decision: 'remote-conflict',
|
|
803
|
+
mismatches,
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
throw new ReleaseError(
|
|
807
|
+
REMOTE_CONFLICT,
|
|
808
|
+
`Remote state conflict for action "${action.id}": ${mismatches.join('; ')}`,
|
|
809
|
+
{ actionId: action.id, mismatches },
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// No expected observation or no remote state: needs retry
|
|
814
|
+
actionsToRetry.push(action);
|
|
815
|
+
await evidence.append({
|
|
816
|
+
phase: 'reconcile-observe',
|
|
817
|
+
actionId: action.id,
|
|
818
|
+
actionType: action.type,
|
|
819
|
+
decision: 'retry',
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// --- Phase 2: Validate approval and global preflight before retrying ---
|
|
824
|
+
//
|
|
825
|
+
// Marketplace actions in the retry list do NOT require approval or
|
|
826
|
+
// productionConfirmation; they are isolated consumer checks, not
|
|
827
|
+
// permanent remote writes. Only non-marketplace retries require approval.
|
|
828
|
+
// =======================================================================
|
|
829
|
+
// Split retry actions into marketplace and non-marketplace
|
|
830
|
+
const marketplaceRetries = actionsToRetry.filter((a) => MARKETPLACE_TYPES.has(a.type));
|
|
831
|
+
const nonMarketplaceRetries = actionsToRetry.filter((a) => !MARKETPLACE_TYPES.has(a.type));
|
|
832
|
+
|
|
833
|
+
if (nonMarketplaceRetries.length > 0) {
|
|
834
|
+
// Non-marketplace retries require approval and productionConfirmation
|
|
835
|
+
if (
|
|
836
|
+
plan.production?.mode === 'github-npm-v1' &&
|
|
837
|
+
productionConfirmation !== actualDigest
|
|
838
|
+
) {
|
|
839
|
+
throw new ReleaseError(
|
|
840
|
+
GATE_FAILED,
|
|
841
|
+
'production reconcile confirmation must exactly match the current plan digest before retry',
|
|
842
|
+
{ planDigest: actualDigest, actionsToRetry: nonMarketplaceRetries.map((action) => action.id) },
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
if (!approval) {
|
|
846
|
+
throw new ReleaseError(
|
|
847
|
+
GATE_FAILED,
|
|
848
|
+
'approval record is required when actions need retry but none was provided',
|
|
849
|
+
{ actionsToRetry: nonMarketplaceRetries.map((a) => a.id) },
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
context.externalWritesAuthorized = true;
|
|
854
|
+
|
|
855
|
+
await evidence.append({
|
|
856
|
+
phase: 'reconcile-approval',
|
|
857
|
+
status: 'validated',
|
|
858
|
+
retryActionCount: nonMarketplaceRetries.length,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (actionsToRetry.length > 0) {
|
|
863
|
+
// Global preflight: validate ALL retry actions before any execute
|
|
864
|
+
await evidence.append({ phase: 'reconcile-preflight', status: 'started' });
|
|
865
|
+
|
|
866
|
+
for (const action of actionsToRetry) {
|
|
867
|
+
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
868
|
+
const adapter = adapterRegistry.getAdapter(adapterActionType);
|
|
869
|
+
|
|
870
|
+
// Use appropriate context for each action type
|
|
871
|
+
const preflightCtx = MARKETPLACE_TYPES.has(action.type)
|
|
872
|
+
? { ...context, externalWritesAuthorized: false, isolatedConsumerWritesAuthorized: true }
|
|
873
|
+
: context;
|
|
874
|
+
|
|
875
|
+
const preflightResult = await adapter.preflight(
|
|
876
|
+
{ actionType: adapterActionType, ...action.parameters },
|
|
877
|
+
preflightCtx,
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
if (preflightResult.status === 'PREFLIGHT_FAILED') {
|
|
881
|
+
// Mark all retry actions as failed, stop
|
|
882
|
+
for (const retryAction of actionsToRetry) {
|
|
883
|
+
actionResults.set(retryAction.id, 'failed');
|
|
884
|
+
}
|
|
885
|
+
retryFailed = true;
|
|
886
|
+
|
|
887
|
+
await evidence.append({
|
|
888
|
+
phase: 'reconcile-preflight',
|
|
889
|
+
status: 'failed',
|
|
890
|
+
actionId: action.id,
|
|
891
|
+
error: preflightResult.error,
|
|
892
|
+
});
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (!retryFailed) {
|
|
898
|
+
await evidence.append({ phase: 'reconcile-preflight', status: 'passed' });
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Build an append-only reconcile journal before the first retry execute.
|
|
903
|
+
// It carries the complete source lineage and becomes PARTIAL as soon as an
|
|
904
|
+
// action is marked uncertain, so a process kill is directly recoverable.
|
|
905
|
+
const reconcileStartedAt = clockFn();
|
|
906
|
+
let retryStateSequence = -1;
|
|
907
|
+
let latestRetryState = null;
|
|
908
|
+
const buildReconcileState = (status = PARTIAL, finishedAt) => ({
|
|
909
|
+
runId,
|
|
910
|
+
command: 'reconcile',
|
|
911
|
+
planDigest: plan.digest,
|
|
912
|
+
planPath,
|
|
913
|
+
...(consumedApprovalPath ? {
|
|
914
|
+
approvalPath: consumedApprovalPath,
|
|
915
|
+
approvalDigest: consumedApprovalDigest,
|
|
916
|
+
} : {}),
|
|
917
|
+
sourceRunId: sourceRun.runId,
|
|
918
|
+
sourceRunDigest: sourceAuthorityDigest,
|
|
919
|
+
sourceRunPath,
|
|
920
|
+
status,
|
|
921
|
+
checkpoints: planActions.map((action) => {
|
|
922
|
+
const value = actionResults.get(action.id);
|
|
923
|
+
return {
|
|
924
|
+
actionId: action.id,
|
|
925
|
+
actionType: action.type,
|
|
926
|
+
status: value === 'succeeded' ? 'succeeded'
|
|
927
|
+
: value === 'skipped' ? 'skipped'
|
|
928
|
+
: value === 'failed' ? 'failed'
|
|
929
|
+
: value === 'uncertain' ? 'uncertain'
|
|
930
|
+
: 'pending',
|
|
931
|
+
};
|
|
932
|
+
}),
|
|
933
|
+
startedAt: reconcileStartedAt,
|
|
934
|
+
...(finishedAt ? { finishedAt } : {}),
|
|
935
|
+
});
|
|
936
|
+
const persistRetryState = async (status = PARTIAL, finishedAt) => {
|
|
937
|
+
retryStateSequence += 1;
|
|
938
|
+
latestRetryState = await appendRunState(
|
|
939
|
+
runDir,
|
|
940
|
+
retryStateSequence,
|
|
941
|
+
buildReconcileState(status, finishedAt),
|
|
942
|
+
);
|
|
943
|
+
return latestRetryState;
|
|
944
|
+
};
|
|
945
|
+
if (!retryFailed && actionsToRetry.length > 0) {
|
|
946
|
+
await persistRetryState('PUBLISHING');
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// --- Phase 3: Execute retries ---
|
|
950
|
+
if (!retryFailed && actionsToRetry.length > 0) {
|
|
951
|
+
for (const action of actionsToRetry) {
|
|
952
|
+
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
953
|
+
const adapter = adapterRegistry.getAdapter(adapterActionType);
|
|
954
|
+
|
|
955
|
+
// Use appropriate context: marketplace uses isolated consumer writes,
|
|
956
|
+
// non-marketplace uses external writes
|
|
957
|
+
const retryCtx = MARKETPLACE_TYPES.has(action.type)
|
|
958
|
+
? { ...context, externalWritesAuthorized: false, isolatedConsumerWritesAuthorized: true }
|
|
959
|
+
: context;
|
|
960
|
+
|
|
961
|
+
await evidence.append({
|
|
962
|
+
phase: 'reconcile-retry',
|
|
963
|
+
actionId: action.id,
|
|
964
|
+
actionType: action.type,
|
|
965
|
+
status: 'started',
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// Persist UNCERTAIN before authorizing execute. This snapshot is a
|
|
969
|
+
// complete PARTIAL authority and can be fed back to reconcile after a
|
|
970
|
+
// process kill.
|
|
971
|
+
actionResults.set(action.id, 'uncertain');
|
|
972
|
+
await persistRetryState(PARTIAL);
|
|
973
|
+
|
|
974
|
+
let executeResult;
|
|
975
|
+
try {
|
|
976
|
+
executeResult = await adapter.execute(
|
|
977
|
+
{ actionType: adapterActionType, ...action.parameters },
|
|
978
|
+
retryCtx,
|
|
979
|
+
);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
executeResult = { status: 'EXECUTE_FAILED', error: error.message };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
if (executeResult.status === 'EXECUTED') {
|
|
985
|
+
// Must verify after execute to check remote/install state
|
|
986
|
+
if (MARKETPLACE_TYPES.has(action.type)) {
|
|
987
|
+
// Marketplace: verify via adapter.verify (observe + matchObservation)
|
|
988
|
+
const verifyResult = await adapter.verify(
|
|
989
|
+
{ actionType: adapterActionType, ...action.parameters, expected: action.expected },
|
|
990
|
+
retryCtx,
|
|
991
|
+
);
|
|
992
|
+
|
|
993
|
+
if (verifyResult.status !== 'VERIFIED') {
|
|
994
|
+
actionResults.set(action.id, 'failed');
|
|
995
|
+
retryFailed = true;
|
|
996
|
+
await evidence.append({
|
|
997
|
+
phase: 'reconcile-retry',
|
|
998
|
+
actionId: action.id,
|
|
999
|
+
actionType: action.type,
|
|
1000
|
+
status: 'verify-mismatch',
|
|
1001
|
+
error: verifyResult.error,
|
|
1002
|
+
});
|
|
1003
|
+
await persistRetryState(PARTIAL);
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
} else {
|
|
1007
|
+
// Non-marketplace: observe after execute to verify remote state
|
|
1008
|
+
let observation;
|
|
1009
|
+
let observeError;
|
|
1010
|
+
try {
|
|
1011
|
+
const observeResult = await adapter.observe(
|
|
1012
|
+
{ actionType: adapterActionType, ...action.parameters, expected: action.expected },
|
|
1013
|
+
retryCtx,
|
|
1014
|
+
);
|
|
1015
|
+
observation = observeResult.observation;
|
|
1016
|
+
observeError = observeResult.error;
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
observeError = error.message;
|
|
1019
|
+
}
|
|
1020
|
+
if (!observation || (observeError && Object.keys(observation).length === 0)) {
|
|
1021
|
+
actionResults.set(action.id, 'uncertain');
|
|
1022
|
+
retryFailed = true;
|
|
1023
|
+
await evidence.append({
|
|
1024
|
+
phase: 'reconcile-retry',
|
|
1025
|
+
actionId: action.id,
|
|
1026
|
+
actionType: action.type,
|
|
1027
|
+
status: 'observe-failed',
|
|
1028
|
+
error: observeError ?? 'empty observation after retry execute',
|
|
1029
|
+
});
|
|
1030
|
+
await persistRetryState(PARTIAL);
|
|
1031
|
+
break;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Check observation mismatch
|
|
1035
|
+
if (action.expected) {
|
|
1036
|
+
const { matches } = matchObservation(action.expected, observation);
|
|
1037
|
+
if (!matches) {
|
|
1038
|
+
actionResults.set(action.id, 'failed');
|
|
1039
|
+
retryFailed = true;
|
|
1040
|
+
await evidence.append({
|
|
1041
|
+
phase: 'reconcile-retry',
|
|
1042
|
+
actionId: action.id,
|
|
1043
|
+
actionType: action.type,
|
|
1044
|
+
status: 'observe-mismatch',
|
|
1045
|
+
error: 'observation does not match expected after retry execute',
|
|
1046
|
+
});
|
|
1047
|
+
await persistRetryState(PARTIAL);
|
|
1048
|
+
break;
|
|
1049
|
+
}
|
|
1050
|
+
} else if (observation && observation.mismatched) {
|
|
1051
|
+
actionResults.set(action.id, 'failed');
|
|
1052
|
+
retryFailed = true;
|
|
1053
|
+
await evidence.append({
|
|
1054
|
+
phase: 'reconcile-retry',
|
|
1055
|
+
actionId: action.id,
|
|
1056
|
+
actionType: action.type,
|
|
1057
|
+
status: 'observe-mismatch',
|
|
1058
|
+
error: 'observation indicates mismatch after retry execute',
|
|
1059
|
+
});
|
|
1060
|
+
await persistRetryState(PARTIAL);
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
actionResults.set(action.id, 'succeeded');
|
|
1066
|
+
await evidence.append({
|
|
1067
|
+
phase: 'reconcile-retry',
|
|
1068
|
+
actionId: action.id,
|
|
1069
|
+
actionType: action.type,
|
|
1070
|
+
status: 'completed',
|
|
1071
|
+
});
|
|
1072
|
+
await persistRetryState(PARTIAL);
|
|
1073
|
+
} else {
|
|
1074
|
+
// A transport failure may occur after the remote accepted the
|
|
1075
|
+
// write. Non-marketplace actions must therefore be observed before
|
|
1076
|
+
// classifying the checkpoint; unknown remains uncertain and an
|
|
1077
|
+
// already-consistent remote is treated as succeeded.
|
|
1078
|
+
if (!MARKETPLACE_TYPES.has(action.type)) {
|
|
1079
|
+
let observeResult;
|
|
1080
|
+
try {
|
|
1081
|
+
observeResult = await adapter.observe(
|
|
1082
|
+
{ actionType: adapterActionType, ...action.parameters, expected: action.expected },
|
|
1083
|
+
retryCtx,
|
|
1084
|
+
);
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
observeResult = { observation: null, error: error.message };
|
|
1087
|
+
}
|
|
1088
|
+
const observation = observeResult?.observation;
|
|
1089
|
+
const missing = observation?.exists === false
|
|
1090
|
+
|| observation?.remoteCommit === ''
|
|
1091
|
+
|| observation?.commit === ''
|
|
1092
|
+
|| observation?.published === false;
|
|
1093
|
+
const matches = action.expected && observation
|
|
1094
|
+
? matchObservation(action.expected, observation).matches
|
|
1095
|
+
: false;
|
|
1096
|
+
if (matches) {
|
|
1097
|
+
actionResults.set(action.id, 'succeeded');
|
|
1098
|
+
await evidence.append({
|
|
1099
|
+
phase: 'reconcile-retry',
|
|
1100
|
+
actionId: action.id,
|
|
1101
|
+
actionType: action.type,
|
|
1102
|
+
status: 'completed-after-execute-failure-observe',
|
|
1103
|
+
});
|
|
1104
|
+
await persistRetryState(PARTIAL);
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
actionResults.set(action.id, missing ? 'failed' : 'uncertain');
|
|
1108
|
+
} else {
|
|
1109
|
+
actionResults.set(action.id, 'failed');
|
|
1110
|
+
}
|
|
1111
|
+
retryFailed = true;
|
|
1112
|
+
|
|
1113
|
+
await evidence.append({
|
|
1114
|
+
phase: 'reconcile-retry',
|
|
1115
|
+
actionId: action.id,
|
|
1116
|
+
actionType: action.type,
|
|
1117
|
+
status: actionResults.get(action.id),
|
|
1118
|
+
error: executeResult.error,
|
|
1119
|
+
});
|
|
1120
|
+
await persistRetryState(PARTIAL);
|
|
1121
|
+
break;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Mark remaining retry actions as pending if we stopped early
|
|
1127
|
+
if (retryFailed) {
|
|
1128
|
+
let foundFailed = false;
|
|
1129
|
+
for (const action of actionsToRetry) {
|
|
1130
|
+
if (['failed', 'uncertain'].includes(actionResults.get(action.id))) {
|
|
1131
|
+
foundFailed = true;
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
if (foundFailed) {
|
|
1135
|
+
actionResults.set(action.id, 'pending');
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// =======================================================================
|
|
1141
|
+
// Determine final status
|
|
1142
|
+
// =======================================================================
|
|
1143
|
+
const allSucceeded = planActions.every(
|
|
1144
|
+
(a) => {
|
|
1145
|
+
const result = actionResults.get(a.id);
|
|
1146
|
+
return result === 'succeeded' || result === 'skipped';
|
|
1147
|
+
},
|
|
1148
|
+
);
|
|
1149
|
+
|
|
1150
|
+
const effectiveFromStatus = PARTIAL;
|
|
1151
|
+
|
|
1152
|
+
// Map reconcile outcome to state machine target
|
|
1153
|
+
let overallStatus;
|
|
1154
|
+
if (allSucceeded && !retryFailed) {
|
|
1155
|
+
overallStatus = PUBLISHED;
|
|
1156
|
+
} else if (retryFailed) {
|
|
1157
|
+
// Once any retry execute was attempted, recovery must remain PARTIAL
|
|
1158
|
+
// even when no success was observed. BLOCKED is only pre-execute.
|
|
1159
|
+
overallStatus = PARTIAL;
|
|
1160
|
+
} else {
|
|
1161
|
+
// All remote checkpoints are consistent, but installation smoke still
|
|
1162
|
+
// belongs to the separate verify command.
|
|
1163
|
+
overallStatus = PUBLISHED;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Only validate state transition if status actually changes
|
|
1167
|
+
if (effectiveFromStatus !== overallStatus) {
|
|
1168
|
+
assertTransition(effectiveFromStatus, overallStatus);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
await evidence.append({
|
|
1172
|
+
phase: 'reconcile',
|
|
1173
|
+
status: 'completed',
|
|
1174
|
+
overallStatus,
|
|
1175
|
+
actionStatuses: planActions.map((a) => actionResults.get(a.id)),
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
// Build checkpoints for return value and run file
|
|
1179
|
+
const resultCheckpoints = planActions.map((a) => {
|
|
1180
|
+
const status = actionResults.get(a.id) ?? 'pending';
|
|
1181
|
+
return {
|
|
1182
|
+
actionId: a.id,
|
|
1183
|
+
status: status === 'succeeded' ? 'succeeded'
|
|
1184
|
+
: status === 'failed' ? 'failed'
|
|
1185
|
+
: status === 'skipped' ? 'skipped'
|
|
1186
|
+
: status === 'uncertain' ? 'uncertain'
|
|
1187
|
+
: 'pending',
|
|
1188
|
+
};
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
// Write new reconcile run with sourceRunId
|
|
1192
|
+
const runPath = join(runDir, 'release-run.json');
|
|
1193
|
+
const sourceRunDigest = sourceAuthorityDigest;
|
|
1194
|
+
const runState = {
|
|
1195
|
+
runId,
|
|
1196
|
+
command: 'reconcile',
|
|
1197
|
+
planDigest: plan.digest,
|
|
1198
|
+
planPath,
|
|
1199
|
+
...(consumedApprovalPath ? {
|
|
1200
|
+
approvalPath: consumedApprovalPath,
|
|
1201
|
+
approvalDigest: consumedApprovalDigest,
|
|
1202
|
+
} : {}),
|
|
1203
|
+
sourceRunId: sourceRun.runId,
|
|
1204
|
+
sourceRunDigest,
|
|
1205
|
+
sourceRunPath,
|
|
1206
|
+
status: overallStatus,
|
|
1207
|
+
checkpoints: planActions.map((a) => {
|
|
1208
|
+
const status = actionResults.get(a.id) ?? 'pending';
|
|
1209
|
+
return {
|
|
1210
|
+
actionId: a.id,
|
|
1211
|
+
actionType: a.type,
|
|
1212
|
+
status: status === 'succeeded' ? 'succeeded'
|
|
1213
|
+
: status === 'failed' ? 'failed'
|
|
1214
|
+
: status === 'skipped' ? 'skipped'
|
|
1215
|
+
: status === 'uncertain' ? 'uncertain'
|
|
1216
|
+
: 'pending',
|
|
1217
|
+
};
|
|
1218
|
+
}),
|
|
1219
|
+
startedAt: reconcileStartedAt,
|
|
1220
|
+
finishedAt: clockFn(),
|
|
1221
|
+
};
|
|
1222
|
+
if (retryStateSequence >= 0) {
|
|
1223
|
+
await persistRetryState(overallStatus, runState.finishedAt);
|
|
1224
|
+
}
|
|
1225
|
+
const persistedRun = await writeRunAtomic(runPath, runState);
|
|
1226
|
+
|
|
1227
|
+
await evidence.finish({
|
|
1228
|
+
status: overallStatus,
|
|
1229
|
+
planPath,
|
|
1230
|
+
runPath,
|
|
1231
|
+
sourceRunId: sourceRun.runId,
|
|
1232
|
+
sourceRunDigest,
|
|
1233
|
+
sourceRunPath,
|
|
1234
|
+
runDigest: persistedRun.runDigest,
|
|
1235
|
+
actionStatuses: planActions.map((a) => actionResults.get(a.id)),
|
|
1236
|
+
completedAt: clockFn(),
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
return { planPath, runPath, status: overallStatus, checkpoints: resultCheckpoints };
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
await evidence.append({
|
|
1242
|
+
phase: 'reconcile',
|
|
1243
|
+
status: 'failed',
|
|
1244
|
+
error: { code: err.code, message: err.message },
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
await evidence.finish({
|
|
1248
|
+
status: 'FAILED',
|
|
1249
|
+
error: { code: err.code, message: err.message },
|
|
1250
|
+
failedAt: clockFn(),
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
throw err;
|
|
1254
|
+
}
|
|
1255
|
+
}
|