agentic-workflow-manager 3.4.0 → 3.5.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.
Files changed (52) hide show
  1. package/dist/src/commands/job/exec-wrapper.js +136 -0
  2. package/dist/src/commands/job/export.js +94 -0
  3. package/dist/src/commands/job/gate.js +118 -0
  4. package/dist/src/commands/job/heartbeat.js +15 -0
  5. package/dist/src/commands/job/index.js +246 -0
  6. package/dist/src/commands/job/query.js +37 -0
  7. package/dist/src/commands/job/reap.js +24 -0
  8. package/dist/src/commands/job/reconcile.js +112 -0
  9. package/dist/src/commands/job/request.js +27 -0
  10. package/dist/src/commands/watch/apply.js +352 -0
  11. package/dist/src/commands/watch/generations.js +249 -0
  12. package/dist/src/commands/watch/index.js +49 -0
  13. package/dist/src/commands/watch/init.js +72 -0
  14. package/dist/src/commands/watch/lock.js +89 -0
  15. package/dist/src/commands/watch/runner.js +191 -0
  16. package/dist/src/commands/watch/supervisor.js +266 -0
  17. package/dist/src/core/atomic-file.js +31 -0
  18. package/dist/src/core/export/pack.js +7 -1
  19. package/dist/src/core/journal/adapter.js +27 -0
  20. package/dist/src/core/journal/fingerprint.js +80 -0
  21. package/dist/src/core/journal/paths.js +56 -0
  22. package/dist/src/core/journal/process.js +284 -0
  23. package/dist/src/core/journal/redact.js +142 -0
  24. package/dist/src/core/journal/requests.js +132 -0
  25. package/dist/src/core/journal/store.js +107 -0
  26. package/dist/src/core/journal/types.js +165 -0
  27. package/dist/src/index.js +4 -0
  28. package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
  29. package/dist/tests/commands/job/export.test.js +76 -0
  30. package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
  31. package/dist/tests/commands/job/reap-cli.test.js +101 -0
  32. package/dist/tests/commands/job/verbs.test.js +56 -0
  33. package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
  34. package/dist/tests/commands/watch/apply.test.js +397 -0
  35. package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
  36. package/dist/tests/commands/watch/generations.test.js +115 -0
  37. package/dist/tests/commands/watch/integration.test.js +124 -0
  38. package/dist/tests/commands/watch/lock.test.js +60 -0
  39. package/dist/tests/commands/watch/runner.test.js +239 -0
  40. package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
  41. package/dist/tests/commands/watch/watch-init.test.js +43 -0
  42. package/dist/tests/core/atomic-file-durable.test.js +42 -0
  43. package/dist/tests/core/journal/adapter.test.js +27 -0
  44. package/dist/tests/core/journal/fingerprint.test.js +164 -0
  45. package/dist/tests/core/journal/paths.test.js +35 -0
  46. package/dist/tests/core/journal/process.test.js +213 -0
  47. package/dist/tests/core/journal/redact.test.js +59 -0
  48. package/dist/tests/core/journal/requests.test.js +134 -0
  49. package/dist/tests/core/journal/store.test.js +88 -0
  50. package/dist/tests/core/journal/types.test.js +78 -0
  51. package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
  52. package/package.json +1 -1
