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,284 @@
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
+ exports.EXEC_STDIO = exports.NONCE_ENV = void 0;
7
+ exports.argvDigest = argvDigest;
8
+ exports.psArgsDigestOf = psArgsDigestOf;
9
+ exports.captureRefFor = captureRefFor;
10
+ exports.captureSelfRef = captureSelfRef;
11
+ exports.spawnStructured = spawnStructured;
12
+ exports.refIsAlive = refIsAlive;
13
+ exports.processStatesAreGone = processStatesAreGone;
14
+ exports.groupIsGone = groupIsGone;
15
+ exports.activitySnapshot = activitySnapshot;
16
+ exports.terminateGroupConfirmed = terminateGroupConfirmed;
17
+ exports.terminatePreviouslyOwnedGroup = terminatePreviouslyOwnedGroup;
18
+ const crypto_1 = __importDefault(require("crypto"));
19
+ const child_process_1 = require("child_process");
20
+ exports.NONCE_ENV = 'AWM_SPAWN_NONCE';
21
+ /** Contrato dual, a proposito (tomo 3 rondas de fixes reales llegar aca —
22
+ * ver historial de Task 10): devuelve `null` SOLO cuando `ps` corrio y
23
+ * confirmo positivamente que el pid no existe (exit status 1). Cualquier
24
+ * otro fallo (ENOENT del binario, permisos, error transitorio) se
25
+ * RELANZA — nunca se traduce a `null`, porque un `null` aca significaria
26
+ * "muerte confirmada" para cualquier caller que no distinga los casos.
27
+ * Hay DOS formas correctas de consumir esto, segun el contexto:
28
+ * - Declaracion de muerte (`refIsAlive`, `activitySnapshot`): el caller
29
+ * DEBE envolver en su propio try/catch y fallar A FAVOR de "vivo" —
30
+ * nunca asumir muerto por un throw. El silencio jamas es prueba.
31
+ * - Captura de identidad en spawn (`captureRefFor`, `stablePsArgs`): usar
32
+ * `psFieldSafe` en vez de esta funcion — ahi "no se pudo determinar" ya
33
+ * tiene un fallback seguro documentado ('unknown'), sin riesgo de
34
+ * declarar muerte por error. */
35
+ /** stdio explicito en TODOS los execFileSync de este archivo (ver EXEC_STDIO):
36
+ * sin esto, `execFileSync` por defecto hace `inheritStderr` — relayea el
37
+ * stderr del subproceso hacia el stderr DEL PROCESO LLAMANTE. Si ese stderr
38
+ * llegara a ser un pipe roto/destruido (ej. wrapper detached, ver
39
+ * spawnStructured), el relay mismo dispara el EPIPE que crashea al
40
+ * llamante — el mismo bug de raiz, reintroducido via esta funcion en vez
41
+ * de via el spawn del hijo. Con stdio explicito ('pipe' para stdout/stderr)
42
+ * ese relay jamas ocurre: `execFileSync` captura el stderr del subproceso
43
+ * internamente y listo, sin tocar el fd real del proceso actual. */
44
+ exports.EXEC_STDIO = ['ignore', 'pipe', 'pipe'];
45
+ function psField(pid, field) {
46
+ try {
47
+ const out = (0, child_process_1.execFileSync)('ps', ['-o', `${field}=`, '-p', String(pid)], { encoding: 'utf8', stdio: exports.EXEC_STDIO }).trim();
48
+ return out.length > 0 ? out : null;
49
+ }
50
+ catch (error) {
51
+ const status = error.status;
52
+ if (status === 1)
53
+ return null; // ps corrio y confirmo: el pid no existe
54
+ throw error; // ps no pudo ejecutarse (ENOENT/permisos/etc): NO es prueba de nada
55
+ }
56
+ }
57
+ function sleepSync(seconds) {
58
+ try {
59
+ (0, child_process_1.execFileSync)('sleep', [seconds], { stdio: exports.EXEC_STDIO });
60
+ }
61
+ catch { /* sin sleep: seguimos */ }
62
+ }
63
+ /** Variante de psField para contextos de CAPTURA de identidad (spawn time):
64
+ * aqui "no se pudo determinar" ya tiene un fallback seguro documentado
65
+ * ('unknown') — no es un contexto de declaracion de muerte, asi que
66
+ * cualquier fallo de ps se traga, igual que siempre. */
67
+ function psFieldSafe(pid, field) {
68
+ try {
69
+ return psField(pid, field);
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ /** ps args estable: dos lecturas consecutivas iguales (evita capturar el
76
+ * estado pre-exec del fork). null si el proceso ya no existe O si ps
77
+ * fallo en ejecutarse (via psFieldSafe) — ambos casos son "no se pudo
78
+ * determinar" aca, sin riesgo: este es un contexto de captura, no de
79
+ * declaracion de muerte. */
80
+ function stablePsArgs(pid) {
81
+ for (let i = 0; i < 5; i++) {
82
+ const a = psFieldSafe(pid, 'args');
83
+ if (a === null)
84
+ return null;
85
+ sleepSync('0.05');
86
+ const b = psFieldSafe(pid, 'args');
87
+ if (b === a)
88
+ return a;
89
+ }
90
+ return psFieldSafe(pid, 'args');
91
+ }
92
+ function argvDigest(argv) {
93
+ return crypto_1.default.createHash('sha256').update(argv.join('\0')).digest('hex').slice(0, 16);
94
+ }
95
+ /** EXPORTADA pero hereda el contrato crudo de psField (throws si ps falla
96
+ * en ejecutarse, mas alla de "pid no existe"). Hoy el unico caller es
97
+ * refIsAlive, que ya envuelve en su propio try/catch fail-safe — cualquier
98
+ * caller NUEVO que la use standalone debe hacer lo mismo (ver comentario
99
+ * de psField) o usar psFieldSafe si esta en un contexto de captura. */
100
+ function identityDigest(psArgs, spawnNonce, requestedArgvDigest) {
101
+ return crypto_1.default.createHash('sha256').update(`${psArgs}\0${spawnNonce}\0${requestedArgvDigest}`).digest('hex').slice(0, 16);
102
+ }
103
+ function psArgsDigestOf(pid, spawnNonce = '', requestedArgvDigest = '') {
104
+ const args = psField(pid, 'args');
105
+ if (args === null)
106
+ return null;
107
+ return identityDigest(args, spawnNonce, requestedArgvDigest);
108
+ }
109
+ /** Captura la identidad COMPLETA de un pid recien spawneado (R2.1):
110
+ * startTime + pgid reales de ps + digest de `ps -o args=` estable. */
111
+ function captureRefFor(pid, nonce, argv) {
112
+ let start = null;
113
+ for (let i = 0; i < 5 && start === null; i++) {
114
+ start = psFieldSafe(pid, 'lstart');
115
+ if (start === null)
116
+ sleepSync('0.05');
117
+ }
118
+ const pgid = psFieldSafe(pid, 'pgid');
119
+ const args = stablePsArgs(pid);
120
+ const requestedArgvDigest = argvDigest(argv);
121
+ return {
122
+ pid,
123
+ startTime: start ?? 'unknown',
124
+ spawnNonce: nonce,
125
+ argvDigest: requestedArgvDigest,
126
+ processGroup: pgid !== null ? Number(pgid) : pid,
127
+ // Liga nonce + argv solicitado con la observacion real de ps. Alterar
128
+ // cualquier miembro de la tupla invalida la identidad completa.
129
+ psArgsDigest: args !== null ? identityDigest(args, nonce, requestedArgvDigest) : 'unknown',
130
+ };
131
+ }
132
+ /** Identidad del proceso ACTUAL (la usa el wrapper externo y el lock). */
133
+ function captureSelfRef(nonce) {
134
+ return captureRefFor(process.pid, nonce, process.argv);
135
+ }
136
+ /** Ejecucion segura (design R4.7): executable+argv como array, shell:false,
137
+ * nonce por entorno (referencia, no valor persistido), grupo propio (detached). */
138
+ function spawnStructured(argv, cwd, nonce, extraEnv = {}) {
139
+ const [exe, ...args] = argv;
140
+ const child = (0, child_process_1.spawn)(exe, args, {
141
+ cwd, shell: false, detached: true,
142
+ env: { ...process.env, [exports.NONCE_ENV]: nonce, ...extraEnv },
143
+ // stdio:'ignore' completo (nada de pipes): un pipe destruido/abandonado
144
+ // por el padre puede EPIPE-crashear al hijo si este escribe a su propio
145
+ // stdout/stderr despues (ver defaultWrapperSpawner) — el hijo maneja su
146
+ // stdio propio, el padre no necesita capturarlo.
147
+ stdio: 'ignore',
148
+ });
149
+ if (child.pid === undefined)
150
+ throw new Error(`spawn fallo para ${exe}`);
151
+ return { child, ref: captureRefFor(child.pid, nonce, argv) };
152
+ }
153
+ /** Vivo Y con la MISMA identidad — tupla completa, nunca PID solo (R2.1,
154
+ * bloqueador 6): startTime + pgid + digest de ps args. */
155
+ function refIsAlive(ref) {
156
+ try {
157
+ const stat = psField(ref.pid, 'stat');
158
+ if (stat === null || stat.startsWith('Z'))
159
+ return false; // zombie = proceso terminado, solo espera reap
160
+ const start = psField(ref.pid, 'lstart');
161
+ if (start === null || start !== ref.startTime)
162
+ return false;
163
+ const pgid = psField(ref.pid, 'pgid');
164
+ if (pgid === null || Number(pgid) !== ref.processGroup)
165
+ return false;
166
+ const argsDig = psArgsDigestOf(ref.pid, ref.spawnNonce, ref.argvDigest);
167
+ if (argsDig === null || argsDig !== ref.psArgsDigest)
168
+ return false;
169
+ return true;
170
+ }
171
+ catch {
172
+ // ps no pudo ejecutarse: sin evidencia, jamas declarar muerte — se
173
+ // trata como vivo (R2.1, bloqueador de Task 10: silencio no es prueba).
174
+ return true;
175
+ }
176
+ }
177
+ /** true <=> pgrep no encuentra miembros ejecutables en el grupo. Los zombies
178
+ * ya terminaron y solo esperan reap; no pueden responder seniales ni retener
179
+ * trabajo. Un fallo de observacion devuelve false (R2.1). */
180
+ function processStatesAreGone(states) {
181
+ return states.every((stat) => stat === null || stat.startsWith('Z'));
182
+ }
183
+ function groupIsGone(pgid) {
184
+ let pids;
185
+ try {
186
+ const out = (0, child_process_1.execFileSync)('pgrep', ['-g', String(pgid)], { encoding: 'utf8', stdio: exports.EXEC_STDIO });
187
+ pids = out.split('\n').filter(Boolean).map(Number).filter(Number.isInteger);
188
+ }
189
+ catch (error) {
190
+ const status = error.status;
191
+ return status === 1; // pgrep exit 1 = cero matches; cualquier otra cosa NO confirma
192
+ }
193
+ if (pids.length === 0)
194
+ return true;
195
+ try {
196
+ // `pgrep` tambien devuelve zombies. No pueden ejecutar, mantener FDs ni
197
+ // responder seniales; contarlos como vivos fuerza esperas completas y
198
+ // custodia falsa hasta que el parent haga reap. Solo un miembro no-zombie
199
+ // conserva ownership ejecutable del grupo.
200
+ return processStatesAreGone(pids.map((pid) => psField(pid, 'stat')));
201
+ }
202
+ catch {
203
+ return false; // sin observacion completa, falla cerrado
204
+ }
205
+ }
206
+ function activitySnapshot(ref) {
207
+ if (!refIsAlive(ref))
208
+ return null;
209
+ let cpu = '0';
210
+ try {
211
+ cpu = psField(ref.pid, 'time') ?? '0';
212
+ }
213
+ catch {
214
+ cpu = '0';
215
+ }
216
+ let groupSize = 1;
217
+ try {
218
+ groupSize = (0, child_process_1.execFileSync)('pgrep', ['-g', String(ref.processGroup)], { encoding: 'utf8', stdio: exports.EXEC_STDIO })
219
+ .split('\n').filter(Boolean).length;
220
+ }
221
+ catch {
222
+ groupSize = 1;
223
+ }
224
+ return { cpuTime: cpu, groupSize };
225
+ }
226
+ /** Escalera de gracia (design R4.2b): SIGTERM -> confirmar -> SIGKILL -> confirmar.
227
+ * true <=> lider muerto por identidad Y grupo entero desaparecido (pgrep -g
228
+ * vacio) — jamas confirmar solo el lider (bloqueador 6). */
229
+ async function terminateGroupConfirmed(ref, opts) {
230
+ const waitUntilGone = async (maxMs) => {
231
+ const deadline = Date.now() + maxMs;
232
+ while (Date.now() < deadline) {
233
+ if (groupIsGone(ref.processGroup))
234
+ return true;
235
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
236
+ }
237
+ return groupIsGone(ref.processGroup);
238
+ };
239
+ if (groupIsGone(ref.processGroup))
240
+ return true;
241
+ // Un PGID ocupado con lider de identidad distinta NO es nuestro. Nunca
242
+ // usar la falta de match como autorizacion para senializar ese grupo.
243
+ if (!refIsAlive(ref))
244
+ return false;
245
+ try {
246
+ process.kill(-ref.processGroup, 'SIGTERM');
247
+ }
248
+ catch { /* grupo ya ausente */ }
249
+ if (await waitUntilGone(opts.termGraceMs))
250
+ return true;
251
+ try {
252
+ process.kill(-ref.processGroup, 'SIGKILL');
253
+ }
254
+ catch { /* idem */ }
255
+ return waitUntilGone(opts.killGraceMs);
256
+ }
257
+ /** Drena un grupo cuya propiedad fue capturada por el caller mientras el
258
+ * lider aun estaba vivo. Se usa inmediatamente tras el exit del lider para
259
+ * eliminar descendientes remanentes; el PGID no puede reutilizarse mientras
260
+ * esos miembros sigan presentes. */
261
+ async function terminatePreviouslyOwnedGroup(ref, opts) {
262
+ const waitUntilGone = async (maxMs) => {
263
+ const deadline = Date.now() + maxMs;
264
+ while (Date.now() < deadline) {
265
+ if (groupIsGone(ref.processGroup))
266
+ return true;
267
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
268
+ }
269
+ return groupIsGone(ref.processGroup);
270
+ };
271
+ if (groupIsGone(ref.processGroup))
272
+ return true;
273
+ try {
274
+ process.kill(-ref.processGroup, 'SIGTERM');
275
+ }
276
+ catch { /* ya ausente */ }
277
+ if (await waitUntilGone(opts.termGraceMs))
278
+ return true;
279
+ try {
280
+ process.kill(-ref.processGroup, 'SIGKILL');
281
+ }
282
+ catch { /* ya ausente */ }
283
+ return waitUntilGone(opts.killGraceMs);
284
+ }
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ // Redacción EN EL EMISOR, antes de cualquier escritura (design R2.3).
3
+ // Patrones alineados con el sensor-pack de secretos del registry baseline.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.redactText = redactText;
6
+ exports.findLiteralSecretFlag = findLiteralSecretFlag;
7
+ exports.redactArgv = redactArgv;
8
+ const SECRET_WORD = /(password|passwd|secret|api[-_]?key|apikey|token|credential)/i;
9
+ // ASSIGNMENT (regex de único backtracking) tuvo 3 rondas de fallas reales:
10
+ // sin cota => ReDoS cuadrático (corridas largas sin match, o muchas
11
+ // ocurrencias sueltas del keyword sin separador); cotado en ambos lados =>
12
+ // fuga de secretos con identificadores largos entre el keyword y el "=".
13
+ // Sustituido por escaneo manual caracter-a-caracter: O(n) sin backtracking,
14
+ // sin cota de longitud posible en ningún lado, inmune a ambos hallazgos.
15
+ const KEYWORD_RE = /password|passwd|secret|api[-_]?key|apikey|token|credential/gi;
16
+ const IDENT_CHAR = /[a-z0-9_-]/i;
17
+ const WHITESPACE = /\s/;
18
+ // Distinto de SECRET_WORD (substring, para nombres de flag reales): aquí el
19
+ // keyword debe ser un segmento completo delimitado por -, _ o los bordes del
20
+ // string. Se usa SOLO dentro de redactArgv para decidir hasta dónde extender
21
+ // la redacción en cadena — nunca para decidir SI redactar: ver comentario en
22
+ // redactArgv sobre por qué la ambigüedad siempre se resuelve redactando de
23
+ // más, nunca de menos.
24
+ const SECRET_FLAG_SEGMENT = /(^|[-_])(password|passwd|secret|api[-_]?key|apikey|token|credential)($|[-_])/i;
25
+ function looksLikeSensitiveFlag(token) {
26
+ if (!token.startsWith('-'))
27
+ return false;
28
+ const eq = token.indexOf('=');
29
+ const flag = eq === -1 ? token : token.slice(0, eq);
30
+ return SECRET_FLAG_SEGMENT.test(flag.replace(/^-+/, ''));
31
+ }
32
+ function redactText(text) {
33
+ let result = '';
34
+ let cursor = 0;
35
+ KEYWORD_RE.lastIndex = 0;
36
+ let m;
37
+ while ((m = KEYWORD_RE.exec(text)) !== null) {
38
+ if (m.index < cursor)
39
+ continue;
40
+ let keyEnd = m.index + m[0].length;
41
+ while (keyEnd < text.length && IDENT_CHAR.test(text[keyEnd]))
42
+ keyEnd++;
43
+ let sepStart = keyEnd;
44
+ while (sepStart < text.length && WHITESPACE.test(text[sepStart]))
45
+ sepStart++;
46
+ if (sepStart >= text.length || (text[sepStart] !== '=' && text[sepStart] !== ':')) {
47
+ KEYWORD_RE.lastIndex = keyEnd;
48
+ continue;
49
+ }
50
+ let valueStart = sepStart + 1;
51
+ while (valueStart < text.length && WHITESPACE.test(text[valueStart]))
52
+ valueStart++;
53
+ if (valueStart >= text.length) {
54
+ KEYWORD_RE.lastIndex = keyEnd;
55
+ continue;
56
+ }
57
+ let valueEnd = valueStart;
58
+ while (valueEnd < text.length && !WHITESPACE.test(text[valueEnd]))
59
+ valueEnd++;
60
+ result += text.slice(cursor, valueStart) + '[REDACTED]';
61
+ cursor = valueEnd;
62
+ KEYWORD_RE.lastIndex = cursor;
63
+ }
64
+ result += text.slice(cursor);
65
+ return result;
66
+ }
67
+ /** Flag sensible que porta un secreto LITERAL (no una referencia `-env`):
68
+ * la request se rechaza, no se persiste ni redactada (R2.3). Deliberadamente
69
+ * NO intenta distinguir "el siguiente token es un flag hermano" de "es el
70
+ * valor literal": el rechazo es todo-o-nada (emitRequest lanza antes de
71
+ * persistir nada), así que la ambigüedad nunca importa — cualquier token
72
+ * después de un flag sensible ya es motivo suficiente de rechazo.
73
+ *
74
+ * LIMITACIÓN ACEPTADA (no es un bug pendiente): esta función solo reconoce
75
+ * secretos cuyo NOMBRE de flag contiene una palabra clave (password/token/
76
+ * secret/api-key/credential), sea con cualquier cantidad de guiones (-token, --token).
77
+ * Mnemónicos de una sola letra sin texto ninguno (ej. `-p` de mysql, `-i` de
78
+ * ssh, `-u` de curl) son indistinguibles de cualquier otro flag corto por
79
+ * texto solo — cerrar ese caso exigiría una tabla fija de convenciones por
80
+ * herramienta externa, que es enumeración no acotada y no pertenece a un
81
+ * mecanismo genérico (ver CLAUDE.md, frontera genérico/específico). Si esto
82
+ * resulta ser un problema real y recurrente en este proyecto, se resuelve
83
+ * vía harness-retro con una regla específica, no aquí. */
84
+ function findLiteralSecretFlag(argv) {
85
+ for (let i = 0; i < argv.length; i++) {
86
+ const arg = argv[i];
87
+ if (!arg.startsWith('-'))
88
+ continue;
89
+ const eq = arg.indexOf('=');
90
+ const flag = eq === -1 ? arg : arg.slice(0, eq);
91
+ const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1);
92
+ if (!SECRET_WORD.test(flag))
93
+ continue;
94
+ if (/-env$/i.test(flag))
95
+ continue; // referencia, permitida (R4.7)
96
+ const value = inlineValue !== undefined ? inlineValue : argv[i + 1];
97
+ if (value !== undefined)
98
+ return flag;
99
+ }
100
+ return null;
101
+ }
102
+ /** A diferencia de findLiteralSecretFlag (todo-o-nada), esta función SÍ debe
103
+ * devolver un array persistible — no puede simplemente rechazar. Cuando el
104
+ * token que sigue a un flag sensible también PARECE un flag sensible, es
105
+ * imposible distinguir por texto solo "es un flag hermano real" de "es el
106
+ * valor literal (adversario) del flag anterior disfrazado de flag" — ambas
107
+ * lecturas son indistinguibles sin un esquema de flags real (hallazgo de
108
+ * spec-review, R2.3). Ante esa ambigüedad se redacta TODA la cadena de
109
+ * tokens con forma de flag sensible más el token final que la cierra,
110
+ * nunca menos: sobre-redactar un nombre de flag es un costo cosmético,
111
+ * dejar pasar un secreto no lo es. */
112
+ function redactArgv(argv) {
113
+ const out = [];
114
+ let i = 0;
115
+ while (i < argv.length) {
116
+ const arg = argv[i];
117
+ const eq = arg.indexOf('=');
118
+ const flag = eq === -1 ? arg : arg.slice(0, eq);
119
+ const isSensitive = arg.startsWith('-') && SECRET_WORD.test(flag) && !/-env$/i.test(flag);
120
+ if (!isSensitive) {
121
+ out.push(redactText(arg));
122
+ i++;
123
+ continue;
124
+ }
125
+ if (eq !== -1) {
126
+ out.push(`${flag}=[REDACTED]`);
127
+ i++;
128
+ continue;
129
+ }
130
+ out.push(arg);
131
+ i++;
132
+ while (i < argv.length && looksLikeSensitiveFlag(argv[i])) {
133
+ out.push('[REDACTED]');
134
+ i++;
135
+ }
136
+ if (i < argv.length) {
137
+ out.push('[REDACTED]');
138
+ i++;
139
+ }
140
+ }
141
+ return out;
142
+ }
@@ -0,0 +1,132 @@
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
+ exports.digestOf = digestOf;
7
+ exports.emitRequest = emitRequest;
8
+ exports.listPendingRequests = listPendingRequests;
9
+ exports.applyOutcome = applyOutcome;
10
+ exports.ackFor = ackFor;
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const crypto_1 = __importDefault(require("crypto"));
14
+ const paths_1 = require("./paths");
15
+ const redact_1 = require("./redact");
16
+ const atomic_file_1 = require("../atomic-file");
17
+ function digestOf(payload) {
18
+ return crypto_1.default.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
19
+ }
20
+ function countPendingRequests(dir) {
21
+ try {
22
+ return fs_1.default.readdirSync(dir).filter((f) => f.endsWith('.json')).length;
23
+ }
24
+ catch {
25
+ return 0;
26
+ }
27
+ }
28
+ /** Publicacion atomica Y durable (R1.3, bloqueador 4): tmp + fsync + close +
29
+ * rename + fsync del DIRECTORIO. Redaccion EN EL EMISOR; secreto literal en
30
+ * flag sensible => rechazo sin persistir (R2.3). El segmento `seq` es un
31
+ * conteo del directorio de requests AL MOMENTO DE EMITIR (no un contador
32
+ * en memoria del proceso): las emisiones causalmente dependientes son, por
33
+ * definicion, secuenciales desde la perspectiva del emisor (el agente
34
+ * orquestador espera a que el proceso A termine — incluyendo su
35
+ * fsyncDirSync — antes de lanzar el proceso B), asi que el archivo de A ya
36
+ * esta durablemente presente cuando B escanea el directorio. Esto preserva
37
+ * el orden causal AUN ENTRE PROCESOS SEPARADOS, a diferencia de un
38
+ * contador en memoria por proceso (que reinicia en 0 en cada invocacion
39
+ * nueva de la CLI). Emisores genuinamente independientes/concurrentes
40
+ * pueden empatar en el conteo y caer al sufijo hex aleatorio — aceptable,
41
+ * porque requests independientes no requieren orden relativo. */
42
+ function emitRequest(repoRoot, branch, env) {
43
+ const payload = { ...env.payload };
44
+ if (Array.isArray(payload.argv)) {
45
+ const secretFlag = (0, redact_1.findLiteralSecretFlag)(payload.argv);
46
+ if (secretFlag !== null)
47
+ throw new Error(`secreto literal en ${secretFlag}: pasalo por referencia (-env), no por valor`);
48
+ payload.argv = (0, redact_1.redactArgv)(payload.argv);
49
+ }
50
+ const dir = (0, paths_1.requestsDir)(repoRoot, branch);
51
+ const seq = countPendingRequests(dir).toString().padStart(10, '0');
52
+ const requestId = `req-${Date.now()}-${seq}-${crypto_1.default.randomBytes(4).toString('hex')}`;
53
+ const body = JSON.stringify({ requestId, ...env, payload }, null, 2) + '\n';
54
+ const tmp = path_1.default.join(dir, `.${requestId}.tmp`);
55
+ const final = path_1.default.join(dir, `${requestId}.json`);
56
+ const fd = fs_1.default.openSync(tmp, 'wx', 0o600);
57
+ try {
58
+ fs_1.default.writeFileSync(fd, body);
59
+ fs_1.default.fsyncSync(fd);
60
+ }
61
+ finally {
62
+ fs_1.default.closeSync(fd);
63
+ }
64
+ fs_1.default.renameSync(tmp, final);
65
+ (0, atomic_file_1.fsyncDirSync)(dir); // la ENTRADA renombrada tambien debe sobrevivir un crash (R1.3)
66
+ return { requestId, idempotencyKey: env.idempotencyKey, payloadDigest: digestOf(payload), file: final };
67
+ }
68
+ const KNOWN_KINDS = ['job-request', 'register-entity', 'controller-heartbeat', 'verdict'];
69
+ function isRecord(x) {
70
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
71
+ }
72
+ function isWellFormedEnvelope(x) {
73
+ if (!isRecord(x))
74
+ return false;
75
+ return typeof x.requestId === 'string' && x.requestId.length > 0
76
+ && KNOWN_KINDS.includes(x.kind)
77
+ && typeof x.generationToken === 'string' && x.generationToken.length > 0
78
+ && typeof x.idempotencyKey === 'string' && x.idempotencyKey.length > 0
79
+ && isRecord(x.payload);
80
+ }
81
+ function listPendingRequests(repoRoot, branch) {
82
+ const dir = (0, paths_1.requestsDir)(repoRoot, branch);
83
+ return fs_1.default.readdirSync(dir).filter((f) => f.endsWith('.json')).sort().map((f) => {
84
+ const file = path_1.default.join(dir, f);
85
+ try {
86
+ const parsed = JSON.parse(fs_1.default.readFileSync(file, 'utf8'));
87
+ // Shape completa antes de cualquier uso downstream. JSON valido no
88
+ // implica envelope valido: payload/generation/idempotency ausentes
89
+ // tambien son corrupcion visible, no una excepcion del supervisor.
90
+ if (!isWellFormedEnvelope(parsed)) {
91
+ return { requestId: f, envelope: null, file, corrupt: true };
92
+ }
93
+ if (f !== `${parsed.requestId}.json`) {
94
+ return { requestId: f, envelope: null, file, corrupt: true };
95
+ }
96
+ return { requestId: parsed.requestId, envelope: parsed, file, corrupt: false };
97
+ }
98
+ catch {
99
+ return { requestId: f, envelope: null, file, corrupt: true };
100
+ }
101
+ });
102
+ }
103
+ /** Registro del resultado en el state (el ack es derivable — R1.3).
104
+ * - mismo requestId ya registrado => no-op (replay seguro);
105
+ * - misma idempotencyKey con digest DISTINTO => error explicito;
106
+ * - misma idempotencyKey con mismo digest => ALIAS: el requestId nuevo
107
+ * registra SU PROPIA entrada con el mismo outcome/resultRef, para poder
108
+ * regenerar su ack (bloqueador 4 de la review).
109
+ * Devuelve el state mutado (el caller es el supervisor, que luego hace
110
+ * writeJournal — orden estado -> journal -> borrado de archivos). */
111
+ function applyOutcome(state, applied) {
112
+ if (state.appliedRequests[applied.requestId] !== undefined)
113
+ return state;
114
+ const prior = Object.values(state.appliedRequests).find((a) => a.idempotencyKey === applied.idempotencyKey);
115
+ if (prior !== undefined && prior.payloadDigest !== applied.payloadDigest) {
116
+ if (applied.outcome === 'applied') {
117
+ throw new Error(`idempotencyKey ${applied.idempotencyKey} reutilizada con payload digest distinto`);
118
+ }
119
+ // outcome de rechazo: ninguna mutacion ocurrio, seguro registrar aun con digest distinto
120
+ state.appliedRequests[applied.requestId] = applied;
121
+ return state;
122
+ }
123
+ if (prior !== undefined) {
124
+ state.appliedRequests[applied.requestId] = { ...applied, outcome: prior.outcome, resultRef: prior.resultRef };
125
+ return state;
126
+ }
127
+ state.appliedRequests[applied.requestId] = applied;
128
+ return state;
129
+ }
130
+ function ackFor(state, requestId) {
131
+ return state.appliedRequests[requestId] ?? null;
132
+ }
@@ -0,0 +1,107 @@
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
+ exports.initJournal = initJournal;
7
+ exports.readJournal = readJournal;
8
+ exports.writeJournal = writeJournal;
9
+ exports.appendEvent = appendEvent;
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const atomic_file_1 = require("../atomic-file");
12
+ const types_1 = require("./types");
13
+ const paths_1 = require("./paths");
14
+ /** Schema 1 evoluciono de forma aditiva durante R1. Normalizamos solamente
15
+ * campos que antes no existian; evidencia legacy queda deliberadamente con
16
+ * fingerprint vacio para que el gate la considere stale, nunca certificada. */
17
+ function normalizeSchemaOne(value) {
18
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
19
+ return value;
20
+ const parsed = structuredClone(value);
21
+ if (parsed.schema !== 1)
22
+ return parsed;
23
+ if (parsed.requestProblems === undefined)
24
+ parsed.requestProblems = [];
25
+ if (parsed.custodyDecisions === undefined)
26
+ parsed.custodyDecisions = [];
27
+ if (typeof parsed.cycle === 'object' && parsed.cycle !== null && !Array.isArray(parsed.cycle)) {
28
+ const cycle = parsed.cycle;
29
+ if (cycle.status === 'IN_PROGRESS' && cycle.nextAction === undefined) {
30
+ cycle.nextAction = { actionId: 'bootstrap-cycle', type: 'plan-cycle', target: 'cycle', preconditions: [], attempt: 0, state: 'pending' };
31
+ }
32
+ }
33
+ if (Array.isArray(parsed.verdicts)) {
34
+ for (const item of parsed.verdicts) {
35
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
36
+ continue;
37
+ const verdict = item;
38
+ if (verdict.fingerprint === undefined)
39
+ verdict.fingerprint = '';
40
+ if (verdict.argv === undefined)
41
+ verdict.argv = [];
42
+ if (verdict.paths === undefined)
43
+ verdict.paths = [];
44
+ if (verdict.cwd === undefined)
45
+ verdict.cwd = '.';
46
+ }
47
+ }
48
+ return parsed;
49
+ }
50
+ function initJournal(repoRoot, branch) {
51
+ for (const d of [(0, paths_1.journalDir)(repoRoot, branch), (0, paths_1.requestsDir)(repoRoot, branch), (0, paths_1.acksDir)(repoRoot, branch), (0, paths_1.logsDir)(repoRoot, branch), (0, paths_1.exportDir)(repoRoot, branch)]) {
52
+ fs_1.default.mkdirSync(d, { recursive: true, mode: 0o700 });
53
+ fs_1.default.chmodSync(d, 0o700); // mkdirSync mode es umask-dependiente: fijar explicito (R1.2)
54
+ }
55
+ const sp = (0, paths_1.statePath)(repoRoot, branch);
56
+ if (!fs_1.default.existsSync(sp)) {
57
+ (0, atomic_file_1.writeFileAtomicDurable)(sp, JSON.stringify((0, types_1.emptyState)(branch), null, 2) + '\n', 0o600);
58
+ }
59
+ }
60
+ /** Lectura corrupt-aware (R1.6): sintaxis invalida O shape invalido => corrupt:true.
61
+ * Los CONSUMIDORES deciden: consultas muestran 'corrupt'; gate/reconcile bloquean. */
62
+ function readJournal(repoRoot, branch) {
63
+ const sp = (0, paths_1.statePath)(repoRoot, branch);
64
+ let raw;
65
+ try {
66
+ raw = fs_1.default.readFileSync(sp, 'utf8');
67
+ }
68
+ catch {
69
+ return { state: null, corrupt: true };
70
+ }
71
+ let parsed;
72
+ try {
73
+ parsed = normalizeSchemaOne(JSON.parse(raw));
74
+ }
75
+ catch {
76
+ return { state: null, corrupt: true, raw };
77
+ }
78
+ if (!(0, types_1.isWellFormedState)(parsed))
79
+ return { state: null, corrupt: true, raw };
80
+ return { state: parsed, corrupt: false, raw };
81
+ }
82
+ /** Escritura canonica: SOLO el supervisor la invoca (single-writer). CAS por
83
+ * revision monotonica: el snapshot que traes debe ser el vigente. */
84
+ function writeJournal(repoRoot, branch, state) {
85
+ if (state.branch !== branch)
86
+ throw new Error(`writeJournal: branch del estado (${state.branch}) no coincide con branch destino (${branch})`);
87
+ const current = readJournal(repoRoot, branch);
88
+ if (current.corrupt)
89
+ throw new Error('journal corrupto: no se escribe sobre corrupcion (R1.6)');
90
+ if (current.state !== null && current.state.revision !== state.revision) {
91
+ throw new Error(`revision desactualizada: disco=${current.state.revision} propuesta=${state.revision}`);
92
+ }
93
+ const next = { ...state, revision: state.revision + 1 };
94
+ if (!(0, types_1.isWellFormedState)(next))
95
+ throw new Error('writeJournal: estado propuesto con forma invalida, no se persiste (R1.6)');
96
+ (0, atomic_file_1.writeFileAtomicDurable)((0, paths_1.statePath)(repoRoot, branch), JSON.stringify(next, null, 2) + '\n', 0o600);
97
+ }
98
+ /** Auditoria derivada best-effort (R4.6): la escribe SOLO el supervisor, un
99
+ * fallo aqui jamas invalida el estado — state.json es la unica autoridad. */
100
+ function appendEvent(repoRoot, branch, event) {
101
+ try {
102
+ fs_1.default.appendFileSync((0, paths_1.eventsPath)(repoRoot, branch), JSON.stringify({ at: new Date().toISOString(), ...event }) + '\n', { mode: 0o600 });
103
+ }
104
+ catch {
105
+ // best-effort: un evento perdido no se reconstruye ni bloquea (R4.6)
106
+ }
107
+ }