agentic-workflow-manager 3.4.0 → 3.6.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/dist/src/commands/job/exec-wrapper.js +136 -0
- package/dist/src/commands/job/export.js +94 -0
- package/dist/src/commands/job/gate.js +118 -0
- package/dist/src/commands/job/heartbeat.js +15 -0
- package/dist/src/commands/job/index.js +246 -0
- package/dist/src/commands/job/query.js +37 -0
- package/dist/src/commands/job/reap.js +24 -0
- package/dist/src/commands/job/reconcile.js +112 -0
- package/dist/src/commands/job/request.js +27 -0
- package/dist/src/commands/sensors/exec.js +121 -0
- package/dist/src/commands/sensors/index.js +4 -4
- package/dist/src/commands/sensors/run.js +124 -71
- package/dist/src/commands/watch/apply.js +352 -0
- package/dist/src/commands/watch/generations.js +249 -0
- package/dist/src/commands/watch/index.js +49 -0
- package/dist/src/commands/watch/init.js +72 -0
- package/dist/src/commands/watch/lock.js +89 -0
- package/dist/src/commands/watch/runner.js +191 -0
- package/dist/src/commands/watch/supervisor.js +266 -0
- package/dist/src/core/atomic-file.js +31 -0
- package/dist/src/core/export/pack.js +7 -1
- package/dist/src/core/journal/adapter.js +27 -0
- package/dist/src/core/journal/fingerprint.js +80 -0
- package/dist/src/core/journal/paths.js +56 -0
- package/dist/src/core/journal/process.js +284 -0
- package/dist/src/core/journal/redact.js +142 -0
- package/dist/src/core/journal/requests.js +132 -0
- package/dist/src/core/journal/store.js +107 -0
- package/dist/src/core/journal/types.js +165 -0
- package/dist/src/index.js +4 -0
- package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
- package/dist/tests/commands/job/export.test.js +76 -0
- package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
- package/dist/tests/commands/job/reap-cli.test.js +101 -0
- package/dist/tests/commands/job/verbs.test.js +56 -0
- package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
- package/dist/tests/commands/sensors/exec-fixtures.js +24 -0
- package/dist/tests/commands/sensors/exec.test.js +91 -0
- package/dist/tests/commands/sensors/run-inconclusive.test.js +55 -66
- package/dist/tests/commands/sensors/run-partial.test.js +225 -0
- package/dist/tests/commands/sensors/run-tool-missing.test.js +6 -6
- package/dist/tests/commands/sensors/run.test.js +64 -81
- package/dist/tests/commands/watch/apply.test.js +397 -0
- package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
- package/dist/tests/commands/watch/generations.test.js +115 -0
- package/dist/tests/commands/watch/integration.test.js +124 -0
- package/dist/tests/commands/watch/lock.test.js +60 -0
- package/dist/tests/commands/watch/runner.test.js +239 -0
- package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
- package/dist/tests/commands/watch/watch-init.test.js +43 -0
- package/dist/tests/core/atomic-file-durable.test.js +42 -0
- package/dist/tests/core/journal/adapter.test.js +27 -0
- package/dist/tests/core/journal/fingerprint.test.js +164 -0
- package/dist/tests/core/journal/paths.test.js +35 -0
- package/dist/tests/core/journal/process.test.js +213 -0
- package/dist/tests/core/journal/redact.test.js +59 -0
- package/dist/tests/core/journal/requests.test.js +134 -0
- package/dist/tests/core/journal/store.test.js +88 -0
- package/dist/tests/core/journal/types.test.js +78 -0
- package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
- package/package.json +1 -1
|
@@ -0,0 +1,213 @@
|
|
|
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 child_process_1 = require("child_process");
|
|
10
|
+
const process_1 = require("../../../src/core/journal/process");
|
|
11
|
+
describe('process identity', () => {
|
|
12
|
+
test('spawnStructured produce ProcessRef con tupla completa (R2.1, R4.7)', async () => {
|
|
13
|
+
const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nonce-abc');
|
|
14
|
+
expect(ref.pid).toBe(child.pid);
|
|
15
|
+
expect(ref.spawnNonce).toBe('nonce-abc');
|
|
16
|
+
expect(typeof ref.startTime).toBe('string');
|
|
17
|
+
expect(ref.processGroup).toBeGreaterThan(0);
|
|
18
|
+
expect(ref.psArgsDigest).toMatch(/^[0-9a-f]{16}$/);
|
|
19
|
+
expect((0, process_1.refIsAlive)(ref)).toBe(true);
|
|
20
|
+
const dead = await (0, process_1.terminateGroupConfirmed)(ref, { termGraceMs: 300, killGraceMs: 300 });
|
|
21
|
+
expect(dead).toBe(true);
|
|
22
|
+
expect((0, process_1.refIsAlive)(ref)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
test('refIsAlive rechaza cualquier campo distinto de la tupla completa (R2.1)', () => {
|
|
25
|
+
const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'n2');
|
|
26
|
+
expect((0, process_1.refIsAlive)({ ...ref, startTime: 'otro-momento' })).toBe(false);
|
|
27
|
+
expect((0, process_1.refIsAlive)({ ...ref, spawnNonce: 'otro-nonce' })).toBe(false);
|
|
28
|
+
expect((0, process_1.refIsAlive)({ ...ref, argvDigest: 'ffffffffffffffff' })).toBe(false);
|
|
29
|
+
expect((0, process_1.refIsAlive)({ ...ref, psArgsDigest: 'ffffffffffffffff' })).toBe(false);
|
|
30
|
+
expect((0, process_1.refIsAlive)({ ...ref, processGroup: ref.processGroup + 1 })).toBe(false);
|
|
31
|
+
child.kill('SIGKILL');
|
|
32
|
+
});
|
|
33
|
+
test('terminateGroupConfirmed no senializa un PGID si la identidad del lider no coincide', async () => {
|
|
34
|
+
const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'n-no-kill');
|
|
35
|
+
const mismatched = { ...ref, startTime: 'identidad-de-otro-proceso' };
|
|
36
|
+
const confirmed = await (0, process_1.terminateGroupConfirmed)(mismatched, { termGraceMs: 20, killGraceMs: 20 });
|
|
37
|
+
expect(confirmed).toBe(false);
|
|
38
|
+
expect((0, process_1.refIsAlive)(ref)).toBe(true);
|
|
39
|
+
child.kill('SIGKILL');
|
|
40
|
+
});
|
|
41
|
+
test('refIsAlive rechaza un ProcessRef que simula reutilizacion de PID: el pid actual esta vivo, pero pertenecia a un proceso YA MUERTO con identidad distinta (R6)', () => {
|
|
42
|
+
// El fixture de R6 explicito en el design doc: los PIDs son reciclados
|
|
43
|
+
// por el SO, asi que la identidad JAMAS puede confiarse solo del pid.
|
|
44
|
+
// Simulacion deterministica (evita depender de que el SO realmente
|
|
45
|
+
// recicle un pid dentro del test): un proceso REAL y vivo ahora mismo
|
|
46
|
+
// (mismo pid), pero un ProcessRef que describe un proceso DISTINTO,
|
|
47
|
+
// ya finalizado, que alguna vez tuvo ese mismo pid — startTime, nonce,
|
|
48
|
+
// digest de argv, grupo y digest de `ps args` todos DISTINTOS del
|
|
49
|
+
// proceso real que hoy ocupa ese pid.
|
|
50
|
+
const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'n-reuse-real');
|
|
51
|
+
const reusedPidStaleRef = {
|
|
52
|
+
pid: ref.pid, // el numero de pid SI esta vivo hoy...
|
|
53
|
+
startTime: 'Mon Jan 1 00:00:00 2024', // ...pero como un proceso YA MUERTO distinto
|
|
54
|
+
spawnNonce: 'nonce-de-un-job-anterior-ya-terminado',
|
|
55
|
+
argvDigest: 'deadbeefdeadbeef',
|
|
56
|
+
processGroup: ref.processGroup + 9999,
|
|
57
|
+
psArgsDigest: 'cafebabecafebabe',
|
|
58
|
+
};
|
|
59
|
+
expect((0, process_1.refIsAlive)(reusedPidStaleRef)).toBe(false); // no es prueba de vida DE ESE proceso logico
|
|
60
|
+
expect((0, process_1.refIsAlive)(ref)).toBe(true); // el pid real, con su identidad real, sigue vivo
|
|
61
|
+
child.kill('SIGKILL');
|
|
62
|
+
});
|
|
63
|
+
test('terminateGroupConfirmed confirma el GRUPO entero, no solo el lider (R2.1)', async () => {
|
|
64
|
+
// El hijo spawnea un nieto en su mismo grupo; la confirmacion exige pgrep -g vacio.
|
|
65
|
+
const spawnGrandchild = "require('child_process').spawn(process.execPath, ['-e', 'setTimeout(()=>{},5000)']); setTimeout(()=>{}, 5000)";
|
|
66
|
+
const { ref } = (0, process_1.spawnStructured)(['node', '-e', spawnGrandchild], process.cwd(), 'n3');
|
|
67
|
+
await new Promise((r) => setTimeout(r, 400)); // dejar nacer al nieto
|
|
68
|
+
const dead = await (0, process_1.terminateGroupConfirmed)(ref, { termGraceMs: 2000, killGraceMs: 2000 });
|
|
69
|
+
expect(dead).toBe(true);
|
|
70
|
+
expect((0, process_1.groupIsGone)(ref.processGroup)).toBe(true);
|
|
71
|
+
}, 15000);
|
|
72
|
+
test('activitySnapshot reporta cpu y tamanio de grupo de un proceso vivo (soporte R4.2)', () => {
|
|
73
|
+
const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'n4');
|
|
74
|
+
const snap = (0, process_1.activitySnapshot)(ref);
|
|
75
|
+
expect(snap).not.toBeNull();
|
|
76
|
+
expect(typeof snap.cpuTime).toBe('string');
|
|
77
|
+
expect(snap.groupSize).toBeGreaterThanOrEqual(1);
|
|
78
|
+
child.kill('SIGKILL');
|
|
79
|
+
});
|
|
80
|
+
test('captureSelfRef captura la identidad del proceso actual (R2.1)', () => {
|
|
81
|
+
const self = (0, process_1.captureSelfRef)('nonce-self');
|
|
82
|
+
expect(self.pid).toBe(process.pid);
|
|
83
|
+
expect((0, process_1.refIsAlive)(self)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
test('un grupo compuesto solo por zombies esta terminado; no espera una senial imposible', () => {
|
|
86
|
+
expect((0, process_1.processStatesAreGone)(['Z', 'Z+', 'Zs', null])).toBe(true);
|
|
87
|
+
expect((0, process_1.processStatesAreGone)(['Z', 'S'])).toBe(false);
|
|
88
|
+
expect((0, process_1.processStatesAreGone)(['R'])).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
test('refIsAlive nunca declara muerte si ps falla en ejecutarse (no ENOENT como prueba) (R2.1)', () => {
|
|
91
|
+
const cp = require('child_process');
|
|
92
|
+
const self = (0, process_1.captureSelfRef)('nonce-ps-fail');
|
|
93
|
+
const spy = jest.spyOn(cp, 'execFileSync').mockImplementation(() => {
|
|
94
|
+
const err = new Error('spawn ps ENOENT');
|
|
95
|
+
err.code = 'ENOENT';
|
|
96
|
+
throw err;
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
expect((0, process_1.refIsAlive)(self)).toBe(true);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
spy.mockRestore();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
test('activitySnapshot no crashea si ps falla en la lectura de cpu time (R2.1)', () => {
|
|
106
|
+
const cp = require('child_process');
|
|
107
|
+
const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'n5');
|
|
108
|
+
const spy = jest.spyOn(cp, 'execFileSync').mockImplementation((...args) => {
|
|
109
|
+
const cmd = args[0];
|
|
110
|
+
if (cmd === 'ps') {
|
|
111
|
+
const err = new Error('spawn ps ENOENT');
|
|
112
|
+
err.code = 'ENOENT';
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
throw new Error('unexpected execFileSync call in this test: ' + cmd);
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
expect(() => (0, process_1.activitySnapshot)(ref)).not.toThrow();
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
spy.mockRestore();
|
|
122
|
+
child.kill('SIGKILL');
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
test('spawnStructured usa stdio:ignore — sin pipes que destruir ni EPIPE posible en el hijo (regresion: wrapper detached moria silenciosamente al escribir a un pipe destruido por el padre)', () => {
|
|
126
|
+
const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'n-stdio-ignore');
|
|
127
|
+
expect(child.stdout).toBeNull();
|
|
128
|
+
expect(child.stderr).toBeNull();
|
|
129
|
+
expect(child.stdin).toBeNull();
|
|
130
|
+
expect((0, process_1.refIsAlive)(ref)).toBe(true);
|
|
131
|
+
child.kill('SIGKILL');
|
|
132
|
+
});
|
|
133
|
+
test('captureRefFor degrada a unknown si ps falla, nunca crashea al capturar identidad (R2.1)', () => {
|
|
134
|
+
const cp = require('child_process');
|
|
135
|
+
const spy = jest.spyOn(cp, 'execFileSync').mockImplementation((...args) => {
|
|
136
|
+
const err = new Error('spawn ps ENOENT');
|
|
137
|
+
err.code = 'ENOENT';
|
|
138
|
+
throw err;
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
expect(() => (0, process_1.captureSelfRef)('nonce-capture-fail')).not.toThrow();
|
|
142
|
+
const ref = (0, process_1.captureSelfRef)('nonce-capture-fail');
|
|
143
|
+
expect(ref.startTime).toBe('unknown');
|
|
144
|
+
expect(ref.psArgsDigest).toBe('unknown');
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
spy.mockRestore();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
/** Defense-in-depth: los execFileSync internos de este archivo (psField,
|
|
152
|
+
* sleepSync, groupIsGone, activitySnapshot) deben capturar el stderr del
|
|
153
|
+
* subproceso INTERNAMENTE, nunca relayearlo al stderr del proceso llamante
|
|
154
|
+
* (`inheritStderr`, el default de Node cuando no se pasa `stdio`). Si el
|
|
155
|
+
* proceso llamante corre con su propio stderr como un pipe roto/destruido
|
|
156
|
+
* (el patron exacto que crasheaba al wrapper detached antes del fix de
|
|
157
|
+
* spawnStructured), un relay de inheritStderr dispara un `write EPIPE` no
|
|
158
|
+
* catcheable por ningun try/catch sincronico (el throw ocurre via el
|
|
159
|
+
* 'error' event del stream, asincronico) — proceso muerto sin excepcion
|
|
160
|
+
* visible. Este test reproduce esa condicion contra el `dist/` compilado
|
|
161
|
+
* REAL (no mockeado): un hijo real con stdio pipe cuyos extremos el padre
|
|
162
|
+
* destruye (igual que hacia el viejo defaultWrapperSpawner), corriendo
|
|
163
|
+
* `groupIsGone` contra un `pgrep` stub que escribe a stderr (simulando un
|
|
164
|
+
* binario real que emite ruido/warnings en stderr incluso en el caso
|
|
165
|
+
* "sin matches") — sin el fix, esto tumba al hijo; con el fix, sobrevive
|
|
166
|
+
* y devuelve el resultado correcto. */
|
|
167
|
+
describe('process.ts execFileSync: stdio explicito evita inheritStderr hacia un pipe roto', () => {
|
|
168
|
+
const DIST_ENTRY = path_1.default.resolve(__dirname, '..', '..', '..', 'dist', 'src', 'core', 'journal', 'process.js');
|
|
169
|
+
beforeAll(() => {
|
|
170
|
+
if (!fs_1.default.existsSync(DIST_ENTRY)) {
|
|
171
|
+
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)');
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
test('groupIsGone sobrevive un pgrep que escribe a stderr, corriendo en un hijo con stdio pipe destruido por su padre (regresion: inheritStderr de execFileSync sin stdio explicito)', async () => {
|
|
175
|
+
const workDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-execfilesync-hardening-'));
|
|
176
|
+
const stubBin = path_1.default.join(workDir, 'pgrep');
|
|
177
|
+
// pgrep "real" que, incluso en el caso feliz (sin matches, exit 1),
|
|
178
|
+
// tambien emite algo en stderr — plausible en entornos reales
|
|
179
|
+
// (locale warnings, etc.) y suficiente para ejercitar inheritStderr.
|
|
180
|
+
fs_1.default.writeFileSync(stubBin, '#!/bin/sh\necho "warning: ruido de stderr" 1>&2\nexit 1\n', { mode: 0o755 });
|
|
181
|
+
const outFile = path_1.default.join(workDir, 'out.txt');
|
|
182
|
+
const childScript = path_1.default.join(workDir, 'child.js');
|
|
183
|
+
fs_1.default.writeFileSync(childScript, `
|
|
184
|
+
const fs = require('fs');
|
|
185
|
+
const { groupIsGone } = require(${JSON.stringify(DIST_ENTRY)});
|
|
186
|
+
try {
|
|
187
|
+
const result = groupIsGone(999999);
|
|
188
|
+
fs.writeFileSync(${JSON.stringify(outFile)}, 'RESULT:' + result);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
fs.writeFileSync(${JSON.stringify(outFile)}, 'THREW:' + e.message);
|
|
191
|
+
}
|
|
192
|
+
`);
|
|
193
|
+
const child = (0, child_process_1.spawn)(process.execPath, [childScript], {
|
|
194
|
+
cwd: workDir,
|
|
195
|
+
env: { ...process.env, PATH: `${workDir}:${process.env.PATH}` },
|
|
196
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
197
|
+
detached: true,
|
|
198
|
+
});
|
|
199
|
+
// El patron exacto que causaba el crash original: el padre destruye
|
|
200
|
+
// su extremo de los pipes del hijo, cerrando el read-end — cualquier
|
|
201
|
+
// escritura del hijo a su propio stdout/stderr despues de esto EPIPE-ea.
|
|
202
|
+
child.stdout?.destroy();
|
|
203
|
+
child.stderr?.destroy();
|
|
204
|
+
const exit = await new Promise((resolve) => {
|
|
205
|
+
child.on('exit', (code, signal) => resolve({ code, signal }));
|
|
206
|
+
});
|
|
207
|
+
expect(exit.signal).toBeNull(); // nunca deberia morir por senial (crash de node = SIGABRT/uncaught != signal, pero confirmamos ausencia de kill externo)
|
|
208
|
+
expect(exit.code).toBe(0); // el hijo debe sobrevivir y salir limpio, NO crashear por EPIPE no catcheable
|
|
209
|
+
const out = fs_1.default.readFileSync(outFile, 'utf8');
|
|
210
|
+
expect(out).toBe('RESULT:true'); // logica de negocio intacta: pgrep exit 1 => groupIsGone true
|
|
211
|
+
fs_1.default.rmSync(workDir, { recursive: true, force: true });
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const redact_1 = require("../../../src/core/journal/redact");
|
|
4
|
+
describe('redact', () => {
|
|
5
|
+
test('redactText enmascara asignaciones sospechosas (R2.3)', () => {
|
|
6
|
+
expect((0, redact_1.redactText)('export API_KEY=abc123secreto')).toContain('[REDACTED]');
|
|
7
|
+
expect((0, redact_1.redactText)('password: hunter2')).toContain('[REDACTED]');
|
|
8
|
+
expect((0, redact_1.redactText)('linea inocente')).toBe('linea inocente');
|
|
9
|
+
});
|
|
10
|
+
test('findLiteralSecretFlag detecta flags sensibles con valor literal (R2.3)', () => {
|
|
11
|
+
expect((0, redact_1.findLiteralSecretFlag)(['cmd', '--token', 'abc123'])).toBe('--token');
|
|
12
|
+
expect((0, redact_1.findLiteralSecretFlag)(['cmd', '--api-key=xyz'])).toBe('--api-key');
|
|
13
|
+
expect((0, redact_1.findLiteralSecretFlag)(['cmd', '--token-env', 'MY_TOKEN'])).toBeNull();
|
|
14
|
+
expect((0, redact_1.findLiteralSecretFlag)(['npm', 'test'])).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
test('findLiteralSecretFlag detecta un valor literal que empieza con -- (R2.3)', () => {
|
|
17
|
+
expect((0, redact_1.findLiteralSecretFlag)(['cmd', '--token', '--abc123secretvalue'])).toBe('--token');
|
|
18
|
+
});
|
|
19
|
+
test('findLiteralSecretFlag rechaza sin importar si el siguiente token parece otro flag (R2.3)', () => {
|
|
20
|
+
expect((0, redact_1.findLiteralSecretFlag)(['cmd', '--token', '--password', 'hunter2'])).toBe('--token');
|
|
21
|
+
expect((0, redact_1.findLiteralSecretFlag)(['cmd', '--token', '--my-secret-data'])).toBe('--token');
|
|
22
|
+
});
|
|
23
|
+
test('redactArgv nunca deja el valor de un flag sensible (R2.3)', () => {
|
|
24
|
+
expect((0, redact_1.redactArgv)(['x', '--password', 'hunter2'])).toEqual(['x', '--password', '[REDACTED]']);
|
|
25
|
+
});
|
|
26
|
+
test('redactArgv redacta un valor literal que empieza con -- (R2.3)', () => {
|
|
27
|
+
expect((0, redact_1.redactArgv)(['cmd', '--token', '--abc123secretvalue'])).toEqual(['cmd', '--token', '[REDACTED]']);
|
|
28
|
+
});
|
|
29
|
+
test('redactArgv redacta TODA la cadena ambigua cuando el valor parece otro flag sensible, sin dejar pasar el secreto real (R2.3)', () => {
|
|
30
|
+
expect((0, redact_1.redactArgv)(['cmd', '--token', '--password', 'hunter2'])).toEqual(['cmd', '--token', '[REDACTED]', '[REDACTED]']);
|
|
31
|
+
expect((0, redact_1.redactArgv)(['cmd', '--token', '--my-secret-data'])).toEqual(['cmd', '--token', '[REDACTED]']);
|
|
32
|
+
expect((0, redact_1.redactArgv)(['cmd', '--api-key', '--secret'])).toEqual(['cmd', '--api-key', '[REDACTED]']);
|
|
33
|
+
});
|
|
34
|
+
test('findLiteralSecretFlag y redactArgv reconocen flags de un solo guion con palabra clave (R2.3)', () => {
|
|
35
|
+
expect((0, redact_1.findLiteralSecretFlag)(['cmd', '-token', 'abc123'])).toBe('-token');
|
|
36
|
+
expect((0, redact_1.redactArgv)(['cmd', '-token', 'abc123'])).toEqual(['cmd', '-token', '[REDACTED]']);
|
|
37
|
+
});
|
|
38
|
+
test('findLiteralSecretFlag NO reconoce mnemonicos de una letra sin palabra clave — limitacion aceptada (R2.3)', () => {
|
|
39
|
+
// Documenta el limite, no lo intenta cerrar: ver comentario en redact.ts.
|
|
40
|
+
expect((0, redact_1.findLiteralSecretFlag)(['cmd', '-p', 'hunter2secret'])).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
test('redactText no sufre backtracking catastrofico con corridas largas sin match (R2.3)', () => {
|
|
43
|
+
const start = Date.now();
|
|
44
|
+
(0, redact_1.redactText)('x'.repeat(100000));
|
|
45
|
+
expect(Date.now() - start).toBeLessThan(500);
|
|
46
|
+
});
|
|
47
|
+
test('redactText no pierde secretos con identificadores largos entre keyword y separador (R2.3)', () => {
|
|
48
|
+
const text = 'api_key' + 'x'.repeat(70) + '=sk-VERY-REAL-SECRET-VALUE-12345';
|
|
49
|
+
const result = (0, redact_1.redactText)(text);
|
|
50
|
+
expect(result).not.toContain('sk-VERY-REAL-SECRET-VALUE-12345');
|
|
51
|
+
expect(result).toContain('[REDACTED]');
|
|
52
|
+
});
|
|
53
|
+
test('redactText no sufre backtracking cuadratico con muchas ocurrencias del keyword sin separador (R2.3)', () => {
|
|
54
|
+
const start = Date.now();
|
|
55
|
+
const result = (0, redact_1.redactText)('token'.repeat(2400));
|
|
56
|
+
expect(Date.now() - start).toBeLessThan(500);
|
|
57
|
+
expect(result).toBe('token'.repeat(2400)); // sin separador en ningun lado: nada que redactar
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
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 crypto_1 = __importDefault(require("crypto"));
|
|
10
|
+
const child_process_1 = require("child_process");
|
|
11
|
+
const requests_1 = require("../../../src/core/journal/requests");
|
|
12
|
+
const store_1 = require("../../../src/core/journal/store");
|
|
13
|
+
const paths_1 = require("../../../src/core/journal/paths");
|
|
14
|
+
// Bootstrap para invocar el codigo REAL de src/ (.ts) desde procesos `node`
|
|
15
|
+
// separados sin depender de un build previo (dist/ esta gitignored) ni de
|
|
16
|
+
// ts-node (no instalado): registra un require.extensions['.ts'] que
|
|
17
|
+
// transpila on-demand via el compilador de TypeScript (devDependency ya
|
|
18
|
+
// presente). NODE_PATH apunta a cli/node_modules para que el propio script
|
|
19
|
+
// bootstrap pueda resolver 'typescript' aunque viva fuera del arbol del repo.
|
|
20
|
+
const CLI_ROOT = path_1.default.resolve(__dirname, '../../..');
|
|
21
|
+
const REQUESTS_TS = path_1.default.join(CLI_ROOT, 'src/core/journal/requests.ts');
|
|
22
|
+
const TS_BOOTSTRAP = `
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const ts = require('typescript');
|
|
25
|
+
require.extensions['.ts'] = function (mod, filename) {
|
|
26
|
+
const source = fs.readFileSync(filename, 'utf8');
|
|
27
|
+
const { outputText } = ts.transpileModule(source, {
|
|
28
|
+
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
|
|
29
|
+
fileName: filename,
|
|
30
|
+
});
|
|
31
|
+
mod._compile(outputText, filename);
|
|
32
|
+
};
|
|
33
|
+
`;
|
|
34
|
+
function runInSeparateProcess(script, args) {
|
|
35
|
+
const scriptFile = path_1.default.join(os_1.default.tmpdir(), `awm-cross-proc-${crypto_1.default.randomUUID()}.js`);
|
|
36
|
+
fs_1.default.writeFileSync(scriptFile, TS_BOOTSTRAP + script);
|
|
37
|
+
try {
|
|
38
|
+
return (0, child_process_1.execFileSync)('node', [scriptFile, ...args], {
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
env: { ...process.env, NODE_PATH: path_1.default.join(CLI_ROOT, 'node_modules') },
|
|
41
|
+
}).trim();
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
fs_1.default.rmSync(scriptFile, { force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
describe('requests', () => {
|
|
48
|
+
let repo;
|
|
49
|
+
beforeEach(() => { repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-req-')); (0, store_1.initJournal)(repo, 'rama'); });
|
|
50
|
+
afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
|
|
51
|
+
test('emitRequest publica atomico: nunca hay .tmp visible como pendiente (R1.3)', () => {
|
|
52
|
+
const r = (0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'k1', payload: { argv: ['npm', 'test'] } });
|
|
53
|
+
expect(r.requestId).toMatch(/^req-/);
|
|
54
|
+
const files = fs_1.default.readdirSync((0, paths_1.requestsDir)(repo, 'rama'));
|
|
55
|
+
expect(files.some((f) => f.endsWith('.tmp'))).toBe(false);
|
|
56
|
+
expect((0, requests_1.listPendingRequests)(repo, 'rama')).toHaveLength(1);
|
|
57
|
+
});
|
|
58
|
+
test('request con secreto literal se rechaza sin persistir (R2.3 via emisor)', () => {
|
|
59
|
+
expect(() => (0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'k2', payload: { argv: ['x', '--token', 'abc'] } }))
|
|
60
|
+
.toThrow(/secreto literal/);
|
|
61
|
+
expect((0, requests_1.listPendingRequests)(repo, 'rama')).toHaveLength(0);
|
|
62
|
+
});
|
|
63
|
+
test('JSON con kind conocido pero envelope incompleto se clasifica corrupto antes de consumirlo', () => {
|
|
64
|
+
const file = path_1.default.join((0, paths_1.requestsDir)(repo, 'rama'), 'bad.json');
|
|
65
|
+
fs_1.default.writeFileSync(file, JSON.stringify({ requestId: 'bad', kind: 'job-request', generationToken: 'g1', idempotencyKey: 'k1' }));
|
|
66
|
+
const [pending] = (0, requests_1.listPendingRequests)(repo, 'rama');
|
|
67
|
+
expect(pending.corrupt).toBe(true);
|
|
68
|
+
expect(pending.requestId).toBe('bad.json');
|
|
69
|
+
});
|
|
70
|
+
test('idempotencyKey repetida: digest distinto se rechaza; mismo digest registra ALIAS con el mismo resultRef (R1.3)', () => {
|
|
71
|
+
const r1 = (0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'k3', payload: { argv: ['npm', 'test'] } });
|
|
72
|
+
let s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
73
|
+
s = (0, requests_1.applyOutcome)(s, { requestId: r1.requestId, idempotencyKey: r1.idempotencyKey, payloadDigest: r1.payloadDigest, outcome: 'applied', resultRef: 'job-1' });
|
|
74
|
+
(0, store_1.writeJournal)(repo, 'rama', s);
|
|
75
|
+
// ack perdido en disco: se regenera desde state.json
|
|
76
|
+
expect((0, requests_1.ackFor)((0, store_1.readJournal)(repo, 'rama').state, r1.requestId).resultRef).toBe('job-1');
|
|
77
|
+
// misma key + MISMO payload => alias: el requestId nuevo tiene SU entrada con el mismo resultRef
|
|
78
|
+
const r2 = (0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'k3', payload: { argv: ['npm', 'test'] } });
|
|
79
|
+
const s2 = (0, store_1.readJournal)(repo, 'rama').state;
|
|
80
|
+
(0, requests_1.applyOutcome)(s2, { requestId: r2.requestId, idempotencyKey: 'k3', payloadDigest: r2.payloadDigest, outcome: 'applied' });
|
|
81
|
+
expect((0, requests_1.ackFor)(s2, r2.requestId)).not.toBeNull();
|
|
82
|
+
expect((0, requests_1.ackFor)(s2, r2.requestId).resultRef).toBe('job-1'); // alias regenerable (bloqueador 4)
|
|
83
|
+
// misma key, payload distinto => rechazo explicito
|
|
84
|
+
const r3 = (0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'k3', payload: { argv: ['npm', 'run', 'otro'] } });
|
|
85
|
+
expect(() => (0, requests_1.applyOutcome)(s2, { requestId: r3.requestId, idempotencyKey: 'k3', payloadDigest: r3.payloadDigest, outcome: 'applied' }))
|
|
86
|
+
.toThrow(/digest/);
|
|
87
|
+
});
|
|
88
|
+
test('Fix1 Parte A: idempotencyKey repetida con digest distinto es SEGURA de registrar cuando el outcome NO es applied (ninguna mutacion ocurrio)', () => {
|
|
89
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
90
|
+
(0, requests_1.applyOutcome)(s, { requestId: 'req-a', idempotencyKey: 'kdup', payloadDigest: 'digest-a', outcome: 'applied', resultRef: 'job-1' });
|
|
91
|
+
// digest DISTINTO, pero outcome de rechazo: no debe tirar (a diferencia de 'applied')
|
|
92
|
+
expect(() => (0, requests_1.applyOutcome)(s, { requestId: 'req-b', idempotencyKey: 'kdup', payloadDigest: 'digest-b', outcome: 'rejected-digest-mismatch' }))
|
|
93
|
+
.not.toThrow();
|
|
94
|
+
expect(s.appliedRequests['req-b'].outcome).toBe('rejected-digest-mismatch');
|
|
95
|
+
expect(s.appliedRequests['req-b'].payloadDigest).toBe('digest-b'); // registra SU PROPIO digest, no el de la entrada previa
|
|
96
|
+
// 'applied' con digest distinto sigue tirando (comportamiento previo intacto)
|
|
97
|
+
expect(() => (0, requests_1.applyOutcome)(s, { requestId: 'req-c', idempotencyKey: 'kdup', payloadDigest: 'digest-c', outcome: 'applied' }))
|
|
98
|
+
.toThrow(/digest/);
|
|
99
|
+
});
|
|
100
|
+
test('replay del MISMO requestId es no-op (R1.3)', () => {
|
|
101
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
102
|
+
(0, requests_1.applyOutcome)(s, { requestId: 'req-x', idempotencyKey: 'kx', payloadDigest: 'd', outcome: 'applied', resultRef: 'job-9' });
|
|
103
|
+
(0, requests_1.applyOutcome)(s, { requestId: 'req-x', idempotencyKey: 'kx', payloadDigest: 'd', outcome: 'applied', resultRef: 'job-9' });
|
|
104
|
+
expect(Object.keys(s.appliedRequests)).toHaveLength(1);
|
|
105
|
+
});
|
|
106
|
+
test('emitRequest preserva orden de emision incluso con Date.now() colisionado (RNF-T.7)', () => {
|
|
107
|
+
const originalNow = Date.now;
|
|
108
|
+
Date.now = () => 1700000000000;
|
|
109
|
+
try {
|
|
110
|
+
const a = (0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'ka', payload: { argv: ['npm', 'test'] } });
|
|
111
|
+
const b = (0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'kb', payload: { argv: ['npm', 'test'] } });
|
|
112
|
+
const listed = (0, requests_1.listPendingRequests)(repo, 'rama').map((p) => p.requestId);
|
|
113
|
+
expect(listed.indexOf(a.requestId)).toBeLessThan(listed.indexOf(b.requestId));
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
Date.now = originalNow;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
test('emitRequest preserva orden causal entre procesos SEPARADOS (RNF-T.7)', () => {
|
|
120
|
+
// El propio directorio de requests YA fue inicializado por initJournal
|
|
121
|
+
// en beforeEach (mismo proceso jest); lo que importa es que emitRequest
|
|
122
|
+
// A y B corran en procesos `node` DISTINTOS, uno tras otro.
|
|
123
|
+
const emitScript = `
|
|
124
|
+
const { emitRequest } = require(${JSON.stringify(REQUESTS_TS)});
|
|
125
|
+
const [, , repoArg, keyArg] = process.argv;
|
|
126
|
+
const r = emitRequest(repoArg, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: keyArg, payload: { argv: ['npm', 'test'] } });
|
|
127
|
+
console.log(r.requestId);
|
|
128
|
+
`;
|
|
129
|
+
const idA = runInSeparateProcess(emitScript, [repo, 'proc-a']);
|
|
130
|
+
const idB = runInSeparateProcess(emitScript, [repo, 'proc-b']);
|
|
131
|
+
const listed = (0, requests_1.listPendingRequests)(repo, 'rama').map((p) => p.requestId);
|
|
132
|
+
expect(listed.indexOf(idA)).toBeLessThan(listed.indexOf(idB));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
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 store_1 = require("../../../src/core/journal/store");
|
|
10
|
+
const paths_1 = require("../../../src/core/journal/paths");
|
|
11
|
+
describe('journal store', () => {
|
|
12
|
+
let repo;
|
|
13
|
+
beforeEach(() => { repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-store-')); });
|
|
14
|
+
afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
|
|
15
|
+
test('initJournal crea 0700/0600 y estado inicial valido (R1.2)', () => {
|
|
16
|
+
(0, store_1.initJournal)(repo, 'rama');
|
|
17
|
+
const dir = (0, paths_1.journalDir)(repo, 'rama');
|
|
18
|
+
expect(fs_1.default.statSync(dir).mode & 0o777).toBe(0o700);
|
|
19
|
+
expect(fs_1.default.statSync((0, paths_1.statePath)(repo, 'rama')).mode & 0o777).toBe(0o600);
|
|
20
|
+
const r = (0, store_1.readJournal)(repo, 'rama');
|
|
21
|
+
expect(r.corrupt).toBe(false);
|
|
22
|
+
expect(r.state.revision).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
test('writeJournal incrementa revision y rechaza revision vieja (R1.2)', () => {
|
|
25
|
+
(0, store_1.initJournal)(repo, 'rama');
|
|
26
|
+
const s1 = (0, store_1.readJournal)(repo, 'rama').state;
|
|
27
|
+
(0, store_1.writeJournal)(repo, 'rama', s1); // rev 0 -> 1
|
|
28
|
+
const s2 = (0, store_1.readJournal)(repo, 'rama').state;
|
|
29
|
+
expect(s2.revision).toBe(1);
|
|
30
|
+
expect(() => (0, store_1.writeJournal)(repo, 'rama', s1)).toThrow(/revision/); // CAS: s1 quedo vieja
|
|
31
|
+
});
|
|
32
|
+
test('nextAction persiste estructurado (R1.5)', () => {
|
|
33
|
+
(0, store_1.initJournal)(repo, 'rama');
|
|
34
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
35
|
+
s.cycle.nextAction = { actionId: 'a1', type: 'implement-task', target: 'T1', preconditions: [], attempt: 1, state: 'pending' };
|
|
36
|
+
(0, store_1.writeJournal)(repo, 'rama', s);
|
|
37
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.cycle.nextAction.actionId).toBe('a1');
|
|
38
|
+
});
|
|
39
|
+
test('estado corrupto se reporta, jamas se descarta en silencio (R1.6)', () => {
|
|
40
|
+
(0, store_1.initJournal)(repo, 'rama');
|
|
41
|
+
fs_1.default.writeFileSync((0, paths_1.statePath)(repo, 'rama'), 'null'); // JSON valido, shape invalido
|
|
42
|
+
const r = (0, store_1.readJournal)(repo, 'rama');
|
|
43
|
+
expect(r.corrupt).toBe(true);
|
|
44
|
+
expect(r.state).toBeNull();
|
|
45
|
+
fs_1.default.writeFileSync((0, paths_1.statePath)(repo, 'rama'), '{roto'); // sintaxis invalida
|
|
46
|
+
expect((0, store_1.readJournal)(repo, 'rama').corrupt).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
test('normaliza campos aditivos de snapshots schema 1 sin certificar evidencia legacy', () => {
|
|
49
|
+
(0, store_1.initJournal)(repo, 'rama');
|
|
50
|
+
const legacy = (0, store_1.readJournal)(repo, 'rama').state;
|
|
51
|
+
delete legacy.requestProblems;
|
|
52
|
+
delete legacy.custodyDecisions;
|
|
53
|
+
delete legacy.cycle.nextAction;
|
|
54
|
+
legacy.verdicts.push({ id: 'v-old', obligationId: 'o-old', result: 'pass', detail: 'legacy', receivedAt: new Date().toISOString() });
|
|
55
|
+
fs_1.default.writeFileSync((0, paths_1.statePath)(repo, 'rama'), JSON.stringify(legacy));
|
|
56
|
+
const read = (0, store_1.readJournal)(repo, 'rama');
|
|
57
|
+
expect(read.corrupt).toBe(false);
|
|
58
|
+
expect(read.state.requestProblems).toEqual([]);
|
|
59
|
+
expect(read.state.cycle.nextAction?.actionId).toBe('bootstrap-cycle');
|
|
60
|
+
expect(read.state.verdicts[0]).toEqual(expect.objectContaining({ fingerprint: '', argv: [], paths: [], cwd: '.' }));
|
|
61
|
+
});
|
|
62
|
+
test('appendEvent agrega lineas de auditoria best-effort (R4.6)', () => {
|
|
63
|
+
(0, store_1.initJournal)(repo, 'rama');
|
|
64
|
+
(0, store_1.appendEvent)(repo, 'rama', { kind: 'generation-launched', n: 1 });
|
|
65
|
+
(0, store_1.appendEvent)(repo, 'rama', { kind: 'request-rejected-stale' });
|
|
66
|
+
const lines = fs_1.default.readFileSync((0, paths_1.eventsPath)(repo, 'rama'), 'utf8').trim().split('\n');
|
|
67
|
+
expect(lines).toHaveLength(2);
|
|
68
|
+
expect(JSON.parse(lines[0]).kind).toBe('generation-launched');
|
|
69
|
+
expect(typeof JSON.parse(lines[0]).at).toBe('string');
|
|
70
|
+
});
|
|
71
|
+
test('writeJournal rechaza un estado propuesto con forma invalida, nunca lo persiste (R1.6)', () => {
|
|
72
|
+
(0, store_1.initJournal)(repo, 'rama');
|
|
73
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
74
|
+
const malformed = { ...s };
|
|
75
|
+
delete malformed.tasks;
|
|
76
|
+
expect(() => (0, store_1.writeJournal)(repo, 'rama', malformed)).toThrow(/forma invalida/);
|
|
77
|
+
// el journal sigue intacto y legible tras el intento rechazado
|
|
78
|
+
const after = (0, store_1.readJournal)(repo, 'rama');
|
|
79
|
+
expect(after.corrupt).toBe(false);
|
|
80
|
+
expect(after.state.revision).toBe(s.revision);
|
|
81
|
+
});
|
|
82
|
+
test('writeJournal rechaza un estado cuyo branch no coincide con el branch destino (R1.2)', () => {
|
|
83
|
+
(0, store_1.initJournal)(repo, 'rama');
|
|
84
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
85
|
+
const wrongBranch = { ...s, branch: 'otra-rama' };
|
|
86
|
+
expect(() => (0, store_1.writeJournal)(repo, 'rama', wrongBranch)).toThrow(/branch/);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const types_1 = require("../../../src/core/journal/types");
|
|
4
|
+
describe('journal types', () => {
|
|
5
|
+
test('execution states incluyen orphaned y cancel (R1.7)', () => {
|
|
6
|
+
expect(types_1.EXECUTION_STATES).toEqual([
|
|
7
|
+
'received', 'spawn-intent', 'claimed', 'running',
|
|
8
|
+
'exited', 'cancel-requested', 'cancelled', 'orphaned',
|
|
9
|
+
]);
|
|
10
|
+
expect(types_1.GENERATION_STATES).toEqual([
|
|
11
|
+
'active', 'controller-suspected-stall', 'terminated', 'superseded',
|
|
12
|
+
]);
|
|
13
|
+
});
|
|
14
|
+
test('emptyState produce un estado bien formado (R1.4)', () => {
|
|
15
|
+
const s = (0, types_1.emptyState)('mi-rama');
|
|
16
|
+
expect((0, types_1.isWellFormedState)(s)).toBe(true);
|
|
17
|
+
expect(s.schema).toBe(1);
|
|
18
|
+
expect(s.revision).toBe(0);
|
|
19
|
+
expect(s.cycle.status).toBe('IN_PROGRESS');
|
|
20
|
+
expect(typeof s.cycle.startedAt).toBe('string');
|
|
21
|
+
expect(s.cycle.nextAction?.actionId).toBe('bootstrap-cycle');
|
|
22
|
+
expect(s.requiredVerifiers).toEqual([]);
|
|
23
|
+
expect(s.dispatches).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
test('isWellFormedState rechaza no-objetos y shapes rotos (R1.6)', () => {
|
|
26
|
+
expect((0, types_1.isWellFormedState)(null)).toBe(false);
|
|
27
|
+
expect((0, types_1.isWellFormedState)(42)).toBe(false);
|
|
28
|
+
expect((0, types_1.isWellFormedState)({ schema: 1 })).toBe(false);
|
|
29
|
+
const bad = (0, types_1.emptyState)('x');
|
|
30
|
+
bad.revision = 'no-un-numero';
|
|
31
|
+
expect((0, types_1.isWellFormedState)(bad)).toBe(false);
|
|
32
|
+
const bad2 = (0, types_1.emptyState)('y');
|
|
33
|
+
delete bad2.requiredVerifiers;
|
|
34
|
+
expect((0, types_1.isWellFormedState)(bad2)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
test('isWellFormedProcessRef exige la tupla COMPLETA (R2.1)', () => {
|
|
37
|
+
const full = { pid: 1, startTime: 'x', spawnNonce: 'n', argvDigest: 'd', processGroup: 1, psArgsDigest: 'p' };
|
|
38
|
+
expect((0, types_1.isWellFormedProcessRef)(full)).toBe(true);
|
|
39
|
+
expect((0, types_1.isWellFormedProcessRef)({ ...full, psArgsDigest: undefined })).toBe(false);
|
|
40
|
+
expect((0, types_1.isWellFormedProcessRef)({ ...full, processGroup: 'uno' })).toBe(false);
|
|
41
|
+
expect((0, types_1.isWellFormedProcessRef)(null)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
test('isWellFormedJob exige TODOS los campos requeridos, no un subset (R1.6)', () => {
|
|
44
|
+
const full = {
|
|
45
|
+
id: 'j1',
|
|
46
|
+
fingerprint: 'f',
|
|
47
|
+
commandDigest: 'c',
|
|
48
|
+
argv: ['x'],
|
|
49
|
+
cwd: '.',
|
|
50
|
+
paths: ['**/*.ts'],
|
|
51
|
+
expandedPaths: ['a.ts'],
|
|
52
|
+
executionState: 'received',
|
|
53
|
+
observationState: 'progressing',
|
|
54
|
+
phaseTimestamps: {},
|
|
55
|
+
};
|
|
56
|
+
expect((0, types_1.isWellFormedJob)(full)).toBe(true);
|
|
57
|
+
for (const key of ['paths', 'expandedPaths', 'observationState', 'phaseTimestamps']) {
|
|
58
|
+
const broken = { ...full };
|
|
59
|
+
delete broken[key];
|
|
60
|
+
expect((0, types_1.isWellFormedJob)(broken)).toBe(false);
|
|
61
|
+
}
|
|
62
|
+
expect((0, types_1.isWellFormedJob)({ ...full, phaseTimestamps: [] })).toBe(false);
|
|
63
|
+
expect((0, types_1.isWellFormedJob)({ ...full, argv: [42] })).toBe(false);
|
|
64
|
+
expect((0, types_1.isWellFormedJob)({ ...full, paths: [{ glob: '**/*' }] })).toBe(false);
|
|
65
|
+
expect((0, types_1.isWellFormedJob)({ ...full, observationState: 'stuck' })).toBe(false);
|
|
66
|
+
expect((0, types_1.isWellFormedJob)({ ...full, verdict: 'maybe' })).toBe(false);
|
|
67
|
+
expect((0, types_1.isWellFormedJob)({ ...full, result: { exitCode: 0 } })).toBe(false);
|
|
68
|
+
expect((0, types_1.isWellFormedJob)({ ...full, processRef: { pid: 1 } })).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
test('IN_PROGRESS exige nextAction estructurada y status del ciclo conocido (R1.5/R1.6)', () => {
|
|
71
|
+
const missing = (0, types_1.emptyState)('x');
|
|
72
|
+
delete missing.cycle.nextAction;
|
|
73
|
+
expect((0, types_1.isWellFormedState)(missing)).toBe(false);
|
|
74
|
+
const badStatus = (0, types_1.emptyState)('x');
|
|
75
|
+
badStatus.cycle.status = 'FINISHED';
|
|
76
|
+
expect((0, types_1.isWellFormedState)(badStatus)).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|