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,165 @@
1
+ "use strict";
2
+ // Única fuente de tipos del journal. CONSTITUTION: estados separados, nunca
3
+ // sobrecargados; shape validation antes de usar campos deserializados.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.GENERATION_STATES = exports.EXECUTION_STATES = void 0;
6
+ exports.emptyState = emptyState;
7
+ exports.isWellFormedState = isWellFormedState;
8
+ exports.isWellFormedProcessRef = isWellFormedProcessRef;
9
+ exports.isWellFormedJob = isWellFormedJob;
10
+ exports.EXECUTION_STATES = [
11
+ 'received', 'spawn-intent', 'claimed', 'running',
12
+ 'exited', 'cancel-requested', 'cancelled', 'orphaned',
13
+ ];
14
+ exports.GENERATION_STATES = [
15
+ 'active', 'controller-suspected-stall', 'terminated', 'superseded',
16
+ ];
17
+ function emptyState(branch) {
18
+ return {
19
+ schema: 1, revision: 0, branch,
20
+ cycle: {
21
+ status: 'IN_PROGRESS', startedAt: new Date().toISOString(),
22
+ nextAction: { actionId: 'bootstrap-cycle', type: 'plan-cycle', target: 'cycle', preconditions: [], attempt: 0, state: 'pending' },
23
+ },
24
+ cycleVerificationPlan: [], requiredVerifiers: [], generations: [], tasks: [],
25
+ dispatches: [], jobs: {}, verdicts: [], fixes: [], appliedRequests: {}, requestProblems: [], custodyDecisions: [],
26
+ };
27
+ }
28
+ function isObj(x) {
29
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
30
+ }
31
+ function isWellFormedState(x) {
32
+ if (!isObj(x))
33
+ return false;
34
+ if (x.schema !== 1)
35
+ return false;
36
+ if (typeof x.revision !== 'number')
37
+ return false;
38
+ if (typeof x.branch !== 'string')
39
+ return false;
40
+ if (!isObj(x.cycle) || !['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(String(x.cycle.status))
41
+ || typeof x.cycle.startedAt !== 'string'
42
+ || (x.cycle.completedAt !== undefined && typeof x.cycle.completedAt !== 'string')
43
+ || (x.cycle.blockedReason !== undefined && typeof x.cycle.blockedReason !== 'string')
44
+ || (x.cycle.nextAction !== undefined && !isWellFormedNextAction(x.cycle.nextAction))
45
+ || (x.cycle.status === 'IN_PROGRESS' && x.cycle.nextAction === undefined))
46
+ return false;
47
+ if (!Array.isArray(x.generations) || !Array.isArray(x.tasks))
48
+ return false;
49
+ if (!Array.isArray(x.cycleVerificationPlan) || !Array.isArray(x.verdicts) || !Array.isArray(x.fixes))
50
+ return false;
51
+ if (!Array.isArray(x.requiredVerifiers) || !x.requiredVerifiers.every((kind) => ['test', 'lint', 'sensors', 'review', 'qa', 'interlock'].includes(String(kind)))
52
+ || !Array.isArray(x.dispatches) || !x.dispatches.every(isWellFormedDispatch))
53
+ return false;
54
+ if (!isObj(x.jobs) || !Object.values(x.jobs).every(isWellFormedJob))
55
+ return false;
56
+ if (!isObj(x.appliedRequests) || !Object.values(x.appliedRequests).every(isWellFormedAppliedRequest))
57
+ return false;
58
+ if (!Array.isArray(x.requestProblems) || !x.requestProblems.every(isWellFormedRequestProblem))
59
+ return false;
60
+ if (x.custodyDecisions !== undefined && (!Array.isArray(x.custodyDecisions) || !x.custodyDecisions.every(isWellFormedCustodyDecision)))
61
+ return false;
62
+ if (!x.generations.every(isWellFormedGeneration) || !x.tasks.every(isWellFormedTask))
63
+ return false;
64
+ if (!x.cycleVerificationPlan.every(isWellFormedVerificationItem))
65
+ return false;
66
+ if (!x.verdicts.every(isWellFormedVerdict) || !x.fixes.every(isWellFormedFix))
67
+ return false;
68
+ return true;
69
+ }
70
+ function isWellFormedNextAction(x) {
71
+ return isObj(x) && typeof x.actionId === 'string' && typeof x.type === 'string' && typeof x.target === 'string'
72
+ && strings(x.preconditions) && typeof x.attempt === 'number'
73
+ && (x.state === 'pending' || x.state === 'in-progress');
74
+ }
75
+ function isWellFormedDispatch(x) {
76
+ return isObj(x) && typeof x.id === 'string' && typeof x.taskId === 'string' && typeof x.at === 'string';
77
+ }
78
+ function strings(x) {
79
+ return Array.isArray(x) && x.every((item) => typeof item === 'string');
80
+ }
81
+ function isWellFormedVerificationItem(x) {
82
+ return isObj(x) && typeof x.id === 'string'
83
+ && ['test', 'lint', 'sensors', 'review', 'qa', 'interlock'].includes(String(x.kind))
84
+ && (x.satisfiedBy === undefined || typeof x.satisfiedBy === 'string');
85
+ }
86
+ function isWellFormedReviewObligation(x) {
87
+ return isObj(x) && typeof x.id === 'string' && typeof x.taskId === 'string'
88
+ && (x.kind === 'spec' || x.kind === 'quality')
89
+ && (x.verdictId === undefined || typeof x.verdictId === 'string');
90
+ }
91
+ function isWellFormedTask(x) {
92
+ return isObj(x) && typeof x.id === 'string' && typeof x.title === 'string'
93
+ && ['pending', 'in-progress', 'done'].includes(String(x.status))
94
+ && typeof x.attempts === 'number'
95
+ && Array.isArray(x.verificationPlan) && x.verificationPlan.every(isWellFormedVerificationItem)
96
+ && Array.isArray(x.reviewObligations) && x.reviewObligations.every(isWellFormedReviewObligation);
97
+ }
98
+ function isWellFormedGeneration(x) {
99
+ return isObj(x) && typeof x.n === 'number' && typeof x.token === 'string'
100
+ && exports.GENERATION_STATES.includes(String(x.state))
101
+ && typeof x.launchedAt === 'string'
102
+ && (x.controllerJobId === undefined || typeof x.controllerJobId === 'string')
103
+ && (x.spawnNonce === undefined || typeof x.spawnNonce === 'string')
104
+ && (x.provider === undefined || typeof x.provider === 'string')
105
+ && (x.resumePrompt === undefined || typeof x.resumePrompt === 'string')
106
+ && (x.processRef === undefined || isWellFormedProcessRef(x.processRef))
107
+ && (x.wrapperRef === undefined || isWellFormedProcessRef(x.wrapperRef));
108
+ }
109
+ function isWellFormedVerdict(x) {
110
+ return isObj(x) && typeof x.id === 'string' && typeof x.obligationId === 'string'
111
+ && ['pass', 'fail', 'inconclusive'].includes(String(x.result))
112
+ && typeof x.detail === 'string' && typeof x.receivedAt === 'string'
113
+ && typeof x.fingerprint === 'string' && strings(x.argv) && strings(x.paths) && typeof x.cwd === 'string';
114
+ }
115
+ function isWellFormedFix(x) {
116
+ return isObj(x) && typeof x.id === 'string' && typeof x.verdictId === 'string' && typeof x.closed === 'boolean';
117
+ }
118
+ function isWellFormedAppliedRequest(x) {
119
+ return isObj(x) && typeof x.requestId === 'string' && typeof x.idempotencyKey === 'string'
120
+ && typeof x.payloadDigest === 'string'
121
+ && ['applied', 'rejected-stale-generation', 'rejected-digest-mismatch', 'rejected-secret'].includes(String(x.outcome))
122
+ && (x.resultRef === undefined || typeof x.resultRef === 'string');
123
+ }
124
+ function isWellFormedRequestProblem(x) {
125
+ return isObj(x) && typeof x.file === 'string' && (x.kind === 'corrupt' || x.kind === 'rejected')
126
+ && typeof x.detail === 'string' && typeof x.at === 'string';
127
+ }
128
+ function isWellFormedCustodyDecision(x) {
129
+ return isObj(x) && typeof x.at === 'string' && x.decision === 'resume'
130
+ && typeof x.reason === 'string' && typeof x.generationToken === 'string';
131
+ }
132
+ function isWellFormedProcessRef(x) {
133
+ if (!isObj(x))
134
+ return false;
135
+ return typeof x.pid === 'number' && Number.isInteger(x.pid) && x.pid > 0
136
+ && typeof x.startTime === 'string'
137
+ && typeof x.spawnNonce === 'string'
138
+ && typeof x.argvDigest === 'string'
139
+ && typeof x.processGroup === 'number' && Number.isInteger(x.processGroup) && x.processGroup > 0
140
+ && typeof x.psArgsDigest === 'string';
141
+ }
142
+ function isWellFormedJob(x) {
143
+ if (!isObj(x))
144
+ return false;
145
+ return typeof x.id === 'string'
146
+ && typeof x.fingerprint === 'string'
147
+ && typeof x.commandDigest === 'string'
148
+ && strings(x.argv)
149
+ && typeof x.cwd === 'string'
150
+ && strings(x.paths)
151
+ && strings(x.expandedPaths)
152
+ && (x.observationState === 'progressing' || x.observationState === 'suspected-stall')
153
+ && isObj(x.phaseTimestamps) && Object.entries(x.phaseTimestamps).every(([state, at]) => exports.EXECUTION_STATES.includes(state) && typeof at === 'string')
154
+ && (x.verdict === undefined || ['pass', 'fail', 'inconclusive'].includes(String(x.verdict)))
155
+ && (x.spawnNonce === undefined || typeof x.spawnNonce === 'string')
156
+ && (x.processRef === undefined || isWellFormedProcessRef(x.processRef))
157
+ && (x.wrapperRef === undefined || isWellFormedProcessRef(x.wrapperRef))
158
+ && (x.lastProgressAt === undefined || typeof x.lastProgressAt === 'string')
159
+ && (x.logPath === undefined || typeof x.logPath === 'string')
160
+ && (x.result === undefined || (isObj(x.result) && typeof x.result.exitCode === 'number'
161
+ && typeof x.result.endedAt === 'string' && typeof x.result.resultPath === 'string'))
162
+ && (x.satisfies === undefined || typeof x.satisfies === 'string')
163
+ && (x.attemptOf === undefined || typeof x.attemptOf === 'string')
164
+ && exports.EXECUTION_STATES.includes(x.executionState);
165
+ }
package/dist/src/index.js CHANGED
@@ -33,6 +33,8 @@ const registry_1 = require("./commands/registry");
33
33
  const pin_1 = require("./commands/pin");
34
34
  const export_1 = require("./commands/export");
35
35
  const agent_1 = require("./commands/agent");
36
+ const job_1 = require("./commands/job");
37
+ const watch_1 = require("./commands/watch");
36
38
  const add_1 = require("./commands/add");
37
39
  const sync_1 = require("./commands/sync");
38
40
  const update_1 = require("./commands/update");
@@ -611,4 +613,6 @@ miroCmd.command('sync <storyMapPath>')
611
613
  (0, pin_1.registerPinCommands)(program);
612
614
  (0, export_1.registerExportCommand)(program);
613
615
  (0, agent_1.registerAgentCommand)(program);
616
+ (0, job_1.registerJobCommand)(program);
617
+ (0, watch_1.registerWatchCommand)(program);
614
618
  program.parse();
@@ -0,0 +1,85 @@
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 exec_wrapper_1 = require("../../../src/commands/job/exec-wrapper");
10
+ const process_1 = require("../../../src/core/journal/process");
11
+ describe('exec-wrapper', () => {
12
+ let dir;
13
+ beforeEach(() => { dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-wrap-')); });
14
+ afterEach(() => { fs_1.default.rmSync(dir, { recursive: true, force: true }); });
15
+ test('claim + identity sidecar + resultado terminal atomico (R1.8)', async () => {
16
+ const out = await (0, exec_wrapper_1.runExecWrapper)({ logsRoot: dir, jobId: 'job1', nonce: 'nonceA', argv: ['node', '-e', 'setTimeout(()=>process.exit(0), 300)'], cwd: '.' });
17
+ expect(out.exitCode).toBe(0);
18
+ expect(fs_1.default.existsSync((0, exec_wrapper_1.claimPath)(dir, 'job1', 'nonceA'))).toBe(true);
19
+ const identity = JSON.parse(fs_1.default.readFileSync((0, exec_wrapper_1.identityPath)(dir, 'job1', 'nonceA'), 'utf8'));
20
+ expect(identity.wrapper.pid).toBe(process.pid); // ProcessRef REAL del wrapper
21
+ expect(identity.command.pid).toBeGreaterThan(0); // ProcessRef REAL del comando
22
+ expect(identity.command.psArgsDigest).toMatch(/^[0-9a-f]{16}$/);
23
+ expect(identity.command.processGroup).not.toBe(identity.wrapper.processGroup); // el wrapper puede limpiar el grupo sin matarse
24
+ const result = JSON.parse(fs_1.default.readFileSync((0, exec_wrapper_1.resultPath)(dir, 'job1', 'nonceA'), 'utf8'));
25
+ expect(result.exitCode).toBe(0);
26
+ });
27
+ test('segundo claim con el mismo nonce falla: exactly-once (R1.8)', async () => {
28
+ await (0, exec_wrapper_1.runExecWrapper)({ logsRoot: dir, jobId: 'job2', nonce: 'nonceB', argv: ['node', '-e', 'process.exit(0)'], cwd: '.' });
29
+ await expect((0, exec_wrapper_1.runExecWrapper)({ logsRoot: dir, jobId: 'job2', nonce: 'nonceB', argv: ['node', '-e', 'process.exit(0)'], cwd: '.' }))
30
+ .rejects.toThrow(/claim/);
31
+ });
32
+ test('comando inexistente produce resultado 127, no crash (R1.8)', async () => {
33
+ const out = await (0, exec_wrapper_1.runExecWrapper)({ logsRoot: dir, jobId: 'job3', nonce: 'nonceC', argv: ['binario-inexistente-xyz'], cwd: '.' });
34
+ expect(out.exitCode).toBe(127);
35
+ expect((0, exec_wrapper_1.replayVerdict)(dir, 'job3', 'nonceC')).toBe('completed');
36
+ });
37
+ test('matriz de replay: sin claim / claim+resultado / claim sin resultado (R1.8)', async () => {
38
+ expect((0, exec_wrapper_1.replayVerdict)(dir, 'jobX', 'n1')).toBe('never-started'); // sin claim => re-spawn seguro
39
+ await (0, exec_wrapper_1.runExecWrapper)({ logsRoot: dir, jobId: 'jobY', nonce: 'n2', argv: ['node', '-e', 'process.exit(3)'], cwd: '.' });
40
+ expect((0, exec_wrapper_1.replayVerdict)(dir, 'jobY', 'n2')).toBe('completed'); // adoptar resultado
41
+ fs_1.default.writeFileSync((0, exec_wrapper_1.claimPath)(dir, 'jobZ', 'n3'), '{"claimed":true}'); // claim sin resultado
42
+ expect((0, exec_wrapper_1.replayVerdict)(dir, 'jobZ', 'n3')).toBe('unprovable'); // orphaned, jamas relanzar solo
43
+ });
44
+ test('el log captura la salida completa incluso si exit llega antes que el flush de stdio (R2.5)', async () => {
45
+ await (0, exec_wrapper_1.runExecWrapper)({ logsRoot: dir, jobId: 'job4', nonce: 'nonceD', argv: ['node', '-e', "process.stdout.write('linea-final-no-se-debe-perder'); process.exit(0)"], cwd: '.' });
46
+ const log = fs_1.default.readFileSync((0, exec_wrapper_1.logPath)(dir, 'job4', 'nonceD'), 'utf8');
47
+ expect(log).toContain('linea-final-no-se-debe-perder');
48
+ });
49
+ test('redacta secretos aunque la asignacion llegue dividida entre chunks de stdout (R2.3)', async () => {
50
+ const script = "process.stdout.write('API_'); setTimeout(()=>{process.stdout.write('KEY=hunter2\\n'); process.exit(0)}, 80)";
51
+ await (0, exec_wrapper_1.runExecWrapper)({ logsRoot: dir, jobId: 'job-split', nonce: 'nonce-split', argv: ['node', '-e', script], cwd: '.' });
52
+ const log = fs_1.default.readFileSync((0, exec_wrapper_1.logPath)(dir, 'job-split', 'nonce-split'), 'utf8');
53
+ expect(log).toContain('API_KEY=[REDACTED]');
54
+ expect(log).not.toContain('hunter2');
55
+ });
56
+ test('el log se acota aprox. en MAX_LOG_BYTES cuando la salida supera el limite, no crece sin cota (R2.5)', async () => {
57
+ const MAX_LOG_BYTES = 1024 * 1024;
58
+ const bytesToWrite = 2 * MAX_LOG_BYTES; // 2MB, muy por encima del cap de 1MB
59
+ const out = await (0, exec_wrapper_1.runExecWrapper)({
60
+ logsRoot: dir, jobId: 'job6', nonce: 'nonceF',
61
+ argv: ['node', '-e', `process.stdout.write('x'.repeat(${bytesToWrite}))`],
62
+ cwd: '.',
63
+ });
64
+ expect(out.exitCode).toBe(0);
65
+ const size = fs_1.default.statSync((0, exec_wrapper_1.logPath)(dir, 'job6', 'nonceF')).size;
66
+ // el append corta apenas se cruza el cap (chequeo ANTES de cada chunk),
67
+ // asi que el tamano final ronda MAX_LOG_BYTES +/- un ultimo chunk de
68
+ // pipe, jamas los 2MB reales escritos por el comando (R2.5).
69
+ expect(size).toBeGreaterThanOrEqual(MAX_LOG_BYTES);
70
+ expect(size).toBeLessThan(MAX_LOG_BYTES * 1.5);
71
+ expect(size).toBeLessThan(bytesToWrite);
72
+ }, 10000);
73
+ test('no se cuelga si un descendiente hereda stdio y no lo cierra (R1.8)', async () => {
74
+ const script = "const {spawn}=require('child_process'); const gc=spawn('sleep',['3'],{stdio:'inherit',detached:true}); gc.unref(); process.exit(0);";
75
+ const out = await (0, exec_wrapper_1.runExecWrapper)({ logsRoot: dir, jobId: 'job5', nonce: 'nonceE', argv: ['node', '-e', script], cwd: '.' });
76
+ expect(out.exitCode).toBe(0);
77
+ }, 10000);
78
+ test('no publica pass hasta drenar descendientes que quedan en el process group del comando', async () => {
79
+ const script = "require('child_process').spawn('sleep',['30'],{stdio:'ignore'}); process.exit(0);";
80
+ const out = await (0, exec_wrapper_1.runExecWrapper)({ logsRoot: dir, jobId: 'job7', nonce: 'nonceG', argv: ['node', '-e', script], cwd: '.' });
81
+ const identity = JSON.parse(fs_1.default.readFileSync((0, exec_wrapper_1.identityPath)(dir, 'job7', 'nonceG'), 'utf8'));
82
+ expect(out.exitCode).toBe(0);
83
+ expect((0, process_1.groupIsGone)(identity.command.processGroup)).toBe(true);
84
+ }, 10000);
85
+ });
@@ -0,0 +1,76 @@
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 export_1 = require("../../../src/commands/job/export");
11
+ const types_1 = require("../../../src/core/journal/types");
12
+ function job(partial) {
13
+ return {
14
+ id: 'j1', fingerprint: 'fp', commandDigest: 'cd', argv: ['npm', 'test'], cwd: '.',
15
+ paths: [], expandedPaths: [], executionState: 'exited', observationState: 'progressing',
16
+ phaseTimestamps: {}, ...partial,
17
+ };
18
+ }
19
+ describe('export', () => {
20
+ let logs;
21
+ beforeEach(() => { logs = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-exp-')); });
22
+ afterEach(() => { fs_1.default.rmSync(logs, { recursive: true, force: true }); });
23
+ test('schema, timestamps por fase, wall time por task y ciclo (R3.7 / RNF-T.4)', () => {
24
+ const s = (0, types_1.emptyState)('r');
25
+ s.cycle.startedAt = '2026-08-01T10:00:00.000Z';
26
+ s.cycle.completedAt = '2026-08-01T10:30:00.000Z';
27
+ s.tasks.push({ id: 'T1', title: 't', status: 'done', attempts: 2, verificationPlan: [], reviewObligations: [], createdAt: '2026-08-01T10:00:00.000Z', completedAt: '2026-08-01T10:10:00.000Z' });
28
+ s.tasks.push({ id: 'T2', title: 'sin-timestamps', status: 'done', attempts: 1, verificationPlan: [], reviewObligations: [] });
29
+ s.jobs['j1'] = job({ phaseTimestamps: { received: 'a', running: 'b', exited: 'c' } });
30
+ const e = (0, export_1.buildExport)(s, 'codex', { logsRoot: null, baseline: null });
31
+ expect(e.schema).toBe(2);
32
+ expect(e.cycle.wallTimeMs).toBe(30 * 60000);
33
+ expect(e.tasks[0].wallTimeMs).toBe(10 * 60000);
34
+ expect(e.tasks[1].wallTimeMs).toBe('unobservable'); // sin timestamps => declarado, no cero
35
+ expect(e.jobs[0].phaseTimestamps.running).toBe('b');
36
+ });
37
+ test('despachos REALES, dedup real, evidencia con hash + comando reproducible (RNF-T.8/T.9)', () => {
38
+ const s = (0, types_1.emptyState)('r');
39
+ s.dispatches.push({ id: 'd1', taskId: 'T1', at: 'x' }, { id: 'd2', taskId: 'T1', at: 'y' });
40
+ s.jobs['j1'] = job({ id: 'j1', spawnNonce: 'n1' });
41
+ s.jobs['j2'] = job({ id: 'j2', spawnNonce: 'n2' }); // mismo fingerprint+cmd => dedup
42
+ const resultBody = JSON.stringify({ exitCode: 0, endedAt: 'x', resultPath: 'p' });
43
+ fs_1.default.writeFileSync(path_1.default.join(logs, 'j1.n1.result.json'), resultBody);
44
+ const e = (0, export_1.buildExport)(s, 'codex', { logsRoot: logs, baseline: null });
45
+ expect(e.metrics.dispatches).toBe(2); // reales, no attempts-proxy
46
+ expect(e.metrics.mechanicalRunsReal).toBe(2);
47
+ expect(e.metrics.mechanicalRunsDeduplicated).toBe(1);
48
+ expect(e.jobs.find((j) => j.id === 'j2').deduplicated).toBe(true);
49
+ const ev1 = e.evidence.find((x) => x.jobId === 'j1');
50
+ expect(ev1.resultHash).toBe(crypto_1.default.createHash('sha256').update(resultBody).digest('hex'));
51
+ expect(ev1.reproduce).toContain('npm test');
52
+ expect(e.evidence.find((x) => x.jobId === 'j2').resultHash).toBe('unobservable'); // sin result file
53
+ });
54
+ test('baselineComparison: con baseline compara, sin baseline declara unobservable (R3.7)', () => {
55
+ const s = (0, types_1.emptyState)('r');
56
+ s.cycle.startedAt = '2026-08-01T10:00:00.000Z';
57
+ s.cycle.completedAt = '2026-08-01T10:20:00.000Z';
58
+ s.dispatches.push({ id: 'd1', taskId: 'T1', at: 'x' });
59
+ const withBase = (0, export_1.buildExport)(s, 'codex', { logsRoot: null, baseline: { source: 'docs/baseline-2026-07-29.json', wallTimeMs: 40 * 60000, dispatches: 3 } });
60
+ expect(withBase.baselineComparison.baselineDate).toBe('2026-07-29');
61
+ expect(withBase.baselineComparison.wallTimeMs).toEqual({ current: 20 * 60000, baseline: 40 * 60000, delta: -20 * 60000 });
62
+ expect(withBase.baselineComparison.dispatches).toEqual({ current: 1, baseline: 3, delta: -2 });
63
+ expect(withBase.baselineComparison.tokensPerRole).toBe('unobservable'); // ningun provider lo reporta (R0)
64
+ const noBase = (0, export_1.buildExport)(s, 'codex', { logsRoot: null, baseline: null });
65
+ expect(noBase.baselineComparison.wallTimeMs.baseline).toBe('unobservable');
66
+ expect(noBase.baselineComparison.wallTimeMs.delta).toBe('unobservable');
67
+ expect(noBase.metrics.tokensPerRole).toBe('unobservable');
68
+ });
69
+ test('wallMs declara unobservable ante timestamps invertidos, nunca un numero negativo (R3.7)', () => {
70
+ const s = (0, types_1.emptyState)('r');
71
+ s.cycle.startedAt = '2026-08-01T10:30:00.000Z';
72
+ s.cycle.completedAt = '2026-08-01T10:00:00.000Z'; // invertido: dato corrupto/anomalo
73
+ const e = (0, export_1.buildExport)(s, 'codex', { logsRoot: null, baseline: null });
74
+ expect(e.cycle.wallTimeMs).toBe('unobservable');
75
+ });
76
+ });