@ikon85/agent-workflow-kit 0.34.4 → 0.34.5
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/skills/kit-update/SKILL.md +6 -3
- package/.agents/skills/setup-workflow/SKILL.md +13 -5
- package/.agents/skills/setup-workflow/assets/agent-workflow-kit-update.yml +1 -3
- package/.claude/skills/kit-update/SKILL.md +6 -3
- package/.claude/skills/setup-workflow/SKILL.md +13 -5
- package/.claude/skills/setup-workflow/assets/agent-workflow-kit-update.yml +1 -3
- package/README.md +10 -0
- package/agent-workflow-kit.package.json +8 -8
- package/docs/adr/0001-consumer-divergence-policy.md +4 -0
- package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +63 -0
- package/docs/agents/board-sync.md +1 -1
- package/docs/agents/workflow-capabilities.json +17 -0
- package/docs/research/benchlm-routing-source.md +198 -0
- package/docs/research/consumer-owned-protocol-files.md +238 -0
- package/docs/research/frontend-agent-benchmarks.md +282 -0
- package/docs/research/model-effort-routing-benchmarks.md +261 -0
- package/docs/research/provider-neutral-agent-routing.md +207 -0
- package/package.json +1 -1
- package/scripts/kit-update-pr.mjs +1 -1
- package/scripts/kit-update-pr.test.mjs +2 -0
- package/scripts/test_skill_readiness_contract.py +6 -1
- package/scripts/test_skill_setup_workflow_seeds.py +18 -17
- package/src/commands/update.mjs +52 -20
- package/src/lib/bundle.mjs +1 -1
- package/src/lib/updateCandidate.mjs +278 -50
- package/src/lib/verifyUpdateCandidate.mjs +220 -0
- package/src/lib/verifyUpdateCandidateArtifacts.mjs +78 -0
- package/src/lib/verifyUpdateCandidateProtocol.mjs +221 -0
- package/src/lib/verifyUpdateCandidateTransaction.mjs +152 -0
package/src/commands/update.mjs
CHANGED
|
@@ -2,8 +2,11 @@ import { readFile, rm } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { assertConsumerReleaseParity } from '../../scripts/release-parity.mjs';
|
|
4
4
|
import {
|
|
5
|
-
activateCandidate, adoptReadinessCandidate,
|
|
5
|
+
activateCandidate, adoptReadinessCandidate, materializeUpdateCandidate, readReadinessManifest,
|
|
6
6
|
} from '../lib/updateCandidate.mjs';
|
|
7
|
+
import {
|
|
8
|
+
verifyCandidateSchema, verifyUpdateCandidate,
|
|
9
|
+
} from '../lib/verifyUpdateCandidate.mjs';
|
|
7
10
|
import { reconcile } from '../lib/updateReconcile.mjs';
|
|
8
11
|
import {
|
|
9
12
|
CONSUMER_MANIFEST_NAME, PACKAGE_MANIFEST_NAME, readManifest,
|
|
@@ -46,7 +49,7 @@ export async function update(options) {
|
|
|
46
49
|
async function updatePackage(options) {
|
|
47
50
|
const {
|
|
48
51
|
kitRoot, consumerRoot, decide = () => false, dryRun = false,
|
|
49
|
-
releaseIdentities, verify =
|
|
52
|
+
releaseIdentities, verify = verifyUpdateCandidate, activate = activateCandidate,
|
|
50
53
|
signal, onState = () => {}, resumeFrom,
|
|
51
54
|
} = options;
|
|
52
55
|
const history = [];
|
|
@@ -57,7 +60,8 @@ async function updatePackage(options) {
|
|
|
57
60
|
if (!pkg) throw new Error('kit package manifest not found');
|
|
58
61
|
if (!dryRun) verifyRelease(releaseIdentities, pkg.kitVersion);
|
|
59
62
|
const consumerManifestPath = join(consumerRoot, CONSUMER_MANIFEST_NAME);
|
|
60
|
-
|
|
63
|
+
const priorConsumerManifest = await readManifest(consumerManifestPath);
|
|
64
|
+
if (!priorConsumerManifest) {
|
|
61
65
|
throw new Error('not initialised — run `init` first');
|
|
62
66
|
}
|
|
63
67
|
const consumerManifestBefore = await readFile(consumerManifestPath);
|
|
@@ -75,7 +79,7 @@ async function updatePackage(options) {
|
|
|
75
79
|
let previewFailure;
|
|
76
80
|
try {
|
|
77
81
|
Object.assign(preview, await previewReadinessAdoption({
|
|
78
|
-
kitRoot, consumerRoot, priorReadinessManifest, nextReadinessManifest,
|
|
82
|
+
kitRoot, consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
|
|
79
83
|
}));
|
|
80
84
|
preview.conflicts.push(...(preview.migrationConflicts ?? []).map((path) => ({
|
|
81
85
|
path,
|
|
@@ -108,18 +112,26 @@ async function updatePackage(options) {
|
|
|
108
112
|
}
|
|
109
113
|
return applyTransaction({
|
|
110
114
|
kitRoot, consumerRoot, pkg, preview: resolvedPreview, decisions, verify, activate, signal, resumeFrom,
|
|
111
|
-
consumerManifestBefore,
|
|
115
|
+
consumerManifestBefore, priorConsumerManifest,
|
|
116
|
+
priorReadinessManifest, nextReadinessManifest, history, transition,
|
|
112
117
|
});
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
async function previewReadinessAdoption(context) {
|
|
116
|
-
const {
|
|
117
|
-
|
|
121
|
+
const {
|
|
122
|
+
kitRoot, consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
|
|
123
|
+
} = context;
|
|
124
|
+
const candidateRoot = await materializeUpdateCandidate({
|
|
125
|
+
consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
|
|
126
|
+
});
|
|
118
127
|
try {
|
|
119
|
-
await reconcile({
|
|
128
|
+
const candidatePreview = await reconcile({
|
|
120
129
|
kitRoot, consumerRoot: candidateRoot,
|
|
121
130
|
decide: (action) => action === 'collision' ? 'keep-as-owned' : false,
|
|
122
131
|
});
|
|
132
|
+
await verifyCandidateSchema(candidateRoot, {
|
|
133
|
+
pkg, preview: candidatePreview, priorReadinessManifest, nextReadinessManifest,
|
|
134
|
+
});
|
|
123
135
|
return await adoptReadinessCandidate({
|
|
124
136
|
candidateRoot, consumerRoot, priorManifest: priorReadinessManifest,
|
|
125
137
|
nextManifest: nextReadinessManifest,
|
|
@@ -146,7 +158,8 @@ async function resolvePreview({ kitRoot, consumerRoot, preview, decisions, decid
|
|
|
146
158
|
async function applyTransaction(context) {
|
|
147
159
|
const {
|
|
148
160
|
kitRoot, consumerRoot, pkg, preview, decisions, verify, activate, signal, resumeFrom,
|
|
149
|
-
consumerManifestBefore,
|
|
161
|
+
consumerManifestBefore, priorConsumerManifest,
|
|
162
|
+
priorReadinessManifest, nextReadinessManifest, history, transition,
|
|
150
163
|
} = context;
|
|
151
164
|
let candidateRoot = resumeFrom;
|
|
152
165
|
let keepCandidate = false;
|
|
@@ -157,12 +170,29 @@ async function applyTransaction(context) {
|
|
|
157
170
|
throw new Error('collision-bearing candidate cannot be resumed safely');
|
|
158
171
|
}
|
|
159
172
|
if (!candidateRoot) {
|
|
160
|
-
candidateRoot = await
|
|
173
|
+
candidateRoot = await materializeUpdateCandidate({
|
|
174
|
+
consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
|
|
175
|
+
});
|
|
161
176
|
await reconcile({
|
|
162
177
|
kitRoot, consumerRoot: candidateRoot,
|
|
163
178
|
decide: (action, path) => decisions.get(decisionKey(action, path)),
|
|
164
179
|
});
|
|
165
180
|
}
|
|
181
|
+
phase = 'verification';
|
|
182
|
+
await transition('verifying');
|
|
183
|
+
const abort = async () => {
|
|
184
|
+
keepCandidate = true;
|
|
185
|
+
return { ...await terminal(preview, 'aborted', history, transition), candidateRoot };
|
|
186
|
+
};
|
|
187
|
+
if (signal?.aborted) return abort();
|
|
188
|
+
const canonicalContext = {
|
|
189
|
+
pkg: structuredClone(pkg),
|
|
190
|
+
preview: structuredClone(preview),
|
|
191
|
+
priorConsumerManifest: structuredClone(priorConsumerManifest),
|
|
192
|
+
priorReadinessManifest: structuredClone(priorReadinessManifest),
|
|
193
|
+
nextReadinessManifest: structuredClone(nextReadinessManifest),
|
|
194
|
+
};
|
|
195
|
+
await verifyCandidateSchema(candidateRoot, canonicalContext);
|
|
166
196
|
const readiness = await adoptReadinessCandidate({
|
|
167
197
|
candidateRoot, consumerRoot, priorManifest: priorReadinessManifest,
|
|
168
198
|
nextManifest: nextReadinessManifest,
|
|
@@ -178,18 +208,20 @@ async function applyTransaction(context) {
|
|
|
178
208
|
if (readiness.incompatible.length) {
|
|
179
209
|
throw new Error(`monotonic compatibility would block existing skill core: ${readiness.incompatible.join(', ')}`);
|
|
180
210
|
}
|
|
181
|
-
|
|
182
|
-
await
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
await verify(candidateRoot);
|
|
211
|
+
canonicalContext.preview = structuredClone(preview);
|
|
212
|
+
await verifyUpdateCandidate(candidateRoot, canonicalContext);
|
|
213
|
+
if (verify !== verifyUpdateCandidate) {
|
|
214
|
+
const extensionContext = structuredClone(canonicalContext);
|
|
215
|
+
await verify(candidateRoot, extensionContext);
|
|
216
|
+
await verifyUpdateCandidate(candidateRoot, canonicalContext);
|
|
217
|
+
}
|
|
189
218
|
if (signal?.aborted) return abort();
|
|
190
219
|
phase = 'activation';
|
|
191
220
|
await activate({
|
|
192
|
-
candidateRoot, consumerRoot,
|
|
221
|
+
candidateRoot, consumerRoot,
|
|
222
|
+
pkg: canonicalContext.pkg,
|
|
223
|
+
preview: canonicalContext.preview,
|
|
224
|
+
consumerManifestBefore,
|
|
193
225
|
});
|
|
194
226
|
return { ...await terminal(preview, 'applied', history, transition), status: 'updated' };
|
|
195
227
|
} catch (error) {
|
|
@@ -252,4 +284,4 @@ async function terminal(result, state, history, transition) {
|
|
|
252
284
|
};
|
|
253
285
|
}
|
|
254
286
|
|
|
255
|
-
export { verifyCandidate } from '../lib/
|
|
287
|
+
export { verifyUpdateCandidate, verifyUpdateCandidate as verifyCandidate } from '../lib/verifyUpdateCandidate.mjs';
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -107,7 +107,7 @@ export const HELPER_FILES = [
|
|
|
107
107
|
// wave. Library (imported by the Phase-0 claim protocol) → 0o644.
|
|
108
108
|
{ path: 'src/lib/waveClaim.mjs', kind: 'script', mode: 0o644 },
|
|
109
109
|
// GitHub-consumer automation: invokes the existing update command, then owns
|
|
110
|
-
// only the stable
|
|
110
|
+
// only the stable Kit-verified branch/pull-request upsert.
|
|
111
111
|
{ path: 'scripts/kit-update-pr.mjs', kind: 'script', mode: 0o755 },
|
|
112
112
|
// Stdlib-only project census foundation. index.mjs is the stable consumer
|
|
113
113
|
// entrypoint; its five local modules must ship with it as one helper unit.
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import {
|
|
3
|
+
access, lstat, mkdtemp, open, readFile, realpath, rm, stat,
|
|
4
|
+
} from 'node:fs/promises';
|
|
3
5
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
+
import {
|
|
7
|
+
isAbsolute, join, normalize, posix, relative, sep,
|
|
8
|
+
} from 'node:path';
|
|
6
9
|
import { writeAtomic } from './atomicWrite.mjs';
|
|
7
10
|
import { validateConsumerFile } from './consumerPath.mjs';
|
|
8
11
|
import { sha256File } from './hash.mjs';
|
|
@@ -10,38 +13,195 @@ import { stubSentinel } from './sentinel.mjs';
|
|
|
10
13
|
import { STUB_TARGETS } from './bundle.mjs';
|
|
11
14
|
import {
|
|
12
15
|
CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, READINESS_MANIFEST_PATH,
|
|
13
|
-
indexByPath, readManifest, writeManifest,
|
|
16
|
+
filesForInstallRole, indexByPath, readManifest, writeManifest,
|
|
14
17
|
} from './manifest.mjs';
|
|
15
18
|
import { checkSkill, evaluateCapability, inspectProdSections } from '../../scripts/readiness.mjs';
|
|
16
19
|
|
|
17
|
-
const run = promisify(execFile);
|
|
18
20
|
const exists = (path) => access(path).then(() => true, () => false);
|
|
19
21
|
const pathEntryExists = (path) => lstat(path).then(() => true, (error) => {
|
|
20
22
|
if (error.code === 'ENOENT') return false;
|
|
21
23
|
throw error;
|
|
22
24
|
});
|
|
23
25
|
const MIGRATABLE_INSTRUCTION_PATHS = new Set(['CLAUDE.md', 'AGENTS.md']);
|
|
26
|
+
const INTEGRATION_INPUTS = [
|
|
27
|
+
'package.json', '.claude/settings.json', '.claude/settings.local.json',
|
|
28
|
+
];
|
|
29
|
+
const FORBIDDEN_CANDIDATE_ROOTS = new Set(['.git', '.worktrees', 'node_modules']);
|
|
30
|
+
const PLATFORM_PATH_SEMANTICS = { isAbsolute, normalize, sep };
|
|
24
31
|
|
|
25
|
-
/**
|
|
26
|
-
export async function
|
|
32
|
+
/** Materialize only manifest state and declared Consumer inputs for verification. */
|
|
33
|
+
export async function materializeUpdateCandidate({
|
|
34
|
+
consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
|
|
35
|
+
afterInputValidation = async () => {},
|
|
36
|
+
}) {
|
|
27
37
|
const candidateRoot = await mkdtemp(join(tmpdir(), 'agent-workflow-kit-stage-'));
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
try {
|
|
39
|
+
const paths = candidateInputPaths({
|
|
40
|
+
pkg, manifests: [priorReadinessManifest, nextReadinessManifest],
|
|
41
|
+
});
|
|
42
|
+
for (const path of paths) {
|
|
43
|
+
await copyCandidateInput(consumerRoot, candidateRoot, path, afterInputValidation);
|
|
44
|
+
}
|
|
45
|
+
await copyDeclaredRunbooks({
|
|
46
|
+
consumerRoot, candidateRoot, manifests: [priorReadinessManifest, nextReadinessManifest],
|
|
47
|
+
afterInputValidation,
|
|
48
|
+
});
|
|
49
|
+
return candidateRoot;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
await rm(candidateRoot, { recursive: true, force: true });
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function candidateInputPaths({ pkg, manifests }) {
|
|
57
|
+
const candidates = [
|
|
58
|
+
CONSUMER_MANIFEST_NAME,
|
|
59
|
+
...INTEGRATION_INPUTS,
|
|
60
|
+
...filesForInstallRole(pkg).map(({ path }) => path),
|
|
61
|
+
];
|
|
62
|
+
for (const manifest of manifests) {
|
|
63
|
+
for (const capability of Object.values(manifest?.readiness?.capabilities ?? {})) {
|
|
64
|
+
candidates.push(...(capability.evidence?.paths ?? []));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const paths = new Set(candidates.filter((path) => !isForbiddenCandidatePath(path)));
|
|
68
|
+
return [...paths].sort();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function copyDeclaredRunbooks({
|
|
72
|
+
consumerRoot, candidateRoot, manifests, afterInputValidation,
|
|
73
|
+
}) {
|
|
74
|
+
const runbooks = await declaredCandidateRunbookPaths({ candidateRoot, manifests });
|
|
75
|
+
for (const path of runbooks) {
|
|
76
|
+
await copyCandidateInput(consumerRoot, candidateRoot, path, afterInputValidation);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function declaredCandidateRunbookPaths({ candidateRoot, manifests }) {
|
|
81
|
+
const runbooks = new Set();
|
|
82
|
+
for (const manifest of manifests) {
|
|
83
|
+
for (const capability of Object.values(manifest?.readiness?.capabilities ?? {})) {
|
|
84
|
+
const evidence = capability.evidence;
|
|
85
|
+
if (evidence?.type !== 'runbook-reference') continue;
|
|
86
|
+
const declaration = await readCandidateText(candidateRoot, evidence.paths?.[0]);
|
|
87
|
+
for (const match of declaration?.matchAll(/`([^`\n]+\.md)`/g) ?? []) {
|
|
88
|
+
if (!match[1].includes('template')) runbooks.add(match[1]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [...runbooks].sort();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readCandidateText(candidateRoot, path) {
|
|
96
|
+
if (!path) return null;
|
|
97
|
+
try {
|
|
98
|
+
return await readFile(join(candidateRoot, path), 'utf8');
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error.code === 'ENOENT') return null;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function copyCandidateInput(consumerRoot, candidateRoot, path, afterInputValidation) {
|
|
106
|
+
if (isForbiddenCandidatePath(path)) return;
|
|
107
|
+
const consumerPath = validateCandidateManifestPath(path);
|
|
108
|
+
let source;
|
|
109
|
+
try {
|
|
110
|
+
source = await validateConsumerFile(consumerRoot, consumerPath);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!error.message.startsWith('unsafe consumer path (not a regular file):')) throw error;
|
|
113
|
+
if (!await pathEntryExists(join(consumerRoot, path))) return;
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
const root = await realpath(consumerRoot);
|
|
117
|
+
const resolved = await realpath(source);
|
|
118
|
+
assertResolvedConsumerPath(root, resolved, path);
|
|
119
|
+
const validated = await stat(resolved, { bigint: true });
|
|
120
|
+
const pathname = await lstat(source, { bigint: true });
|
|
121
|
+
if (!sameFile(validated, pathname)) {
|
|
122
|
+
throw new Error(`consumer input changed while staging: ${path}`);
|
|
123
|
+
}
|
|
124
|
+
await afterInputValidation(path);
|
|
125
|
+
|
|
126
|
+
let handle;
|
|
127
|
+
try {
|
|
128
|
+
handle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
129
|
+
const opened = await handle.stat({ bigint: true });
|
|
130
|
+
if (!opened.isFile() || !sameFile(validated, opened)) {
|
|
131
|
+
throw new Error(`consumer input changed while staging: ${path}`);
|
|
132
|
+
}
|
|
133
|
+
const bytes = await handle.readFile();
|
|
134
|
+
const finished = await handle.stat({ bigint: true });
|
|
135
|
+
if (!sameFileSnapshot(opened, finished)) {
|
|
136
|
+
throw new Error(`consumer input changed while staging: ${path}`);
|
|
137
|
+
}
|
|
138
|
+
const resolvedAfter = await realpath(source);
|
|
139
|
+
assertResolvedConsumerPath(root, resolvedAfter, path);
|
|
140
|
+
const current = await stat(resolvedAfter, { bigint: true });
|
|
141
|
+
if (!sameFile(opened, current)) {
|
|
142
|
+
throw new Error(`consumer input changed while staging: ${path}`);
|
|
143
|
+
}
|
|
144
|
+
await writeAtomic(join(candidateRoot, path), bytes, Number(opened.mode));
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error.code === 'ELOOP') {
|
|
147
|
+
throw new Error(`unsafe consumer path (not a regular file): ${path}`, { cause: error });
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
} finally {
|
|
151
|
+
await handle?.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Accept the package manifest's canonical slash-separated path and translate it
|
|
157
|
+
* only after platform-independent lexical validation.
|
|
158
|
+
*/
|
|
159
|
+
export function validateCandidateManifestPath(path, pathSemantics = PLATFORM_PATH_SEMANTICS) {
|
|
160
|
+
if (typeof path !== 'string' || !path || path === '.' || path.includes('\\')
|
|
161
|
+
|| path.split('/').includes('..')
|
|
162
|
+
|| posix.isAbsolute(path) || posix.normalize(path) !== path) {
|
|
163
|
+
throw new Error(`unsafe candidate manifest path: ${path}`);
|
|
164
|
+
}
|
|
165
|
+
const platformPath = path.split('/').join(pathSemantics.sep);
|
|
166
|
+
if (pathSemantics.isAbsolute(platformPath)
|
|
167
|
+
|| pathSemantics.normalize(platformPath) !== platformPath) {
|
|
168
|
+
throw new Error(`unsafe candidate manifest path: ${path}`);
|
|
169
|
+
}
|
|
170
|
+
return platformPath;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isForbiddenCandidatePath(path) {
|
|
174
|
+
if (typeof path !== 'string') return false;
|
|
175
|
+
const [root] = path.split(/[\\/]/);
|
|
176
|
+
return FORBIDDEN_CANDIDATE_ROOTS.has(root);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function assertResolvedConsumerPath(root, resolved, path) {
|
|
180
|
+
const fromRoot = relative(root, resolved);
|
|
181
|
+
if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${pathSeparator()}`)
|
|
182
|
+
|| isAbsolute(fromRoot)) {
|
|
183
|
+
throw new Error(`unsafe consumer path (resolved outside root): ${path}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function pathSeparator() {
|
|
188
|
+
return process.platform === 'win32' ? '\\' : '/';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function sameFile(left, right) {
|
|
192
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sameFileSnapshot(left, right) {
|
|
196
|
+
return sameFile(left, right) && left.size === right.size &&
|
|
197
|
+
left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
39
198
|
}
|
|
40
199
|
|
|
41
200
|
/** Activate only verified kit-owned deltas, rolling every touched path back on failure. */
|
|
42
201
|
export async function activateCandidate({
|
|
43
202
|
candidateRoot, consumerRoot, pkg, preview, consumerManifestBefore,
|
|
44
|
-
afterGenerated = async () => {},
|
|
203
|
+
afterSnapshot = async () => {}, afterGenerated = async () => {},
|
|
204
|
+
beforeTargetRevalidation = async () => {},
|
|
45
205
|
}) {
|
|
46
206
|
const changed = [...preview.added, ...preview.updated];
|
|
47
207
|
const generated = preview.generated ?? [];
|
|
@@ -50,10 +210,6 @@ export async function activateCandidate({
|
|
|
50
210
|
...changed, ...generated, ...migrations.map(({ path }) => path),
|
|
51
211
|
...preview.deleted, CONSUMER_MANIFEST_NAME,
|
|
52
212
|
];
|
|
53
|
-
const currentManifest = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
54
|
-
if (!currentManifest.equals(consumerManifestBefore)) {
|
|
55
|
-
throw new Error('consumer manifest changed during verification');
|
|
56
|
-
}
|
|
57
213
|
const pkgIdx = indexByPath(pkg, 'files');
|
|
58
214
|
for (const path of changed) {
|
|
59
215
|
if (await sha256File(join(candidateRoot, path)) !== pkgIdx.get(path)?.sha256) {
|
|
@@ -72,28 +228,77 @@ export async function activateCandidate({
|
|
|
72
228
|
throw new Error(`migrated candidate hash mismatch: ${migration.path}`);
|
|
73
229
|
}
|
|
74
230
|
}
|
|
231
|
+
const manifestBeforeSnapshot = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
232
|
+
if (!manifestBeforeSnapshot.equals(consumerManifestBefore)) {
|
|
233
|
+
throw new Error('consumer manifest changed during verification');
|
|
234
|
+
}
|
|
75
235
|
await assertConsumerStillMatchesPreview(consumerRoot, preview);
|
|
76
236
|
const rollback = new Map();
|
|
77
|
-
for (const path of touched)
|
|
237
|
+
for (const path of touched) {
|
|
238
|
+
rollback.set(path, await snapshot(join(consumerRoot, path), path));
|
|
239
|
+
}
|
|
240
|
+
await afterSnapshot();
|
|
241
|
+
const currentManifest = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
242
|
+
if (!currentManifest.equals(consumerManifestBefore)) {
|
|
243
|
+
throw new Error('consumer manifest changed during verification');
|
|
244
|
+
}
|
|
245
|
+
await assertConsumerStillMatchesPreview(consumerRoot, preview);
|
|
246
|
+
const applied = [];
|
|
247
|
+
const applyTarget = async (path, action) => {
|
|
248
|
+
await beforeTargetRevalidation(path);
|
|
249
|
+
await assertTargetStillMatchesSnapshot(
|
|
250
|
+
join(consumerRoot, path), rollback.get(path), path,
|
|
251
|
+
);
|
|
252
|
+
// Optimistic revalidation cannot remove the filesystem check-to-rename
|
|
253
|
+
// micro-window; it does keep every later destination behind a fresh check.
|
|
254
|
+
await action();
|
|
255
|
+
const record = { path, snapshot: null, captured: false };
|
|
256
|
+
applied.push(record);
|
|
257
|
+
record.snapshot = await snapshot(join(consumerRoot, path), path);
|
|
258
|
+
record.captured = true;
|
|
259
|
+
};
|
|
78
260
|
try {
|
|
79
261
|
for (const path of changed) {
|
|
80
|
-
|
|
262
|
+
const bytes = await readFile(join(candidateRoot, path));
|
|
263
|
+
await applyTarget(path, () => writeAtomic(
|
|
264
|
+
join(consumerRoot, path), bytes, pkgIdx.get(path)?.mode,
|
|
265
|
+
));
|
|
81
266
|
}
|
|
82
267
|
for (const path of generated) {
|
|
83
|
-
|
|
268
|
+
const bytes = await readFile(join(candidateRoot, path));
|
|
269
|
+
await applyTarget(path, () => writeAtomic(join(consumerRoot, path), bytes));
|
|
84
270
|
}
|
|
85
271
|
for (const { path } of migrations) {
|
|
86
|
-
|
|
272
|
+
const bytes = await readFile(join(candidateRoot, path));
|
|
273
|
+
await applyTarget(path, () => writeAtomic(join(consumerRoot, path), bytes));
|
|
87
274
|
}
|
|
88
275
|
await afterGenerated();
|
|
89
|
-
for (const path of preview.deleted)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
276
|
+
for (const path of preview.deleted) {
|
|
277
|
+
await applyTarget(path, () => rm(join(consumerRoot, path), { force: true }));
|
|
278
|
+
}
|
|
279
|
+
const manifestBytes = await readFile(join(candidateRoot, CONSUMER_MANIFEST_NAME));
|
|
280
|
+
await applyTarget(CONSUMER_MANIFEST_NAME, () => writeAtomic(
|
|
281
|
+
join(consumerRoot, CONSUMER_MANIFEST_NAME), manifestBytes,
|
|
282
|
+
));
|
|
94
283
|
} catch (error) {
|
|
95
|
-
|
|
96
|
-
|
|
284
|
+
const rollbackConflicts = [];
|
|
285
|
+
for (const record of applied.reverse()) {
|
|
286
|
+
const target = join(consumerRoot, record.path);
|
|
287
|
+
if (!record.captured
|
|
288
|
+
|| !await targetStillMatchesSnapshot(target, record.snapshot, record.path)) {
|
|
289
|
+
rollbackConflicts.push(record.path);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
await restore(target, rollback.get(record.path));
|
|
293
|
+
}
|
|
294
|
+
if (rollbackConflicts.length) {
|
|
295
|
+
rollbackConflicts.sort();
|
|
296
|
+
error.message = `${error.message}; rollback preserved concurrent edits: ` +
|
|
297
|
+
rollbackConflicts.join(', ');
|
|
298
|
+
}
|
|
299
|
+
error.consumerState = rollbackConflicts.length
|
|
300
|
+
? 'rollback-conflicted'
|
|
301
|
+
: (applied.length ? 'rolled-back' : 'unchanged');
|
|
97
302
|
throw error;
|
|
98
303
|
}
|
|
99
304
|
}
|
|
@@ -278,26 +483,49 @@ export async function readReadinessManifest(root) {
|
|
|
278
483
|
return readManifest(join(root, READINESS_MANIFEST_PATH));
|
|
279
484
|
}
|
|
280
485
|
|
|
281
|
-
async function snapshot(path) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
486
|
+
async function snapshot(path, displayPath = path) {
|
|
487
|
+
let before;
|
|
488
|
+
try {
|
|
489
|
+
before = await lstat(path, { bigint: true });
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (error.code === 'ENOENT') return null;
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
if (!before.isFile()) {
|
|
495
|
+
throw new Error(`unsafe consumer activation path: ${displayPath}`);
|
|
496
|
+
}
|
|
497
|
+
const bytes = await readFile(path);
|
|
498
|
+
const after = await lstat(path, { bigint: true });
|
|
499
|
+
if (!sameFileSnapshot(before, after)) {
|
|
500
|
+
throw new Error(`consumer changed during activation: ${displayPath}`);
|
|
501
|
+
}
|
|
502
|
+
return { bytes, mode: Number(before.mode), identity: before };
|
|
285
503
|
}
|
|
286
504
|
|
|
287
|
-
async function
|
|
288
|
-
if (!
|
|
289
|
-
|
|
505
|
+
async function assertTargetStillMatchesSnapshot(path, expected, displayPath) {
|
|
506
|
+
if (!await targetStillMatchesSnapshot(path, expected, displayPath)) {
|
|
507
|
+
throw new Error(`consumer changed during activation: ${displayPath}`);
|
|
508
|
+
}
|
|
290
509
|
}
|
|
291
510
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
let pkg;
|
|
511
|
+
async function targetStillMatchesSnapshot(path, expected, displayPath) {
|
|
512
|
+
let current;
|
|
295
513
|
try {
|
|
296
|
-
|
|
514
|
+
current = await snapshot(path, displayPath);
|
|
297
515
|
} catch (error) {
|
|
298
|
-
|
|
299
|
-
throw error;
|
|
516
|
+
return false;
|
|
300
517
|
}
|
|
301
|
-
|
|
302
|
-
|
|
518
|
+
return sameActivationSnapshot(expected, current);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function sameActivationSnapshot(expected, current) {
|
|
522
|
+
if (!expected || !current) return expected === current;
|
|
523
|
+
return expected.mode === current.mode
|
|
524
|
+
&& expected.bytes.equals(current.bytes)
|
|
525
|
+
&& sameFileSnapshot(expected.identity, current.identity);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function restore(path, saved) {
|
|
529
|
+
if (!saved) return rm(path, { force: true });
|
|
530
|
+
await writeAtomic(path, saved.bytes, saved.mode);
|
|
303
531
|
}
|