agentic-workflow-manager 3.12.0 → 3.13.1
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/hooks/shared.js +14 -1
- package/dist/src/commands/preflight/checks.js +63 -5
- package/dist/src/commands/preflight/index.js +6 -1
- package/dist/src/commands/registry/add.js +10 -1
- package/dist/src/commands/sensors/baseline.js +4 -3
- package/dist/src/core/atomic-file.js +24 -2
- package/dist/src/core/executor.js +40 -1
- package/dist/src/core/install-transaction.js +13 -1
- package/dist/src/core/journal/process.js +241 -9
- package/dist/tests/commands/hooks/install-symlink-fallback.test.js +25 -0
- package/dist/tests/commands/hooks/status.test.js +23 -3
- package/dist/tests/commands/job/exec-wrapper.test.js +15 -1
- package/dist/tests/commands/job/gate-reconcile.test.js +31 -5
- package/dist/tests/commands/preflight/preflight.test.js +99 -1
- package/dist/tests/commands/registry/add.test.js +27 -0
- package/dist/tests/commands/sensors/changed.test.js +13 -0
- package/dist/tests/commands/sensors/exec-windows.test.js +13 -0
- package/dist/tests/commands/sensors/exec.test.js +28 -6
- package/dist/tests/commands/sensors/formatters/ruff.test.js +22 -6
- package/dist/tests/commands/sensors/run-changed.test.js +33 -0
- package/dist/tests/commands/watch/e2e-crash.test.js +54 -18
- package/dist/tests/commands/watch/runner.test.js +32 -2
- package/dist/tests/commands/watch/supervisor-loop.test.js +24 -3
- package/dist/tests/core/artifact-state.test.js +11 -1
- package/dist/tests/core/atomic-file-durable.test.js +48 -1
- package/dist/tests/core/atomic-file.test.js +14 -3
- package/dist/tests/core/executor.test.js +58 -0
- package/dist/tests/core/install-transaction.test.js +59 -2
- package/dist/tests/core/journal/adapter.test.js +54 -0
- package/dist/tests/core/journal/fingerprint.test.js +10 -2
- package/dist/tests/core/journal/process.test.js +208 -9
- package/dist/tests/core/journal/store.test.js +8 -2
- package/dist/tests/core/no-color.test.js +23 -0
- package/dist/tests/core/registries-sync.test.js +32 -3
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ exports.terminateGroupConfirmed = terminateGroupConfirmed;
|
|
|
17
17
|
exports.terminatePreviouslyOwnedGroup = terminatePreviouslyOwnedGroup;
|
|
18
18
|
const crypto_1 = __importDefault(require("crypto"));
|
|
19
19
|
const child_process_1 = require("child_process");
|
|
20
|
+
const paths_1 = require("../paths");
|
|
20
21
|
exports.NONCE_ENV = 'AWM_SPAWN_NONCE';
|
|
21
22
|
/** Contrato dual, a proposito (tomo 3 rondas de fixes reales llegar aca —
|
|
22
23
|
* ver historial de Task 10): devuelve `null` SOLO cuando `ps` corrio y
|
|
@@ -42,23 +43,90 @@ exports.NONCE_ENV = 'AWM_SPAWN_NONCE';
|
|
|
42
43
|
* ese relay jamas ocurre: `execFileSync` captura el stderr del subproceso
|
|
43
44
|
* internamente y listo, sin tocar el fd real del proceso actual. */
|
|
44
45
|
exports.EXEC_STDIO = ['ignore', 'pipe', 'pipe'];
|
|
46
|
+
/** timeout explicito (defense-in-depth, mismo patron que win32ProcessInfo):
|
|
47
|
+
* sin esto, un `ps`/`pgrep` que cuelga (recurso bloqueado, binario
|
|
48
|
+
* emulado con comportamiento anomalo) cuelga TODO el proceso llamante
|
|
49
|
+
* indefinidamente — sin excepcion que atrapar, sin manera de fallar a
|
|
50
|
+
* favor de "vivo" porque el codigo nunca vuelve a ejecutar. Un timeout
|
|
51
|
+
* convierte ese cuelgue en un error normal, que el contrato existente de
|
|
52
|
+
* cada caller ya sabe manejar (nunca declarar muerte por un error). */
|
|
53
|
+
const PS_TIMEOUT_MS = 3000;
|
|
45
54
|
function psField(pid, field) {
|
|
46
55
|
try {
|
|
47
|
-
const out = (0, child_process_1.execFileSync)('ps', ['-o', `${field}=`, '-p', String(pid)], { encoding: 'utf8', stdio: exports.EXEC_STDIO }).trim();
|
|
56
|
+
const out = (0, child_process_1.execFileSync)('ps', ['-o', `${field}=`, '-p', String(pid)], { encoding: 'utf8', stdio: exports.EXEC_STDIO, timeout: PS_TIMEOUT_MS }).trim();
|
|
48
57
|
return out.length > 0 ? out : null;
|
|
49
58
|
}
|
|
50
59
|
catch (error) {
|
|
51
60
|
const status = error.status;
|
|
52
61
|
if (status === 1)
|
|
53
62
|
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
|
|
63
|
+
throw error; // ps no pudo ejecutarse (ENOENT/permisos/timeout/etc): NO es prueba de nada
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
66
|
function sleepSync(seconds) {
|
|
58
67
|
try {
|
|
59
|
-
(0, child_process_1.execFileSync)('sleep', [seconds], { stdio: exports.EXEC_STDIO });
|
|
68
|
+
(0, child_process_1.execFileSync)('sleep', [seconds], { stdio: exports.EXEC_STDIO, timeout: PS_TIMEOUT_MS });
|
|
60
69
|
}
|
|
61
70
|
catch { /* sin sleep: seguimos */ }
|
|
71
|
+
// Nota win32: `sleep` no existe nativamente ahi (ver callers via
|
|
72
|
+
// win32ProcessInfo/captureRefFor) — el catch de arriba lo absorbe
|
|
73
|
+
// silenciosamente, asi que en esa plataforma los reintentos que llaman
|
|
74
|
+
// a esta funcion ocurren espalda-con-espalda sin pausa real. No es un
|
|
75
|
+
// fallo: solo pierde el espaciado, nunca crashea ni miente sobre
|
|
76
|
+
// resultado alguno.
|
|
77
|
+
}
|
|
78
|
+
/** Analogo win32 de `psField`/`stablePsArgs` (R2.1, R6): identidad real de un
|
|
79
|
+
* pid via WMI (clase Win32_Process), consultada a traves de PowerShell's
|
|
80
|
+
* Get-CimInstance — el reemplazo moderno soportado de `wmic` (que Microsoft
|
|
81
|
+
* viene retirando de instalaciones nuevas), y presente en cualquier Windows
|
|
82
|
+
* no deliberadamente reducido (a diferencia de `ps`/`pgrep`, que en Windows
|
|
83
|
+
* SOLO existen si algo como Git for Windows los puso en el PATH, y ahi son
|
|
84
|
+
* la capa emulada de MSYS/Cygwin ciega a procesos nativos — ver
|
|
85
|
+
* pidExistsNative). Contrato de TRES vias, deliberado, paralelo al de
|
|
86
|
+
* `psField`:
|
|
87
|
+
* - `'absent'`: PowerShell corrio bien y WMI no encontro NINGUN proceso
|
|
88
|
+
* con ese pid — evidencia POSITIVA de ausencia (el analogo exacto del
|
|
89
|
+
* exit-1 de `ps`/`pgrep` sobre un pid real), independiente de
|
|
90
|
+
* `process.kill` (que pidExistsNative usa) — asi que sigue siendo
|
|
91
|
+
* evidencia valida incluso si `process.kill` fuera mockeado/erroneo en
|
|
92
|
+
* algun caller (visto en CI real: un test que mockea process.kill para
|
|
93
|
+
* verificar que executeReap NUNCA senializa, sin intencion de simular
|
|
94
|
+
* un pid vivo — WMI no comparte ese mock y corrige la vista).
|
|
95
|
+
* - `null`: PowerShell no pudo ejecutarse, timeout, politica de ejecucion
|
|
96
|
+
* restrictiva, WMI/CIM deshabilitado, salida no parseable, etc. — CERO
|
|
97
|
+
* evidencia, ni de vida ni de muerte. Igual que el ENOENT de `ps` en
|
|
98
|
+
* POSIX: nunca se traduce a "muerto".
|
|
99
|
+
* - objeto con `creationDate`/`commandLine` reales: exito, comparables
|
|
100
|
+
* con `ref.startTime`/`ref.psArgsDigest` (via identityDigest) igual que
|
|
101
|
+
* `lstart`/`args` en la rama POSIX.
|
|
102
|
+
* Acotado con `timeout` (nunca cuelga al caller indefinidamente si el
|
|
103
|
+
* proveedor WMI no responde) y con `-NoProfile -NonInteractive -NoLogo`
|
|
104
|
+
* (arranque mas rapido y determinista, sin depender de perfiles del
|
|
105
|
+
* usuario). Solo se invoca desde refIsAlive/captureRefFor en win32, jamas
|
|
106
|
+
* desde un path "barato" (groupIsGone, pidExistsNative) — ver refIsAlive
|
|
107
|
+
* para el razonamiento de costo/beneficio. */
|
|
108
|
+
function win32ProcessInfo(pid) {
|
|
109
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
110
|
+
return null;
|
|
111
|
+
let out;
|
|
112
|
+
try {
|
|
113
|
+
const script = `try { $p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" -ErrorAction Stop; if ($p) { [PSCustomObject]@{ CreationDate = $p.CreationDate.ToString('o'); CommandLine = [string]$p.CommandLine } | ConvertTo-Json -Compress } } catch { exit 1 }`;
|
|
114
|
+
out = (0, child_process_1.execFileSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-NoLogo', '-Command', script], { encoding: 'utf8', stdio: exports.EXEC_STDIO, timeout: 2000 }).trim();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null; // powershell/WMI no disponible, timeout, o error interno: sin evidencia (nunca "ausente")
|
|
118
|
+
}
|
|
119
|
+
if (out.length === 0)
|
|
120
|
+
return 'absent'; // corrio bien, cero coincidencias: evidencia positiva
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(out);
|
|
123
|
+
if (typeof parsed.CreationDate !== 'string' || typeof parsed.CommandLine !== 'string')
|
|
124
|
+
return null;
|
|
125
|
+
return { creationDate: parsed.CreationDate, commandLine: parsed.CommandLine };
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
} // salida inesperada: inconclusive, jamas "ausente" por un parseo roto
|
|
62
130
|
}
|
|
63
131
|
/** Variante de psField para contextos de CAPTURA de identidad (spawn time):
|
|
64
132
|
* aqui "no se pudo determinar" ya tiene un fallback seguro documentado
|
|
@@ -107,8 +175,34 @@ function psArgsDigestOf(pid, spawnNonce = '', requestedArgvDigest = '') {
|
|
|
107
175
|
return identityDigest(args, spawnNonce, requestedArgvDigest);
|
|
108
176
|
}
|
|
109
177
|
/** Captura la identidad COMPLETA de un pid recien spawneado (R2.1):
|
|
110
|
-
* startTime + pgid reales de ps + digest de `ps -o args=` estable.
|
|
178
|
+
* startTime + pgid reales de ps + digest de `ps -o args=` estable.
|
|
179
|
+
*
|
|
180
|
+
* win32: sin pgid POSIX real jamas (convencion fija: processGroup === pid,
|
|
181
|
+
* la misma que asume killTreeWindows/groupIsGone); startTime/psArgsDigest
|
|
182
|
+
* se intentan via WMI (win32ProcessInfo) con el mismo reintento acotado que
|
|
183
|
+
* la rama POSIX. Si WMI no responde (politica restrictiva, servicio
|
|
184
|
+
* deshabilitado, sin powershell.exe) degrada al mismo sentinel 'unknown'
|
|
185
|
+
* ya documentado y testeado (ver 'captureRefFor degrada a unknown...') —
|
|
186
|
+
* jamas crashea, jamas fabrica una identidad falsa. */
|
|
111
187
|
function captureRefFor(pid, nonce, argv) {
|
|
188
|
+
const requestedArgvDigest = argvDigest(argv);
|
|
189
|
+
if ((0, paths_1.isWindowsNative)()) {
|
|
190
|
+
let info = null;
|
|
191
|
+
for (let i = 0; i < 5 && (info === null || info === 'absent'); i++) {
|
|
192
|
+
info = win32ProcessInfo(pid);
|
|
193
|
+
if (info === null || info === 'absent')
|
|
194
|
+
sleepSync('0.05');
|
|
195
|
+
}
|
|
196
|
+
const resolved = info !== null && info !== 'absent' ? info : null;
|
|
197
|
+
return {
|
|
198
|
+
pid,
|
|
199
|
+
startTime: resolved?.creationDate ?? 'unknown',
|
|
200
|
+
spawnNonce: nonce,
|
|
201
|
+
argvDigest: requestedArgvDigest,
|
|
202
|
+
processGroup: pid,
|
|
203
|
+
psArgsDigest: resolved !== null ? identityDigest(resolved.commandLine, nonce, requestedArgvDigest) : 'unknown',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
112
206
|
let start = null;
|
|
113
207
|
for (let i = 0; i < 5 && start === null; i++) {
|
|
114
208
|
start = psFieldSafe(pid, 'lstart');
|
|
@@ -117,7 +211,6 @@ function captureRefFor(pid, nonce, argv) {
|
|
|
117
211
|
}
|
|
118
212
|
const pgid = psFieldSafe(pid, 'pgid');
|
|
119
213
|
const args = stablePsArgs(pid);
|
|
120
|
-
const requestedArgvDigest = argvDigest(argv);
|
|
121
214
|
return {
|
|
122
215
|
pid,
|
|
123
216
|
startTime: start ?? 'unknown',
|
|
@@ -138,7 +231,25 @@ function captureSelfRef(nonce) {
|
|
|
138
231
|
function spawnStructured(argv, cwd, nonce, extraEnv = {}) {
|
|
139
232
|
const [exe, ...args] = argv;
|
|
140
233
|
const child = (0, child_process_1.spawn)(exe, args, {
|
|
141
|
-
cwd, shell: false,
|
|
234
|
+
cwd, shell: false,
|
|
235
|
+
// R6 ronda 4 probo `detached: true` incondicional en win32 (razonando
|
|
236
|
+
// desde la doc de Node sobre supervivencia post-muerte del padre) y
|
|
237
|
+
// la ronda 5 lo REVIERTE: la primera corrida real en windows-latest
|
|
238
|
+
// mostro `refIsAlive(ref)` devolviendo `false` INMEDIATAMENTE despues
|
|
239
|
+
// de un spawn normal, sin matar nada — el test mas basico del
|
|
240
|
+
// archivo ('spawnStructured produce ProcessRef con tupla completa').
|
|
241
|
+
// `captureRefFor` fija `processGroup: pid` por convencion en win32
|
|
242
|
+
// (siempre), asi que ese `false` solo puede venir de
|
|
243
|
+
// `pidExistsNative` viendo ESRCH real — el proceso hijo aparentaba
|
|
244
|
+
// no existir casi de inmediato. Evidencia real > lectura de
|
|
245
|
+
// documentacion: se revierte a `!isWindowsNative()` (el diseño ya
|
|
246
|
+
// probado, correcto, en 4 rondas previas de CI real). La garantia de
|
|
247
|
+
// R1.8 ("el wrapper sobrevive al supervisor") queda como gap
|
|
248
|
+
// ABIERTO en win32 — ver el test skippeado en
|
|
249
|
+
// tests/commands/watch/e2e-crash.test.ts para el detalle y el
|
|
250
|
+
// proximo intento debe verificarse contra CI real antes de asumir
|
|
251
|
+
// que un cambio de `detached` lo resuelve.
|
|
252
|
+
detached: !(0, paths_1.isWindowsNative)(),
|
|
142
253
|
env: { ...process.env, [exports.NONCE_ENV]: nonce, ...extraEnv },
|
|
143
254
|
// stdio:'ignore' completo (nada de pipes): un pipe destruido/abandonado
|
|
144
255
|
// por el padre puede EPIPE-crashear al hijo si este escribe a su propio
|
|
@@ -150,9 +261,77 @@ function spawnStructured(argv, cwd, nonce, extraEnv = {}) {
|
|
|
150
261
|
throw new Error(`spawn fallo para ${exe}`);
|
|
151
262
|
return { child, ref: captureRefFor(child.pid, nonce, argv) };
|
|
152
263
|
}
|
|
264
|
+
/** Existencia de pid respaldada DIRECTAMENTE por el kernel via libuv
|
|
265
|
+
* (process.kill(pid,0) — funciona en cualquier plataforma que Node
|
|
266
|
+
* soporte, incluido win32), a diferencia de `ps`/`pgrep`: en Windows esos
|
|
267
|
+
* binarios, cuando resuelven en el PATH, son el `ps`/`pgrep` de
|
|
268
|
+
* MSYS/Cygwin (Git for Windows) — una capa de EMULACION con su propia
|
|
269
|
+
* tabla de pids, ciega a procesos nativos spawneados via CreateProcess
|
|
270
|
+
* (exactamente lo que produce spawnStructured). Confirmado en CI
|
|
271
|
+
* windows-latest: `ps -o stat= -p <pid>` para un hijo real y vivo salia
|
|
272
|
+
* con exit 1 ("no such process" segun la vista emulada), y `psField`
|
|
273
|
+
* interpretaba ese exit 1 como "el SO confirmo que el pid no existe" —
|
|
274
|
+
* falso: era ceguera de la herramienta, no evidencia de muerte. Eso
|
|
275
|
+
* rompia el invariante "JAMAS safe sin evidencia" (safeToReplace
|
|
276
|
+
* devolvia 'safe' para un proceso vivo). process.kill(pid,0) evita la
|
|
277
|
+
* capa de emulacion por completo. */
|
|
278
|
+
function pidExistsNative(pid) {
|
|
279
|
+
try {
|
|
280
|
+
process.kill(pid, 0);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
// ESRCH = el SO confirmo que el pid no existe. Cualquier otro
|
|
285
|
+
// codigo (ej. EPERM: existe pero sin permiso de senializarlo) NO
|
|
286
|
+
// es evidencia de muerte — falla a favor de "vivo" (R2.1).
|
|
287
|
+
return error.code !== 'ESRCH';
|
|
288
|
+
}
|
|
289
|
+
}
|
|
153
290
|
/** Vivo Y con la MISMA identidad — tupla completa, nunca PID solo (R2.1,
|
|
154
|
-
* bloqueador 6): startTime + pgid + digest de ps args.
|
|
291
|
+
* bloqueador 6): startTime + pgid + digest de ps args.
|
|
292
|
+
*
|
|
293
|
+
* win32 (R6, ronda 3 — REVIERTE la ronda 2): la ronda 2 intento cerrar el
|
|
294
|
+
* gap de reciclado de PID via una comparacion completa de identidad contra
|
|
295
|
+
* WMI (win32ProcessInfo, `Get-CimInstance Win32_Process`) en cada llamada.
|
|
296
|
+
* La PRIMERA corrida real en windows-latest CI (2026-08-08) mostro
|
|
297
|
+
* `refIsAlive` devolviendo `false` para un proceso node recien spawneado y
|
|
298
|
+
* genuinamente vivo (test platform-agnostico, SIN mocks de por medio) —
|
|
299
|
+
* osea que la comparacion WMI produjo un FALSO NEGATIVO real, la direccion
|
|
300
|
+
* de fallo MAS peligrosa para esta funcion (un supervisor que trata un job
|
|
301
|
+
* vivo como muerto puede duplicar trabajo o corromper estado). La misma
|
|
302
|
+
* corrida tambien mostro 2 tests E2E pesados (supervisor-loop, e2e-crash)
|
|
303
|
+
* colgando hasta el timeout, consistente con un loop de polling que nunca
|
|
304
|
+
* converge si su chequeo de vida es inestable.
|
|
305
|
+
*
|
|
306
|
+
* `pidExistsNative` (process.kill(pid,0), directo al kernel via libuv) en
|
|
307
|
+
* cambio sobrevivio SIN NINGUN falso negativo/positivo a 3 corridas reales
|
|
308
|
+
* de CI consecutivas (rondas anteriores de este mismo release) — evidencia
|
|
309
|
+
* empirica solida de que es confiable en windows-latest, mientras que WMI
|
|
310
|
+
* demostradamente no lo es (razon exacta de la inestabilia sin determinar:
|
|
311
|
+
* podria ser latencia de indexado de WMI para procesos recien creados,
|
|
312
|
+
* podria ser una conversion de CreationDate no perfectamente determinista
|
|
313
|
+
* entre dos consultas separadas — no hay Windows real disponible en este
|
|
314
|
+
* entorno para experimentar y confirmar cual).
|
|
315
|
+
*
|
|
316
|
+
* Decision (systematic-debugging: 2+ intentos revelando problemas nuevos en
|
|
317
|
+
* lugares distintos exige cuestionar la arquitectura, no seguir parchando):
|
|
318
|
+
* se revierte a existencia + convencion de processGroup solamente en
|
|
319
|
+
* win32, la MISMA superficie que la ronda 1 ya tenia probada. La proteccion
|
|
320
|
+
* contra reciclado de PID completa (bloqueador 6) queda como gap ACEPTADO
|
|
321
|
+
* y documentado en esta plataforma — la ventana real para que ocurra
|
|
322
|
+
* (Windows reciclando un pid entre esta verificacion y la captura original,
|
|
323
|
+
* tipicamente milisegundos/segundos antes, en el mismo proceso controlador)
|
|
324
|
+
* es angosta, y el costo de intentar cerrarla con un mecanismo demostrado
|
|
325
|
+
* no confiable es peor que el gap mismo. `win32ProcessInfo`/`captureRefFor`
|
|
326
|
+
* siguen poblando startTime/psArgsDigest REALES via WMI cuando responden
|
|
327
|
+
* (uso informativo — quedan en el ProcessRef persistido) pero refIsAlive
|
|
328
|
+
* YA NO los usa para su veredicto go/no-go. */
|
|
155
329
|
function refIsAlive(ref) {
|
|
330
|
+
if ((0, paths_1.isWindowsNative)()) {
|
|
331
|
+
if (!pidExistsNative(ref.pid))
|
|
332
|
+
return false;
|
|
333
|
+
return ref.processGroup === ref.pid;
|
|
334
|
+
}
|
|
156
335
|
try {
|
|
157
336
|
const stat = psField(ref.pid, 'stat');
|
|
158
337
|
if (stat === null || stat.startsWith('Z'))
|
|
@@ -181,9 +360,20 @@ function processStatesAreGone(states) {
|
|
|
181
360
|
return states.every((stat) => stat === null || stat.startsWith('Z'));
|
|
182
361
|
}
|
|
183
362
|
function groupIsGone(pgid) {
|
|
363
|
+
if ((0, paths_1.isWindowsNative)()) {
|
|
364
|
+
// Sin pgrep confiable en esta plataforma (ver pidExistsNative):
|
|
365
|
+
// mejor esfuerzo, solo confirma al LIDER (pgid === pid, por la
|
|
366
|
+
// convencion de fallback de captureRefFor en win32 — nunca hay un
|
|
367
|
+
// pgid real de ps ahi). No enumeramos descendientes sin Job
|
|
368
|
+
// Objects (fuera de alcance) — killTreeWindows ya los alcanza al
|
|
369
|
+
// matar via `taskkill /T`, aunque este check no los confirme
|
|
370
|
+
// individualmente. Nunca declarar "grupo ausente" por ceguera de
|
|
371
|
+
// una herramienta POSIX inexistente/emulada.
|
|
372
|
+
return !pidExistsNative(pgid);
|
|
373
|
+
}
|
|
184
374
|
let pids;
|
|
185
375
|
try {
|
|
186
|
-
const out = (0, child_process_1.execFileSync)('pgrep', ['-g', String(pgid)], { encoding: 'utf8', stdio: exports.EXEC_STDIO });
|
|
376
|
+
const out = (0, child_process_1.execFileSync)('pgrep', ['-g', String(pgid)], { encoding: 'utf8', stdio: exports.EXEC_STDIO, timeout: PS_TIMEOUT_MS });
|
|
187
377
|
pids = out.split('\n').filter(Boolean).map(Number).filter(Number.isInteger);
|
|
188
378
|
}
|
|
189
379
|
catch (error) {
|
|
@@ -203,9 +393,26 @@ function groupIsGone(pgid) {
|
|
|
203
393
|
return false; // sin observacion completa, falla cerrado
|
|
204
394
|
}
|
|
205
395
|
}
|
|
396
|
+
/** Llamada UNA VEZ POR TICK por el supervisor (ver superviseController en
|
|
397
|
+
* commands/watch/supervisor.ts) mientras un controlador este activo — el
|
|
398
|
+
* path mas caliente de todo el modulo. En win32, `ps`/`pgrep`, cuando
|
|
399
|
+
* resuelven en el PATH, son el binario EMULADO de MSYS/Cygwin (Git for
|
|
400
|
+
* Windows) — ciego a procesos nativos (el mismo hecho ya documentado y
|
|
401
|
+
* probado para pidExistsNative/refIsAlive/groupIsGone en este archivo).
|
|
402
|
+
* A diferencia de esas funciones, ESTA no fue tocada por ninguna ronda
|
|
403
|
+
* previa del fix de R6 — seguia intentando `ps`/`pgrep` reales en CADA
|
|
404
|
+
* tick, sin timeout, sobre un binario ya sabido no confiable ahi.
|
|
405
|
+
* Degradar directo (sin tocar el proceso real) evita acumular latencia de
|
|
406
|
+
* subprocess real en un loop de polling de alta frecuencia, con o sin el
|
|
407
|
+
* timeout de PS_TIMEOUT_MS de defensa — ese timeout cubre el caso en que
|
|
408
|
+
* el binario SI exista mal comportado; esta rama cubre el caso, ya
|
|
409
|
+
* confirmado real en este archivo, de que la herramienta simplemente no
|
|
410
|
+
* es fuente de verdad en esta plataforma. */
|
|
206
411
|
function activitySnapshot(ref) {
|
|
207
412
|
if (!refIsAlive(ref))
|
|
208
413
|
return null;
|
|
414
|
+
if ((0, paths_1.isWindowsNative)())
|
|
415
|
+
return { cpuTime: 'unknown', groupSize: 1 };
|
|
209
416
|
let cpu = '0';
|
|
210
417
|
try {
|
|
211
418
|
cpu = psField(ref.pid, 'time') ?? '0';
|
|
@@ -215,7 +422,7 @@ function activitySnapshot(ref) {
|
|
|
215
422
|
}
|
|
216
423
|
let groupSize = 1;
|
|
217
424
|
try {
|
|
218
|
-
groupSize = (0, child_process_1.execFileSync)('pgrep', ['-g', String(ref.processGroup)], { encoding: 'utf8', stdio: exports.EXEC_STDIO })
|
|
425
|
+
groupSize = (0, child_process_1.execFileSync)('pgrep', ['-g', String(ref.processGroup)], { encoding: 'utf8', stdio: exports.EXEC_STDIO, timeout: PS_TIMEOUT_MS })
|
|
219
426
|
.split('\n').filter(Boolean).length;
|
|
220
427
|
}
|
|
221
428
|
catch {
|
|
@@ -223,6 +430,17 @@ function activitySnapshot(ref) {
|
|
|
223
430
|
}
|
|
224
431
|
return { cpuTime: cpu, groupSize };
|
|
225
432
|
}
|
|
433
|
+
/** win32: sin process groups POSIX ni convencion de pid negativo, y sin
|
|
434
|
+
* distincion real SIGTERM/SIGKILL (Node mapea ambas a TerminateProcess
|
|
435
|
+
* alla) — un `taskkill /T /F` (recursivo, forzado) sustituye a la
|
|
436
|
+
* escalera completa. Mismo patron ya usado y testeado en
|
|
437
|
+
* sensors/exec.ts::killTree (ver tests/commands/sensors/exec-windows.test.ts). */
|
|
438
|
+
function killTreeWindows(pid) {
|
|
439
|
+
try {
|
|
440
|
+
(0, child_process_1.execFileSync)('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: exports.EXEC_STDIO });
|
|
441
|
+
}
|
|
442
|
+
catch { /* ya ausente, o taskkill no disponible: best-effort — la confirmacion la hace el poll de groupIsGone */ }
|
|
443
|
+
}
|
|
226
444
|
/** Escalera de gracia (design R4.2b): SIGTERM -> confirmar -> SIGKILL -> confirmar.
|
|
227
445
|
* true <=> lider muerto por identidad Y grupo entero desaparecido (pgrep -g
|
|
228
446
|
* vacio) — jamas confirmar solo el lider (bloqueador 6). */
|
|
@@ -242,6 +460,13 @@ async function terminateGroupConfirmed(ref, opts) {
|
|
|
242
460
|
// usar la falta de match como autorizacion para senializar ese grupo.
|
|
243
461
|
if (!refIsAlive(ref))
|
|
244
462
|
return false;
|
|
463
|
+
if ((0, paths_1.isWindowsNative)()) {
|
|
464
|
+
killTreeWindows(ref.pid);
|
|
465
|
+
if (await waitUntilGone(opts.termGraceMs))
|
|
466
|
+
return true;
|
|
467
|
+
killTreeWindows(ref.pid);
|
|
468
|
+
return waitUntilGone(opts.killGraceMs);
|
|
469
|
+
}
|
|
245
470
|
try {
|
|
246
471
|
process.kill(-ref.processGroup, 'SIGTERM');
|
|
247
472
|
}
|
|
@@ -270,6 +495,13 @@ async function terminatePreviouslyOwnedGroup(ref, opts) {
|
|
|
270
495
|
};
|
|
271
496
|
if (groupIsGone(ref.processGroup))
|
|
272
497
|
return true;
|
|
498
|
+
if ((0, paths_1.isWindowsNative)()) {
|
|
499
|
+
killTreeWindows(ref.pid);
|
|
500
|
+
if (await waitUntilGone(opts.termGraceMs))
|
|
501
|
+
return true;
|
|
502
|
+
killTreeWindows(ref.pid);
|
|
503
|
+
return waitUntilGone(opts.killGraceMs);
|
|
504
|
+
}
|
|
273
505
|
try {
|
|
274
506
|
process.kill(-ref.processGroup, 'SIGTERM');
|
|
275
507
|
}
|
|
@@ -56,4 +56,29 @@ describe('hooks/install — skill symlink fallback to copy', () => {
|
|
|
56
56
|
expect(fs_1.default.lstatSync(skillDest).isSymbolicLink()).toBe(false); // it was copied, not linked
|
|
57
57
|
expect(fs_1.default.readFileSync(skillDest, 'utf-8')).toContain('using-awm');
|
|
58
58
|
});
|
|
59
|
+
// Regression: syncExecutable (shared.ts) — used for the hook SCRIPT files
|
|
60
|
+
// (session-start, run-hook.cmd), not just the bootstrap skill above — called
|
|
61
|
+
// fs.symlinkSync unconditionally when installMethod is 'symlink', with no
|
|
62
|
+
// EPERM fallback at all. On native Windows without SeCreateSymbolicLinkPrivilege
|
|
63
|
+
// (the default for GitHub Actions' windows-latest runner), that would throw
|
|
64
|
+
// out of installHook uninstall, failing the whole `awm init` step. Proves the
|
|
65
|
+
// same fallback-to-copy this file already established for the skill file also
|
|
66
|
+
// now covers the script files when the caller actually requests 'symlink'.
|
|
67
|
+
it('copies the hook scripts when symlink throws (EPERM), instead of throwing out of installHook', () => {
|
|
68
|
+
const registryRoot = path_1.default.join(tmpHome, 'registry');
|
|
69
|
+
seedRegistry(registryRoot);
|
|
70
|
+
symlinkSpy = jest.spyOn(fs_1.default, 'symlinkSync').mockImplementation(() => {
|
|
71
|
+
const err = new Error('EPERM: operation not permitted, symlink');
|
|
72
|
+
err.code = 'EPERM';
|
|
73
|
+
throw err;
|
|
74
|
+
});
|
|
75
|
+
const { installHook } = require('../../../src/commands/hooks/install');
|
|
76
|
+
const result = installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
|
|
77
|
+
const scriptDest = path_1.default.join(result.scriptsDir, 'session-start');
|
|
78
|
+
const wrapperDest = path_1.default.join(result.scriptsDir, 'run-hook.cmd');
|
|
79
|
+
expect(fs_1.default.existsSync(scriptDest)).toBe(true);
|
|
80
|
+
expect(fs_1.default.lstatSync(scriptDest).isSymbolicLink()).toBe(false); // copied, not linked
|
|
81
|
+
expect(fs_1.default.existsSync(wrapperDest)).toBe(true);
|
|
82
|
+
expect(fs_1.default.lstatSync(wrapperDest).isSymbolicLink()).toBe(false);
|
|
83
|
+
});
|
|
59
84
|
});
|
|
@@ -78,8 +78,23 @@ describe('computeHookStatus', () => {
|
|
|
78
78
|
fs_1.default.chmodSync(path_1.default.join(tmpHome, '.awm/hooks/session-start'), 0o644);
|
|
79
79
|
const { computeHookStatus } = require('../../../src/commands/hooks/status');
|
|
80
80
|
const result = computeHookStatus('claude-code');
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
if (process.platform === 'win32') {
|
|
82
|
+
// Windows has no POSIX executable-bit concept at all, and this isn't a
|
|
83
|
+
// gap in computeHookStatus's checkExecutable() — it's Node's own
|
|
84
|
+
// documented behavior: fs.accessSync(file, X_OK) "has no effect on
|
|
85
|
+
// Windows (will behave like fs.constants.F_OK)" (Node fs docs). So
|
|
86
|
+
// chmod(0o644) here only clears write bits, which Windows collapses
|
|
87
|
+
// into "still not read-only" either way — there was never a distinct
|
|
88
|
+
// exec permission to remove, and the script remains just as runnable
|
|
89
|
+
// (via its interpreter/file association) as before the chmod. HEALTHY
|
|
90
|
+
// is the factually correct report here, not a gap to paper over.
|
|
91
|
+
expect(result.overall).toBe('HEALTHY');
|
|
92
|
+
expect(result.checks.sessionStartScript.ok).toBe(true);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
expect(result.overall).toBe('DEGRADED');
|
|
96
|
+
expect(result.checks.sessionStartScript.ok).toBe(false);
|
|
97
|
+
}
|
|
83
98
|
});
|
|
84
99
|
it('throws when agent target has no hooks config', () => {
|
|
85
100
|
const { computeHookStatus } = require('../../../src/commands/hooks/status');
|
|
@@ -91,7 +106,12 @@ describe('computeHookStatus', () => {
|
|
|
91
106
|
// never touching Claude's settings.json path.
|
|
92
107
|
const { computeHookStatus } = require('../../../src/commands/hooks/status');
|
|
93
108
|
const result = computeHookStatus('codex');
|
|
94
|
-
|
|
109
|
+
// Separator-agnostic: the detail embeds a real OS path (`path.join`
|
|
110
|
+
// under the hood), so it's `\` on windows-latest and `/` elsewhere —
|
|
111
|
+
// assert the two path segments independently rather than one
|
|
112
|
+
// POSIX-shaped fragment.
|
|
113
|
+
expect(result.checks.settingsEntry.detail).toContain('.codex');
|
|
114
|
+
expect(result.checks.settingsEntry.detail).toContain('hooks.json');
|
|
95
115
|
expect(result.checks.bootstrapSkill).toBeUndefined();
|
|
96
116
|
expect(result.checks.runHookWrapper).toBeUndefined();
|
|
97
117
|
});
|
|
@@ -8,6 +8,11 @@ const path_1 = __importDefault(require("path"));
|
|
|
8
8
|
const os_1 = __importDefault(require("os"));
|
|
9
9
|
const exec_wrapper_1 = require("../../../src/commands/job/exec-wrapper");
|
|
10
10
|
const process_1 = require("../../../src/core/journal/process");
|
|
11
|
+
// Real subprocess spawn + termination + fsync per test; the jest default of
|
|
12
|
+
// 5000ms proved too tight on a loaded windows-latest CI runner (regression:
|
|
13
|
+
// real CI, "Exceeded timeout of 5000 ms"). Same class of issue already fixed
|
|
14
|
+
// in registries-sync.test.ts this round.
|
|
15
|
+
jest.setTimeout(20000);
|
|
11
16
|
describe('exec-wrapper', () => {
|
|
12
17
|
let dir;
|
|
13
18
|
beforeEach(() => { dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-wrap-')); });
|
|
@@ -19,7 +24,16 @@ describe('exec-wrapper', () => {
|
|
|
19
24
|
const identity = JSON.parse(fs_1.default.readFileSync((0, exec_wrapper_1.identityPath)(dir, 'job1', 'nonceA'), 'utf8'));
|
|
20
25
|
expect(identity.wrapper.pid).toBe(process.pid); // ProcessRef REAL del wrapper
|
|
21
26
|
expect(identity.command.pid).toBeGreaterThan(0); // ProcessRef REAL del comando
|
|
22
|
-
|
|
27
|
+
// hex real cuando la plataforma pudo observar el proceso (ps en
|
|
28
|
+
// POSIX, WMI/powershell en win32 — ver captureRefFor en
|
|
29
|
+
// src/core/journal/process.ts); sentinel 'unknown' documentado
|
|
30
|
+
// cuando esa observacion no estuvo disponible (ps ausente, o en
|
|
31
|
+
// win32 powershell/WMI restringido/deshabilitado). Mismo criterio
|
|
32
|
+
// ya establecido en tests/core/journal/process.test.ts — el formato
|
|
33
|
+
// exacto de este campo nunca es la fuente de verdad de vida/muerte
|
|
34
|
+
// (esa es refIsAlive), asi que este test no puede exigir mas
|
|
35
|
+
// certeza de la que la plataforma real puede dar.
|
|
36
|
+
expect(identity.command.psArgsDigest).toMatch(/^([0-9a-f]{16}|unknown)$/);
|
|
23
37
|
expect(identity.command.processGroup).not.toBe(identity.wrapper.processGroup); // el wrapper puede limpiar el grupo sin matarse
|
|
24
38
|
const result = JSON.parse(fs_1.default.readFileSync((0, exec_wrapper_1.resultPath)(dir, 'job1', 'nonceA'), 'utf8'));
|
|
25
39
|
expect(result.exitCode).toBe(0);
|
|
@@ -272,14 +272,40 @@ describe('reap — limpieza explicita con identidad validada (R2.2)', () => {
|
|
|
272
272
|
const deadRef = { pid: 999999, startTime: 'gone', spawnNonce: 'n1', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
|
|
273
273
|
s.jobs['sinRef'] = job({ id: 'sinRef', executionState: 'running' });
|
|
274
274
|
s.jobs['muerto'] = job({ id: 'muerto', executionState: 'running', processRef: deadRef });
|
|
275
|
-
|
|
275
|
+
// deadRef.pid (999999) debe comportarse como un pid REALMENTE
|
|
276
|
+
// ausente ante un sondeo de existencia (signal 0) — igual que en
|
|
277
|
+
// windows-latest real, donde `isWindowsNative()` es cierto de
|
|
278
|
+
// verdad y refIsAlive/groupIsGone dependen de pidExistsNative
|
|
279
|
+
// (process.kill(pid, 0)) como UNICA fuente de veredicto (ronda 3,
|
|
280
|
+
// ver process.ts). Mockear esto como "siempre exito" incondicional
|
|
281
|
+
// rompia esa unica fuente de verdad en CI real: pidExistsNative
|
|
282
|
+
// reportaba "vivo" para un pid que nunca existio, y la escalera de
|
|
283
|
+
// terminacion quedaba reintentando hasta el timeout del test — no
|
|
284
|
+
// por una señal real enviada, sino porque nunca podia CONFIRMAR
|
|
285
|
+
// ausencia. En POSIX este spy ni siquiera se ejercita (refIsAlive
|
|
286
|
+
// ahi usa ps/pgrep, no process.kill), asi que el fix solo cambia
|
|
287
|
+
// comportamiento win32.
|
|
288
|
+
const killSpy = jest.spyOn(process, 'kill').mockImplementation((pid) => {
|
|
289
|
+
if (pid === deadRef.pid) {
|
|
290
|
+
const err = new Error('no such process');
|
|
291
|
+
err.code = 'ESRCH';
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
return true;
|
|
295
|
+
});
|
|
276
296
|
try {
|
|
277
297
|
const killed = await (0, reap_1.executeReap)(s, ['sinRef', 'muerto', 'no-existe']);
|
|
278
298
|
expect(killed).toEqual([]);
|
|
279
|
-
// no solo el resultado: nunca se
|
|
280
|
-
// la ausencia de identidad viva confirmada
|
|
281
|
-
// a terminateGroupConfirmed
|
|
282
|
-
|
|
299
|
+
// no solo el resultado: nunca se envio una señal REAL de
|
|
300
|
+
// terminacion (R2.1) — la ausencia de identidad viva confirmada
|
|
301
|
+
// corta antes de llegar a terminateGroupConfirmed. pidExistsNative
|
|
302
|
+
// SI llama a process.kill(pid, 0) como sondeo de existencia
|
|
303
|
+
// (signal 0, no una señal real) — eso aparece legitimamente en
|
|
304
|
+
// este spy y no es evidencia de una señal enviada; se filtra
|
|
305
|
+
// explicitamente (mismo patron ya aplicado en adapter.test.ts
|
|
306
|
+
// para execFileSync, narrowing en vez de "cero llamadas").
|
|
307
|
+
const realSignals = killSpy.mock.calls.filter((call) => call[1] !== 0);
|
|
308
|
+
expect(realSignals).toEqual([]);
|
|
283
309
|
}
|
|
284
310
|
finally {
|
|
285
311
|
killSpy.mockRestore();
|
|
@@ -167,8 +167,11 @@ describe('preflight', () => {
|
|
|
167
167
|
it('reports github + gh available, and does not affect status', () => {
|
|
168
168
|
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
169
169
|
gitRepo(dir, 'git@github.com:kodria/agentic-workflow.git');
|
|
170
|
+
// `resolveOnPath('gh')` runs `command -v gh` on POSIX but `where gh` on
|
|
171
|
+
// win32 (see paths.ts) — match both invocation forms so this test's
|
|
172
|
+
// "gh is available" fixture holds on windows-latest CI too.
|
|
170
173
|
mockExecSync.mockImplementation(((cmd) => {
|
|
171
|
-
if (cmd === 'command -v gh')
|
|
174
|
+
if (cmd === 'command -v gh' || cmd === 'where gh')
|
|
172
175
|
return Buffer.from('/usr/bin/gh');
|
|
173
176
|
throw new Error(`not found: ${cmd}`);
|
|
174
177
|
}));
|
|
@@ -312,6 +315,81 @@ describe('preflight', () => {
|
|
|
312
315
|
expect(check(report, 'host').detail).toContain('gitlab detected');
|
|
313
316
|
});
|
|
314
317
|
});
|
|
318
|
+
describe('sensors-baseline check (advisory — never changes the exit code)', () => {
|
|
319
|
+
it('nudges toward `awm sensors baseline` when sensors are configured but no baseline exists', () => {
|
|
320
|
+
// The team-rollout gap this addresses: a legacy repo adopts AWM, sensors get
|
|
321
|
+
// configured, and the ratchet mechanism exists to snapshot pre-existing debt —
|
|
322
|
+
// but nothing tells the operator it's there until they hit a wall of red
|
|
323
|
+
// findings and go looking for it.
|
|
324
|
+
const dir = make({
|
|
325
|
+
manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
|
|
326
|
+
bins: ['eslint'],
|
|
327
|
+
files: ['package.json'],
|
|
328
|
+
});
|
|
329
|
+
const report = (0, checks_1.preflight)(dir);
|
|
330
|
+
expect(check(report, 'sensors-baseline').ok).toBe(true);
|
|
331
|
+
expect(check(report, 'sensors-baseline').detail).toContain('no baseline yet');
|
|
332
|
+
expect(check(report, 'sensors-baseline').remedy).toContain('awm sensors baseline');
|
|
333
|
+
expect(report.status).toBe('ready');
|
|
334
|
+
});
|
|
335
|
+
it('reports the no-advisory-needed state when a baseline already exists, without nudging', () => {
|
|
336
|
+
const dir = make({
|
|
337
|
+
manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
|
|
338
|
+
bins: ['eslint'],
|
|
339
|
+
files: ['package.json'],
|
|
340
|
+
});
|
|
341
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
|
|
342
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.baseline.json'), JSON.stringify({ lint: [] }));
|
|
343
|
+
const report = (0, checks_1.preflight)(dir);
|
|
344
|
+
expect(check(report, 'sensors-baseline').ok).toBe(true);
|
|
345
|
+
expect(check(report, 'sensors-baseline').detail).toBe('baseline present');
|
|
346
|
+
expect(check(report, 'sensors-baseline').remedy).toBeUndefined();
|
|
347
|
+
expect(report.status).toBe('ready');
|
|
348
|
+
});
|
|
349
|
+
it('is omitted entirely when there is no manifest at all — nothing to baseline without sensors', () => {
|
|
350
|
+
const dir = make();
|
|
351
|
+
const report = (0, checks_1.preflight)(dir);
|
|
352
|
+
expect(report.checks.find(c => c.id === 'sensors-baseline')).toBeUndefined();
|
|
353
|
+
expect(report.status).toBe('not_configured');
|
|
354
|
+
});
|
|
355
|
+
it('does not nudge on a deliberate opt-out (every sensor disabled) — nothing to baseline', () => {
|
|
356
|
+
// Regression: the trigger condition originally checked only manifestExists, so a
|
|
357
|
+
// repo that deliberately opted out (checkManifest's own documented pattern: every
|
|
358
|
+
// sensor `enabled: false`) still got told to run `awm sensors baseline` — nothing
|
|
359
|
+
// to baseline when there's nothing enabled to have findings in the first place.
|
|
360
|
+
const dir = make({
|
|
361
|
+
manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .', enabled: false } } },
|
|
362
|
+
});
|
|
363
|
+
const report = (0, checks_1.preflight)(dir);
|
|
364
|
+
expect(check(report, 'sensors-baseline').ok).toBe(true);
|
|
365
|
+
expect(check(report, 'sensors-baseline').detail).toBe('no enabled sensors — nothing to baseline');
|
|
366
|
+
expect(check(report, 'sensors-baseline').remedy).toBeUndefined();
|
|
367
|
+
});
|
|
368
|
+
it('does not nudge on an unparseable manifest — nothing to baseline', () => {
|
|
369
|
+
const dir = make({ manifest: '{not valid json' });
|
|
370
|
+
const report = (0, checks_1.preflight)(dir);
|
|
371
|
+
expect(check(report, 'sensors-baseline').ok).toBe(true);
|
|
372
|
+
expect(check(report, 'sensors-baseline').detail).toBe('no enabled sensors — nothing to baseline');
|
|
373
|
+
expect(check(report, 'sensors-baseline').remedy).toBeUndefined();
|
|
374
|
+
});
|
|
375
|
+
it('still nudges when the baseline path exists but is not a readable file (e.g. a stray directory)', () => {
|
|
376
|
+
// Regression: checking presence via `fs.existsSync` alone would have reported
|
|
377
|
+
// "baseline present" here, reassuring the operator that debt is suppressed —
|
|
378
|
+
// but the real gate (`readBaseline`, used by `partition()`) treats an unreadable
|
|
379
|
+
// baseline path as "no baseline, nothing suppressed". The advisory must track
|
|
380
|
+
// what the runtime actually does, not just whether something exists at the path.
|
|
381
|
+
const dir = make({
|
|
382
|
+
manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
|
|
383
|
+
bins: ['eslint'],
|
|
384
|
+
files: ['package.json'],
|
|
385
|
+
});
|
|
386
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, '.awm', 'sensors.baseline.json'), { recursive: true });
|
|
387
|
+
const report = (0, checks_1.preflight)(dir);
|
|
388
|
+
expect(check(report, 'sensors-baseline').ok).toBe(true);
|
|
389
|
+
expect(check(report, 'sensors-baseline').detail).toContain('no baseline yet');
|
|
390
|
+
expect(check(report, 'sensors-baseline').remedy).toContain('awm sensors baseline');
|
|
391
|
+
});
|
|
392
|
+
});
|
|
315
393
|
it('tells the operator not to hand a broken harness to an unattended run', () => {
|
|
316
394
|
const out = (0, preflight_1.formatReport)({
|
|
317
395
|
status: 'not_configured',
|
|
@@ -320,4 +398,24 @@ describe('preflight', () => {
|
|
|
320
398
|
expect(out).toContain('unattended');
|
|
321
399
|
expect(out).toContain('awm sensors init');
|
|
322
400
|
});
|
|
401
|
+
it('pads the id column to the widest id actually present, not a hardcoded width', () => {
|
|
402
|
+
// Regression: a literal `.padEnd(9)` silently misaligned once `sensors-baseline`
|
|
403
|
+
// (16 chars) was added as a check id — every detail column shifted left of where
|
|
404
|
+
// shorter ids' details landed. The width must be derived from the report itself.
|
|
405
|
+
// Marker prefixes (@@) pin exactly where each detail column starts, independent
|
|
406
|
+
// of the detail text's own content.
|
|
407
|
+
const out = (0, preflight_1.formatReport)({
|
|
408
|
+
status: 'ready',
|
|
409
|
+
checks: [
|
|
410
|
+
{ id: 'host', ok: true, detail: '@@marker' },
|
|
411
|
+
{ id: 'sensors-baseline', ok: true, detail: '@@marker' },
|
|
412
|
+
],
|
|
413
|
+
});
|
|
414
|
+
const lines = out.split('\n').filter(l => l.includes('@@marker'));
|
|
415
|
+
expect(lines).toHaveLength(2);
|
|
416
|
+
expect(lines[0].indexOf('@@marker')).toBe(lines[1].indexOf('@@marker'));
|
|
417
|
+
// And the column is genuinely sized to the longest id (16, 'sensors-baseline'),
|
|
418
|
+
// not the old hardcoded 9 — the shorter id's row must carry visible padding.
|
|
419
|
+
expect(lines[0]).toMatch(/host {12,}@@marker/);
|
|
420
|
+
});
|
|
323
421
|
});
|