release-skill 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +53 -0
- package/INSTALL.md +4 -4
- package/INSTALL.zh-CN.md +4 -4
- package/README.md +18 -33
- package/README.zh-CN.md +17 -23
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +2740 -1844
- package/adapters/claude/schemas/.render-manifest.json +8 -8
- package/adapters/claude/schemas/approval-record.schema.json +1 -1
- package/adapters/claude/schemas/release-plan.schema.json +6 -2
- package/adapters/claude/schemas/release-project.schema.json +14 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +2740 -1844
- package/adapters/codex/schemas/.render-manifest.json +8 -8
- package/adapters/codex/schemas/approval-record.schema.json +1 -1
- package/adapters/codex/schemas/release-plan.schema.json +6 -2
- package/adapters/codex/schemas/release-project.schema.json +14 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +2740 -1844
- package/adapters/kimi/schemas/.render-manifest.json +8 -8
- package/adapters/kimi/schemas/approval-record.schema.json +1 -1
- package/adapters/kimi/schemas/release-plan.schema.json +6 -2
- package/adapters/kimi/schemas/release-project.schema.json +14 -0
- package/bin/release-skill-cli.mjs +3 -0
- package/bin/release-skill.bundle.mjs +2740 -1844
- package/package.json +8 -2
- package/references/.render-manifest.json +8 -8
- package/references/01-state-machine.md +5 -5
- package/references/02-project-config.md +1 -1
- package/references/05-evidence-and-errors.md +1 -1
- package/references/06-adapter-contract.md +41 -1
- package/schemas/.render-manifest.json +8 -8
- package/schemas/approval-record.schema.json +1 -1
- package/schemas/release-plan.schema.json +6 -2
- package/schemas/release-project.schema.json +14 -0
- package/scripts/sync-public-files.mjs +462 -0
- package/src/adapters/contract.mjs +60 -0
- package/src/adapters/plugin-marketplace.mjs +289 -730
- package/src/commands/prepare.mjs +195 -182
- package/src/commands/publish.mjs +438 -122
- package/src/commands/reconcile.mjs +369 -191
- package/src/commands/verify.mjs +13 -2
- package/src/core/approval.mjs +72 -45
- package/src/core/baseline.mjs +16 -0
- package/src/core/checkpoints.mjs +143 -0
- package/src/core/evidence.mjs +30 -3
- package/src/core/hook-cache.mjs +254 -0
- package/src/core/hooks.mjs +37 -1
- package/src/core/observe-retry.mjs +223 -0
- package/src/core/plan.mjs +162 -253
- package/src/platforms/kimi.mjs +514 -0
- package/src/platforms/registry.mjs +393 -0
- package/src/producers/build-adapters.mjs +14 -22
- package/src/snapshot/frozen.mjs +29 -5
package/src/commands/verify.mjs
CHANGED
|
@@ -120,7 +120,10 @@ function matchesSubset(actual, expected) {
|
|
|
120
120
|
* - When smokeBin is configured: the specified bin is resolved, validated
|
|
121
121
|
* against path-escape/symlink/non-regular-file guards, and executed with
|
|
122
122
|
* smokeArgs; output is validated against smokeExpectedJson (recursive
|
|
123
|
-
* subset match) when present.
|
|
123
|
+
* subset match) when present. The expected `version` field is injected at
|
|
124
|
+
* runtime from the unit's resolved targetVersion and overrides any
|
|
125
|
+
* config-declared value, so the version check never depends on a
|
|
126
|
+
* hand-written config version (T2.1 §4.3).
|
|
124
127
|
* - When smokeBin is not configured: install + name/version check passes
|
|
125
128
|
* immediately; runBin is never called; result records
|
|
126
129
|
* cliSmoke: "not-configured".
|
|
@@ -162,7 +165,15 @@ export async function runSmokeTest(plan, root, options = {}) {
|
|
|
162
165
|
unitId: unit.id,
|
|
163
166
|
smokeBin: dist.smokeBin,
|
|
164
167
|
smokeArgs: dist.smokeArgs ?? [],
|
|
165
|
-
|
|
168
|
+
// The expected `version` is always the unit's resolved
|
|
169
|
+
// targetVersion (whose source is version.source → package.json),
|
|
170
|
+
// injected at runtime. A config-declared smokeExpectedJson.version
|
|
171
|
+
// is redundant and overridden. This keeps the version check strong
|
|
172
|
+
// without hand-writing the version into project config, so a
|
|
173
|
+
// version bump never churns configDigest (T2.1 §4.3).
|
|
174
|
+
smokeExpectedJson: dist.smokeExpectedJson
|
|
175
|
+
? { ...dist.smokeExpectedJson, version: unit.targetVersion }
|
|
176
|
+
: dist.smokeExpectedJson,
|
|
166
177
|
});
|
|
167
178
|
}
|
|
168
179
|
}
|
package/src/core/approval.mjs
CHANGED
|
@@ -4,13 +4,21 @@
|
|
|
4
4
|
* Centralises the safety gates that an approval record must pass before
|
|
5
5
|
* any external write actions can proceed:
|
|
6
6
|
* - planDigest matches the computed plan digest
|
|
7
|
-
* - baseline.gitTreeHash matches the plan baseline
|
|
7
|
+
* - baseline.gitTreeHash matches the plan baseline (v1 plans only)
|
|
8
8
|
* - targetVersion matches the plan's first unit target version
|
|
9
9
|
* - approvedActions exactly equals plan external action ids (no superset, no subset)
|
|
10
10
|
* - approval has not expired
|
|
11
11
|
* - approval duration does not exceed 24 hours
|
|
12
12
|
* - approvedAt is not in the future (beyond 5-minute clock skew tolerance)
|
|
13
13
|
*
|
|
14
|
+
* The planVersion fork (design: t1-2-digest-decoupling.md §4.3) is
|
|
15
|
+
* centralized here: for planVersion 2 plans the baseline is record-layer
|
|
16
|
+
* data -- gitTreeHash/workspaceDigest equality and the production workspace
|
|
17
|
+
* digest algorithm double-check are NOT invalidation conditions (artifact
|
|
18
|
+
* integrity is sealed by the frozen-artifact re-verification at publish).
|
|
19
|
+
* Version, action-list, planDigest, and time-window bindings are preserved
|
|
20
|
+
* for every plan version. v1 plans keep the full legacy path untouched.
|
|
21
|
+
*
|
|
14
22
|
* @module core/approval
|
|
15
23
|
*/
|
|
16
24
|
|
|
@@ -89,7 +97,19 @@ export function validateApproval(plan, approval, options = {}) {
|
|
|
89
97
|
);
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
|
|
100
|
+
// planVersion fork (centralized here; see module header): v2 plans treat
|
|
101
|
+
// the baseline as optional record-layer data.
|
|
102
|
+
const planV2 = plan?.planVersion === 2;
|
|
103
|
+
|
|
104
|
+
if (planV2) {
|
|
105
|
+
if (!approval.planDigest || !approval.expiresAt) {
|
|
106
|
+
throw new ReleaseError(
|
|
107
|
+
GATE_FAILED,
|
|
108
|
+
'approval record missing required fields: planDigest or expiresAt',
|
|
109
|
+
{ approval },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
} else if (!approval.planDigest || !approval.baseline?.gitTreeHash || !approval.expiresAt) {
|
|
93
113
|
throw new ReleaseError(
|
|
94
114
|
GATE_FAILED,
|
|
95
115
|
'approval record missing required fields: planDigest, baseline.gitTreeHash, or expiresAt',
|
|
@@ -97,35 +117,37 @@ export function validateApproval(plan, approval, options = {}) {
|
|
|
97
117
|
);
|
|
98
118
|
}
|
|
99
119
|
|
|
100
|
-
if (
|
|
101
|
-
if (plan.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
120
|
+
if (!planV2) {
|
|
121
|
+
if (plan.production?.mode === 'github-npm-v1') {
|
|
122
|
+
if (plan.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
|
|
123
|
+
throw new ReleaseError(
|
|
124
|
+
GATE_FAILED,
|
|
125
|
+
`production plan workspace digest algorithm is missing or obsolete; expected ${WORKSPACE_DIGEST_ALGORITHM}`,
|
|
126
|
+
{ expected: WORKSPACE_DIGEST_ALGORITHM, actual: plan.baseline?.workspaceDigestAlgorithm ?? null },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (approval.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
|
|
130
|
+
throw new ReleaseError(
|
|
131
|
+
GATE_FAILED,
|
|
132
|
+
`production approval workspace digest algorithm is missing or obsolete; expected ${WORKSPACE_DIGEST_ALGORITHM}`,
|
|
133
|
+
{ expected: WORKSPACE_DIGEST_ALGORITHM, actual: approval.baseline?.workspaceDigestAlgorithm ?? null },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
107
136
|
}
|
|
108
|
-
if (
|
|
137
|
+
if (
|
|
138
|
+
plan.baseline?.workspaceDigestAlgorithm &&
|
|
139
|
+
approval.baseline?.workspaceDigestAlgorithm !== plan.baseline.workspaceDigestAlgorithm
|
|
140
|
+
) {
|
|
109
141
|
throw new ReleaseError(
|
|
110
142
|
GATE_FAILED,
|
|
111
|
-
|
|
112
|
-
{
|
|
143
|
+
'approval workspace digest algorithm does not match the frozen plan',
|
|
144
|
+
{
|
|
145
|
+
planAlgorithm: plan.baseline.workspaceDigestAlgorithm,
|
|
146
|
+
approvalAlgorithm: approval.baseline?.workspaceDigestAlgorithm ?? null,
|
|
147
|
+
},
|
|
113
148
|
);
|
|
114
149
|
}
|
|
115
150
|
}
|
|
116
|
-
if (
|
|
117
|
-
plan.baseline?.workspaceDigestAlgorithm &&
|
|
118
|
-
approval.baseline?.workspaceDigestAlgorithm !== plan.baseline.workspaceDigestAlgorithm
|
|
119
|
-
) {
|
|
120
|
-
throw new ReleaseError(
|
|
121
|
-
GATE_FAILED,
|
|
122
|
-
'approval workspace digest algorithm does not match the frozen plan',
|
|
123
|
-
{
|
|
124
|
-
planAlgorithm: plan.baseline.workspaceDigestAlgorithm,
|
|
125
|
-
approvalAlgorithm: approval.baseline?.workspaceDigestAlgorithm ?? null,
|
|
126
|
-
},
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
151
|
|
|
130
152
|
// --- planDigest match ---
|
|
131
153
|
const actualDigest = computePlanDigest(plan);
|
|
@@ -137,30 +159,35 @@ export function validateApproval(plan, approval, options = {}) {
|
|
|
137
159
|
);
|
|
138
160
|
}
|
|
139
161
|
|
|
140
|
-
// --- baseline
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// --- baseline.workspaceDigest match ---
|
|
150
|
-
if (plan.baseline?.workspaceDigest) {
|
|
151
|
-
if (!approval.baseline?.workspaceDigest) {
|
|
162
|
+
// --- baseline equality checks (v1 plans only) ---
|
|
163
|
+
// For planVersion 2 plans the baseline is record-layer data: it stays in
|
|
164
|
+
// the plan/approval files for audit but is not an invalidation condition.
|
|
165
|
+
if (!planV2) {
|
|
166
|
+
// --- baseline.gitTreeHash match ---
|
|
167
|
+
if (approval.baseline.gitTreeHash !== plan.baseline?.gitTreeHash) {
|
|
152
168
|
throw new ReleaseError(
|
|
153
169
|
GATE_FAILED,
|
|
154
|
-
|
|
155
|
-
{
|
|
170
|
+
`approval baseline mismatch: approval says ${approval.baseline.gitTreeHash}, plan says ${plan.baseline?.gitTreeHash}`,
|
|
171
|
+
{ approvalTreeHash: approval.baseline.gitTreeHash, planTreeHash: plan.baseline?.gitTreeHash },
|
|
156
172
|
);
|
|
157
173
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
174
|
+
|
|
175
|
+
// --- baseline.workspaceDigest match ---
|
|
176
|
+
if (plan.baseline?.workspaceDigest) {
|
|
177
|
+
if (!approval.baseline?.workspaceDigest) {
|
|
178
|
+
throw new ReleaseError(
|
|
179
|
+
GATE_FAILED,
|
|
180
|
+
'approval record missing baseline.workspaceDigest (plan has workspaceDigest)',
|
|
181
|
+
{ planWorkspaceDigest: plan.baseline.workspaceDigest },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (approval.baseline.workspaceDigest !== plan.baseline.workspaceDigest) {
|
|
185
|
+
throw new ReleaseError(
|
|
186
|
+
GATE_FAILED,
|
|
187
|
+
`approval workspaceDigest mismatch: approval says ${approval.baseline.workspaceDigest}, plan says ${plan.baseline.workspaceDigest}`,
|
|
188
|
+
{ approvalWorkspaceDigest: approval.baseline.workspaceDigest, planWorkspaceDigest: plan.baseline.workspaceDigest },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
164
191
|
}
|
|
165
192
|
}
|
|
166
193
|
|
package/src/core/baseline.mjs
CHANGED
|
@@ -17,6 +17,16 @@ const execFile = promisify(execFileCb);
|
|
|
17
17
|
* use reserved prefixes; immutable plans and approvals use exact digest-shaped
|
|
18
18
|
* paths so arbitrary files under similarly named directories remain visible.
|
|
19
19
|
*
|
|
20
|
+
* `kimi-attestations` holds Kimi's closure-protocol lifecycle artifacts:
|
|
21
|
+
* the manual installation requirement that publish/reconcile itself emits
|
|
22
|
+
* for a PARTIAL kimi checkpoint, and the human-written attestation that is
|
|
23
|
+
* the designed closure input (independently bound to planDigest,
|
|
24
|
+
* payloadDigest, exact version/tag, install path, responsible person, and
|
|
25
|
+
* expiry). Neither is publishable source or project configuration — they
|
|
26
|
+
* never enter the frozen snapshot — so excluding them keeps reconcile's own
|
|
27
|
+
* requirement output and the flow-required attestation from invalidating
|
|
28
|
+
* the baseline of every subsequent reconcile.
|
|
29
|
+
*
|
|
20
30
|
* `project.yaml` is intentionally **not** listed — changes to project
|
|
21
31
|
* configuration must always cause a baseline drift.
|
|
22
32
|
*/
|
|
@@ -27,6 +37,12 @@ const CONTROL_PLANE_PREFIXES = [
|
|
|
27
37
|
'.release-skill/lock-audit',
|
|
28
38
|
'.release-skill/runs',
|
|
29
39
|
'.release-skill/transactions',
|
|
40
|
+
'.release-skill/kimi-attestations',
|
|
41
|
+
// T3.2 incremental hook cache: a pure local optimisation written by prepare.
|
|
42
|
+
// Excluding it keeps cache records from destabilising workspaceDigest on
|
|
43
|
+
// every prepare (and hook-cache.mjs also skips this prefix when fingerprinting
|
|
44
|
+
// inputs, so records never hash themselves).
|
|
45
|
+
'.release-skill/cache',
|
|
30
46
|
];
|
|
31
47
|
const RESERVED_CONTROL_PREFIXES = [
|
|
32
48
|
...CONTROL_PLANE_PREFIXES,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared checkpoint ordering and dependency-tier constants for the publish
|
|
3
|
+
* and reconcile sagas.
|
|
4
|
+
*
|
|
5
|
+
* These were previously duplicated byte-for-byte in `commands/publish.mjs`
|
|
6
|
+
* and `commands/reconcile.mjs` (the reconcile copy carried a `Must match
|
|
7
|
+
* publish.mjs` comment). They live here as the single source of truth so the
|
|
8
|
+
* two commands cannot drift apart (T3.1 §4.7).
|
|
9
|
+
*
|
|
10
|
+
* The tier table is a HARD-CODED dependency layering. It is never derived at
|
|
11
|
+
* runtime (no topological sort, no dynamic inference): every dependency it
|
|
12
|
+
* encodes is backed by concrete code evidence (see t3-1-parallel-checkpoints.md
|
|
13
|
+
* §3). Action types absent from every tier fail closed; they are never
|
|
14
|
+
* silently appended to the last tier.
|
|
15
|
+
*
|
|
16
|
+
* @module core/checkpoints
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Checkpoint order for the publish/reconcile sagas.
|
|
21
|
+
*
|
|
22
|
+
* Used to sort a plan's external actions into a deterministic execution
|
|
23
|
+
* order. Action types not present here sort to the end (index 999), matching
|
|
24
|
+
* the legacy inline comparator in publish.mjs.
|
|
25
|
+
*/
|
|
26
|
+
export const CHECKPOINT_ORDER = [
|
|
27
|
+
'push-commit',
|
|
28
|
+
'push-snapshot',
|
|
29
|
+
'set-default-branch',
|
|
30
|
+
'create-tag',
|
|
31
|
+
'npm-publish',
|
|
32
|
+
'github-release',
|
|
33
|
+
'claude-marketplace-install',
|
|
34
|
+
'codex-marketplace-install',
|
|
35
|
+
'kimi-marketplace-install',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Map plan action type to adapter ActionType.
|
|
40
|
+
*
|
|
41
|
+
* Plan uses `push-commit`, `push-snapshot`, `create-tag`, `npm-publish`,
|
|
42
|
+
* `github-release`. The adapter contract uses `git-push`, `git-tag`,
|
|
43
|
+
* `npm-publish`, `github-release`.
|
|
44
|
+
*/
|
|
45
|
+
export const ADAPTER_ACTION_TYPE_MAP = {
|
|
46
|
+
'push-commit': 'git-push',
|
|
47
|
+
'push-snapshot': 'push-snapshot',
|
|
48
|
+
'set-default-branch': 'set-default-branch',
|
|
49
|
+
'create-tag': 'git-tag',
|
|
50
|
+
'npm-publish': 'npm-publish',
|
|
51
|
+
'github-release': 'github-release',
|
|
52
|
+
'claude-marketplace-install': 'claude-marketplace-install',
|
|
53
|
+
'codex-marketplace-install': 'codex-marketplace-install',
|
|
54
|
+
'kimi-marketplace-install': 'kimi-marketplace-install',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Hard-coded dependency tiers for parallel checkpoint execution (T3.1 §4.1).
|
|
59
|
+
*
|
|
60
|
+
* Tiers execute strictly serially (a whole tier completes before the next
|
|
61
|
+
* begins); the actions within a tier are independent and run concurrently.
|
|
62
|
+
* Each entry's dependency is backed by code evidence:
|
|
63
|
+
* - Tier 1 `set-default-branch` / `create-tag` depend on Tier 0
|
|
64
|
+
* `push-snapshot` (the frozen commit must exist on the remote before a tag
|
|
65
|
+
* or branch tip can point at it). `npm-publish` has no git dependency and is
|
|
66
|
+
* placed in Tier 1 only for conservative scheduling.
|
|
67
|
+
* - Tier 2 `github-release` and the claude/codex marketplace installs depend
|
|
68
|
+
* on Tier 1 `create-tag` (release `--verify-tag`; install ref is the tag).
|
|
69
|
+
* - Tier 3 `kimi-marketplace-install` depends on Tier 2 `github-release`
|
|
70
|
+
* (its install URL points at the Release page).
|
|
71
|
+
*
|
|
72
|
+
* Action types not listed in any tier are unknown to the scheduler and fail
|
|
73
|
+
* closed (see groupActionsByTier); they are never silently scheduled.
|
|
74
|
+
*/
|
|
75
|
+
export const TIER_TABLE = [
|
|
76
|
+
['push-commit', 'push-snapshot'], // Tier 0
|
|
77
|
+
['set-default-branch', 'create-tag', 'npm-publish'], // Tier 1
|
|
78
|
+
['github-release', 'claude-marketplace-install', 'codex-marketplace-install'], // Tier 2
|
|
79
|
+
['kimi-marketplace-install'], // Tier 3
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Fast reverse lookup: action type -> tier index (-1 when unknown). */
|
|
83
|
+
const TIER_OF = new Map();
|
|
84
|
+
TIER_TABLE.forEach((tierTypes, tierIndex) => {
|
|
85
|
+
for (const type of tierTypes) {
|
|
86
|
+
TIER_OF.set(type, tierIndex);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Return the tier index for an action type, or -1 if the type is not present
|
|
92
|
+
* in any tier (i.e. unknown to the scheduler and must fail closed).
|
|
93
|
+
*
|
|
94
|
+
* @param {string} actionType - The plan action type.
|
|
95
|
+
* @returns {number} Tier index (0-based) or -1.
|
|
96
|
+
*/
|
|
97
|
+
export function tierOfActionType(actionType) {
|
|
98
|
+
return TIER_OF.has(actionType) ? TIER_OF.get(actionType) : -1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Sort external actions by CHECKPOINT_ORDER.
|
|
103
|
+
*
|
|
104
|
+
* Action types not in CHECKPOINT_ORDER sort to the end (index 999), matching
|
|
105
|
+
* the legacy inline comparator. Returns a new array; the input is not mutated.
|
|
106
|
+
*
|
|
107
|
+
* @param {Object[]} actions - External actions (each has a `type`).
|
|
108
|
+
* @returns {Object[]} A new sorted array.
|
|
109
|
+
*/
|
|
110
|
+
export function sortActionsByCheckpointOrder(actions) {
|
|
111
|
+
return (actions ?? []).slice().sort((a, b) => {
|
|
112
|
+
const ai = CHECKPOINT_ORDER.indexOf(a.type);
|
|
113
|
+
const bi = CHECKPOINT_ORDER.indexOf(b.type);
|
|
114
|
+
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Group an ordered list of external actions into dependency tiers.
|
|
120
|
+
*
|
|
121
|
+
* Actions are bucketed by TIER_TABLE; within a tier the input order is
|
|
122
|
+
* preserved (callers pass CHECKPOINT_ORDER-sorted actions). Actions whose type
|
|
123
|
+
* is absent from every tier are collected in `unknown` so the caller can fail
|
|
124
|
+
* closed instead of silently scheduling an unrecognized external write.
|
|
125
|
+
*
|
|
126
|
+
* @param {Object[]} orderedActions - CHECKPOINT_ORDER-sorted external actions.
|
|
127
|
+
* @returns {{ tiers: Object[][], unknown: Object[] }}
|
|
128
|
+
* `tiers[i]` is the array of actions in tier i (possibly empty);
|
|
129
|
+
* `unknown` holds actions whose type is not in any tier.
|
|
130
|
+
*/
|
|
131
|
+
export function groupActionsByTier(orderedActions) {
|
|
132
|
+
const tiers = TIER_TABLE.map(() => []);
|
|
133
|
+
const unknown = [];
|
|
134
|
+
for (const action of orderedActions ?? []) {
|
|
135
|
+
const tierIndex = tierOfActionType(action.type);
|
|
136
|
+
if (tierIndex === -1) {
|
|
137
|
+
unknown.push(action);
|
|
138
|
+
} else {
|
|
139
|
+
tiers[tierIndex].push(action);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { tiers, unknown };
|
|
143
|
+
}
|
package/src/core/evidence.mjs
CHANGED
|
@@ -95,12 +95,22 @@ export function createEvidenceWriter({ runDir, command, clock }) {
|
|
|
95
95
|
const evidencePath = `${runDir}/evidence.jsonl`;
|
|
96
96
|
const summaryPath = `${runDir}/summary.json`;
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
// Sequences start at 1, matching schemas/evidence-event.schema.json
|
|
99
|
+
// (`sequence.minimum: 1`). The historical implementation started at 0; that
|
|
100
|
+
// was an implementation/schema drift, corrected here (T3.1 §4.4).
|
|
101
|
+
let sequence = 1;
|
|
99
102
|
let handle = null;
|
|
103
|
+
// Mutex chain serializing every append. T3.1 runs same-tier checkpoints
|
|
104
|
+
// concurrently, so multiple `append` calls can be in flight at once; even
|
|
105
|
+
// under JS single-threading their awaits would interleave and could tear a
|
|
106
|
+
// line. Chaining each append behind the previous one guarantees a complete
|
|
107
|
+
// line is written before the next event starts.
|
|
108
|
+
let appendChain = Promise.resolve();
|
|
100
109
|
|
|
101
110
|
/**
|
|
102
111
|
* Lazily open the evidence file for appending.
|
|
103
112
|
* Creates the run directory if it does not exist.
|
|
113
|
+
* Must only be called from inside the serialized append chain.
|
|
104
114
|
*/
|
|
105
115
|
async function ensureHandle() {
|
|
106
116
|
if (handle === null) {
|
|
@@ -115,16 +125,23 @@ export function createEvidenceWriter({ runDir, command, clock }) {
|
|
|
115
125
|
* The event is enriched with automatic metadata:
|
|
116
126
|
* - `schemaVersion`: always 1
|
|
117
127
|
* - `runId`: extracted from the run directory name
|
|
118
|
-
* - `sequence`: auto-incrementing integer starting at
|
|
128
|
+
* - `sequence`: auto-incrementing integer starting at 1
|
|
119
129
|
* - `timestamp`: ISO-8601 string from the clock
|
|
120
130
|
* - `command`: the command passed at creation time
|
|
121
131
|
*
|
|
122
132
|
* The entire event object is redacted before writing.
|
|
123
133
|
*
|
|
134
|
+
* Ordering semantics (T3.1 §4.4): `sequence` is guaranteed monotonic but is
|
|
135
|
+
* NOT guaranteed to match real-world completion order. Same-tier checkpoints
|
|
136
|
+
* run concurrently, so their events are serialized in whichever order reaches
|
|
137
|
+
* the mutex first. Callers that need layer context attach it as `details.tier`
|
|
138
|
+
* (no top-level field is added; the evidence schema is `additionalProperties:
|
|
139
|
+
* false` at the top level).
|
|
140
|
+
*
|
|
124
141
|
* @param {Object} event - The event data. Must include `phase` and `status`;
|
|
125
142
|
* may include `error` and any other fields.
|
|
126
143
|
*/
|
|
127
|
-
async function
|
|
144
|
+
async function appendOnce(event) {
|
|
128
145
|
await ensureHandle();
|
|
129
146
|
|
|
130
147
|
const enriched = {
|
|
@@ -144,6 +161,14 @@ export function createEvidenceWriter({ runDir, command, clock }) {
|
|
|
144
161
|
await handle.write(`${line}\n`, null, 'utf8');
|
|
145
162
|
}
|
|
146
163
|
|
|
164
|
+
function append(event) {
|
|
165
|
+
const result = appendChain.then(() => appendOnce(event));
|
|
166
|
+
// Keep the chain alive even if one append rejects; the caller still
|
|
167
|
+
// receives that rejection through `result`.
|
|
168
|
+
appendChain = result.then(() => undefined, () => undefined);
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
147
172
|
/**
|
|
148
173
|
* Write the final summary file and close the evidence stream.
|
|
149
174
|
*
|
|
@@ -152,6 +177,8 @@ export function createEvidenceWriter({ runDir, command, clock }) {
|
|
|
152
177
|
* @param {Object} summary - The run summary object.
|
|
153
178
|
*/
|
|
154
179
|
async function finish(summary) {
|
|
180
|
+
// Drain any in-flight appends before closing the handle.
|
|
181
|
+
await appendChain;
|
|
155
182
|
await ensureHandle();
|
|
156
183
|
|
|
157
184
|
const redacted = redact(summary);
|