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.
- 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/ledger/index.js +1 -1
- 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/export/transform.js +51 -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/core/ledger/cluster.js +138 -0
- package/dist/src/core/ledger/store.js +20 -11
- 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/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/export/engine.test.js +7 -2
- package/dist/tests/core/export/transform.test.js +77 -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/core/ledger/cluster.test.js +240 -0
- package/dist/tests/core/ledger/store.test.js +42 -7
- package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
+
});
|