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,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
|
+
}
|