agentic-workflow-manager 3.3.1 → 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 (60) 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/ledger/index.js +1 -1
  11. package/dist/src/commands/watch/apply.js +352 -0
  12. package/dist/src/commands/watch/generations.js +249 -0
  13. package/dist/src/commands/watch/index.js +49 -0
  14. package/dist/src/commands/watch/init.js +72 -0
  15. package/dist/src/commands/watch/lock.js +89 -0
  16. package/dist/src/commands/watch/runner.js +191 -0
  17. package/dist/src/commands/watch/supervisor.js +266 -0
  18. package/dist/src/core/atomic-file.js +31 -0
  19. package/dist/src/core/export/pack.js +7 -1
  20. package/dist/src/core/export/transform.js +51 -1
  21. package/dist/src/core/journal/adapter.js +27 -0
  22. package/dist/src/core/journal/fingerprint.js +80 -0
  23. package/dist/src/core/journal/paths.js +56 -0
  24. package/dist/src/core/journal/process.js +284 -0
  25. package/dist/src/core/journal/redact.js +142 -0
  26. package/dist/src/core/journal/requests.js +132 -0
  27. package/dist/src/core/journal/store.js +107 -0
  28. package/dist/src/core/journal/types.js +165 -0
  29. package/dist/src/core/ledger/cluster.js +138 -0
  30. package/dist/src/core/ledger/store.js +20 -11
  31. package/dist/src/index.js +4 -0
  32. package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
  33. package/dist/tests/commands/job/export.test.js +76 -0
  34. package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
  35. package/dist/tests/commands/job/reap-cli.test.js +101 -0
  36. package/dist/tests/commands/job/verbs.test.js +56 -0
  37. package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
  38. package/dist/tests/commands/watch/apply.test.js +397 -0
  39. package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
  40. package/dist/tests/commands/watch/generations.test.js +115 -0
  41. package/dist/tests/commands/watch/integration.test.js +124 -0
  42. package/dist/tests/commands/watch/lock.test.js +60 -0
  43. package/dist/tests/commands/watch/runner.test.js +239 -0
  44. package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
  45. package/dist/tests/commands/watch/watch-init.test.js +43 -0
  46. package/dist/tests/core/atomic-file-durable.test.js +42 -0
  47. package/dist/tests/core/export/engine.test.js +7 -2
  48. package/dist/tests/core/export/transform.test.js +77 -0
  49. package/dist/tests/core/journal/adapter.test.js +27 -0
  50. package/dist/tests/core/journal/fingerprint.test.js +164 -0
  51. package/dist/tests/core/journal/paths.test.js +35 -0
  52. package/dist/tests/core/journal/process.test.js +213 -0
  53. package/dist/tests/core/journal/redact.test.js +59 -0
  54. package/dist/tests/core/journal/requests.test.js +134 -0
  55. package/dist/tests/core/journal/store.test.js +88 -0
  56. package/dist/tests/core/journal/types.test.js +78 -0
  57. package/dist/tests/core/ledger/cluster.test.js +240 -0
  58. package/dist/tests/core/ledger/store.test.js +42 -7
  59. package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
  60. 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
