agentic-workflow-manager 6.3.0 → 6.4.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/job/gate.js +22 -8
- package/dist/src/commands/track/emit.js +40 -0
- package/dist/src/commands/track/index.js +26 -1
- package/dist/src/commands/track/supervisor-wrapper.js +17 -1
- package/dist/src/commands/update.js +62 -8
- package/dist/src/commands/watch/apply.js +39 -0
- package/dist/src/commands/watch/tracks.js +51 -0
- package/dist/src/core/update-check.js +22 -2
- package/dist/src/index.js +6 -1
- package/dist/tests/commands/job/gate-reconcile.test.js +17 -0
- package/dist/tests/commands/multi-agent-targeting.test.js +9 -4
- package/dist/tests/commands/track/supervisor-wrapper.test.js +21 -0
- package/dist/tests/commands/track/verbs.test.js +50 -3
- package/dist/tests/commands/update.test.js +243 -0
- package/dist/tests/commands/watch/apply.test.js +50 -0
- package/dist/tests/commands/watch/track-join-request.test.js +145 -0
- package/dist/tests/core/update-check.test.js +5 -1
- package/dist/tests/structural/sensor-configs-are-present.test.js +49 -0
- package/package.json +1 -1
|
@@ -22,6 +22,8 @@ function evaluateEvidence(state, fingerprintNow, scope) {
|
|
|
22
22
|
reasons.push({ category: 'pending-task', detail: `task ${t.id} en ${t.status}` });
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
+
const allPlans = [...tasksInScope.flatMap((t) => t.verificationPlan), ...state.cycleVerificationPlan];
|
|
26
|
+
const presentKinds = new Set(allPlans.map((i) => i.kind));
|
|
25
27
|
if (scope.requireGlobalKinds) {
|
|
26
28
|
if (state.cycleVerificationPlan.length === 0) {
|
|
27
29
|
reasons.push({ category: 'empty-cycle-plan', detail: 'CycleVerificationPlan vacio: un ciclo sin plan de cierre jamas certifica (R1.4b)' });
|
|
@@ -31,16 +33,28 @@ function evaluateEvidence(state, fingerprintNow, scope) {
|
|
|
31
33
|
reasons.push({ category: 'missing-verifier', detail: `CycleVerificationPlan requiere '${required}'` });
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
// R3.6 es una afirmación sobre la CONFIG DEL REPO ("este repo no tiene test/sensors,
|
|
37
|
+
// así que no se certifica por ausencia"), y por eso vive del lado global del scope.
|
|
38
|
+
//
|
|
39
|
+
// Estaba afuera del guard, y eso hacía imposible que un track se congelara NUNCA:
|
|
40
|
+
// `computeTrackGate` pasa `requireGlobalKinds: false` precisamente porque el journal
|
|
41
|
+
// de un track no evalúa los kinds globales, pero heredaba igual esta exigencia — y
|
|
42
|
+
// `requiredVerifiers` de un journal de track es SIEMPRE `[]`, porque
|
|
43
|
+
// `initTrackJournal` llama a `initJournal` y no a `initWatch`, así que la detección
|
|
44
|
+
// mecánica nunca corre ahí. Gate local rojo para siempre => `attemptFreeze` devolvía
|
|
45
|
+
// `continue` en cada tick => la cohorte no podía congelarse => sin C3 no hay merge, y
|
|
46
|
+
// por lo tanto no hay `COMPLETE`. Se observó en la certificación con supervisor vivo:
|
|
47
|
+
// `alpha` con el freeze pedido, su worktree limpio, cero jobs vivos, y estas dos
|
|
48
|
+
// razones como único motivo.
|
|
49
|
+
for (const mechanical of ['test', 'sensors']) {
|
|
50
|
+
if (!state.requiredVerifiers.includes(mechanical)) {
|
|
51
|
+
reasons.push({ category: 'missing-verifier', detail: `el repo no tiene '${mechanical}' configurado; no se certifica por ausencia (R3.6)` });
|
|
52
|
+
}
|
|
42
53
|
}
|
|
43
54
|
}
|
|
55
|
+
// Este otro sí es por scope: "lo que el repo exige tiene que estar en ALGÚN plan del
|
|
56
|
+
// scope". En un journal de track es un no-op (`requiredVerifiers` vacío), y si algún día
|
|
57
|
+
// se poblara, exigirlo sobre los planes del track es lo correcto.
|
|
44
58
|
for (const required of state.requiredVerifiers) {
|
|
45
59
|
if (!presentKinds.has(required)) {
|
|
46
60
|
reasons.push({ category: 'missing-verifier', detail: `el repo exige verificador '${required}' y ningun plan lo contiene` });
|
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.emitTrackRequest = emitTrackRequest;
|
|
7
|
+
exports.emitFinalizeRequest = emitFinalizeRequest;
|
|
7
8
|
// Emisores de requests de track (R6.1): `add`/`join`/`remove` jamas mutan
|
|
8
9
|
// Git ni el journal directamente — el unico efecto observable es publicar
|
|
9
10
|
// una request inmutable que el supervisor del plan consume despues (mismo
|
|
@@ -13,6 +14,7 @@ exports.emitTrackRequest = emitTrackRequest;
|
|
|
13
14
|
// intents distintos sobre el mismo track jamas colisionan.
|
|
14
15
|
const crypto_1 = __importDefault(require("crypto"));
|
|
15
16
|
const requests_1 = require("../../core/journal/requests");
|
|
17
|
+
const git_1 = require("../../core/tracks/git");
|
|
16
18
|
function emitTrackRequest(repoRoot, branch, generationToken, kind, trackId) {
|
|
17
19
|
if (trackId.length === 0)
|
|
18
20
|
throw new Error('trackId obligatorio');
|
|
@@ -24,3 +26,41 @@ function emitTrackRequest(repoRoot, branch, generationToken, kind, trackId) {
|
|
|
24
26
|
payload,
|
|
25
27
|
});
|
|
26
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* `track-finalize-request`: el autoreporte del controller del PLAN — "corrí la QA global
|
|
31
|
+
* sobre el HEAD ya mergeado de todos los tracks, corregí los hallazgos y comiteé; este es
|
|
32
|
+
* mi HEAD limpio".
|
|
33
|
+
*
|
|
34
|
+
* Es PLAN-scoped y no lleva `trackId`, por eso no pasa por `emitTrackRequest`.
|
|
35
|
+
*
|
|
36
|
+
* Sin este emisor la cohorte no tenía salida: `runRequestGlobalQa` (watch/tracks.ts) espera
|
|
37
|
+
* `s.qaFinalizeRequested`, que solo se puebla aplicando esta request, y NADA en el producto
|
|
38
|
+
* la emitía — el único lugar del repo que la producía era el harness de tests, llamando
|
|
39
|
+
* `emitRequest` directo. Es decir, `COMPLETE` era alcanzable solo desde adentro de los
|
|
40
|
+
* tests: en producción, con todos los tracks en `MERGED_UNVERIFIED`, el supervisor esperaba
|
|
41
|
+
* para siempre una evidencia que ningún controller tenía forma de producir.
|
|
42
|
+
*
|
|
43
|
+
* El HEAD se LEE del repo, no se recibe por flag: el controller reporta el suyo, y un flag
|
|
44
|
+
* solo abriría la puerta a reportar un HEAD ajeno. El árbol sucio se rechaza acá, en el
|
|
45
|
+
* borde, para que el fallo diga qué falta en vez de manifestarse como una espera muda — pero
|
|
46
|
+
* eso NO reemplaza la re-verificación independiente del supervisor (HEAD real + árbol
|
|
47
|
+
* limpio en el momento de consumir la request), que sigue siendo la autoridad fail-closed.
|
|
48
|
+
* El chequeo del borde es un diagnóstico, nunca la prueba.
|
|
49
|
+
*/
|
|
50
|
+
function emitFinalizeRequest(repoRoot, branch, generationToken) {
|
|
51
|
+
const dirty = (0, git_1.dirtyPaths)(repoRoot);
|
|
52
|
+
if (dirty.length > 0) {
|
|
53
|
+
throw new Error(`el arbol del plan tiene ${dirty.length} ruta(s) sin comitear (${dirty.slice(0, 3).join(', ')}${dirty.length > 3 ? ', …' : ''}): `
|
|
54
|
+
+ 'la QA global se reporta sobre un HEAD limpio — comiteá las correcciones antes de finalizar');
|
|
55
|
+
}
|
|
56
|
+
const qaHeadSha = (0, git_1.headSha)(repoRoot);
|
|
57
|
+
// La key liga el SHA: re-reportar el mismo HEAD colapsa (retry seguro), y un HEAD nuevo
|
|
58
|
+
// — porque la QA encontró algo más y se comiteó otra corrección — es una request nueva.
|
|
59
|
+
const emitted = (0, requests_1.emitRequest)(repoRoot, branch, {
|
|
60
|
+
kind: 'track-finalize-request', generationToken,
|
|
61
|
+
idempotencyKey: crypto_1.default.createHash('sha256')
|
|
62
|
+
.update(`track-finalize-request\0${branch}\0${qaHeadSha}`).digest('hex'),
|
|
63
|
+
payload: { qaHeadSha },
|
|
64
|
+
});
|
|
65
|
+
return { ...emitted, qaHeadSha };
|
|
66
|
+
}
|
|
@@ -85,8 +85,33 @@ function registerTrackCommand(program) {
|
|
|
85
85
|
const r = (0, emit_1.emitTrackRequest)(repo, branch, opts.generation, 'track-join-request', trackId);
|
|
86
86
|
process.stdout.write(JSON.stringify({ requestId: r.requestId, idempotencyKey: r.idempotencyKey }, null, 2) + '\n');
|
|
87
87
|
});
|
|
88
|
+
track.command('finalize')
|
|
89
|
+
.description('emite track-finalize-request — autoreporte de QA global del controller del plan sobre el HEAD ya mergeado (R7.2)')
|
|
90
|
+
.requiredOption('--generation <token>', 'token de la generacion vigente')
|
|
91
|
+
.action((opts) => {
|
|
92
|
+
const repo = process.cwd();
|
|
93
|
+
const branch = branchOf(repo);
|
|
94
|
+
// Plan-scoped, igual que `list`/`status`: la QA global corre sobre el HEAD del
|
|
95
|
+
// PLAN con todos los tracks ya mergeados. Emitirla desde el worktree de un track
|
|
96
|
+
// escribiría el autoreporte en el journal equivocado, donde nadie lo espera.
|
|
97
|
+
assertPlanCwd(repo, branch);
|
|
98
|
+
let r;
|
|
99
|
+
try {
|
|
100
|
+
r = (0, emit_1.emitFinalizeRequest)(repo, branch, opts.generation);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
failGuard(`track finalize: ${e.message}`);
|
|
104
|
+
}
|
|
105
|
+
process.stdout.write(JSON.stringify({ requestId: r.requestId, idempotencyKey: r.idempotencyKey, qaHeadSha: r.qaHeadSha }, null, 2) + '\n');
|
|
106
|
+
});
|
|
107
|
+
// NO IMPLEMENTADO del lado del supervisor: no hay handler para `track-teardown-request`,
|
|
108
|
+
// así que la request se emite y el supervisor la RECHAZA (`request-rejected-invalid`).
|
|
109
|
+
// La descripción lo dice en vez de prometer un desmantelamiento que no ocurre — hasta
|
|
110
|
+
// este release afirmaba que el supervisor desmantelaba worktree/rama, y no lo hacía.
|
|
111
|
+
// El teardown que sí funciona es el automático de la degradación a serial
|
|
112
|
+
// (`begin-teardown` desde `FALLBACK_PENDING`), que no pasa por este verbo.
|
|
88
113
|
track.command('remove')
|
|
89
|
-
.description('emite track-teardown-request
|
|
114
|
+
.description('NO IMPLEMENTADO: emite track-teardown-request, que el supervisor todavía no sabe aplicar y rechaza')
|
|
90
115
|
.requiredOption('--generation <token>', 'token de la generacion vigente')
|
|
91
116
|
.argument('<trackId>')
|
|
92
117
|
.action((trackId, opts) => {
|
|
@@ -18,6 +18,11 @@ const atomic_file_1 = require("../../core/atomic-file");
|
|
|
18
18
|
const store_1 = require("../../core/journal/store");
|
|
19
19
|
const paths_1 = require("../../core/journal/paths");
|
|
20
20
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
/** Fases desde las cuales el track YA tiene trabajo propio que atender, así que su
|
|
22
|
+
* supervisor debe estar corriendo. Es el conjunto monótono "ACTIVE o posterior": el plan
|
|
23
|
+
* puede haber avanzado varias fases entre dos polls de este wrapper, y perderse el arranque
|
|
24
|
+
* por no haber visto la fase exacta deja al track sin nadie que consuma sus requests. */
|
|
25
|
+
const LAUNCH_PHASES = ['ACTIVE', 'JOIN_REQUESTED', 'FROZEN', 'JOIN_INTENT', 'MERGED_UNVERIFIED', 'JOINED'];
|
|
21
26
|
function localBranch(worktreePath) {
|
|
22
27
|
return (0, child_process_2.execFileSync)('git', ['branch', '--show-current'], { cwd: worktreePath, encoding: 'utf8', stdio: process_1.EXEC_STDIO }).trim();
|
|
23
28
|
}
|
|
@@ -67,7 +72,18 @@ async function runSupervisorWrapper(opts) {
|
|
|
67
72
|
const r = (0, store_1.readJournal)(opts.planRoot, opts.planBranch);
|
|
68
73
|
if (!r.corrupt && r.state !== null) {
|
|
69
74
|
const ref = r.state.tracks?.find((t) => t.trackId === opts.trackId);
|
|
70
|
-
|
|
75
|
+
// MONOTONO, nunca la igualdad exacta con `ACTIVE`. Esperar a ver una fase
|
|
76
|
+
// puntual con un poll de 2 s es una carrera: el plan puede cruzar `ACTIVE` entre
|
|
77
|
+
// dos lecturas y este wrapper quedarse esperando para siempre una fase que ya
|
|
78
|
+
// pasó — sin lanzar `awm watch`, así que las requests cross-journal del plan
|
|
79
|
+
// (empezando por `track-freeze-request`) no las consume nadie y la cohorte se
|
|
80
|
+
// traba en el freeze.
|
|
81
|
+
//
|
|
82
|
+
// Pasó de verdad: al reparar el join, un track podía ir `ARMED -> ACTIVE ->
|
|
83
|
+
// JOIN_REQUESTED` dentro de un mismo `reconcileTracks`, y la ventana `ACTIVE`
|
|
84
|
+
// dejó de ser observable. La igualdad exacta solo funcionaba mientras el join
|
|
85
|
+
// estaba roto y los tracks se quedaban parados en `ACTIVE`.
|
|
86
|
+
if (ref !== undefined && LAUNCH_PHASES.includes(ref.phase))
|
|
71
87
|
break;
|
|
72
88
|
// BLOCKED/REMOVED: el plan ya decidió que este track no avanza —
|
|
73
89
|
// el wrapper nunca lanza `awm watch` para un track que el plan
|
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.updateOutro = updateOutro;
|
|
6
7
|
exports.runUpdateCore = runUpdateCore;
|
|
7
8
|
// src/commands/update.ts
|
|
8
9
|
//
|
|
@@ -24,6 +25,7 @@ const paths_1 = require("../core/paths");
|
|
|
24
25
|
const config_1 = require("../utils/config");
|
|
25
26
|
const agent_targets_1 = require("../core/agent-targets");
|
|
26
27
|
const registries_1 = require("../core/registries");
|
|
28
|
+
const paths_2 = require("../core/paths");
|
|
27
29
|
const regenerate_1 = require("../core/context/regenerate");
|
|
28
30
|
const reconciliation_1 = require("../core/reconciliation");
|
|
29
31
|
const install_transaction_1 = require("../core/install-transaction");
|
|
@@ -35,12 +37,34 @@ const defaultDeps = {
|
|
|
35
37
|
planReconciliation: reconciliation_1.planReconciliation,
|
|
36
38
|
applyInstallPlan: install_transaction_1.applyInstallPlan,
|
|
37
39
|
resyncInstalledHooks: resync_1.resyncInstalledHooks,
|
|
38
|
-
offerSelfUpdate: async () => {
|
|
40
|
+
offerSelfUpdate: async (mode) => {
|
|
39
41
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
40
42
|
const { offerSelfUpdate: real } = require('../core/update-check');
|
|
41
|
-
await real();
|
|
43
|
+
await real({ mode });
|
|
42
44
|
},
|
|
43
45
|
};
|
|
46
|
+
const NO_REGISTRIES = { configured: 0, synced: 0, failed: [] };
|
|
47
|
+
/**
|
|
48
|
+
* El texto de cierre, derivado del resultado. Vive acá y no en la registración de
|
|
49
|
+
* Commander porque el literal de `index.ts` era el defecto: decía
|
|
50
|
+
* "✅ Registries, skills and hooks updated." con `code === 0`, y `code` valía 0 pase lo
|
|
51
|
+
* que pase — así que una máquina SIN NINGÚN registry configurado recibía la confirmación
|
|
52
|
+
* de que se le habían actualizado los registries, los skills y los hooks. Un comando que
|
|
53
|
+
* miente es peor que uno que falla: el que falla te manda a mirar, el que miente te manda
|
|
54
|
+
* a otro lado a buscar el problema.
|
|
55
|
+
*/
|
|
56
|
+
function updateOutro(result) {
|
|
57
|
+
const { registries: reg } = result;
|
|
58
|
+
if (reg.configured === 0)
|
|
59
|
+
return 'Nothing updated — no registries configured on this machine.';
|
|
60
|
+
if (result.code !== 0)
|
|
61
|
+
return 'Update failed — see errors above.';
|
|
62
|
+
if (reg.failed.length > 0) {
|
|
63
|
+
return `⚠ Updated with stale content — ${reg.synced}/${reg.configured} registries synced, `
|
|
64
|
+
+ `kept on-disk content for: ${reg.failed.join(', ')}.`;
|
|
65
|
+
}
|
|
66
|
+
return `✅ ${reg.synced} ${reg.synced === 1 ? 'registry' : 'registries'}, skills and hooks updated.`;
|
|
67
|
+
}
|
|
44
68
|
/**
|
|
45
69
|
* Core, UI-free `awm update` logic: resolves agent targets, runs every stage
|
|
46
70
|
* in order (registry sync → CLI-version gate → context regen → artifact
|
|
@@ -58,7 +82,7 @@ async function runUpdateCore(options = {}, deps = {}) {
|
|
|
58
82
|
const resolved = (0, agent_targets_1.resolveAgentTargetsOrError)({ prefs, explicit: options.agent });
|
|
59
83
|
if (!resolved.ok) {
|
|
60
84
|
console.error(picocolors_1.default.red(resolved.error));
|
|
61
|
-
return { code: 1, selectedAgents: [] };
|
|
85
|
+
return { code: 1, selectedAgents: [], registries: NO_REGISTRIES };
|
|
62
86
|
}
|
|
63
87
|
const selectedAgents = resolved.targets;
|
|
64
88
|
const registryResults = await d.syncRegistries();
|
|
@@ -68,12 +92,39 @@ async function runUpdateCore(options = {}, deps = {}) {
|
|
|
68
92
|
else
|
|
69
93
|
console.log(picocolors_1.default.green(` ✓ Registry ${r.name} ${r.action === 'pulled' ? 'updated' : 're-cloned'} @ ${r.version}`));
|
|
70
94
|
}
|
|
95
|
+
const registries = {
|
|
96
|
+
configured: registryResults.length,
|
|
97
|
+
synced: registryResults.filter((r) => r.action !== 'error').length,
|
|
98
|
+
failed: registryResults.filter((r) => r.action === 'error').map((r) => r.name),
|
|
99
|
+
};
|
|
100
|
+
// Cero registries no es "todo al día": es una máquina que nunca corrió `awm init`
|
|
101
|
+
// (o cuyo registries.json se perdió). Seguir adelante regenera contexto vacío,
|
|
102
|
+
// reconcilia contra cero content roots y no re-sincroniza ningún hook — cada etapa
|
|
103
|
+
// "pasa" porque no tiene nada que hacer, y el comando terminaba anunciando éxito.
|
|
104
|
+
// Se corta acá, nombrando el archivo que falta y el comando que lo crea.
|
|
105
|
+
if (registries.configured === 0) {
|
|
106
|
+
console.error(picocolors_1.default.red(`No registries configured in ${(0, paths_2.awmHome)()} — nothing to update.`));
|
|
107
|
+
console.error(picocolors_1.default.dim(" This machine was never initialized: run 'awm init' first."));
|
|
108
|
+
return { code: 1, selectedAgents, registries };
|
|
109
|
+
}
|
|
110
|
+
// Falla CERRADO solo cuando el registry quedó sin contenido en disco: ahí lo que sigue
|
|
111
|
+
// leería un árbol inexistente y el fallo reaparecería más tarde, disfrazado de otra
|
|
112
|
+
// etapa. Un registry que falló pero conserva su contenido queda stale, no roto — se
|
|
113
|
+
// sigue (doctrina de `unusableSyncedRegistries`: un registry secundario flaky nunca
|
|
114
|
+
// aborta la corrida) y el cierre lo nombra en vez de declarar éxito parejo.
|
|
115
|
+
try {
|
|
116
|
+
(0, registries_1.assertSyncedRegistriesUsable)(registryResults);
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
console.error(picocolors_1.default.red(e.message));
|
|
120
|
+
return { code: 1, selectedAgents, registries };
|
|
121
|
+
}
|
|
71
122
|
try {
|
|
72
123
|
(0, registries_1.assertRegistryGates)(d.verifyMinCliVersions());
|
|
73
124
|
}
|
|
74
125
|
catch (e) {
|
|
75
126
|
console.error(picocolors_1.default.red(e.message));
|
|
76
|
-
return { code: 1, selectedAgents };
|
|
127
|
+
return { code: 1, selectedAgents, registries };
|
|
77
128
|
}
|
|
78
129
|
const regen = d.regenerateGlobalContext(selectedAgents);
|
|
79
130
|
const refreshed = regen.filter((r) => r.action === 'refreshed').map((r) => r.agent);
|
|
@@ -86,7 +137,7 @@ async function runUpdateCore(options = {}, deps = {}) {
|
|
|
86
137
|
}
|
|
87
138
|
catch (e) {
|
|
88
139
|
console.error(picocolors_1.default.red(`Artifact reconciliation failed: ${e.message}`));
|
|
89
|
-
return { code: 1, selectedAgents };
|
|
140
|
+
return { code: 1, selectedAgents, registries };
|
|
90
141
|
}
|
|
91
142
|
if (artifactResult.installed.length > 0) {
|
|
92
143
|
console.log(picocolors_1.default.green(` ✓ Reconciled artifacts: ${artifactResult.installed.join(', ')}`));
|
|
@@ -103,9 +154,12 @@ async function runUpdateCore(options = {}, deps = {}) {
|
|
|
103
154
|
}
|
|
104
155
|
catch (e) {
|
|
105
156
|
console.error(picocolors_1.default.red(`Hook resync failed: ${e.message}`));
|
|
106
|
-
return { code: 1, selectedAgents };
|
|
157
|
+
return { code: 1, selectedAgents, registries };
|
|
107
158
|
}
|
|
108
159
|
}
|
|
109
|
-
|
|
110
|
-
|
|
160
|
+
// `--yes` es consentimiento explícito para reemplazar el binario global. Sin él se
|
|
161
|
+
// pasa `undefined` a propósito, para que la decisión la tome `defaultSelfUpdateMode()`
|
|
162
|
+
// según haya o no un humano en stdin — no este llamador.
|
|
163
|
+
await d.offerSelfUpdate(options.yes === true ? 'assume-yes' : undefined);
|
|
164
|
+
return { code: 0, selectedAgents, registries };
|
|
111
165
|
}
|
|
@@ -223,6 +223,33 @@ function applyRequestToState(s, env, digest, repoRoot) {
|
|
|
223
223
|
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: trackId });
|
|
224
224
|
return;
|
|
225
225
|
}
|
|
226
|
+
if (env.kind === 'track-join-request') {
|
|
227
|
+
// R6.1: el ÚNICO efecto es marcar la intención. Quien decide si eso mueve la fase es
|
|
228
|
+
// el reducer puro (`reconcileProtocol`, rama `join-requested`: solo un track en
|
|
229
|
+
// `ACTIVE` pasa a `JOIN_REQUESTED`), y quien congela/mergea es `reconcileTracks` —
|
|
230
|
+
// jamás este consumo transaccional. Mismo criterio declarativo que
|
|
231
|
+
// `track-freeze-request` arriba.
|
|
232
|
+
//
|
|
233
|
+
// Este handler NO EXISTÍA: `awm track join` emitía la request, `applyRequestToState`
|
|
234
|
+
// se caía por el final sin reconocer el kind, y `consumePendingRequests` la contaba
|
|
235
|
+
// como `applied` y borraba el archivo. El comando salía 0 imprimiendo un requestId,
|
|
236
|
+
// el supervisor sumaba una request "aplicada", y el track se quedaba en `ACTIVE`
|
|
237
|
+
// para siempre: la cohorte no podía congelarse (C3), así que jamás llegaba a
|
|
238
|
+
// `MERGED_UNVERIFIED` ni a `COMPLETE`. `join-requested` era la única observación del
|
|
239
|
+
// protocolo con CERO productores en todo `src/`.
|
|
240
|
+
const p = env.payload;
|
|
241
|
+
if (typeof p.trackId !== 'string' || p.trackId.length === 0)
|
|
242
|
+
throw new Error('track-join-request requiere trackId');
|
|
243
|
+
const trackId = p.trackId;
|
|
244
|
+
const ref = s.tracks?.find((t) => t.trackId === trackId);
|
|
245
|
+
// Fail-closed: un join sobre un track que no existe es un error del emisor, no algo
|
|
246
|
+
// que se absorba en silencio (que es exactamente lo que hacía la ausencia de handler).
|
|
247
|
+
if (ref === undefined)
|
|
248
|
+
throw new Error(`track-join-request: track desconocido: ${trackId}`);
|
|
249
|
+
ref.joinRequested = true;
|
|
250
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: trackId });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
226
253
|
if (env.kind === 'track-freeze-request') {
|
|
227
254
|
// R5.2/R6.3 (Task 10): request administrativa CROSS-JOURNAL — el
|
|
228
255
|
// supervisor del PLAN la emite directamente al `requestsDir` de ESTE
|
|
@@ -443,6 +470,18 @@ function applyRequestToState(s, env, digest, repoRoot) {
|
|
|
443
470
|
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: verdictId });
|
|
444
471
|
return;
|
|
445
472
|
}
|
|
473
|
+
// FALLA CERRADO ante un kind sin handler. Antes esta función simplemente se caía por el
|
|
474
|
+
// final: `consumePendingRequests` no veía excepción, hacía `applied++` y BORRABA el
|
|
475
|
+
// archivo. Una request emitida por un comando real quedaba contada como aplicada, sin
|
|
476
|
+
// evento, sin cambio de estado y sin error — el modo de falla más caro que existe, porque
|
|
477
|
+
// todo el sistema reporta éxito. Así vivió `track-join-request` (ver su handler arriba):
|
|
478
|
+
// el comando salía 0, el journal sumaba una request "aplicada", y la cohorte no avanzaba.
|
|
479
|
+
//
|
|
480
|
+
// Con este throw, un kind sin handler se convierte en `request-rejected-invalid`: queda en
|
|
481
|
+
// `requestProblems`, deja evento durable, y el archivo se renombra a `.rejected` en vez de
|
|
482
|
+
// desaparecer. Agregar un `RequestKind` sin su handler pasa a ser ruidoso — que es la
|
|
483
|
+
// única forma de que no se repita.
|
|
484
|
+
throw new Error(`request kind sin handler en el supervisor: ${env.kind} — emitida pero nunca aplicada`);
|
|
446
485
|
}
|
|
447
486
|
/** Consume TODAS las requests pendientes en orden. ORDEN CRITICO (R1.3,
|
|
448
487
|
* bloqueador 4): (1) mutar estado, (2) writeJournal, (3) borrar archivos,
|
|
@@ -70,6 +70,12 @@ const RUNTIME_EFFECTS = new Set([
|
|
|
70
70
|
// `runRunFinalInterlock` más abajo).
|
|
71
71
|
'request-global-qa', 'request-final-integration', 'run-final-interlock',
|
|
72
72
|
]);
|
|
73
|
+
/** Fases en las que un `joinRequested` todavía tiene futuro: el track aún no llegó a
|
|
74
|
+
* `ACTIVE`, así que el pedido espera a que llegue en vez de perderse. `ACTIVE` está incluida
|
|
75
|
+
* porque es justamente donde se aplica; cualquier otra fase lo vuelve moot. */
|
|
76
|
+
const JOIN_PENDING_PHASES = [
|
|
77
|
+
'DECLARED', 'PREPARE_INTENT', 'WORKTREE_CREATED', 'JOURNAL_CREATED', 'SUPERVISOR_STARTING', 'ARMED', 'ACTIVE',
|
|
78
|
+
];
|
|
73
79
|
function toProtocol(state, maxParallel) {
|
|
74
80
|
const tracks = {};
|
|
75
81
|
for (const ref of state.tracks ?? []) {
|
|
@@ -812,6 +818,51 @@ async function reconcileTracks(planRoot, branch, state, runtime, maxParallel) {
|
|
|
812
818
|
if (s.tracks === undefined || s.tracks.length < 2 || s.cohortPhase === undefined) {
|
|
813
819
|
return { state: s, effectExecuted: null };
|
|
814
820
|
}
|
|
821
|
+
// El pedido de join del controller (`joinRequested`, marcado declarativamente al
|
|
822
|
+
// consumir `track-join-request`) se convierte ACÁ en la observación que el reducer
|
|
823
|
+
// entiende. La regla de si eso mueve la fase vive en `reconcileProtocol` (solo
|
|
824
|
+
// `ACTIVE` pasa a `JOIN_REQUESTED`), no acá: este bloque solo produce la observación
|
|
825
|
+
// que hasta ahora nadie producía. Se consume una por vuelta y se persiste, para no
|
|
826
|
+
// colapsar dos fronteras en un mismo call (mismo invariante que el resto del loop).
|
|
827
|
+
//
|
|
828
|
+
// El pedido es DURABLE mientras el track todavía no llegó a `ACTIVE`. Un join que
|
|
829
|
+
// llega antes de la activación no es un error del controller: es la carrera normal
|
|
830
|
+
// — con `maxParallel` chico la cohorte activa de a un track por vez, así que el
|
|
831
|
+
// controller termina su trabajo y pide el join mientras su track sigue en `ARMED`.
|
|
832
|
+
// Se observó en la certificación: dos joins emitidos con ambos tracks en `ARMED`.
|
|
833
|
+
// Descartarlos ahí (que es lo que hacía la primera versión de este bloque) devuelve
|
|
834
|
+
// el mismo modo de falla que este cambio vino a cerrar — un pedido que reporta éxito
|
|
835
|
+
// y no pasa nada — y solo sobrevivía porque el controller scripteado los re-emite.
|
|
836
|
+
const pendingJoin = (s.tracks ?? []).find((t) => t.joinRequested === true && t.phase === 'ACTIVE');
|
|
837
|
+
if (pendingJoin !== undefined) {
|
|
838
|
+
const protocolBefore = toProtocol(s, maxParallel);
|
|
839
|
+
const observed = (0, protocol_1.reconcileProtocol)(protocolBefore, { kind: 'join-requested', trackId: pendingJoin.trackId });
|
|
840
|
+
const next = applyProtocolToState(s, observed);
|
|
841
|
+
for (const t of next.tracks ?? [])
|
|
842
|
+
if (t.trackId === pendingJoin.trackId)
|
|
843
|
+
t.joinRequested = undefined;
|
|
844
|
+
s = persist(planRoot, branch, next);
|
|
845
|
+
(0, store_1.appendEvent)(planRoot, branch, {
|
|
846
|
+
kind: 'track-join-observed', trackId: pendingJoin.trackId,
|
|
847
|
+
phase: s.tracks?.find((t) => t.trackId === pendingJoin.trackId)?.phase,
|
|
848
|
+
});
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
// Pedido que ya no puede aplicarse nunca: el track pasó de `ACTIVE` (join duplicado,
|
|
852
|
+
// o el relevo de un controller que re-emite) o quedó `BLOCKED`. Se limpia la marca
|
|
853
|
+
// — dejarla prendida la haría reevaluar en cada tick sin progreso posible. Lo que NO
|
|
854
|
+
// se limpia es el pedido de un track anterior a `ACTIVE`: ese sigue esperando.
|
|
855
|
+
const mootJoin = (s.tracks ?? []).find((t) => t.joinRequested === true
|
|
856
|
+
&& !JOIN_PENDING_PHASES.includes(t.phase));
|
|
857
|
+
if (mootJoin !== undefined) {
|
|
858
|
+
const next = structuredClone(s);
|
|
859
|
+
for (const t of next.tracks ?? [])
|
|
860
|
+
if (t.trackId === mootJoin.trackId)
|
|
861
|
+
t.joinRequested = undefined;
|
|
862
|
+
s = persist(planRoot, branch, next);
|
|
863
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-join-moot', trackId: mootJoin.trackId, phase: mootJoin.phase });
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
815
866
|
const protocol = toProtocol(s, maxParallel);
|
|
816
867
|
const effect = (0, protocol_1.nextProtocolEffect)(protocol);
|
|
817
868
|
if (effect === null)
|
|
@@ -8,6 +8,7 @@ exports.writeUpdateCache = writeUpdateCache;
|
|
|
8
8
|
exports.fetchLatestVersion = fetchLatestVersion;
|
|
9
9
|
exports.spawnRefreshWorker = spawnRefreshWorker;
|
|
10
10
|
exports.maybeNotifyUpdate = maybeNotifyUpdate;
|
|
11
|
+
exports.defaultSelfUpdateMode = defaultSelfUpdateMode;
|
|
11
12
|
exports.offerSelfUpdate = offerSelfUpdate;
|
|
12
13
|
// cli/src/core/update-check.ts
|
|
13
14
|
//
|
|
@@ -92,20 +93,39 @@ function maybeNotifyUpdate(opts) {
|
|
|
92
93
|
if (!cache || now - cache.lastCheck > TTL_MS)
|
|
93
94
|
spawnWorker();
|
|
94
95
|
}
|
|
95
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Sin nadie del otro lado, un prompt no es una pregunta: es un cuelgue. `awm update`
|
|
98
|
+
* corre en CI, en cron y dentro de sesiones agénticas, y ahí el confirm de self-update
|
|
99
|
+
* bloqueaba el proceso indefinidamente — el comando quedaba a mitad de camino y ningún
|
|
100
|
+
* humano podía destrabarlo. La ausencia de TTY en stdin es la evidencia POSITIVA de que
|
|
101
|
+
* no hay quien conteste, así que se degrada al aviso.
|
|
102
|
+
*
|
|
103
|
+
* Degrada a `skip`, jamás a `assume-yes`: nadie pidió reemplazar el binario global de la
|
|
104
|
+
* máquina, y hacerlo por iniciativa propia porque "no había a quién preguntarle" es
|
|
105
|
+
* exactamente la clase de acción que el silencio no autoriza.
|
|
106
|
+
*/
|
|
107
|
+
function defaultSelfUpdateMode() {
|
|
108
|
+
return process.stdin.isTTY === true ? 'prompt' : 'skip';
|
|
109
|
+
}
|
|
110
|
+
/** Capa 2 — en `awm update`: detecta, pregunta (si hay a quién), ejecuta npm i -g; degrada a aviso. */
|
|
96
111
|
async function offerSelfUpdate(deps = {}) {
|
|
97
112
|
if (process.env.AWM_NO_UPDATE_CHECK)
|
|
98
113
|
return;
|
|
99
114
|
const current = deps.current ?? (0, cli_version_1.cliVersion)();
|
|
115
|
+
const mode = deps.mode ?? defaultSelfUpdateMode();
|
|
100
116
|
const latest = deps.latest !== undefined ? deps.latest : await fetchLatestVersion(deps.fetchImpl ?? fetch);
|
|
101
117
|
writeUpdateCache({ lastCheck: Date.now(), latest: latest ?? null });
|
|
102
118
|
if (!latest || !isNewer(latest, current))
|
|
103
119
|
return;
|
|
120
|
+
if (mode === 'skip') {
|
|
121
|
+
console.log(picocolors_1.default.dim(` ⬆ awm v${latest} available — to update: npm i -g ${cli_version_1.CLI_PACKAGE_NAME}`));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
104
124
|
const confirmImpl = deps.confirmImpl ?? (async (message) => {
|
|
105
125
|
const r = await (0, prompts_1.confirm)({ message });
|
|
106
126
|
return !(0, prompts_1.isCancel)(r) && r === true;
|
|
107
127
|
});
|
|
108
|
-
const yes = await confirmImpl(`Update awm v${current} → v${latest} now?`);
|
|
128
|
+
const yes = mode === 'assume-yes' ? true : await confirmImpl(`Update awm v${current} → v${latest} now?`);
|
|
109
129
|
if (!yes) {
|
|
110
130
|
console.log(picocolors_1.default.dim(` To update later: npm i -g ${cli_version_1.CLI_PACKAGE_NAME}`));
|
|
111
131
|
return;
|
package/dist/src/index.js
CHANGED
|
@@ -403,10 +403,15 @@ program.command('add [name]')
|
|
|
403
403
|
program.command('update')
|
|
404
404
|
.description('Sync all configured registries, reconcile artifacts and re-sync hooks')
|
|
405
405
|
.option('-a, --agent <agent>', `Target agent(s), comma-separated: ${providers_1.AGENT_TARGETS.join(', ')} (defaults to every enabled agent)`)
|
|
406
|
+
.option('-y, --yes', 'Non-interactive: never prompt, and self-update the CLI without asking')
|
|
406
407
|
.action(async (options) => {
|
|
407
408
|
(0, prompts_1.intro)(picocolors_1.default.bgCyan(picocolors_1.default.black(' AWM - Update Registries ')));
|
|
408
409
|
const result = await (0, update_1.runUpdateCore)(options);
|
|
409
|
-
|
|
410
|
+
// El cierre lo DERIVA `updateOutro` del resultado. No se recorta a `code === 0` con
|
|
411
|
+
// un literal fijo: eso es lo que hacía que una máquina sin registries recibiera la
|
|
412
|
+
// confirmación de que se le habían actualizado.
|
|
413
|
+
const message = (0, update_1.updateOutro)(result);
|
|
414
|
+
(0, prompts_1.outro)(result.code === 0 ? message : picocolors_1.default.red(message));
|
|
410
415
|
process.exitCode = result.code;
|
|
411
416
|
});
|
|
412
417
|
program.command('sync')
|
|
@@ -278,6 +278,23 @@ describe('computeTrackGate — gate local de un track (C6, R3.5)', () => {
|
|
|
278
278
|
expect((0, gate_1.computeTrackGate)(s, false, () => 'same')).toEqual({ pass: true, reasons: [] });
|
|
279
279
|
expect((0, gate_1.computeGate)(s, false, () => 'same').pass).toBe(false);
|
|
280
280
|
});
|
|
281
|
+
// El journal de un track REAL tiene `requiredVerifiers: []`: `initTrackJournal` llama a
|
|
282
|
+
// `initJournal`, no a `initWatch`, así que la detección mecánica de verificadores nunca
|
|
283
|
+
// corre sobre el worktree del track. La fixture de arriba los pobla a mano y por eso
|
|
284
|
+
// tapaba el defecto — R3.6 ("el repo no tiene test/sensors configurado") vivía FUERA del
|
|
285
|
+
// guard `requireGlobalKinds`, así que el gate local de todo track quedaba rojo para
|
|
286
|
+
// siempre, `attemptFreeze` devolvía `continue` en cada tick, y sin freeze no hay merge
|
|
287
|
+
// (C3) ni `COMPLETE`. Observado en la certificación con supervisor vivo.
|
|
288
|
+
test('un journal de track SIN requiredVerifiers (el caso real) certifica igual: R3.6 es del gate global', () => {
|
|
289
|
+
const s = stateWithCompletedTrackTask();
|
|
290
|
+
s.requiredVerifiers = [];
|
|
291
|
+
const g = (0, gate_1.computeTrackGate)(s, false, () => 'same');
|
|
292
|
+
expect(g.reasons.filter((r) => /no se certifica por ausencia/.test(r.detail))).toEqual([]);
|
|
293
|
+
expect(g).toEqual({ pass: true, reasons: [] });
|
|
294
|
+
// El gate GLOBAL sí sigue exigiéndolo: la regla no se debilitó, se puso en su scope.
|
|
295
|
+
const global = (0, gate_1.computeGate)({ ...s, requiredVerifiers: [] }, false, () => 'same');
|
|
296
|
+
expect(global.reasons.some((r) => /no se certifica por ausencia/.test(r.detail))).toBe(true);
|
|
297
|
+
});
|
|
281
298
|
test('corrupcion o journal ausente bloquea con categoria propia (corrupt-state)', () => {
|
|
282
299
|
const g = (0, gate_1.computeTrackGate)(null, true, () => 'same');
|
|
283
300
|
expect(g.pass).toBe(false);
|
|
@@ -18,6 +18,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
18
18
|
const fs_1 = __importDefault(require("fs"));
|
|
19
19
|
const os_1 = __importDefault(require("os"));
|
|
20
20
|
const path_1 = __importDefault(require("path"));
|
|
21
|
+
/** Un registry sincronizado con éxito. `awm update` con CERO registries es una máquina sin
|
|
22
|
+
* `awm init`: falla en vez de anunciar que actualizó (ver `tests/commands/update.test.ts`),
|
|
23
|
+
* así que los tests de acá — que miden targeting de agentes y el caveat de Windows, no
|
|
24
|
+
* registries — necesitan al menos uno para llegar a las etapas que sí les importan. */
|
|
25
|
+
const SYNCED_REGISTRY = { name: 'baseline', action: 'pulled', version: 'v1.0.0' };
|
|
21
26
|
describe('multi-agent targeting (add/remove/sync/update/doctor)', () => {
|
|
22
27
|
let tmpHome;
|
|
23
28
|
let originalHome;
|
|
@@ -95,7 +100,7 @@ describe('multi-agent targeting (add/remove/sync/update/doctor)', () => {
|
|
|
95
100
|
const { runUpdateCore } = require('../../src/commands/update');
|
|
96
101
|
let captured = [];
|
|
97
102
|
const outcome = await runUpdateCore({ agent: explicit }, {
|
|
98
|
-
syncRegistries: async () => [],
|
|
103
|
+
syncRegistries: async () => [SYNCED_REGISTRY],
|
|
99
104
|
verifyMinCliVersions: () => [],
|
|
100
105
|
regenerateGlobalContext: () => [],
|
|
101
106
|
planReconciliation: (o) => { captured = o.targets; return { operations: [], records: [], reports: [] }; },
|
|
@@ -174,7 +179,7 @@ describe('multi-agent targeting (add/remove/sync/update/doctor)', () => {
|
|
|
174
179
|
fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'hooks'), { recursive: true });
|
|
175
180
|
fs_1.default.writeFileSync(path_1.default.join(process.env.AWM_HOME, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.com/baseline.git' }], null, 2));
|
|
176
181
|
const { runUpdateCore } = require('../../src/commands/update');
|
|
177
|
-
const syncRegistries = jest.fn(async () => []);
|
|
182
|
+
const syncRegistries = jest.fn(async () => [SYNCED_REGISTRY]);
|
|
178
183
|
const planReconciliation = jest.fn((o) => ({ operations: [], records: [], reports: [] }));
|
|
179
184
|
const applyInstallPlan = jest.fn(() => ({ installed: [], skipped: [], transactionId: 'tx', modifiedFiles: [] }));
|
|
180
185
|
const resyncInstalledHooks = jest.fn(() => []);
|
|
@@ -198,7 +203,7 @@ describe('multi-agent targeting (add/remove/sync/update/doctor)', () => {
|
|
|
198
203
|
writePrefs(['claude-code'], 'claude-code');
|
|
199
204
|
const { runUpdateCore } = require('../../src/commands/update');
|
|
200
205
|
const outcome = await runUpdateCore({}, {
|
|
201
|
-
syncRegistries: async () => [],
|
|
206
|
+
syncRegistries: async () => [SYNCED_REGISTRY],
|
|
202
207
|
verifyMinCliVersions: () => [],
|
|
203
208
|
regenerateGlobalContext: () => [],
|
|
204
209
|
planReconciliation: () => { throw new Error('reconciliation exploded'); },
|
|
@@ -231,7 +236,7 @@ describe('multi-agent targeting (add/remove/sync/update/doctor)', () => {
|
|
|
231
236
|
writePrefs(['claude-code']);
|
|
232
237
|
const { runUpdateCore } = require('../../src/commands/update');
|
|
233
238
|
const deps = {
|
|
234
|
-
syncRegistries: async () => [],
|
|
239
|
+
syncRegistries: async () => [SYNCED_REGISTRY],
|
|
235
240
|
verifyMinCliVersions: () => [],
|
|
236
241
|
regenerateGlobalContext: () => [],
|
|
237
242
|
planReconciliation: () => ({ operations: [], records: [], reports: [] }),
|
|
@@ -93,6 +93,27 @@ describe('runSupervisorWrapper — claim exact-once + espera read-only del ACTIV
|
|
|
93
93
|
expect(second).toBe('already-claimed');
|
|
94
94
|
expect(secondLaunched).toBe(false);
|
|
95
95
|
});
|
|
96
|
+
// El wrapper esperaba la IGUALDAD EXACTA con `ACTIVE`, con un poll de 2 s en producción.
|
|
97
|
+
// Eso es una carrera: el plan puede cruzar `ACTIVE` entre dos lecturas y dejar al wrapper
|
|
98
|
+
// esperando para siempre una fase que ya pasó — sin `awm watch`, nadie consume las
|
|
99
|
+
// requests cross-journal del track (empezando por `track-freeze-request`) y la cohorte se
|
|
100
|
+
// traba en el freeze. Se observó al reparar el join: un track podía ir
|
|
101
|
+
// `ARMED -> ACTIVE -> JOIN_REQUESTED` dentro de un mismo `reconcileTracks`.
|
|
102
|
+
test.each(['ACTIVE', 'JOIN_REQUESTED', 'FROZEN'])('lanza watch con el track ya en %s: la condición es monótona, no la igualdad con ACTIVE', async (phase) => {
|
|
103
|
+
seedTrackRef(phase);
|
|
104
|
+
let launched = null;
|
|
105
|
+
const outcome = await (0, supervisor_wrapper_1.runSupervisorWrapper)({
|
|
106
|
+
worktreePath, trackId: 'cli', nonce: NONCE, readinessNonce: 'nonce-cli'.padEnd(32, '0'), fencingToken: 'fence',
|
|
107
|
+
planRoot, planBranch: 'main', pollMs: 5,
|
|
108
|
+
launchWatch: (wt) => {
|
|
109
|
+
launched = wt;
|
|
110
|
+
fs_1.default.mkdirSync(path_1.default.dirname((0, paths_1.supervisorLockPath)(wt)), { recursive: true });
|
|
111
|
+
fs_1.default.writeFileSync((0, paths_1.supervisorLockPath)(wt), '{}');
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
expect(outcome).toBe('active');
|
|
115
|
+
expect(launched).toBe(worktreePath);
|
|
116
|
+
});
|
|
96
117
|
test('si el plan bloquea el track, el wrapper jamás lanza `awm watch` (C1, R4.9)', async () => {
|
|
97
118
|
seedTrackRef('BLOCKED');
|
|
98
119
|
let launched = false;
|
|
@@ -31,6 +31,11 @@ function git(repo, args) {
|
|
|
31
31
|
function gitInit(repo, branch) {
|
|
32
32
|
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'init', '-q', '-b', branch], { cwd: repo });
|
|
33
33
|
fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'x');
|
|
34
|
+
// `.awm/` gitignoreado, igual que lo deja `awm watch --init` (watch/init.ts) y que
|
|
35
|
+
// `runPrepareTrack` EXIGE en cada worktree nuevo: sin esto el journal propio hace que
|
|
36
|
+
// `git status` reporte el árbol sucio para siempre, y cualquier test que dependa de un
|
|
37
|
+
// árbol limpio mediría un repo que en producción no existe.
|
|
38
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, '.gitignore'), '.awm/\n');
|
|
34
39
|
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'add', '.'], { cwd: repo });
|
|
35
40
|
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'c'], { cwd: repo });
|
|
36
41
|
}
|
|
@@ -98,6 +103,48 @@ describe('awm track — verbos mutantes son request-only (R6.1)', () => {
|
|
|
98
103
|
expect(git(repo, ['rev-parse', 'HEAD'])).toBe(beforeHead);
|
|
99
104
|
expect(fs_1.default.readFileSync((0, paths_1.statePath)(repo, branch), 'utf8')).toBe(beforeState);
|
|
100
105
|
});
|
|
106
|
+
// `finalize` es PLAN-scoped y sin <trackId>, así que no entra en las matrices de
|
|
107
|
+
// add/join/remove — pero comparte con ellas la propiedad que define a la superficie:
|
|
108
|
+
// emite una request y no toca Git ni state.json.
|
|
109
|
+
test('finalize emite track-finalize-request con el HEAD del plan, y no muta Git/state (R7.2)', async () => {
|
|
110
|
+
const beforeHead = git(repo, ['rev-parse', 'HEAD']);
|
|
111
|
+
const beforeState = fs_1.default.readFileSync((0, paths_1.statePath)(repo, branch), 'utf8');
|
|
112
|
+
const out = await runCli(repo, ['track', 'finalize', '--generation', 'g1']);
|
|
113
|
+
expect(out.exitCode).toBe(0);
|
|
114
|
+
expect(readPendingKinds(repo, branch)).toContain('track-finalize-request');
|
|
115
|
+
const pending = (0, requests_1.listPendingRequests)(repo, branch).find((p) => !p.corrupt && p.envelope.kind === 'track-finalize-request');
|
|
116
|
+
// El HEAD se LEE del repo: es el autoreporte del controller sobre SU propio HEAD,
|
|
117
|
+
// no un valor que el llamador pueda elegir.
|
|
118
|
+
expect((pending?.envelope.payload).qaHeadSha).toBe(beforeHead);
|
|
119
|
+
expect(git(repo, ['rev-parse', 'HEAD'])).toBe(beforeHead);
|
|
120
|
+
expect(fs_1.default.readFileSync((0, paths_1.statePath)(repo, branch), 'utf8')).toBe(beforeState);
|
|
121
|
+
});
|
|
122
|
+
test('finalize exige --generation', async () => {
|
|
123
|
+
const out = await runCli(repo, ['track', 'finalize']);
|
|
124
|
+
expect(out.exitCode).not.toBe(0);
|
|
125
|
+
expect(readPendingKinds(repo, branch)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
test('finalize con el árbol sucio falla nombrando lo que falta, en vez de emitir un autoreporte que el supervisor va a rechazar en silencio', async () => {
|
|
128
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'pendiente.txt'), 'sin comitear');
|
|
129
|
+
const out = await runCli(repo, ['track', 'finalize', '--generation', 'g1']);
|
|
130
|
+
expect(out.exitCode).not.toBe(0);
|
|
131
|
+
expect(out.err).toMatch(/sin comitear/);
|
|
132
|
+
expect(readPendingKinds(repo, branch)).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
test('re-reportar el MISMO HEAD colapsa por idempotencyKey; un HEAD nuevo es una request nueva', async () => {
|
|
135
|
+
await runCli(repo, ['track', 'finalize', '--generation', 'g1']);
|
|
136
|
+
const first = (0, requests_1.listPendingRequests)(repo, branch).filter((p) => !p.corrupt).map((p) => p.envelope.idempotencyKey);
|
|
137
|
+
await runCli(repo, ['track', 'finalize', '--generation', 'g1']);
|
|
138
|
+
const second = (0, requests_1.listPendingRequests)(repo, branch).filter((p) => !p.corrupt).map((p) => p.envelope.idempotencyKey);
|
|
139
|
+
expect(new Set(second)).toEqual(new Set(first)); // mismo HEAD => misma key
|
|
140
|
+
// La QA encontró algo más, se corrigió y se comiteó: HEAD nuevo, request nueva.
|
|
141
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'fix.txt'), 'correccion de QA');
|
|
142
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'add', '.'], { cwd: repo });
|
|
143
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fix'], { cwd: repo });
|
|
144
|
+
await runCli(repo, ['track', 'finalize', '--generation', 'g1']);
|
|
145
|
+
const third = new Set((0, requests_1.listPendingRequests)(repo, branch).filter((p) => !p.corrupt).map((p) => p.envelope.idempotencyKey));
|
|
146
|
+
expect(third.size).toBe(new Set(first).size + 1);
|
|
147
|
+
});
|
|
101
148
|
test.each(['add', 'join', 'remove'])('%s exige --generation (Commander valida antes de emitir)', async (verb) => {
|
|
102
149
|
const out = await runCli(repo, ['track', verb, 'cli']);
|
|
103
150
|
expect(out.exitCode).not.toBe(0);
|
|
@@ -316,10 +363,10 @@ describe('awm track status — sale 1 con gate rojo a nivel CLI (Gap: solo se pr
|
|
|
316
363
|
expect(parsed.tracks.cli.gate.reasons.some((r) => r.category === 'pending-task')).toBe(true);
|
|
317
364
|
});
|
|
318
365
|
});
|
|
319
|
-
describe('awm track --help lista exactamente los
|
|
320
|
-
test('help incluye add, list, status, verify-independence, join, remove', async () => {
|
|
366
|
+
describe('awm track --help lista exactamente los 7 verbos', () => {
|
|
367
|
+
test('help incluye add, list, status, verify-independence, join, remove, finalize', async () => {
|
|
321
368
|
const out = await runCli(process.cwd(), ['track', '--help']);
|
|
322
|
-
for (const verb of ['add', 'list', 'status', 'verify-independence', 'join', 'remove']) {
|
|
369
|
+
for (const verb of ['add', 'list', 'status', 'verify-independence', 'join', 'remove', 'finalize']) {
|
|
323
370
|
expect(out.out).toContain(verb);
|
|
324
371
|
}
|
|
325
372
|
});
|
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
// `awm update` no debe anunciar trabajo que no hizo, ni colgarse esperando a un humano
|
|
7
|
+
// que no está.
|
|
8
|
+
//
|
|
9
|
+
// Los dos defectos que estos tests dejan cerrados se reprodujeron en un HOME aislado
|
|
10
|
+
// contra el binario publicado (v6.3.0):
|
|
11
|
+
//
|
|
12
|
+
// 1. Sobre un AWM_HOME recién creado, SIN NINGÚN registry configurado, el comando
|
|
13
|
+
// imprimía "✅ Registries, skills and hooks updated." y salía 0. No había registries,
|
|
14
|
+
// no había skills y no había hooks: cada etapa "pasaba" porque no tenía nada que
|
|
15
|
+
// hacer, y `runUpdateCore` devolvía `{ code: 0 }` incondicionalmente al final. El
|
|
16
|
+
// literal del outro en `index.ts` solo podía decir "actualizado".
|
|
17
|
+
// 2. Con un TTY en stdin, el comando quedaba colgado para siempre en el confirm de
|
|
18
|
+
// self-update ("Update awm v6.2.1 → v6.3.0 now?"). Con `< /dev/null` terminaba bien
|
|
19
|
+
// — es decir, el cuelgue aparecía exactamente cuando había un humano, y desaparecía
|
|
20
|
+
// cuando no lo había, que es al revés de lo que hace falta: en CI, cron o una sesión
|
|
21
|
+
// agéntica no hay nadie para contestar.
|
|
22
|
+
//
|
|
23
|
+
// Ambos se testean por el VALOR DE RETORNO y por el texto derivado, no scrapeando consola:
|
|
24
|
+
// el defecto vivía justamente en que el retorno no llevaba la información que el mensaje
|
|
25
|
+
// necesitaba.
|
|
26
|
+
const fs_1 = __importDefault(require("fs"));
|
|
27
|
+
const os_1 = __importDefault(require("os"));
|
|
28
|
+
const path_1 = __importDefault(require("path"));
|
|
29
|
+
// `@clack/prompts` es ESM-only y el transform CommonJS de ts-jest no puede cargarlo; se
|
|
30
|
+
// mockea para poder `require` `core/update-check` desde acá (mismo patrón que
|
|
31
|
+
// `tests/core/update-check.test.ts`). Los tests inyectan su propio `confirmImpl`, así que
|
|
32
|
+
// este doble nunca llega a usarse: solo evita que el import de nivel de módulo explote.
|
|
33
|
+
jest.mock('@clack/prompts', () => ({
|
|
34
|
+
confirm: jest.fn(),
|
|
35
|
+
isCancel: jest.fn(),
|
|
36
|
+
}));
|
|
37
|
+
/** Preferencias mínimas válidas en el AWM_HOME activo (`normalizePreferences` exige los
|
|
38
|
+
* cuatro campos). Se llama despues de fijar HOME/AWM_HOME, nunca antes. */
|
|
39
|
+
function writePrefs() {
|
|
40
|
+
const { savePreferences } = require('../../src/utils/config');
|
|
41
|
+
savePreferences({
|
|
42
|
+
defaultAgent: 'claude-code',
|
|
43
|
+
enabledAgents: ['claude-code'],
|
|
44
|
+
installMethod: 'symlink',
|
|
45
|
+
defaultScope: 'local',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
describe('awm update — honest outcome', () => {
|
|
49
|
+
const realHome = process.env.HOME;
|
|
50
|
+
const realAwmHome = process.env.AWM_HOME;
|
|
51
|
+
let tmpHome;
|
|
52
|
+
let logSpy;
|
|
53
|
+
let warnSpy;
|
|
54
|
+
let errSpy;
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
// Ningún test toca el `~/.awm` real (CLAUDE.md): HOME y AWM_HOME apuntan al tmpdir.
|
|
57
|
+
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-update-'));
|
|
58
|
+
process.env.HOME = tmpHome;
|
|
59
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
60
|
+
fs_1.default.mkdirSync(process.env.AWM_HOME, { recursive: true });
|
|
61
|
+
writePrefs();
|
|
62
|
+
logSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
|
|
63
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
|
64
|
+
errSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
|
|
65
|
+
jest.resetModules();
|
|
66
|
+
});
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
logSpy.mockRestore();
|
|
69
|
+
warnSpy.mockRestore();
|
|
70
|
+
errSpy.mockRestore();
|
|
71
|
+
if (realHome === undefined)
|
|
72
|
+
delete process.env.HOME;
|
|
73
|
+
else
|
|
74
|
+
process.env.HOME = realHome;
|
|
75
|
+
if (realAwmHome === undefined)
|
|
76
|
+
delete process.env.AWM_HOME;
|
|
77
|
+
else
|
|
78
|
+
process.env.AWM_HOME = realAwmHome;
|
|
79
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
80
|
+
});
|
|
81
|
+
function deps(overrides = {}) {
|
|
82
|
+
return {
|
|
83
|
+
syncRegistries: async () => [{ name: 'baseline', action: 'pulled', version: 'v1.15.1' }],
|
|
84
|
+
verifyMinCliVersions: () => [],
|
|
85
|
+
regenerateGlobalContext: () => [],
|
|
86
|
+
planReconciliation: () => ({ operations: [], records: [], reports: [] }),
|
|
87
|
+
applyInstallPlan: () => ({ installed: [], skipped: [], transactionId: 'tx', modifiedFiles: [] }),
|
|
88
|
+
resyncInstalledHooks: () => [],
|
|
89
|
+
offerSelfUpdate: async () => { },
|
|
90
|
+
...overrides,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
it('sin registries configurados NO declara éxito: sale != 0 y manda a `awm init`', async () => {
|
|
94
|
+
const { runUpdateCore, updateOutro } = require('../../src/commands/update');
|
|
95
|
+
const result = await runUpdateCore({}, deps({ syncRegistries: async () => [] }));
|
|
96
|
+
expect(result.code).not.toBe(0);
|
|
97
|
+
expect(result.registries).toEqual({ configured: 0, synced: 0, failed: [] });
|
|
98
|
+
// El cierre nunca puede afirmar que se actualizó algo.
|
|
99
|
+
expect(updateOutro(result)).toBe('Nothing updated — no registries configured on this machine.');
|
|
100
|
+
expect(updateOutro(result)).not.toMatch(/updated\./i);
|
|
101
|
+
expect(errSpy.mock.calls.flat().join('\n')).toMatch(/awm init/);
|
|
102
|
+
});
|
|
103
|
+
it('sin registries no sigue con las etapas posteriores (que "pasarían" por vacías)', async () => {
|
|
104
|
+
const { runUpdateCore } = require('../../src/commands/update');
|
|
105
|
+
const planReconciliation = jest.fn(() => ({ operations: [], records: [], reports: [] }));
|
|
106
|
+
const resyncInstalledHooks = jest.fn(() => []);
|
|
107
|
+
const offerSelfUpdate = jest.fn(async () => { });
|
|
108
|
+
await runUpdateCore({}, deps({
|
|
109
|
+
syncRegistries: async () => [],
|
|
110
|
+
planReconciliation, resyncInstalledHooks, offerSelfUpdate,
|
|
111
|
+
}));
|
|
112
|
+
expect(planReconciliation).not.toHaveBeenCalled();
|
|
113
|
+
expect(resyncInstalledHooks).not.toHaveBeenCalled();
|
|
114
|
+
expect(offerSelfUpdate).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
it('con registries sincronizados sí declara éxito, y dice cuántos', async () => {
|
|
117
|
+
const { runUpdateCore, updateOutro } = require('../../src/commands/update');
|
|
118
|
+
const result = await runUpdateCore({}, deps({
|
|
119
|
+
syncRegistries: async () => [
|
|
120
|
+
{ name: 'baseline', action: 'pulled', version: 'v1.15.1' },
|
|
121
|
+
{ name: 'docs', action: 'recloned', version: 'v2.0.0' },
|
|
122
|
+
],
|
|
123
|
+
}));
|
|
124
|
+
expect(result.code).toBe(0);
|
|
125
|
+
expect(result.registries).toEqual({ configured: 2, synced: 2, failed: [] });
|
|
126
|
+
expect(updateOutro(result)).toBe('✅ 2 registries, skills and hooks updated.');
|
|
127
|
+
});
|
|
128
|
+
it('un registry que falló y NO dejó contenido en disco falla cerrado', async () => {
|
|
129
|
+
const { runUpdateCore } = require('../../src/commands/update');
|
|
130
|
+
const result = await runUpdateCore({}, deps({
|
|
131
|
+
syncRegistries: async () => [{ name: 'baseline', action: 'error', error: 'network unreachable' }],
|
|
132
|
+
}));
|
|
133
|
+
expect(result.code).toBe(1);
|
|
134
|
+
expect(result.registries.failed).toEqual(['baseline']);
|
|
135
|
+
});
|
|
136
|
+
it('un registry que falló pero conserva contenido sigue, y el cierre lo nombra en vez de anunciar éxito parejo', async () => {
|
|
137
|
+
// `unusableSyncedRegistries` mira contenido EN DISCO: se siembra el content root
|
|
138
|
+
// y el registries.json para que `docs` cuente como stale, no como roto.
|
|
139
|
+
const registriesDir = path_1.default.join(process.env.AWM_HOME, 'registries');
|
|
140
|
+
fs_1.default.mkdirSync(path_1.default.join(registriesDir, 'docs'), { recursive: true });
|
|
141
|
+
fs_1.default.writeFileSync(path_1.default.join(process.env.AWM_HOME, 'registries.json'), JSON.stringify([{ name: 'docs', remote: 'https://example.test/docs.git' }]));
|
|
142
|
+
const { runUpdateCore, updateOutro } = require('../../src/commands/update');
|
|
143
|
+
const result = await runUpdateCore({}, deps({
|
|
144
|
+
syncRegistries: async () => [
|
|
145
|
+
{ name: 'baseline', action: 'pulled', version: 'v1.15.1' },
|
|
146
|
+
{ name: 'docs', action: 'error', error: 'network unreachable' },
|
|
147
|
+
],
|
|
148
|
+
}));
|
|
149
|
+
expect(result.code).toBe(0);
|
|
150
|
+
expect(result.registries).toEqual({ configured: 2, synced: 1, failed: ['docs'] });
|
|
151
|
+
const outro = updateOutro(result);
|
|
152
|
+
expect(outro).toMatch(/stale/i);
|
|
153
|
+
expect(outro).toMatch(/docs/);
|
|
154
|
+
expect(outro).not.toMatch(/^✅/);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
describe('awm update — nunca se cuelga esperando a un humano que no está', () => {
|
|
158
|
+
const realHome = process.env.HOME;
|
|
159
|
+
const realAwmHome = process.env.AWM_HOME;
|
|
160
|
+
let tmpHome;
|
|
161
|
+
let logSpy;
|
|
162
|
+
beforeEach(() => {
|
|
163
|
+
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-update-tty-'));
|
|
164
|
+
process.env.HOME = tmpHome;
|
|
165
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
166
|
+
fs_1.default.mkdirSync(process.env.AWM_HOME, { recursive: true });
|
|
167
|
+
writePrefs();
|
|
168
|
+
logSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
|
|
169
|
+
jest.resetModules();
|
|
170
|
+
});
|
|
171
|
+
afterEach(() => {
|
|
172
|
+
logSpy.mockRestore();
|
|
173
|
+
if (realHome === undefined)
|
|
174
|
+
delete process.env.HOME;
|
|
175
|
+
else
|
|
176
|
+
process.env.HOME = realHome;
|
|
177
|
+
if (realAwmHome === undefined)
|
|
178
|
+
delete process.env.AWM_HOME;
|
|
179
|
+
else
|
|
180
|
+
process.env.AWM_HOME = realAwmHome;
|
|
181
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
182
|
+
});
|
|
183
|
+
function updateDeps(offerSelfUpdate) {
|
|
184
|
+
return {
|
|
185
|
+
syncRegistries: async () => [{ name: 'baseline', action: 'pulled', version: 'v1.15.1' }],
|
|
186
|
+
verifyMinCliVersions: () => [],
|
|
187
|
+
regenerateGlobalContext: () => [],
|
|
188
|
+
planReconciliation: () => ({ operations: [], records: [], reports: [] }),
|
|
189
|
+
applyInstallPlan: () => ({ installed: [], skipped: [], transactionId: 'tx', modifiedFiles: [] }),
|
|
190
|
+
resyncInstalledHooks: () => [],
|
|
191
|
+
offerSelfUpdate,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
it('`--yes` pide assume-yes; sin flag delega la decisión al modo por defecto', async () => {
|
|
195
|
+
const { runUpdateCore } = require('../../src/commands/update');
|
|
196
|
+
const withFlag = jest.fn(async () => { });
|
|
197
|
+
await runUpdateCore({ yes: true }, updateDeps(withFlag));
|
|
198
|
+
expect(withFlag).toHaveBeenCalledWith('assume-yes');
|
|
199
|
+
const withoutFlag = jest.fn(async () => { });
|
|
200
|
+
await runUpdateCore({}, updateDeps(withoutFlag));
|
|
201
|
+
// `undefined`, no `'prompt'`: quién decide es `defaultSelfUpdateMode()`, según
|
|
202
|
+
// haya o no alguien en stdin. Fijar 'prompt' acá reintroduciría el cuelgue.
|
|
203
|
+
expect(withoutFlag).toHaveBeenCalledWith(undefined);
|
|
204
|
+
});
|
|
205
|
+
describe('defaultSelfUpdateMode / offerSelfUpdate', () => {
|
|
206
|
+
const realIsTTY = process.stdin.isTTY;
|
|
207
|
+
const realNoCheck = process.env.AWM_NO_UPDATE_CHECK;
|
|
208
|
+
beforeEach(() => { delete process.env.AWM_NO_UPDATE_CHECK; });
|
|
209
|
+
afterEach(() => {
|
|
210
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: realIsTTY, configurable: true });
|
|
211
|
+
if (realNoCheck === undefined)
|
|
212
|
+
delete process.env.AWM_NO_UPDATE_CHECK;
|
|
213
|
+
else
|
|
214
|
+
process.env.AWM_NO_UPDATE_CHECK = realNoCheck;
|
|
215
|
+
});
|
|
216
|
+
it('sin TTY el modo por defecto es skip; con TTY es prompt', () => {
|
|
217
|
+
const { defaultSelfUpdateMode } = require('../../src/core/update-check');
|
|
218
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true });
|
|
219
|
+
expect(defaultSelfUpdateMode()).toBe('skip');
|
|
220
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
221
|
+
expect(defaultSelfUpdateMode()).toBe('prompt');
|
|
222
|
+
});
|
|
223
|
+
it('sin TTY no pregunta ni actualiza: avisa y sigue', async () => {
|
|
224
|
+
const { offerSelfUpdate } = require('../../src/core/update-check');
|
|
225
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true });
|
|
226
|
+
const confirmImpl = jest.fn(async () => true);
|
|
227
|
+
const runner = jest.fn(() => ({ status: 0 }));
|
|
228
|
+
await offerSelfUpdate({ current: '6.2.1', latest: '6.3.0', confirmImpl, runner });
|
|
229
|
+
expect(confirmImpl).not.toHaveBeenCalled();
|
|
230
|
+
// El silencio no autoriza reemplazar el binario global de la máquina.
|
|
231
|
+
expect(runner).not.toHaveBeenCalled();
|
|
232
|
+
expect(logSpy.mock.calls.flat().join('\n')).toMatch(/npm i -g/);
|
|
233
|
+
});
|
|
234
|
+
it("modo 'assume-yes' actualiza sin preguntar", async () => {
|
|
235
|
+
const { offerSelfUpdate } = require('../../src/core/update-check');
|
|
236
|
+
const confirmImpl = jest.fn(async () => true);
|
|
237
|
+
const runner = jest.fn(() => ({ status: 0 }));
|
|
238
|
+
await offerSelfUpdate({ current: '6.2.1', latest: '6.3.0', mode: 'assume-yes', confirmImpl, runner });
|
|
239
|
+
expect(confirmImpl).not.toHaveBeenCalled();
|
|
240
|
+
expect(runner).toHaveBeenCalledWith('npm', ['i', '-g', expect.stringContaining('@latest')]);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -478,6 +478,56 @@ describe('aplicacion transaccional de requests', () => {
|
|
|
478
478
|
expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
|
|
479
479
|
expect((0, store_1.readJournal)(repo, 'rama').state.trackIntegration).toBeUndefined();
|
|
480
480
|
});
|
|
481
|
+
// El modo de falla que estos dos tests cierran: `applyRequestToState` era una cadena de
|
|
482
|
+
// `if (env.kind === …) { …; return; }` que se caía POR EL FINAL ante un kind sin handler.
|
|
483
|
+
// El caller no veía excepción, hacía `applied++` y BORRABA el archivo. `awm track join`
|
|
484
|
+
// vivió así: salía 0 con su requestId, el journal contaba la request como aplicada, y el
|
|
485
|
+
// track se quedaba en `ACTIVE` para siempre — la cohorte no podía congelarse (C3) y por
|
|
486
|
+
// lo tanto nunca llegaba a `COMPLETE`.
|
|
487
|
+
test('track-join-request marca la intención de join sobre un track existente (declarativo, R6.1)', () => {
|
|
488
|
+
const s0 = (0, store_1.readJournal)(repo, 'rama').state;
|
|
489
|
+
const alpha = {
|
|
490
|
+
trackId: 'alpha', worktreePath: path_1.default.join(repo, '.track-alpha'), branch: 'awm-track/alpha',
|
|
491
|
+
ownership: [], sharedResources: [], dependsOn: [],
|
|
492
|
+
fencingToken: 'f'.repeat(32), phase: 'ACTIVE', readinessNonce: 'n'.repeat(32),
|
|
493
|
+
};
|
|
494
|
+
s0.tracks = [alpha];
|
|
495
|
+
(0, store_1.writeJournal)(repo, 'rama', s0);
|
|
496
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
497
|
+
kind: 'track-join-request', generationToken: 'g1', idempotencyKey: 'join-alpha',
|
|
498
|
+
payload: { trackId: 'alpha' },
|
|
499
|
+
});
|
|
500
|
+
expect((0, apply_1.consumePendingRequests)(repo, 'rama', 'g1').applied).toBe(1);
|
|
501
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
502
|
+
expect(s.tracks.find((t) => t.trackId === 'alpha').joinRequested).toBe(true);
|
|
503
|
+
// La FASE no la mueve este consumo: eso es del reducer puro vía `reconcileTracks`.
|
|
504
|
+
expect(s.tracks.find((t) => t.trackId === 'alpha').phase).toBe('ACTIVE');
|
|
505
|
+
});
|
|
506
|
+
test('un join sobre un track inexistente se RECHAZA en vez de absorberse en silencio', () => {
|
|
507
|
+
const r = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
508
|
+
kind: 'track-join-request', generationToken: 'g1', idempotencyKey: 'join-fantasma',
|
|
509
|
+
payload: { trackId: 'no-existe' },
|
|
510
|
+
});
|
|
511
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
512
|
+
expect(out.applied).toBe(0);
|
|
513
|
+
expect(out.rejectedInvalid).toBe(1);
|
|
514
|
+
expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
|
|
515
|
+
});
|
|
516
|
+
test('un kind SIN handler falla cerrado: rechazo visible, no un `applied` que borra el archivo', () => {
|
|
517
|
+
// `track-teardown-request` es un kind real, emitido por `awm track remove`, que hoy
|
|
518
|
+
// no tiene handler en el supervisor. Antes de este cambio se contaba como aplicado y
|
|
519
|
+
// desaparecía sin dejar rastro; ahora queda registrado como problema y el archivo se
|
|
520
|
+
// conserva con sufijo `.rejected`, que es lo que permite darse cuenta.
|
|
521
|
+
const r = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
522
|
+
kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: 'teardown-1',
|
|
523
|
+
payload: { trackId: 'alpha' },
|
|
524
|
+
});
|
|
525
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
526
|
+
expect(out.applied).toBe(0);
|
|
527
|
+
expect(out.rejectedInvalid).toBe(1);
|
|
528
|
+
expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
|
|
529
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.requestProblems.some((p) => p.kind === 'rejected')).toBe(true);
|
|
530
|
+
});
|
|
481
531
|
test('track-finalize-request persiste el autoreporte del controller (qaFinalizeRequested) — puramente declarativo (R7.2)', () => {
|
|
482
532
|
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
483
533
|
kind: 'track-finalize-request', generationToken: 'g1', idempotencyKey: 'finalize-1',
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
// El camino `awm track join` -> fase `JOIN_REQUESTED`, que NO EXISTÍA.
|
|
7
|
+
//
|
|
8
|
+
// `applyRequestToState` era una cadena de `if (env.kind === …) { …; return; }` sin rama para
|
|
9
|
+
// `track-join-request`: se caía por el final sin lanzar, el caller no veía excepción, hacía
|
|
10
|
+
// `applied++` y BORRABA el archivo. El comando salía 0 con su requestId, el journal contaba
|
|
11
|
+
// la request como aplicada, y el track se quedaba en `ACTIVE` para siempre — como C3 exige la
|
|
12
|
+
// cohorte entera congelada antes de mergear, jamás llegaba a `MERGED_UNVERIFIED` ni a
|
|
13
|
+
// `COMPLETE`. Confirmación cruzada en su momento: `join-requested` era la ÚNICA observación
|
|
14
|
+
// del protocolo con cero productores en todo `src/`.
|
|
15
|
+
//
|
|
16
|
+
// Estos tests cubren las tres propiedades del camino reparado:
|
|
17
|
+
// 1. un join sobre un track `ACTIVE` mueve la fase (vía el reducer, no acá);
|
|
18
|
+
// 2. un join que llega ANTES de la activación ESPERA en vez de perderse;
|
|
19
|
+
// 3. un join que ya no puede aplicarse nunca se limpia, sin girar en cada tick.
|
|
20
|
+
//
|
|
21
|
+
// (2) es un bug encontrado en la certificación con supervisor vivo, sobre la PRIMERA versión
|
|
22
|
+
// de este fix: con `maxParallel` chico la cohorte activa de a un track por vez, así que el
|
|
23
|
+
// controller termina su trabajo y pide el join mientras su track sigue en `ARMED`. Descartar
|
|
24
|
+
// el pedido ahí reintroduce exactamente el modo de falla que este cambio vino a cerrar — un
|
|
25
|
+
// pedido que reporta éxito y no hace nada — y solo pasaba desapercibido porque el controller
|
|
26
|
+
// scripteado re-emite el join en cada vuelta.
|
|
27
|
+
const fs_1 = __importDefault(require("fs"));
|
|
28
|
+
const path_1 = __importDefault(require("path"));
|
|
29
|
+
const git_fixture_1 = require("../../helpers/git-fixture");
|
|
30
|
+
const tracks_1 = require("../../../src/commands/watch/tracks");
|
|
31
|
+
const apply_1 = require("../../../src/commands/watch/apply");
|
|
32
|
+
const emit_1 = require("../../../src/commands/track/emit");
|
|
33
|
+
const store_1 = require("../../../src/core/journal/store");
|
|
34
|
+
const paths_1 = require("../../../src/core/journal/paths");
|
|
35
|
+
const BRANCH = 'main';
|
|
36
|
+
describe('awm track join -> JOIN_REQUESTED (R6.1)', () => {
|
|
37
|
+
let planRoot;
|
|
38
|
+
let baseSha;
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
planRoot = (0, git_fixture_1.initRepo)();
|
|
41
|
+
(0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
|
|
42
|
+
baseSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
|
|
43
|
+
(0, store_1.initJournal)(planRoot, BRANCH);
|
|
44
|
+
});
|
|
45
|
+
afterEach(() => { fs_1.default.rmSync(planRoot, { recursive: true, force: true }); });
|
|
46
|
+
function declareCohort(phaseA, phaseB = 'ACTIVE') {
|
|
47
|
+
const s = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
48
|
+
s.cohortPhase = 'ACTIVE';
|
|
49
|
+
s.cohortBaseSha = baseSha;
|
|
50
|
+
// `assertProtocolInvariants` exige `frozenHeadSha` en todo track congelado o
|
|
51
|
+
// posterior: la fixture lo provee en vez de bajarle el estándar al invariante.
|
|
52
|
+
const frozen = ['FROZEN', 'JOIN_INTENT', 'MERGED_UNVERIFIED', 'JOINED'];
|
|
53
|
+
s.tracks = [
|
|
54
|
+
{
|
|
55
|
+
trackId: 'a', worktreePath: path_1.default.join(planRoot, '../nope-a'), branch: 'awm-track/a',
|
|
56
|
+
ownership: [], sharedResources: [], dependsOn: [],
|
|
57
|
+
fencingToken: 'fence-a'.padEnd(32, '0'), phase: phaseA, readinessNonce: 'ready-a'.padEnd(32, '0'),
|
|
58
|
+
frozenHeadSha: frozen.includes(phaseA) ? baseSha : undefined,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
trackId: 'b', worktreePath: path_1.default.join(planRoot, '../nope-b'), branch: 'awm-track/b',
|
|
62
|
+
ownership: [], sharedResources: [], dependsOn: [],
|
|
63
|
+
fencingToken: 'fence-b'.padEnd(32, '0'), phase: phaseB, readinessNonce: 'ready-b'.padEnd(32, '0'),
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, s);
|
|
67
|
+
return (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
68
|
+
}
|
|
69
|
+
/** Runtime que explota ante cualquier efecto real: estos tests miden SOLO la producción
|
|
70
|
+
* de la observación de join, nunca un freeze/merge de verdad. */
|
|
71
|
+
function inertRuntime() {
|
|
72
|
+
const real = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
|
|
73
|
+
return {
|
|
74
|
+
...real,
|
|
75
|
+
addWorktree: () => { throw new Error('no debería llamarse'); },
|
|
76
|
+
initTrackJournal: () => { throw new Error('no debería llamarse'); },
|
|
77
|
+
spawnSupervisor: () => { throw new Error('no debería llamarse'); },
|
|
78
|
+
observeSupervisor: () => ({ kind: 'absent' }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function events() {
|
|
82
|
+
let raw = '';
|
|
83
|
+
try {
|
|
84
|
+
raw = fs_1.default.readFileSync((0, paths_1.eventsPath)(planRoot, BRANCH), 'utf8');
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
return raw.split('\n').filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
|
|
90
|
+
}
|
|
91
|
+
function phaseOf(s, trackId) {
|
|
92
|
+
return s.tracks?.find((t) => t.trackId === trackId)?.phase;
|
|
93
|
+
}
|
|
94
|
+
function joinFlagOf(s, trackId) {
|
|
95
|
+
return s.tracks?.find((t) => t.trackId === trackId)?.joinRequested;
|
|
96
|
+
}
|
|
97
|
+
it('el join de un track ACTIVE llega hasta la fase, pasando por la request real del CLI', async () => {
|
|
98
|
+
declareCohort('ACTIVE');
|
|
99
|
+
// La request se emite con el MISMO emisor que usa `awm track join`, no a mano: lo que
|
|
100
|
+
// estaba roto era justamente el tramo entre ese emisor y el estado.
|
|
101
|
+
(0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g1', 'track-join-request', 'a');
|
|
102
|
+
expect((0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g1').applied).toBe(1);
|
|
103
|
+
const s1 = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
104
|
+
expect(joinFlagOf(s1, 'a')).toBe(true);
|
|
105
|
+
expect(phaseOf(s1, 'a')).toBe('ACTIVE'); // el consumo transaccional NO mueve fases
|
|
106
|
+
const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s1, inertRuntime(), 2);
|
|
107
|
+
expect(phaseOf(out.state, 'a')).toBe('JOIN_REQUESTED');
|
|
108
|
+
expect(joinFlagOf(out.state, 'a')).toBeUndefined();
|
|
109
|
+
expect(events().some((e) => e.kind === 'track-join-observed' && e.trackId === 'a')).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
it('un join que llega ANTES de la activación espera, y se aplica cuando el track activa', async () => {
|
|
112
|
+
// Carrera real de la certificación: `maxParallel` 1 activa de a un track, así que el
|
|
113
|
+
// controller pide el join de `a` mientras `a` sigue en ARMED.
|
|
114
|
+
declareCohort('ARMED', 'ACTIVE');
|
|
115
|
+
(0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g1', 'track-join-request', 'a');
|
|
116
|
+
(0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g1');
|
|
117
|
+
let s = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
118
|
+
s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, inertRuntime(), 1)).state;
|
|
119
|
+
// Sigue pendiente: no se descartó por haber llegado temprano.
|
|
120
|
+
expect(joinFlagOf(s, 'a')).toBe(true);
|
|
121
|
+
expect(phaseOf(s, 'a')).toBe('ARMED');
|
|
122
|
+
// El track activa (lo hace el protocolo cuando hay cupo) y recién ahí el join aplica.
|
|
123
|
+
const activated = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
124
|
+
for (const t of activated.tracks ?? [])
|
|
125
|
+
if (t.trackId === 'a')
|
|
126
|
+
t.phase = 'ACTIVE';
|
|
127
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, activated);
|
|
128
|
+
const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
|
|
129
|
+
expect(phaseOf(out.state, 'a')).toBe('JOIN_REQUESTED');
|
|
130
|
+
expect(joinFlagOf(out.state, 'a')).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
it('un join que ya no puede aplicarse nunca se limpia y no gira en cada tick', async () => {
|
|
133
|
+
// `a` ya está mergeado: un join re-emitido por un controller relevado es moot.
|
|
134
|
+
declareCohort('MERGED_UNVERIFIED', 'ACTIVE');
|
|
135
|
+
(0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g1', 'track-join-request', 'a');
|
|
136
|
+
(0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g1');
|
|
137
|
+
const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
|
|
138
|
+
expect(joinFlagOf(out.state, 'a')).toBeUndefined();
|
|
139
|
+
expect(phaseOf(out.state, 'a')).toBe('MERGED_UNVERIFIED');
|
|
140
|
+
expect(events().filter((e) => e.kind === 'track-join-moot')).toHaveLength(1);
|
|
141
|
+
// Un segundo pase no vuelve a emitir nada: la marca ya no está.
|
|
142
|
+
await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
|
|
143
|
+
expect(events().filter((e) => e.kind === 'track-join-moot')).toHaveLength(1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -91,7 +91,11 @@ describe('update-check', () => {
|
|
|
91
91
|
const m = require('../../src/core/update-check');
|
|
92
92
|
const runner = jest.fn().mockReturnValue({ status: 1 });
|
|
93
93
|
const warn = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
|
94
|
-
|
|
94
|
+
// `mode: 'prompt'` explícito: la premisa de este test es que HAY un humano
|
|
95
|
+
// confirmando. Bajo jest stdin no es un TTY, y el modo por defecto pasó a ser
|
|
96
|
+
// `skip` justamente para que `awm update` no se cuelgue donde no hay a quién
|
|
97
|
+
// preguntarle — sin declarar el modo, este test mediría el camino desatendido.
|
|
98
|
+
await m.offerSelfUpdate({ current: '2.0.0', latest: '2.1.0', mode: 'prompt', confirmImpl: async () => true, runner });
|
|
95
99
|
expect(runner).toHaveBeenCalled();
|
|
96
100
|
expect(warn.mock.calls.flat().join('\n')).toContain('npm i -g agentic-workflow-manager');
|
|
97
101
|
warn.mockRestore();
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
// Guard estructural — un sensor declarado cuyo archivo de configuración no existe no es un
|
|
7
|
+
// sensor: es un `fail` o un `inconclusive` permanente que entrena a ignorar el rojo.
|
|
8
|
+
//
|
|
9
|
+
// Pasó dos veces en este repo, y las dos veces sobrevivió meses:
|
|
10
|
+
// - `depcheck` apuntaba a `.dep-cruiser.awm.js`, que NUNCA existió en ninguna rama →
|
|
11
|
+
// `awm sensors run` daba `fail` permanente.
|
|
12
|
+
// - `security` apuntaba a `.semgrep.awm.yml`, que existía en el sensor-pack pero el
|
|
13
|
+
// `.gitignore` del repo (`*.awm.*`) impedía versionarlo → `inconclusive` permanente.
|
|
14
|
+
//
|
|
15
|
+
// La regla no enumera esos dos: deriva los configs del PROPIO `sensors.json`, así que un
|
|
16
|
+
// sensor agregado mañana la hereda sin que nadie se acuerde de este comentario.
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
const CLI_ROOT = path_1.default.resolve(__dirname, '..', '..');
|
|
20
|
+
const SENSORS = path_1.default.join(CLI_ROOT, '.awm', 'sensors.json');
|
|
21
|
+
/** Extrae los archivos de config que un comando de sensor referencia. Se buscan por la
|
|
22
|
+
* convención `*.awm.*` que usan todos los packs, no por una lista de nombres — un pack
|
|
23
|
+
* nuevo con otro linter queda cubierto igual. */
|
|
24
|
+
function configFilesIn(cmd) {
|
|
25
|
+
return cmd.split(/\s+/).filter((token) => /\.awm\./.test(token) && !token.startsWith('-'));
|
|
26
|
+
}
|
|
27
|
+
describe('configs de sensores: declarados <=> presentes', () => {
|
|
28
|
+
const manifest = fs_1.default.existsSync(SENSORS)
|
|
29
|
+
? JSON.parse(fs_1.default.readFileSync(SENSORS, 'utf8'))
|
|
30
|
+
: null;
|
|
31
|
+
it('el manifiesto de sensores existe y declara sensores', () => {
|
|
32
|
+
expect(manifest?.sensors).toBeDefined();
|
|
33
|
+
expect(Object.keys(manifest.sensors).length).toBeGreaterThan(0);
|
|
34
|
+
});
|
|
35
|
+
it('cada archivo de config que un sensor referencia existe en el repo', () => {
|
|
36
|
+
const missing = [];
|
|
37
|
+
for (const [name, sensor] of Object.entries(manifest?.sensors ?? {})) {
|
|
38
|
+
for (const cmd of [sensor.cmd, sensor.changedCmd].filter((c) => typeof c === 'string')) {
|
|
39
|
+
for (const file of configFilesIn(cmd)) {
|
|
40
|
+
if (!fs_1.default.existsSync(path_1.default.join(CLI_ROOT, file)))
|
|
41
|
+
missing.push(`${name} -> ${file}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// El mensaje nombra sensor y archivo: un rojo que no dice cuál de los dos falta
|
|
46
|
+
// termina resuelto borrando el sensor, que es la salida equivocada.
|
|
47
|
+
expect(missing.join('\n') || 'todos presentes').toBe('todos presentes');
|
|
48
|
+
});
|
|
49
|
+
});
|