agentic-workflow-manager 9.6.0 → 9.7.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/README.md +2 -0
- package/dist/src/commands/context/index.js +3 -1
- package/dist/src/commands/track/index.js +3 -7
- package/dist/src/commands/watch/apply.js +15 -0
- package/dist/src/commands/watch/tracks.js +56 -7
- package/dist/src/core/discovery.js +79 -4
- package/dist/src/core/journal/types.js +1 -0
- package/dist/src/core/orchestrators.js +34 -10
- package/dist/src/core/text.js +7 -0
- package/dist/src/core/tracks/git.js +15 -3
- package/dist/src/core/tracks/protocol.js +26 -6
- package/dist/tests/commands/context.test.js +9 -1
- package/dist/tests/commands/hooks/install.test.js +4 -0
- package/dist/tests/commands/hooks/resync.test.js +2 -0
- package/dist/tests/commands/track/verbs.test.js +6 -0
- package/dist/tests/commands/watch/apply.test.js +46 -9
- package/dist/tests/commands/watch/track-remove-request.test.js +165 -0
- package/dist/tests/commands/watch/track-teardown-crash.test.js +27 -0
- package/dist/tests/core/context/orchestrator.test.js +2 -0
- package/dist/tests/core/discovery-multiroot.test.js +5 -0
- package/dist/tests/core/discovery-parent-race.test.js +95 -0
- package/dist/tests/core/discovery-safe-read.test.js +115 -0
- package/dist/tests/core/discovery.test.js +28 -1
- package/dist/tests/core/journal/types.test.js +11 -0
- package/dist/tests/core/orchestrators.test.js +171 -14
- package/dist/tests/core/text.test.js +10 -0
- package/dist/tests/core/tracks/git.test.js +7 -0
- package/dist/tests/core/tracks/protocol.test.js +103 -0
- package/dist/tests/integration/context-orchestrators-e2e.test.js +57 -7
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const git_fixture_1 = require("../../helpers/git-fixture");
|
|
9
|
+
const tracks_1 = require("../../../src/commands/watch/tracks");
|
|
10
|
+
const apply_1 = require("../../../src/commands/watch/apply");
|
|
11
|
+
const emit_1 = require("../../../src/commands/track/emit");
|
|
12
|
+
const store_1 = require("../../../src/core/journal/store");
|
|
13
|
+
const paths_1 = require("../../../src/core/journal/paths");
|
|
14
|
+
const BRANCH = 'main';
|
|
15
|
+
describe('awm track remove -> fallback teardown', () => {
|
|
16
|
+
let planRoot;
|
|
17
|
+
let baseSha;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
planRoot = (0, git_fixture_1.initRepo)();
|
|
20
|
+
(0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
|
|
21
|
+
baseSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
|
|
22
|
+
(0, store_1.initJournal)(planRoot, BRANCH);
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => fs_1.default.rmSync(planRoot, { recursive: true, force: true }));
|
|
25
|
+
function declareCohort(cohortPhase = 'ACTIVE') {
|
|
26
|
+
const state = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
27
|
+
state.cohortPhase = cohortPhase;
|
|
28
|
+
state.cohortBaseSha = baseSha;
|
|
29
|
+
state.tracks = ['a', 'b'].map((trackId) => ({
|
|
30
|
+
trackId, worktreePath: path_1.default.join(planRoot, `../nope-${trackId}`), branch: `awm-track/${trackId}`,
|
|
31
|
+
ownership: [], sharedResources: [], dependsOn: [], fencingToken: `fence-${trackId}`.padEnd(32, '0'),
|
|
32
|
+
phase: 'ACTIVE', readinessNonce: `ready-${trackId}`.padEnd(32, '0'),
|
|
33
|
+
}));
|
|
34
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, state);
|
|
35
|
+
return (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
36
|
+
}
|
|
37
|
+
function inertRuntime() {
|
|
38
|
+
const real = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
|
|
39
|
+
return { ...real,
|
|
40
|
+
addWorktree: () => { throw new Error('no external mutation expected'); },
|
|
41
|
+
initTrackJournal: () => { throw new Error('no external mutation expected'); },
|
|
42
|
+
spawnSupervisor: () => { throw new Error('no external mutation expected'); },
|
|
43
|
+
stopOwnSupervisor: () => { throw new Error('no external mutation expected'); },
|
|
44
|
+
removeOwnedWorktree: () => { throw new Error('no external mutation expected'); },
|
|
45
|
+
removeOwnedBranch: () => { throw new Error('no external mutation expected'); },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function events() {
|
|
49
|
+
if (!fs_1.default.existsSync((0, paths_1.eventsPath)(planRoot, BRANCH)))
|
|
50
|
+
return [];
|
|
51
|
+
return fs_1.default.readFileSync((0, paths_1.eventsPath)(planRoot, BRANCH), 'utf8').trim().split('\n').filter(Boolean)
|
|
52
|
+
.map((line) => JSON.parse(line));
|
|
53
|
+
}
|
|
54
|
+
it('consume el marker antes que join y persiste fallback cohort-wide (TR-REQ-03, TR-REQ-04)', async () => {
|
|
55
|
+
const state = declareCohort();
|
|
56
|
+
state.tracks[0].joinRequested = true;
|
|
57
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, state);
|
|
58
|
+
(0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g1', 'track-teardown-request', 'b');
|
|
59
|
+
(0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g1');
|
|
60
|
+
const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
|
|
61
|
+
expect(out.state.cohortPhase).toBe('FALLBACK_PENDING');
|
|
62
|
+
// Consumir el marcador es su propia frontera durable: el teardown
|
|
63
|
+
// empieza recién en la siguiente llamada.
|
|
64
|
+
expect(out.effectExecuted).toBeNull();
|
|
65
|
+
expect(out.state.cohortFallbackReason).toBe('controller-requested:b');
|
|
66
|
+
expect(out.state.tracks?.find((track) => track.trackId === 'b')?.teardownRequested)
|
|
67
|
+
.toBeUndefined();
|
|
68
|
+
expect(events().some((event) => event.kind === 'track-teardown-observed' && event.trackId === 'b'))
|
|
69
|
+
.toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it('un marker sobreviviente a restart converge por el mismo reducer (TR-REQ-07)', async () => {
|
|
72
|
+
const state = declareCohort();
|
|
73
|
+
state.tracks[0].teardownRequested = true;
|
|
74
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, state);
|
|
75
|
+
const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
|
|
76
|
+
expect(out.state.cohortPhase).toBe('FALLBACK_PENDING');
|
|
77
|
+
expect(out.state.tracks[0].teardownRequested).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
it.each(['SERIAL', 'COMPLETE'])('consume remove tardío como moot en %s (TR-REQ-09)', async (phase) => {
|
|
80
|
+
const state = declareCohort(phase);
|
|
81
|
+
for (const track of state.tracks) {
|
|
82
|
+
track.phase = phase === 'SERIAL' ? 'REMOVED' : 'JOINED';
|
|
83
|
+
track.frozenHeadSha = phase === 'COMPLETE' ? baseSha : undefined;
|
|
84
|
+
}
|
|
85
|
+
state.tracks[0].teardownRequested = true;
|
|
86
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, state);
|
|
87
|
+
const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
|
|
88
|
+
expect(out.state.cohortPhase).toBe(phase);
|
|
89
|
+
expect(out.state.tracks[0].teardownRequested).toBeUndefined();
|
|
90
|
+
expect(events()).toContainEqual(expect.objectContaining({ kind: 'track-teardown-moot', trackId: 'a', phase }));
|
|
91
|
+
});
|
|
92
|
+
it('una request en cohorte BLOCKED es moot y no reabre fallback', async () => {
|
|
93
|
+
const state = declareCohort('BLOCKED');
|
|
94
|
+
state.tracks[0].phase = 'BLOCKED';
|
|
95
|
+
state.tracks[1].phase = 'REMOVED';
|
|
96
|
+
state.tracks[1].teardownRequested = true;
|
|
97
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, state);
|
|
98
|
+
const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
|
|
99
|
+
expect(out.effectExecuted).toBeNull();
|
|
100
|
+
expect(out.state.cohortPhase).toBe('BLOCKED');
|
|
101
|
+
expect(out.state.tracks[1].teardownRequested).toBeUndefined();
|
|
102
|
+
expect(events()).toContainEqual(expect.objectContaining({ kind: 'track-teardown-moot', trackId: 'b', phase: 'BLOCKED' }));
|
|
103
|
+
});
|
|
104
|
+
it('consume exactamente un marcador de teardown por reconcile antes de begin-teardown (issue #93)', async () => {
|
|
105
|
+
const s = declareCohort('ACTIVE');
|
|
106
|
+
s.tracks.forEach((track) => { track.teardownRequested = true; });
|
|
107
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, s);
|
|
108
|
+
const first = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
|
|
109
|
+
expect(first.effectExecuted).toBeNull();
|
|
110
|
+
expect(first.state.tracks?.find((track) => track.trackId === 'a')?.teardownRequested).toBeUndefined();
|
|
111
|
+
expect(first.state.tracks?.find((track) => track.trackId === 'b')?.teardownRequested).toBe(true);
|
|
112
|
+
const second = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, first.state, inertRuntime(), 2);
|
|
113
|
+
expect(second.effectExecuted).toBeNull();
|
|
114
|
+
expect(second.state.tracks?.every((track) => track.teardownRequested === undefined)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
it('una request repetida durante teardown no rebobina fases ni ejecuta trabajo destructivo (issue #93)', async () => {
|
|
117
|
+
declareCohort('ACTIVE');
|
|
118
|
+
(0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g1', 'track-teardown-request', 'a');
|
|
119
|
+
(0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g1');
|
|
120
|
+
const afterFirst = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
|
|
121
|
+
(0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g2', 'track-teardown-request', 'a');
|
|
122
|
+
(0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g2');
|
|
123
|
+
const repeated = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
|
|
124
|
+
expect(afterFirst.state.tracks?.find((track) => track.trackId === 'a')?.phase).toBe('TEARDOWN_REQUESTED');
|
|
125
|
+
expect(repeated.effectExecuted).toBeNull();
|
|
126
|
+
expect(repeated.state.tracks?.find((track) => track.trackId === 'a')?.phase).toBe('TEARDOWN_REQUESTED');
|
|
127
|
+
expect(repeated.state.cohortPhase).toBe('FALLBACK_PENDING');
|
|
128
|
+
});
|
|
129
|
+
it('request durante FINAL_INTERLOCK persiste fallback sin evidencia final stale (issue #93)', async () => {
|
|
130
|
+
const s = declareCohort('FINAL_INTERLOCK');
|
|
131
|
+
for (const track of s.tracks)
|
|
132
|
+
track.phase = 'MERGED_UNVERIFIED';
|
|
133
|
+
for (const track of s.tracks)
|
|
134
|
+
track.frozenHeadSha = `${track.trackId}-head`;
|
|
135
|
+
s.globalQaHeadSha = 'qa-head';
|
|
136
|
+
s.finalIntegrationJobId = 'integration-job';
|
|
137
|
+
s.qaFinalizeRequested = { headSha: 'qa-head', at: new Date().toISOString() };
|
|
138
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, s);
|
|
139
|
+
(0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g1', 'track-teardown-request', 'a');
|
|
140
|
+
(0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g1');
|
|
141
|
+
let releases = 0;
|
|
142
|
+
const runtime = { ...inertRuntime(), releaseIntegrationLockIfHeld: () => { releases += 1; } };
|
|
143
|
+
const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, runtime, 2);
|
|
144
|
+
const durable = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
145
|
+
expect(out.state.cohortPhase).toBe('FALLBACK_PENDING');
|
|
146
|
+
expect(durable.globalQaHeadSha).toBeUndefined();
|
|
147
|
+
expect(durable.finalIntegrationJobId).toBeUndefined();
|
|
148
|
+
expect(durable.qaFinalizeRequested).toBeUndefined();
|
|
149
|
+
expect(durable.tracks?.every((track) => track.phase === 'TEARDOWN_REQUESTED')).toBe(true);
|
|
150
|
+
expect(releases).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
it('un probe de branch indemostrable detiene teardown sin serializar (issue #93)', async () => {
|
|
153
|
+
const s = declareCohort('FALLBACK_PENDING');
|
|
154
|
+
s.tracks[0].phase = 'WORKTREE_REMOVED';
|
|
155
|
+
s.tracks[1].phase = 'REMOVED';
|
|
156
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, s);
|
|
157
|
+
const gitDir = path_1.default.join(planRoot, '.git');
|
|
158
|
+
fs_1.default.renameSync(gitDir, `${gitDir}.unavailable`);
|
|
159
|
+
await expect((0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH), 2)).rejects.toThrow(/indemostrable/);
|
|
160
|
+
fs_1.default.renameSync(`${gitDir}.unavailable`, gitDir);
|
|
161
|
+
const durable = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
162
|
+
expect(durable.cohortPhase).toBe('FALLBACK_PENDING');
|
|
163
|
+
expect(durable.tracks?.find((track) => track.trackId === 'a')?.phase).toBe('WORKTREE_REMOVED');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -23,6 +23,8 @@ const child_process_1 = require("child_process");
|
|
|
23
23
|
const git_fixture_1 = require("../../helpers/git-fixture");
|
|
24
24
|
const tracks_1 = require("../../../src/commands/watch/tracks");
|
|
25
25
|
const store_1 = require("../../../src/core/journal/store");
|
|
26
|
+
const apply_1 = require("../../../src/commands/watch/apply");
|
|
27
|
+
const emit_1 = require("../../../src/commands/track/emit");
|
|
26
28
|
const git_1 = require("../../../src/core/tracks/git");
|
|
27
29
|
const descriptor_1 = require("../../../src/core/tracks/descriptor");
|
|
28
30
|
const BRANCH = 'main';
|
|
@@ -263,6 +265,31 @@ describe('reconcileTracks — crash/restart de teardown con git real (Task 13, R
|
|
|
263
265
|
expect(fs_1.default.existsSync(path_1.default.join(root, 'track-a'))).toBe(false);
|
|
264
266
|
expect(branchListed(planRoot, 'awm-track/a')).toBe(false);
|
|
265
267
|
});
|
|
268
|
+
test('request bridge sobrevive crash tras el marcador y converge a SERIAL sin repetir remociones (issue #93)', async () => {
|
|
269
|
+
let s = declareCohort(planRoot, root, baseSha);
|
|
270
|
+
materializeTrackA(s);
|
|
271
|
+
s = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
272
|
+
s.cohortPhase = 'PREPARING';
|
|
273
|
+
s.tracks = s.tracks.map((track) => track.trackId === 'a'
|
|
274
|
+
? { ...track, phase: 'WORKTREE_CREATED', supervisorProcessRef: deadRef() }
|
|
275
|
+
: track);
|
|
276
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, s);
|
|
277
|
+
(0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g1', 'track-teardown-request', 'a');
|
|
278
|
+
expect((0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g1').applied).toBe(1);
|
|
279
|
+
// Crash immediately after the request marker becomes the durable
|
|
280
|
+
// protocol observation: no teardown effect has run yet.
|
|
281
|
+
s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, buildRuntime(planRoot, []), 2)).state;
|
|
282
|
+
expect(trackA(s).phase).toBe('TEARDOWN_REQUESTED');
|
|
283
|
+
const effects = [];
|
|
284
|
+
const restarted = buildRuntime(planRoot, effects);
|
|
285
|
+
for (let i = 0; i < 20 && s.cohortPhase !== 'SERIAL'; i++) {
|
|
286
|
+
s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, restarted, 2)).state;
|
|
287
|
+
}
|
|
288
|
+
expect(s.cohortPhase).toBe('SERIAL');
|
|
289
|
+
expect(trackA(s).phase).toBe('REMOVED');
|
|
290
|
+
expect(effects.filter((effect) => effect === 'worktree-effect:a')).toHaveLength(1);
|
|
291
|
+
expect(effects.filter((effect) => effect === 'branch-effect:awm-track/a')).toHaveLength(1);
|
|
292
|
+
});
|
|
266
293
|
// R4.6/R4.10/C9: un worktree cuyo ownership es indemostrable (descriptor
|
|
267
294
|
// ausente/corrupto, o simplemente no es el que `teardownIntent` espera)
|
|
268
295
|
// NUNCA se adopta ni se borra — el track se bloquea, y el contenido
|
|
@@ -255,6 +255,8 @@ describe('InjectionOrchestrator (declared orchestrators from an installed regist
|
|
|
255
255
|
const registryRoot = (0, registries_1.registryContentRoot)('declaring');
|
|
256
256
|
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills/using-awm'), { recursive: true });
|
|
257
257
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills/using-awm/SKILL.md'), '---\nversion: "1.0.0"\n---\nBODY');
|
|
258
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills/mi-proceso'), { recursive: true });
|
|
259
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills/mi-proceso/SKILL.md'), '---\nname: mi-proceso\n---\n');
|
|
258
260
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({
|
|
259
261
|
orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' },
|
|
260
262
|
}));
|
|
@@ -60,4 +60,9 @@ describe('discovery multi-root', () => {
|
|
|
60
60
|
expect(discoverSkills([rootA, rootB]).length).toBe(1);
|
|
61
61
|
expect(discoverAgents([rootA, rootB])).toEqual([]);
|
|
62
62
|
});
|
|
63
|
+
it('rejects a real directory named SKILL.md as a nonregular artifact without blocking', () => {
|
|
64
|
+
fs_1.default.mkdirSync(path_1.default.join(rootA, 'skills', 'unsafe', 'SKILL.md'), { recursive: true });
|
|
65
|
+
const { discoverSkills } = require('../../src/core/discovery');
|
|
66
|
+
expect(() => discoverSkills([rootA])).toThrow(/SKILL\.md.*regular file/);
|
|
67
|
+
});
|
|
63
68
|
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const discovery_1 = require("../../src/core/discovery");
|
|
10
|
+
describe('real skill parent races', () => {
|
|
11
|
+
let fixture;
|
|
12
|
+
let root;
|
|
13
|
+
let skillDir;
|
|
14
|
+
let skillFile;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
fixture = fs_1.default.mkdtempSync(path_1.default.join(fs_1.default.realpathSync(os_1.default.tmpdir()), 'skill-parent-race-'));
|
|
17
|
+
root = path_1.default.join(fixture, 'registry');
|
|
18
|
+
skillDir = path_1.default.join(root, 'skills', 'safe');
|
|
19
|
+
skillFile = path_1.default.join(skillDir, 'SKILL.md');
|
|
20
|
+
fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
21
|
+
fs_1.default.writeFileSync(skillFile, '---\ndescription: Inside skill\n---\n');
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
jest.restoreAllMocks();
|
|
25
|
+
fs_1.default.rmSync(fixture, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
it('reads an unchanged skill through its descriptor', () => {
|
|
28
|
+
expect((0, discovery_1.discoverSkills)([root])).toEqual([
|
|
29
|
+
{ name: 'safe', path: skillDir, description: 'Inside skill' },
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
describe.each(['root', 'skills', 'skill directory'])('%s substitution', (component) => {
|
|
33
|
+
it.each(['symlink', 'directory'])('rejects a %s swapped after parent inspection before reading outside bytes', (replacement) => {
|
|
34
|
+
const parent = component === 'root' ? root : component === 'skills' ? path_1.default.dirname(skillDir) : skillDir;
|
|
35
|
+
const outside = path_1.default.join(fixture, 'outside');
|
|
36
|
+
const outsideFile = path_1.default.join(outside, path_1.default.relative(parent, skillFile));
|
|
37
|
+
fs_1.default.mkdirSync(path_1.default.dirname(outsideFile), { recursive: true });
|
|
38
|
+
fs_1.default.writeFileSync(outsideFile, '---\ndescription: Outside skill\n---\n');
|
|
39
|
+
const lstat = fs_1.default.lstatSync;
|
|
40
|
+
let swapped = false;
|
|
41
|
+
// Keep real lstat results; only schedule a real rename/symlink at the race boundary.
|
|
42
|
+
jest.spyOn(fs_1.default, 'lstatSync').mockImplementation(((...args) => {
|
|
43
|
+
const stat = lstat(...args);
|
|
44
|
+
if (args[0] === parent && !swapped) {
|
|
45
|
+
swapped = true;
|
|
46
|
+
fs_1.default.renameSync(parent, path_1.default.join(fixture, 'original-parent'));
|
|
47
|
+
if (replacement === 'symlink')
|
|
48
|
+
fs_1.default.symlinkSync(outside, parent, 'junction');
|
|
49
|
+
else
|
|
50
|
+
fs_1.default.renameSync(outside, parent);
|
|
51
|
+
}
|
|
52
|
+
return stat;
|
|
53
|
+
}));
|
|
54
|
+
const read = jest.spyOn(fs_1.default, 'readFileSync');
|
|
55
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/symbolic link|directory identity/i);
|
|
56
|
+
expect(swapped).toBe(true);
|
|
57
|
+
expect(read.mock.calls.filter(([file]) => typeof file === 'number'
|
|
58
|
+
|| String(file).startsWith(fixture + path_1.default.sep))).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
it('rejects an observable parent symlink introduced after open and closes the descriptor without reading', () => {
|
|
62
|
+
const open = fs_1.default.openSync;
|
|
63
|
+
let descriptor;
|
|
64
|
+
jest.spyOn(fs_1.default, 'openSync').mockImplementation((...args) => {
|
|
65
|
+
descriptor = open(...args);
|
|
66
|
+
const moved = path_1.default.join(fixture, 'original-parent');
|
|
67
|
+
fs_1.default.renameSync(skillDir, moved);
|
|
68
|
+
fs_1.default.symlinkSync(moved, skillDir, 'junction');
|
|
69
|
+
return descriptor;
|
|
70
|
+
});
|
|
71
|
+
const read = jest.spyOn(fs_1.default, 'readFileSync');
|
|
72
|
+
const close = jest.spyOn(fs_1.default, 'closeSync');
|
|
73
|
+
let thrown;
|
|
74
|
+
try {
|
|
75
|
+
(0, discovery_1.discoverSkills)([root]);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
thrown = error;
|
|
79
|
+
}
|
|
80
|
+
if (process.platform === 'win32') {
|
|
81
|
+
// Windows holds the opened file's parent directory handle, so the
|
|
82
|
+
// replacement itself is denied before a symlink can be introduced.
|
|
83
|
+
// This is the same security guarantee: outside bytes cannot be read.
|
|
84
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
85
|
+
expect(thrown.message).toMatch(/EPERM.*rename|rename.*EPERM/i);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
89
|
+
expect(thrown.message).toMatch(/symbolic link/i);
|
|
90
|
+
expect(close).toHaveBeenCalledWith(descriptor);
|
|
91
|
+
}
|
|
92
|
+
expect(read.mock.calls.filter(([file]) => typeof file === 'number'
|
|
93
|
+
|| String(file).startsWith(fixture + path_1.default.sep))).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const discovery_1 = require("../../src/core/discovery");
|
|
9
|
+
jest.mock('fs');
|
|
10
|
+
const root = path_1.default.resolve('registry');
|
|
11
|
+
const skillDir = path_1.default.join(root, 'skills', 'safe');
|
|
12
|
+
const skillFile = path_1.default.join(skillDir, 'SKILL.md');
|
|
13
|
+
const content = '---\ndescription: >-\n Ordinary skill\n---\n';
|
|
14
|
+
const descriptor = 42;
|
|
15
|
+
const inspected = {
|
|
16
|
+
dev: 7n, ino: 9007199254740993n, size: BigInt(Buffer.byteLength(content)),
|
|
17
|
+
isFile: () => true, isSymbolicLink: () => false, isDirectory: () => false,
|
|
18
|
+
};
|
|
19
|
+
const directory = { dev: 7n, ino: 8n, isFile: () => false, isSymbolicLink: () => false, isDirectory: () => true };
|
|
20
|
+
describe.each([true, false])('skill descriptor safety (O_NOFOLLOW available: %s)', (noFollowAvailable) => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
jest.resetAllMocks();
|
|
23
|
+
const actual = jest.requireActual('fs');
|
|
24
|
+
jest.replaceProperty(fs_1.default, 'constants', {
|
|
25
|
+
...actual.constants,
|
|
26
|
+
O_NOFOLLOW: noFollowAvailable ? 0x20000 : undefined,
|
|
27
|
+
O_NONBLOCK: 0x800,
|
|
28
|
+
});
|
|
29
|
+
fs_1.default.existsSync.mockReturnValue(true);
|
|
30
|
+
fs_1.default.readdirSync.mockReturnValue([{ name: 'safe', isDirectory: () => true }]);
|
|
31
|
+
fs_1.default.lstatSync.mockImplementation((file) => {
|
|
32
|
+
if (file === skillFile)
|
|
33
|
+
return inspected;
|
|
34
|
+
if (file.endsWith('awm-registry.json'))
|
|
35
|
+
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
36
|
+
return directory;
|
|
37
|
+
});
|
|
38
|
+
fs_1.default.openSync.mockReturnValue(descriptor);
|
|
39
|
+
fs_1.default.fstatSync.mockReturnValue(inspected);
|
|
40
|
+
fs_1.default.readFileSync.mockReturnValue(content);
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => jest.restoreAllMocks());
|
|
43
|
+
it('reads an ordinary skill from the validated descriptor and closes it', () => {
|
|
44
|
+
expect((0, discovery_1.discoverSkills)([root])).toEqual([{ name: 'safe', path: skillDir, description: 'Ordinary skill' }]);
|
|
45
|
+
expect(fs_1.default.readFileSync).toHaveBeenCalledWith(descriptor, 'utf-8');
|
|
46
|
+
expect(fs_1.default.closeSync).toHaveBeenCalledWith(descriptor);
|
|
47
|
+
const flags = fs_1.default.openSync.mock.calls[0][1];
|
|
48
|
+
expect(typeof flags).toBe('number');
|
|
49
|
+
if (noFollowAvailable)
|
|
50
|
+
expect(flags & fs_1.default.constants.O_NOFOLLOW).toBe(fs_1.default.constants.O_NOFOLLOW);
|
|
51
|
+
expect(flags & fs_1.default.constants.O_NONBLOCK).toBe(fs_1.default.constants.O_NONBLOCK);
|
|
52
|
+
});
|
|
53
|
+
it.each([
|
|
54
|
+
['another inode', { ino: inspected.ino + 1n }],
|
|
55
|
+
['another device', { dev: inspected.dev + 1n }],
|
|
56
|
+
['changed size', { size: inspected.size + 1n }],
|
|
57
|
+
['FIFO replacement', { isFile: () => false }],
|
|
58
|
+
['symlink replacement', { isFile: () => false, isSymbolicLink: () => true }],
|
|
59
|
+
['unobservable device', { dev: undefined }],
|
|
60
|
+
['unobservable inode', { ino: undefined }],
|
|
61
|
+
])('rejects %s after inspection, without reading replacement bytes', (_label, change) => {
|
|
62
|
+
fs_1.default.fstatSync.mockReturnValue({ ...inspected, ...change });
|
|
63
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/SKILL\.md.*(identity|regular|size|symbolic)/i);
|
|
64
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
65
|
+
expect(fs_1.default.closeSync).toHaveBeenCalledWith(descriptor);
|
|
66
|
+
});
|
|
67
|
+
it('opens nonblocking so a FIFO replacement cannot hang before fstat rejects it', () => {
|
|
68
|
+
// No FIFO is created: the open mock enforces the flag needed to avoid blocking.
|
|
69
|
+
fs_1.default.openSync.mockImplementation((_file, flags) => {
|
|
70
|
+
if (!(flags & fs_1.default.constants.O_NONBLOCK))
|
|
71
|
+
throw new Error('would block opening FIFO');
|
|
72
|
+
return descriptor;
|
|
73
|
+
});
|
|
74
|
+
fs_1.default.fstatSync.mockReturnValue({ ...inspected, isFile: () => false });
|
|
75
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/regular file/i);
|
|
76
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
77
|
+
expect(fs_1.default.closeSync).toHaveBeenCalledWith(descriptor);
|
|
78
|
+
});
|
|
79
|
+
it.each(['openSync', 'fstatSync', 'readFileSync'])('fails closed on %s errors', (operation) => {
|
|
80
|
+
fs_1.default[operation].mockImplementation(() => { throw new Error('replaced or unreadable'); });
|
|
81
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/SKILL\.md.*replaced or unreadable/);
|
|
82
|
+
if (operation === 'openSync')
|
|
83
|
+
expect(fs_1.default.closeSync).not.toHaveBeenCalled();
|
|
84
|
+
else
|
|
85
|
+
expect(fs_1.default.closeSync).toHaveBeenCalledWith(descriptor);
|
|
86
|
+
if (operation !== 'readFileSync')
|
|
87
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
it.each([root, path_1.default.join(root, 'skills'), skillDir])('rejects an observable directory symlink at %s', (linked) => {
|
|
90
|
+
const lstat = fs_1.default.lstatSync.getMockImplementation();
|
|
91
|
+
fs_1.default.lstatSync.mockImplementation((file, options) => file === linked
|
|
92
|
+
? { ...directory, isDirectory: () => false, isSymbolicLink: () => true }
|
|
93
|
+
: lstat(file, options));
|
|
94
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/symbolic link/i);
|
|
95
|
+
expect(fs_1.default.openSync).not.toHaveBeenCalled();
|
|
96
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
it.each(['dev', 'ino', 'size'])('fails closed when inspected %s is unobservable', (field) => {
|
|
99
|
+
const unknown = { ...inspected, [field]: undefined };
|
|
100
|
+
const lstat = fs_1.default.lstatSync.getMockImplementation();
|
|
101
|
+
fs_1.default.lstatSync.mockImplementation((file, options) => file === skillFile ? unknown : lstat(file, options));
|
|
102
|
+
fs_1.default.fstatSync.mockReturnValue(unknown);
|
|
103
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/identity|size/i);
|
|
104
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
it.each(['dev', 'ino'])('fails closed when parent %s is unobservable', (field) => {
|
|
107
|
+
const lstat = fs_1.default.lstatSync.getMockImplementation();
|
|
108
|
+
fs_1.default.lstatSync.mockImplementation((file, options) => file === skillDir
|
|
109
|
+
? { ...directory, [field]: undefined }
|
|
110
|
+
: lstat(file, options));
|
|
111
|
+
expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/directory identity/i);
|
|
112
|
+
expect(fs_1.default.openSync).not.toHaveBeenCalled();
|
|
113
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -15,16 +15,30 @@ const WORKFLOWS_ROOT = CONTENT_ROOT;
|
|
|
15
15
|
// For backward-compat with mock expectations that check SKILLS_DIR-style paths:
|
|
16
16
|
const SKILLS_DIR = path_1.default.join(CONTENT_ROOT, 'skills');
|
|
17
17
|
const WORKFLOWS_DIR = path_1.default.join(CONTENT_ROOT, 'workflows');
|
|
18
|
+
const directoryStat = { dev: 1n, ino: 3n, isDirectory: () => true, isSymbolicLink: () => false };
|
|
19
|
+
const skillStat = { dev: 1n, ino: 2n, size: 0n, isFile: () => true, isSymbolicLink: () => false };
|
|
18
20
|
describe('Artifact Discovery', () => {
|
|
19
21
|
beforeEach(() => {
|
|
20
22
|
jest.clearAllMocks();
|
|
21
|
-
fs_1.default.lstatSync.mockImplementation(() => {
|
|
23
|
+
fs_1.default.lstatSync.mockImplementation((p) => {
|
|
24
|
+
if (!p.endsWith('.md') && !p.endsWith('.json'))
|
|
25
|
+
return directoryStat;
|
|
22
26
|
const error = Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
23
27
|
throw error;
|
|
24
28
|
});
|
|
29
|
+
fs_1.default.openSync.mockReturnValue(42);
|
|
30
|
+
fs_1.default.fstatSync.mockReturnValue(skillStat);
|
|
31
|
+
fs_1.default.readFileSync.mockReturnValue('');
|
|
25
32
|
});
|
|
26
33
|
describe('discoverSkills', () => {
|
|
27
34
|
it('should return a list of skill directories that contain a SKILL.md', () => {
|
|
35
|
+
fs_1.default.lstatSync.mockImplementation((p) => {
|
|
36
|
+
if (p.endsWith('SKILL.md'))
|
|
37
|
+
return skillStat;
|
|
38
|
+
if (!p.endsWith('.json'))
|
|
39
|
+
return directoryStat;
|
|
40
|
+
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
41
|
+
});
|
|
28
42
|
fs_1.default.existsSync.mockImplementation((p) => {
|
|
29
43
|
if (p === SKILLS_DIR)
|
|
30
44
|
return true;
|
|
@@ -48,6 +62,19 @@ describe('Artifact Discovery', () => {
|
|
|
48
62
|
const skills = (0, discovery_1.discoverSkills)([SKILLS_ROOT]);
|
|
49
63
|
expect(skills).toEqual([]);
|
|
50
64
|
});
|
|
65
|
+
it.each(['fifo', 'symlink'])('rejects a %s SKILL.md before attempting any read', (kind) => {
|
|
66
|
+
fs_1.default.existsSync.mockReturnValue(true);
|
|
67
|
+
fs_1.default.readdirSync.mockReturnValue([{ name: 'unsafe', isDirectory: () => true }]);
|
|
68
|
+
fs_1.default.lstatSync.mockImplementation((p) => {
|
|
69
|
+
if (p.endsWith('SKILL.md'))
|
|
70
|
+
return { isFile: () => false, isSymbolicLink: () => kind === 'symlink' };
|
|
71
|
+
if (!p.endsWith('.json'))
|
|
72
|
+
return directoryStat;
|
|
73
|
+
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
74
|
+
});
|
|
75
|
+
expect(() => (0, discovery_1.discoverSkills)([SKILLS_ROOT])).toThrow(/regular file|symbolic link/);
|
|
76
|
+
expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
51
78
|
it('should skip skill directories without a SKILL.md', () => {
|
|
52
79
|
fs_1.default.existsSync.mockImplementation((p) => {
|
|
53
80
|
if (p === SKILLS_DIR)
|
|
@@ -148,6 +148,17 @@ describe('journal types', () => {
|
|
|
148
148
|
state.tracks[0].supervisorIntent.nonce = '';
|
|
149
149
|
expect((0, types_1.isWellFormedState)(state)).toBe(false);
|
|
150
150
|
});
|
|
151
|
+
test('TrackRef acepta teardownRequested booleano y rechaza otro runtime shape', () => {
|
|
152
|
+
const state = (0, types_1.emptyState)('main');
|
|
153
|
+
state.tracks = [{
|
|
154
|
+
trackId: 'cli', worktreePath: '/tmp/wt', branch: 'awm-track/cli', ownership: ['cli/'],
|
|
155
|
+
sharedResources: [], dependsOn: [], fencingToken: 'f'.repeat(32), phase: 'ACTIVE',
|
|
156
|
+
readinessNonce: 'r'.repeat(32), teardownRequested: true,
|
|
157
|
+
}];
|
|
158
|
+
expect((0, types_1.isWellFormedState)(state)).toBe(true);
|
|
159
|
+
state.tracks[0].teardownRequested = 'true';
|
|
160
|
+
expect((0, types_1.isWellFormedState)(state)).toBe(false);
|
|
161
|
+
});
|
|
151
162
|
test('trackContext, cohortPhase y trackIntegration validan shape cuando presentes (R9.1, R9.3)', () => {
|
|
152
163
|
const state = (0, types_1.emptyState)('main');
|
|
153
164
|
state.trackContext = { trackId: 'cli', taskIds: ['t1'], planDigest: 'd', baseSha: 'abc', planJournalId: 'j-plan' };
|