+ }
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LEXICAL_AFFINITY_MIN = void 0;
4
+ exports.normalizeTokens = normalizeTokens;
5
+ exports.affinity = affinity;
6
+ exports.normalizeRef = normalizeRef;
7
+ exports.clusterEntries = clusterEntries;
8
+ /** Palabras sin valor discriminante de identidad de defecto: se descartan antes
9
+ * de medir afinidad para que no inflen el score de dos hallazgos distintos. */
10
+ const STOPWORDS = new Set([
11
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'not', 'but',
12
+ 'its', 'has', 'have', 'was', 'are', 'were', 'when', 'then', 'than', 'only',
13
+ 'all', 'any', 'via', 'per', 'out', 'off', 'over', 'under',
14
+ ]);
15
+ function normalizeTokens(...texts) {
16
+ const out = new Set();
17
+ for (const text of texts) {
18
+ for (const token of text.toLowerCase().split(/[^a-z0-9]+/)) {
19
+ if (token.length < 2)
20
+ continue;
21
+ if (STOPWORDS.has(token))
22
+ continue;
23
+ out.add(token);
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+ /** Coeficiente de solapamiento — |A∩B| / min(|A|,|B|). Elegido sobre Jaccard
29
+ * porque el caso normal acá es un slug corto contra una descripción larga, y
30
+ * Jaccard castiga esa diferencia de longitud incluso cuando el set corto está
31
+ * completamente contenido en el largo. */
32
+ function affinity(a, b) {
33
+ if (a.size === 0 || b.size === 0)
34
+ return 0;
35
+ let shared = 0;
36
+ for (const token of a)
37
+ if (b.has(token))
38
+ shared++;
39
+ return shared / Math.min(a.size, b.size);
40
+ }
41
+ /** El locus de archivo de un `ref`, o null cuando el ref no apunta a un archivo.
42
+ * `PR #16` devuelve null a propósito: un PR entero no es un locus de defecto, y
43
+ * agrupar por él fundiría todo lo hallado en una misma review. */
44
+ function normalizeRef(ref) {
45
+ if (!ref)
46
+ return null;
47
+ const locus = ref.split(':')[0].trim();
48
+ if (!locus)
49
+ return null;
50
+ if (!locus.includes('/') && !/\.[a-z0-9]+$/i.test(locus))
51
+ return null;
52
+ return locus;
53
+ }
54
+ /** Umbral sin `ref` compartido: alto, porque la afinidad léxica es la única
55
+ * evidencia disponible y un falso positivo acá funde hallazgos de archivos
56
+ * distintos.
57
+ *
58
+ * Con `ref` compartido no hay umbral de ratio: alcanza **un token en común**
59
+ * (`score > 0`). Es deliberado y no es lo mismo que "un umbral muy bajo":
60
+ * compartir archivo ya es evidencia fuerte y barata de mismo locus, así que lo
61
+ * único que falta descartar es el par sin ninguna palabra en común — dos
62
+ * defectos genuinamente distintos que caen en el mismo archivo. Un ratio bajo
63
+ * (probamos 0.2) hacía que el caso real del issue —tres lentes, siete a nueve
64
+ * tokens cada una, un solo token compartido por par— cayera exactamente sobre
65
+ * el borde: agrupaba por casualidad aritmética, y cualquier palabra más en una
66
+ * descripción lo habría vuelto a romper. */
67
+ exports.LEXICAL_AFFINITY_MIN = 0.6;
68
+ /** Devuelve, por índice de entrada, el índice raíz de su cluster. */
69
+ function unify(entries) {
70
+ const parent = entries.map((_, i) => i);
71
+ const find = (i) => {
72
+ let node = i;
73
+ while (parent[node] !== node) {
74
+ parent[node] = parent[parent[node]];
75
+ node = parent[node];
76
+ }
77
+ return node;
78
+ };
79
+ const union = (a, b) => {
80
+ const rootA = find(a);
81
+ const rootB = find(b);
82
+ // La raíz más baja gana: hace el resultado independiente del orden de
83
+ // comparación, y por lo tanto determinístico.
84
+ if (rootA !== rootB)
85
+ parent[Math.max(rootA, rootB)] = Math.min(rootA, rootB);
86
+ };
87
+ const tokens = entries.map((e) => normalizeTokens(e.signature, e.desc));
88
+ const refs = entries.map((e) => normalizeRef(e.ref));
89
+ for (let i = 0; i < entries.length; i++) {
90
+ for (let j = i + 1; j < entries.length; j++) {
91
+ if (entries[i].signature === entries[j].signature) {
92
+ union(i, j); // R1.1 — piso preservado, sin más condiciones
93
+ continue;
94
+ }
95
+ if (entries[i].polarity !== entries[j].polarity)
96
+ continue; // R1.4
97
+ const score = affinity(tokens[i], tokens[j]);
98
+ const sameFile = refs[i] !== null && refs[i] === refs[j];
99
+ // Mismo archivo: cualquier palabra en común alcanza (R1.2).
100
+ // Archivos distintos: la afinidad tiene que sostener sola (R1.3).
101
+ const unionable = sameFile ? score > 0 : score >= exports.LEXICAL_AFFINITY_MIN;
102
+ if (unionable)
103
+ union(i, j);
104
+ }
105
+ }
106
+ return entries.map((_, i) => find(i));
107
+ }
108
+ function clusterEntries(entries, min) {
109
+ const roots = unify(entries);
110
+ const byRoot = new Map();
111
+ for (let i = 0; i < entries.length; i++) {
112
+ const group = byRoot.get(roots[i]) ?? [];
113
+ group.push(entries[i]);
114
+ byRoot.set(roots[i], group);
115
+ }
116
+ const clusters = [];
117
+ for (const group of byRoot.values()) {
118
+ const freq = new Map();
119
+ for (const e of group)
120
+ freq.set(e.signature, (freq.get(e.signature) ?? 0) + 1);
121
+ const signatures = [...freq.keys()].sort();
122
+ // signatures viene ascendente y la comparación es estricta, así que un
123
+ // empate de frecuencia deja parada la primera lexicográfica (R1.8).
124
+ const representative = signatures.reduce((best, s) => (freq.get(s) > freq.get(best) ? s : best), signatures[0]);
125
+ clusters.push({
126
+ signature: representative,
127
+ count: group.length,
128
+ kind: signatures.length > 1 ? 'convergent' : 'exact',
129
+ signatures,
130
+ entries: group,
131
+ });
132
+ }
133
+ return clusters
134
+ .filter((c) => c.count >= min)
135
+ .sort((a, b) => b.count - a.count
136
+ || (a.kind === b.kind ? 0 : a.kind === 'convergent' ? -1 : 1)
137
+ || a.signature.localeCompare(b.signature));
138
+ }
@@ -12,6 +12,7 @@ exports.archiveLedger = archiveLedger;
12
12
  const fs_1 = __importDefault(require("fs"));
13
13
  const path_1 = __importDefault(require("path"));
14
14
  const child_process_1 = require("child_process");
15
+ const cluster_1 = require("./cluster");
15
16
  const LEDGER_DIR = path_1.default.join('.awm', 'ledger');
16
17
  function detectBranch(cwd) {
17
18
  try {
@@ -33,6 +34,21 @@ function addEntry(cwd, entry) {
33
34
  fs_1.default.mkdirSync(path_1.default.dirname(p), { recursive: true });
34
35
  fs_1.default.appendFileSync(p, JSON.stringify(entry) + '\n', 'utf-8');
35
36
  }
37
+ /** Required LedgerEntry fields that `cluster.ts` reads unconditionally
38
+ * (`signature`, `desc`, `ref` when present). A JSONL line can be syntactically
39
+ * valid JSON while still being shape-invalid (e.g. missing `desc`) — that's
40
+ * not a parse error, so it needs its own check, extending the same "skip
41
+ * malformed line" policy this function already applies to JSON syntax errors. */
42
+ function isWellFormedEntry(x) {
43
+ if (!x || typeof x !== 'object')
44
+ return false;
45
+ const e = x;
46
+ return typeof e.signature === 'string'
47
+ && typeof e.desc === 'string'
48
+ && typeof e.branch === 'string'
49
+ && typeof e.polarity === 'string'
50
+ && (e.ref === undefined || typeof e.ref === 'string');
51
+ }
36
52
  function listEntries(cwd, branch) {
37
53
  const p = ledgerPath(cwd, branch);
38
54
  if (!fs_1.default.existsSync(p))
@@ -43,23 +59,16 @@ function listEntries(cwd, branch) {
43
59
  if (!trimmed)
44
60
  continue;
45
61
  try {
46
- out.push(JSON.parse(trimmed));
62
+ const parsed = JSON.parse(trimmed);
63
+ if (isWellFormedEntry(parsed))
64
+ out.push(parsed);
47
65
  }
48
66
  catch { /* skip malformed line */ }
49
67
  }
50
68
  return out;
51
69
  }
52
70
  function recurring(cwd, branch, min) {
53
- const bySig = new Map();
54
- for (const e of listEntries(cwd, branch)) {
55
- const arr = bySig.get(e.signature) ?? [];
56
- arr.push(e);
57
- bySig.set(e.signature, arr);
58
- }
59
- return [...bySig.entries()]
60
- .map(([signature, entries]) => ({ signature, count: entries.length, entries }))
61
- .filter(c => c.count >= min)
62
- .sort((a, b) => b.count - a.count);
71
+ return (0, cluster_1.clusterEntries)(listEntries(cwd, branch), min);
63
72
  }
64
73
  function archiveLedger(cwd, branch, label) {
65
74
  const src = ledgerPath(cwd, branch);
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
+ });