@@ -0,0 +1,203 @@
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 os_1 = __importDefault(require("os"));
9
+ const child_process_1 = require("child_process");
10
+ const supervisor_1 = require("../../../src/commands/watch/supervisor");
11
+ const exec_wrapper_1 = require("../../../src/commands/job/exec-wrapper");
12
+ const generations_1 = require("../../../src/commands/watch/generations");
13
+ const init_1 = require("../../../src/commands/watch/init");
14
+ const request_1 = require("../../../src/commands/job/request");
15
+ const requests_1 = require("../../../src/core/journal/requests");
16
+ const store_1 = require("../../../src/core/journal/store");
17
+ const paths_1 = require("../../../src/core/journal/paths");
18
+ const process_1 = require("../../../src/core/journal/process");
19
+ const fingerprint_1 = require("../../../src/core/journal/fingerprint");
20
+ jest.setTimeout(60000);
21
+ const fakeSpawner = (job, nonce, logsRoot, repoRoot) => {
22
+ void (0, exec_wrapper_1.runExecWrapper)({ logsRoot, jobId: job.id, nonce, argv: job.argv, cwd: job.cwd, repoRoot }).catch(() => { });
23
+ };
24
+ function git(cwd, ...args) {
25
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd });
26
+ }
27
+ function setupRepo() {
28
+ const repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-loop-'));
29
+ git(repo, 'init', '-q', '-b', 'main');
30
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'x');
31
+ git(repo, 'add', '.');
32
+ git(repo, 'commit', '-qm', 'c');
33
+ fs_1.default.mkdirSync(path_1.default.join(repo, '.awm'), { recursive: true });
34
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'package.json'), JSON.stringify({ scripts: { test: 'node -e "process.exit(0)"' } }));
35
+ fs_1.default.writeFileSync(path_1.default.join(repo, '.awm', 'sensors.json'), '{}');
36
+ return repo;
37
+ }
38
+ function emitVerdict(repo, token, obligationId, verdictId) {
39
+ const argv = ['awm-review', obligationId];
40
+ const fp = (0, fingerprint_1.computeFingerprint)(repo, argv, [], '.').fingerprint;
41
+ (0, requests_1.emitRequest)(repo, 'main', { kind: 'verdict', generationToken: token, idempotencyKey: verdictId,
42
+ payload: { verdictId, obligationId, result: 'pass', detail: 'ok', fingerprint: fp, argv, paths: [], cwd: '.' } });
43
+ }
44
+ async function until(fn, ms = 30000) {
45
+ const t0 = Date.now();
46
+ while (!fn()) {
47
+ if (Date.now() - t0 > ms)
48
+ throw new Error('timeout');
49
+ await new Promise((r) => setTimeout(r, 50));
50
+ }
51
+ }
52
+ describe('supervisor loop', () => {
53
+ let repo;
54
+ let stubBin;
55
+ let oldPath;
56
+ beforeEach(() => {
57
+ repo = setupRepo();
58
+ stubBin = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-stub-'));
59
+ fs_1.default.writeFileSync(path_1.default.join(stubBin, 'codex'), '#!/bin/sh\nwhile true; do sleep 1; done\n', { mode: 0o755 });
60
+ oldPath = process.env.PATH;
61
+ process.env.PATH = `${stubBin}:${process.env.PATH}`;
62
+ });
63
+ afterEach(() => {
64
+ process.env.PATH = oldPath;
65
+ fs_1.default.rmSync(repo, { recursive: true, force: true });
66
+ fs_1.default.rmSync(stubBin, { recursive: true, force: true });
67
+ });
68
+ test('ticks drenan y declaran COMPLETE solo con gate verde + cero vivos (R4.5)', async () => {
69
+ (0, init_1.initWatch)(repo, 'main'); // sin package.json => requiredVerifiers []
70
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: 'codex', tickMs: 50, reconcileGraceMs: 10000 };
71
+ const sup = new supervisor_1.Supervisor(repo, 'main', cfg, fakeSpawner);
72
+ // el controlador (aqui: el test) registra plan de ciclo + task + jobs enlazados
73
+ (0, requests_1.emitRequest)(repo, 'main', { kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'e1',
74
+ payload: { entity: 'task', taskId: 'T1', title: 't', verificationPlan: [{ id: 'v1', kind: 'test' }, { id: 'v-sensors', kind: 'sensors' }], reviewObligations: [{ id: 'o-spec', kind: 'spec' }, { id: 'o-quality', kind: 'quality' }] } });
75
+ (0, requests_1.emitRequest)(repo, 'main', { kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'e2',
76
+ payload: { entity: 'cycle-plan', items: [{ id: 'cv1', kind: 'qa' }, { id: 'cv-interlock', kind: 'interlock' }] } });
77
+ (0, request_1.requestJob)(repo, 'main', 'g0', ['node', '-e', 'setTimeout(()=>process.exit(0), 400)'], [], '.', { satisfies: 'v1' });
78
+ (0, request_1.requestJob)(repo, 'main', 'g0', ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'v-sensors' });
79
+ (0, request_1.requestJob)(repo, 'main', 'g0', ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'cv1' });
80
+ (0, request_1.requestJob)(repo, 'main', 'g0', ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'cv-interlock' });
81
+ emitVerdict(repo, 'g0', 'o-spec', 'verd-spec');
82
+ emitVerdict(repo, 'g0', 'o-quality', 'verd-quality');
83
+ (0, requests_1.emitRequest)(repo, 'main', { kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'e3',
84
+ payload: { entity: 'task-status', taskId: 'T1', status: 'done' } });
85
+ let sawContinueWithLiveJob = false;
86
+ let outcome = 'continue';
87
+ for (let i = 0; i < 400 && outcome !== 'complete'; i++) {
88
+ outcome = await sup.tick();
89
+ const s = (0, store_1.readJournal)(repo, 'main').state;
90
+ const live = Object.values(s.jobs).some((j) => ['received', 'spawn-intent', 'claimed', 'running'].includes(j.executionState));
91
+ if (outcome === 'continue' && live)
92
+ sawContinueWithLiveJob = true; // drenaje ANTES de COMPLETE
93
+ await new Promise((r) => setTimeout(r, 50));
94
+ }
95
+ expect(outcome).toBe('complete');
96
+ expect(sawContinueWithLiveJob).toBe(true);
97
+ const final = (0, store_1.readJournal)(repo, 'main').state;
98
+ expect(final.cycle.status).toBe('COMPLETE');
99
+ expect(typeof final.cycle.completedAt).toBe('string');
100
+ expect(Object.values(final.jobs).every((j) => j.executionState === 'exited' && j.verdict === 'pass')).toBe(true);
101
+ });
102
+ test('tick verifica branch antes del launch y un ciclo COMPLETE no lanza otro controller', async () => {
103
+ (0, init_1.initWatch)(repo, 'main');
104
+ let calls = 0;
105
+ const spy = () => { calls++; };
106
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10 };
107
+ const s = (0, store_1.readJournal)(repo, 'main').state;
108
+ s.cycle.status = 'COMPLETE';
109
+ (0, store_1.writeJournal)(repo, 'main', s);
110
+ expect(await new supervisor_1.Supervisor(repo, 'main', cfg, spy).tick()).toBe('complete');
111
+ expect(calls).toBe(0);
112
+ const reset = (0, store_1.readJournal)(repo, 'main').state;
113
+ reset.cycle.status = 'IN_PROGRESS';
114
+ (0, store_1.writeJournal)(repo, 'main', reset);
115
+ git(repo, 'checkout', '-qb', 'otra');
116
+ await expect(new supervisor_1.Supervisor(repo, 'main', cfg, spy).tick()).rejects.toThrow(/rama|branch/i);
117
+ expect(calls).toBe(0);
118
+ });
119
+ test('fallo de launch queda durable y entra en backoff sin tumbar el supervisor (R4.3)', async () => {
120
+ (0, init_1.initWatch)(repo, 'main');
121
+ (0, generations_1.beginGeneration)(repo, 'main');
122
+ let calls = 0;
123
+ const failing = () => { calls++; throw new Error('provider unavailable'); };
124
+ const sup = new supervisor_1.Supervisor(repo, 'main', { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10 }, failing);
125
+ await expect(sup.tick()).resolves.toBe('continue');
126
+ expect(calls).toBe(1);
127
+ const intent = (0, generations_1.activeGeneration)((0, store_1.readJournal)(repo, 'main').state);
128
+ expect(intent.controllerJobId).toBeDefined();
129
+ expect(intent.spawnNonce).toBeDefined();
130
+ await expect(sup.tick()).resolves.toBe('continue');
131
+ expect(calls).toBe(1); // primer backoff es 60 s: no hace hot-loop
132
+ });
133
+ test('custodia: doble senial + indeterminate => tick custody, lock retenido, proceso intacto (R4.2b/R4.5)', async () => {
134
+ (0, store_1.initJournal)(repo, 'main');
135
+ (0, generations_1.beginGeneration)(repo, 'main');
136
+ const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 20000)'], process.cwd(), 'nCtl');
137
+ let s = (0, store_1.readJournal)(repo, 'main').state;
138
+ (0, generations_1.activeGeneration)(s).processRef = ref;
139
+ s.controllerHeartbeatAt = new Date(Date.now() - 3600000).toISOString(); // heartbeat vencido hace 1h
140
+ (0, store_1.writeJournal)(repo, 'main', s);
141
+ fs_1.default.mkdirSync(path_1.default.dirname((0, paths_1.supervisorLockPath)(repo)), { recursive: true });
142
+ fs_1.default.writeFileSync((0, paths_1.supervisorLockPath)(repo), 'lock-del-loop'); // el loop lo tendria: NO debe borrarse
143
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: 'codex', heartbeatTimeoutMs: 1, activityWindowMs: 50, tickMs: 20 };
144
+ const sup = new supervisor_1.Supervisor(repo, 'main', cfg, fakeSpawner);
145
+ await sup.tick(); // primer tick: arranca el tracking de actividad
146
+ await new Promise((r) => setTimeout(r, 150)); // actividad congelada > ventana
147
+ const out = await sup.tick();
148
+ expect(out).toBe('custody');
149
+ const after = (0, store_1.readJournal)(repo, 'main').state;
150
+ expect(after.cycle.status).toBe('BLOCKED');
151
+ expect(fs_1.default.existsSync((0, paths_1.supervisorLockPath)(repo))).toBe(true); // custodia NO libera el lock
152
+ expect(child.killed).toBe(false); // y NO mato al controlador
153
+ child.kill('SIGKILL');
154
+ });
155
+ test('runSupervisorLoop: bootstrap gen-1 con stub codex, COMPLETE => libera lock y termina su generacion (R4.1/R4.5/R2.4)', async () => {
156
+ (0, init_1.initWatch)(repo, 'main');
157
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: 'codex', tickMs: 50, termGraceMs: 300, killGraceMs: 300 };
158
+ const loop = (0, supervisor_1.runSupervisorLoop)(repo, 'main', cfg, fakeSpawner);
159
+ await until(() => {
160
+ const r = (0, store_1.readJournal)(repo, 'main');
161
+ return r.state !== null && (0, generations_1.activeGeneration)(r.state) !== undefined && fs_1.default.existsSync((0, paths_1.supervisorLockPath)(repo));
162
+ });
163
+ const token = (0, generations_1.activeGeneration)((0, store_1.readJournal)(repo, 'main').state).token;
164
+ (0, requests_1.emitRequest)(repo, 'main', { kind: 'register-entity', generationToken: token, idempotencyKey: 'e1',
165
+ payload: { entity: 'cycle-plan', items: [{ id: 'cv1', kind: 'qa' }, { id: 'cv2', kind: 'interlock' }, { id: 'cv3', kind: 'test' }, { id: 'cv4', kind: 'sensors' }] } });
166
+ (0, request_1.requestJob)(repo, 'main', token, ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'cv1' });
167
+ (0, request_1.requestJob)(repo, 'main', token, ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'cv2' });
168
+ (0, request_1.requestJob)(repo, 'main', token, ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'cv3' });
169
+ (0, request_1.requestJob)(repo, 'main', token, ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'cv4' });
170
+ await loop; // auto-exit tras COMPLETE
171
+ expect(fs_1.default.existsSync((0, paths_1.supervisorLockPath)(repo))).toBe(false); // lock liberado
172
+ const final = (0, store_1.readJournal)(repo, 'main').state;
173
+ expect(final.cycle.status).toBe('COMPLETE');
174
+ const gen = final.generations[0];
175
+ // generacion propia terminada: cero procesos codex huerfanos (R2.4)
176
+ const { refIsAlive } = require('../../../src/core/journal/process');
177
+ expect(gen.processRef === undefined || !refIsAlive(gen.processRef)).toBe(true);
178
+ });
179
+ test('reinicio tras crash entre beginGeneration y spawn recupera la misma generacion sin quedar wedged', async () => {
180
+ (0, init_1.initWatch)(repo, 'main');
181
+ const begun = (0, generations_1.beginGeneration)(repo, 'main'); // crash simulado: intent durable, sin ProcessRef
182
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: 'codex', tickMs: 25, reconcileGraceMs: 300,
183
+ termGraceMs: 300, killGraceMs: 300 };
184
+ const loop = (0, supervisor_1.runSupervisorLoop)(repo, 'main', cfg, fakeSpawner);
185
+ let recovered = false;
186
+ try {
187
+ await until(() => {
188
+ const active = (0, generations_1.activeGeneration)((0, store_1.readJournal)(repo, 'main').state);
189
+ recovered = active?.token === begun.token && active.processRef !== undefined;
190
+ return recovered;
191
+ }, 1500);
192
+ }
193
+ catch { /* la asercion de abajo conserva un fallo limpio y permite apagar el loop */ }
194
+ (0, requests_1.emitRequest)(repo, 'main', { kind: 'register-entity', generationToken: begun.token, idempotencyKey: 'recover-plan',
195
+ payload: { entity: 'cycle-plan', items: [{ id: 'r-qa', kind: 'qa' }, { id: 'r-interlock', kind: 'interlock' }, { id: 'r-test', kind: 'test' }, { id: 'r-sensors', kind: 'sensors' }] } });
196
+ for (const item of ['r-qa', 'r-interlock', 'r-test', 'r-sensors']) {
197
+ (0, request_1.requestJob)(repo, 'main', begun.token, ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: item });
198
+ }
199
+ await loop;
200
+ expect(recovered).toBe(true);
201
+ expect((0, store_1.readJournal)(repo, 'main').state.generations).toHaveLength(1);
202
+ });
203
+ });
@@ -0,0 +1,43 @@
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 os_1 = __importDefault(require("os"));
9
+ const init_1 = require("../../../src/commands/watch/init");
10
+ const store_1 = require("../../../src/core/journal/store");
11
+ describe('watch --init: plan-vs-repo mecanico', () => {
12
+ let repo;
13
+ beforeEach(() => { repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-init-')); });
14
+ afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
15
+ test('package.json con script test => verificador test requerido (R1.4b)', () => {
16
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'package.json'), JSON.stringify({ scripts: { test: 'jest' } }));
17
+ expect((0, init_1.detectRequiredVerifiers)(repo)).toEqual(['test']);
18
+ });
19
+ test('sensors.json => verificador sensors requerido; ambos => ambos (R1.4b)', () => {
20
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'package.json'), JSON.stringify({ scripts: { test: 'jest' } }));
21
+ fs_1.default.mkdirSync(path_1.default.join(repo, '.awm'), { recursive: true });
22
+ fs_1.default.writeFileSync(path_1.default.join(repo, '.awm', 'sensors.json'), '{}');
23
+ expect((0, init_1.detectRequiredVerifiers)(repo)).toEqual(['test', 'sensors']);
24
+ });
25
+ test('descubre suite y sensors en paquetes anidados del repositorio', () => {
26
+ const cli = path_1.default.join(repo, 'cli');
27
+ fs_1.default.mkdirSync(path_1.default.join(cli, '.awm'), { recursive: true });
28
+ fs_1.default.writeFileSync(path_1.default.join(cli, 'package.json'), JSON.stringify({ scripts: { test: 'jest' } }));
29
+ fs_1.default.writeFileSync(path_1.default.join(cli, '.awm', 'sensors.json'), '{}');
30
+ expect((0, init_1.detectRequiredVerifiers)(repo)).toEqual(['test', 'sensors']);
31
+ });
32
+ test('repo sin verificadores => lista vacia (el gate degrada por empty-cycle-plan igualmente, R3.6)', () => {
33
+ expect((0, init_1.detectRequiredVerifiers)(repo)).toEqual([]);
34
+ });
35
+ test('initWatch persiste requiredVerifiers y gitignorea el journal (R1.1/R1.4b)', () => {
36
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'package.json'), JSON.stringify({ scripts: { test: 'jest' } }));
37
+ const out = (0, init_1.initWatch)(repo, 'rama');
38
+ expect(out.requiredVerifiers).toEqual(['test']);
39
+ expect((0, store_1.readJournal)(repo, 'rama').state.requiredVerifiers).toEqual(['test']);
40
+ expect(fs_1.default.readFileSync(path_1.default.join(repo, '.gitignore'), 'utf8')).toContain('.awm/');
41
+ expect(() => (0, init_1.initWatch)(repo, 'rama')).not.toThrow(); // idempotente
42
+ });
43
+ });
@@ -0,0 +1,42 @@
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 os_1 = __importDefault(require("os"));
9
+ const atomic_file_1 = require("../../src/core/atomic-file");
10
+ describe('writeFileAtomicDurable', () => {
11
+ let dir;
12
+ beforeEach(() => { dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-durable-')); });
13
+ afterEach(() => { jest.restoreAllMocks(); fs_1.default.rmSync(dir, { recursive: true, force: true }); });
14
+ test('escribe contenido y respeta mode 0600 (R1.2)', () => {
15
+ const f = path_1.default.join(dir, 'state.json');
16
+ (0, atomic_file_1.writeFileAtomicDurable)(f, '{"a":1}', 0o600);
17
+ expect(fs_1.default.readFileSync(f, 'utf8')).toBe('{"a":1}');
18
+ expect(fs_1.default.statSync(f).mode & 0o777).toBe(0o600);
19
+ });
20
+ test('sobrevive a reemplazos consecutivos sin residuo tmp (R1.2)', () => {
21
+ const f = path_1.default.join(dir, 'state.json');
22
+ (0, atomic_file_1.writeFileAtomicDurable)(f, 'v1', 0o600);
23
+ (0, atomic_file_1.writeFileAtomicDurable)(f, 'v2', 0o600);
24
+ expect(fs_1.default.readFileSync(f, 'utf8')).toBe('v2');
25
+ expect(fs_1.default.readdirSync(dir).filter((n) => n.includes('.tmp'))).toEqual([]);
26
+ });
27
+ test('fallo del fsync de directorio LANZA — sin fallback silencioso (R1.2)', () => {
28
+ const f = path_1.default.join(dir, 'state.json');
29
+ const realOpen = fs_1.default.openSync;
30
+ // writeFileAtomic abre el tmp con 'wx'; el fsync de directorio abre con 'r':
31
+ // simulamos que el open del directorio falla (EIO/EPERM segun filesystem).
32
+ jest.spyOn(fs_1.default, 'openSync').mockImplementation(((p, flags, mode) => {
33
+ if (flags === 'r')
34
+ throw new Error('EIO simulado');
35
+ return realOpen(p, flags, mode);
36
+ }));
37
+ expect(() => (0, atomic_file_1.writeFileAtomicDurable)(f, '{"a":1}', 0o600)).toThrow(/fsync de directorio/);
38
+ });
39
+ test('fsyncDirSync exitoso no lanza sobre un directorio real (R1.2)', () => {
40
+ expect(() => (0, atomic_file_1.fsyncDirSync)(dir)).not.toThrow();
41
+ });
42
+ });
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const adapter_1 = require("../../../src/core/journal/adapter");
4
+ const process_1 = require("../../../src/core/journal/process");
5
+ describe('ControllerAdapter', () => {
6
+ test('adapterFor resuelve codex y claude-code; provider desconocido lanza (R4.8)', () => {
7
+ expect((0, adapter_1.adapterFor)('codex').provider).toBe('codex');
8
+ expect((0, adapter_1.adapterFor)('claude-code').provider).toBe('claude-code');
9
+ expect(() => (0, adapter_1.adapterFor)('otro')).toThrow(/provider/);
10
+ });
11
+ test('safeToReplace: muerto probado => safe; vivo => indeterminate, JAMAS safe sin evidencia (R4.2b)', () => {
12
+ const a = (0, adapter_1.adapterFor)('codex');
13
+ const deadRef = { pid: 999999, startTime: 'gone', spawnNonce: 'n', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
14
+ expect(a.safeToReplace(deadRef)).toBe('safe'); // identidad no matchea a nadie vivo => muerte probada
15
+ const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'nA');
16
+ expect(a.safeToReplace(ref)).toBe('indeterminate'); // vivo: codex no observa llamadas en vuelo => custodia
17
+ child.kill('SIGKILL');
18
+ });
19
+ test('launchArgv construye el comando de reanudacion journal-first (R4.8)', () => {
20
+ const argv = (0, adapter_1.adapterFor)('codex').launchArgv('retoma desde next_action');
21
+ expect(argv[0]).toBe('codex');
22
+ expect(argv).toContain('exec');
23
+ expect(argv[argv.length - 1]).toContain('next_action');
24
+ const cl = (0, adapter_1.adapterFor)('claude-code').launchArgv('retoma desde next_action');
25
+ expect(cl[0]).toBe('claude');
26
+ });
27
+ });
@@ -0,0 +1,164 @@
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 os_1 = __importDefault(require("os"));
9
+ const child_process_1 = require("child_process");
10
+ const fingerprint_1 = require("../../../src/core/journal/fingerprint");
11
+ function git(cwd, ...args) {
12
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd });
13
+ }
14
+ describe('computeFingerprint', () => {
15
+ let repo;
16
+ beforeEach(() => {
17
+ repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-fp-'));
18
+ git(repo, 'init', '-q');
19
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'a.txt'), 'uno');
20
+ git(repo, 'add', '.');
21
+ git(repo, 'commit', '-qm', 'c1');
22
+ });
23
+ afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
24
+ test('mismo comando + mismo arbol + mismo cwd => mismo fingerprint (R3.4)', () => {
25
+ const a = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.');
26
+ const b = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.');
27
+ expect(a.fingerprint).toBe(b.fingerprint);
28
+ expect(a.commandDigest).toBe(b.commandDigest);
29
+ });
30
+ test('cambio en tracked, untracked o argv cambia el fingerprint (R3.4)', () => {
31
+ const base = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.').fingerprint;
32
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'a.txt'), 'dos');
33
+ const mod = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.').fingerprint;
34
+ expect(mod).not.toBe(base);
35
+ git(repo, 'checkout', '-q', '--', '.');
36
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'nuevo.txt'), 'x');
37
+ const untracked = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.').fingerprint;
38
+ expect(untracked).not.toBe(base);
39
+ fs_1.default.rmSync(path_1.default.join(repo, 'nuevo.txt'));
40
+ const otherCmd = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'run', 'lint'], [], '.').fingerprint;
41
+ expect(otherCmd).not.toBe(base);
42
+ });
43
+ test('cambio staged-only altera el fingerprint — indice real hasheado (R3.4)', () => {
44
+ const base = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.').fingerprint;
45
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'a.txt'), 'dos');
46
+ git(repo, 'add', 'a.txt');
47
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'a.txt'), 'uno'); // worktree identico al base; SOLO el indice cambio
48
+ const stagedOnly = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.').fingerprint;
49
+ expect(stagedOnly).not.toBe(base);
50
+ });
51
+ test('cwd distinto altera el fingerprint; cwd fuera del repo se rechaza (R3.4)', () => {
52
+ fs_1.default.mkdirSync(path_1.default.join(repo, 'sub'));
53
+ const root = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.').fingerprint;
54
+ const sub = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], 'sub').fingerprint;
55
+ expect(sub).not.toBe(root);
56
+ expect(() => (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '../fuera')).toThrow(/cwd/);
57
+ expect(() => (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '/abs')).toThrow(/cwd/);
58
+ });
59
+ test('cwd que escapa mediante symlink se rechaza antes de persistir o ejecutar', () => {
60
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-fp-outside-'));
61
+ fs_1.default.symlinkSync(outside, path_1.default.join(repo, 'escape'));
62
+ try {
63
+ expect(() => (0, fingerprint_1.computeFingerprint)(repo, ['pwd'], [], 'escape')).toThrow(/symlink|fuera del repo/);
64
+ }
65
+ finally {
66
+ fs_1.default.rmSync(outside, { recursive: true, force: true });
67
+ }
68
+ });
69
+ test('la expansion de paths queda persistida y excluye .awm (R3.4)', () => {
70
+ fs_1.default.mkdirSync(path_1.default.join(repo, '.awm', 'journal'), { recursive: true });
71
+ fs_1.default.writeFileSync(path_1.default.join(repo, '.awm', 'journal', 'state.json'), '{}');
72
+ const r = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], ['a.txt'], '.');
73
+ expect(r.expandedPaths).toEqual(['a.txt']);
74
+ const all = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.');
75
+ expect(all.expandedPaths.some((p) => p.startsWith('.awm/'))).toBe(false);
76
+ });
77
+ test('globs declarados distintos no comparten fingerprint aunque hoy expandan al mismo archivo', () => {
78
+ const narrow = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], ['a.txt'], '.');
79
+ const broad = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], ['*.txt'], '.');
80
+ expect(narrow.expandedPaths).toEqual(broad.expandedPaths);
81
+ expect(narrow.fingerprint).not.toBe(broad.fingerprint);
82
+ });
83
+ test('archivo con nombre no-ASCII: cambio de contenido SI altera el fingerprint (R3.4)', () => {
84
+ const name = 'café.txt';
85
+ fs_1.default.writeFileSync(path_1.default.join(repo, name), 'v1');
86
+ git(repo, 'add', '.');
87
+ git(repo, 'commit', '-qm', 'c2');
88
+ const base = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.').fingerprint;
89
+ fs_1.default.writeFileSync(path_1.default.join(repo, name), 'v2-completely-different-content');
90
+ const mod = (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.').fingerprint;
91
+ expect(mod).not.toBe(base);
92
+ });
93
+ test('repos con muchos archivos no truncan la salida de git (R3.4)', () => {
94
+ for (let i = 0; i < 200; i++)
95
+ fs_1.default.writeFileSync(path_1.default.join(repo, `f${i}.txt`), `contenido-${i}`);
96
+ expect(() => (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.')).not.toThrow();
97
+ });
98
+ });
99
+ /** Defense-in-depth (post-implementation-qa, follow-up a Task 20): el `git()`
100
+ * interno de este archivo backea `computeFingerprint`, invocado en CADA tick
101
+ * del supervisor via `FingerprintNow`/`computeGate` (job/gate.ts). Sin stdio
102
+ * explicito, `execFileSync` relayea el stderr de git hacia el stderr DEL
103
+ * SUPERVISOR (`inheritStderr`, el default de Node cuando no se pasa `stdio`)
104
+ * — si ese fd fuera un pipe roto/destruido, el relay mismo dispara un `write
105
+ * EPIPE` no catcheable (throw asincronico via el evento 'error' del stream,
106
+ * invisible a try/catch sincronico) que tumbaria TODO el proceso `awm watch`
107
+ * en el tick siguiente, no solo un job. Este test reproduce esa condicion
108
+ * contra el `dist/` compilado REAL: un hijo real con stdio pipe cuyos
109
+ * extremos el padre destruye, corriendo `computeFingerprint` contra un `git`
110
+ * stub que emite ruido a stderr en cada invocacion (simulando warnings/locale
111
+ * de un git real) — sin el fix, esto tumba al hijo; con el fix, sobrevive. */
112
+ describe('fingerprint.ts git(): stdio explicito evita inheritStderr hacia un pipe roto', () => {
113
+ const DIST_ENTRY = path_1.default.resolve(__dirname, '..', '..', '..', 'dist', 'src', 'core', 'journal', 'fingerprint.js');
114
+ const REAL_GIT = (0, child_process_1.execFileSync)('which', ['git'], { encoding: 'utf8' }).trim();
115
+ beforeAll(() => {
116
+ if (!fs_1.default.existsSync(DIST_ENTRY)) {
117
+ throw new Error('dist ausente: corre `cd cli && npm run build` antes de este test (verifica el dist compilado real, no el source transpilado por ts-jest)');
118
+ }
119
+ });
120
+ test('computeFingerprint sobrevive un git que escribe a stderr, corriendo en un hijo con stdio pipe destruido por su padre (regresion: inheritStderr de execFileSync sin stdio explicito)', async () => {
121
+ const workDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-fp-execfilesync-hardening-'));
122
+ const repoDir = path_1.default.join(workDir, 'repo');
123
+ fs_1.default.mkdirSync(repoDir);
124
+ (0, child_process_1.execFileSync)(REAL_GIT, ['init', '-q'], { cwd: repoDir });
125
+ (0, child_process_1.execFileSync)(REAL_GIT, ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-qm', 'c1'], { cwd: repoDir });
126
+ fs_1.default.writeFileSync(path_1.default.join(repoDir, 'a.txt'), 'contenido');
127
+ // git "real" que, ademas de delegar al git del sistema, tambien emite
128
+ // algo en stderr en TODA invocacion — plausible en entornos reales
129
+ // (locale warnings, hooks, etc.) y suficiente para ejercitar inheritStderr.
130
+ const stubBin = path_1.default.join(workDir, 'git');
131
+ fs_1.default.writeFileSync(stubBin, `#!/bin/sh\necho "warning: ruido de stderr" 1>&2\nexec "${REAL_GIT}" "$@"\n`, { mode: 0o755 });
132
+ const outFile = path_1.default.join(workDir, 'out.txt');
133
+ const childScript = path_1.default.join(workDir, 'child.js');
134
+ fs_1.default.writeFileSync(childScript, `
135
+ const fs = require('fs');
136
+ const { computeFingerprint } = require(${JSON.stringify(DIST_ENTRY)});
137
+ try {
138
+ const result = computeFingerprint(${JSON.stringify(repoDir)}, ['npm', 'test'], [], '.');
139
+ fs.writeFileSync(${JSON.stringify(outFile)}, 'RESULT:' + result.fingerprint);
140
+ } catch (e) {
141
+ fs.writeFileSync(${JSON.stringify(outFile)}, 'THREW:' + e.message);
142
+ }
143
+ `);
144
+ const child = (0, child_process_1.spawn)(process.execPath, [childScript], {
145
+ cwd: workDir,
146
+ env: { ...process.env, PATH: `${workDir}:${process.env.PATH}` },
147
+ stdio: ['ignore', 'pipe', 'pipe'],
148
+ detached: true,
149
+ });
150
+ // El patron exacto que causaba el crash original: el padre destruye
151
+ // su extremo de los pipes del hijo, cerrando el read-end — cualquier
152
+ // escritura del hijo a su propio stdout/stderr despues de esto EPIPE-ea.
153
+ child.stdout?.destroy();
154
+ child.stderr?.destroy();
155
+ const exit = await new Promise((resolve) => {
156
+ child.on('exit', (code, signal) => resolve({ code, signal }));
157
+ });
158
+ expect(exit.signal).toBeNull();
159
+ expect(exit.code).toBe(0); // el hijo debe sobrevivir y salir limpio, NO crashear por EPIPE no catcheable
160
+ const out = fs_1.default.readFileSync(outFile, 'utf8');
161
+ expect(out.startsWith('RESULT:')).toBe(true); // logica de negocio intacta: computeFingerprint devolvio normalmente
162
+ fs_1.default.rmSync(workDir, { recursive: true, force: true });
163
+ });
164
+ });
@@ -0,0 +1,35 @@
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 os_1 = __importDefault(require("os"));
9
+ const paths_1 = require("../../../src/core/journal/paths");
10
+ describe('journal paths', () => {
11
+ let repo;
12
+ beforeEach(() => { repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-jpaths-')); });
13
+ afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
14
+ test('branchSlug sanea separadores', () => {
15
+ expect((0, paths_1.branchSlug)('claude/mi-rama')).toBe('claude_2Fmi-rama');
16
+ });
17
+ test('branchSlug es biyectivo: ramas distintas no colisionan (R1.1)', () => {
18
+ expect((0, paths_1.branchSlug)('a/b')).not.toBe((0, paths_1.branchSlug)('a_b'));
19
+ expect((0, paths_1.branchSlug)('a/b')).not.toBe((0, paths_1.branchSlug)('a__b'));
20
+ });
21
+ test('journalDir es por-rama; el lock vive FUERA del dir de rama (R1.1)', () => {
22
+ const jd = (0, paths_1.journalDir)(repo, 'a/b');
23
+ const lock = (0, paths_1.supervisorLockPath)(repo);
24
+ expect(jd).toBe(path_1.default.join(repo, '.awm', 'journal', 'a_2Fb'));
25
+ expect(lock).toBe(path_1.default.join(fs_1.default.realpathSync(repo), '.awm', 'journal', 'supervisor.lock'));
26
+ expect(path_1.default.dirname(lock)).not.toBe(jd);
27
+ });
28
+ test('supervisorLockPath resuelve symlinks del worktree (R1.1)', () => {
29
+ const real = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-real-'));
30
+ const link = path_1.default.join(repo, 'link');
31
+ fs_1.default.symlinkSync(real, link);
32
+ expect((0, paths_1.supervisorLockPath)(link)).toBe(path_1.default.join(fs_1.default.realpathSync(real), '.awm', 'journal', 'supervisor.lock'));
33
+ fs_1.default.rmSync(real, { recursive: true, force: true });
34
+ });
35
+ });