agentic-workflow-manager 6.2.0 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/src/commands/job/gate.js +37 -13
- package/dist/src/commands/job/index.js +48 -8
- package/dist/src/commands/job/request.js +44 -6
- package/dist/src/commands/track/emit.js +26 -0
- package/dist/src/commands/track/index.js +194 -0
- package/dist/src/commands/track/status.js +63 -0
- package/dist/src/commands/track/supervisor-wrapper.js +89 -0
- package/dist/src/commands/watch/apply.js +201 -13
- package/dist/src/commands/watch/index.js +14 -0
- package/dist/src/commands/watch/runner.js +20 -1
- package/dist/src/commands/watch/supervisor.js +251 -27
- package/dist/src/commands/watch/teardown-driver.js +189 -0
- package/dist/src/commands/watch/tracks.js +1100 -0
- package/dist/src/core/diagnostics/provider-checks.js +12 -9
- package/dist/src/core/journal/adapter.js +46 -6
- package/dist/src/core/journal/paths.js +9 -0
- package/dist/src/core/journal/process.js +101 -1
- package/dist/src/core/journal/requests.js +5 -1
- package/dist/src/core/journal/store.js +34 -2
- package/dist/src/core/journal/types.js +93 -4
- package/dist/src/core/paths.js +51 -0
- package/dist/src/core/profile.js +15 -1
- package/dist/src/core/tracks/concurrency.js +85 -0
- package/dist/src/core/tracks/context.js +89 -0
- package/dist/src/core/tracks/descriptor.js +40 -0
- package/dist/src/core/tracks/git.js +318 -0
- package/dist/src/core/tracks/join.js +185 -0
- package/dist/src/core/tracks/ownership.js +100 -0
- package/dist/src/core/tracks/plan-parser.js +108 -0
- package/dist/src/core/tracks/protocol.js +466 -0
- package/dist/src/core/tracks/teardown.js +34 -0
- package/dist/src/core/tracks/types.js +14 -0
- package/dist/src/core/update-check.js +5 -1
- package/dist/src/index.js +2 -0
- package/dist/tests/commands/job/gate-reconcile.test.js +267 -0
- package/dist/tests/commands/track/fixtures.js +13 -0
- package/dist/tests/commands/track/status.test.js +157 -0
- package/dist/tests/commands/track/supervisor-wrapper-cli.test.js +129 -0
- package/dist/tests/commands/track/supervisor-wrapper.test.js +131 -0
- package/dist/tests/commands/track/verbs.test.js +326 -0
- package/dist/tests/commands/watch/apply.test.js +116 -4
- package/dist/tests/commands/watch/runner.test.js +22 -0
- package/dist/tests/commands/watch/supervisor-loop.test.js +150 -0
- package/dist/tests/commands/watch/track-bootstrap-crash.test.js +342 -0
- package/dist/tests/commands/watch/track-bootstrap.test.js +350 -0
- package/dist/tests/commands/watch/track-finalize.test.js +643 -0
- package/dist/tests/commands/watch/track-freeze.test.js +591 -0
- package/dist/tests/commands/watch/track-join-crash.test.js +439 -0
- package/dist/tests/commands/watch/track-runtime-git.test.js +119 -0
- package/dist/tests/commands/watch/track-teardown-crash.test.js +426 -0
- package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
- package/dist/tests/core/journal/adapter-override.test.js +51 -0
- package/dist/tests/core/journal/process.test.js +37 -0
- package/dist/tests/core/journal/requests.test.js +21 -0
- package/dist/tests/core/journal/store.test.js +28 -0
- package/dist/tests/core/journal/types.test.js +89 -0
- package/dist/tests/core/profile.test.js +9 -2
- package/dist/tests/core/same-existing-path.test.js +48 -0
- package/dist/tests/core/tracks/concurrency.test.js +134 -0
- package/dist/tests/core/tracks/context.test.js +177 -0
- package/dist/tests/core/tracks/descriptor.test.js +76 -0
- package/dist/tests/core/tracks/git.test.js +132 -0
- package/dist/tests/core/tracks/join-reconcile.test.js +53 -0
- package/dist/tests/core/tracks/join.test.js +197 -0
- package/dist/tests/core/tracks/ownership.test.js +96 -0
- package/dist/tests/core/tracks/plan-parser.test.js +94 -0
- package/dist/tests/core/tracks/protocol.test.js +416 -0
- package/dist/tests/core/tracks/teardown.test.js +62 -0
- package/dist/tests/core/update-check.test.js +34 -3
- package/dist/tests/helpers/git-fixture.js +35 -0
- package/dist/tests/integration/parallel-tracks.e2e.test.js +343 -0
- package/dist/tests/integration/r5-provider-evidence.test.js +67 -0
- package/dist/tests/structural/path-identity-not-string-compare.test.js +51 -0
- package/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.deriveDefaultParallelism = deriveDefaultParallelism;
|
|
7
|
+
exports.parseMaxParallel = parseMaxParallel;
|
|
8
|
+
exports.scheduleTracks = scheduleTracks;
|
|
9
|
+
exports.loadDefaultParallelism = loadDefaultParallelism;
|
|
10
|
+
// R10.2/R10.3: tope de paralelismo derivado de una medición real (fingerprint
|
|
11
|
+
// budget, ver docs/research/r5/benchmark-fingerprint.mjs), nunca de un número
|
|
12
|
+
// escrito a mano. Funciones puras/testeables — sin I/O aquí salvo
|
|
13
|
+
// loadDefaultParallelism, que es un loader delgado sobre el artefacto
|
|
14
|
+
// empaquetado y cae a serial (1) si no lo puede resolver (nunca crashea `awm watch`).
|
|
15
|
+
const fs_1 = __importDefault(require("fs"));
|
|
16
|
+
const path_1 = __importDefault(require("path"));
|
|
17
|
+
/** R10.3: el mayor N de supervisores concurrentes cuyo p95 medido no supera
|
|
18
|
+
* 1.5x el baseline serial (N=1) ni el 20% del tick — nunca N=1 si ninguna
|
|
19
|
+
* medición lo habilita (R10.2, fail-closed hacia serial). */
|
|
20
|
+
function deriveDefaultParallelism(budget) {
|
|
21
|
+
if (!Number.isInteger(budget.cpuCount) || budget.cpuCount < 1 || budget.samples.length === 0)
|
|
22
|
+
throw new Error('budget inválido');
|
|
23
|
+
const baseline = budget.samples.find((s) => s.supervisors === 1);
|
|
24
|
+
if (baseline === undefined || baseline.p95Ms <= 0)
|
|
25
|
+
throw new Error('budget sin baseline N=1');
|
|
26
|
+
return budget.samples
|
|
27
|
+
.filter((s) => Number.isInteger(s.supervisors) && s.supervisors <= budget.cpuCount
|
|
28
|
+
&& s.p95Ms <= baseline.p95Ms * 1.5 && s.p95Ms <= budget.tickMs * 0.2)
|
|
29
|
+
.reduce((max, s) => Math.max(max, s.supervisors), 1);
|
|
30
|
+
}
|
|
31
|
+
/** Parsea `--max-parallel <n>`, mismo estilo de validación que `minutes()` en
|
|
32
|
+
* watch/index.ts: entero >= 1, fail-closed en cualquier otro caso. */
|
|
33
|
+
function parseMaxParallel(raw) {
|
|
34
|
+
const n = Number(raw);
|
|
35
|
+
if (!Number.isInteger(n) || n < 1)
|
|
36
|
+
throw new Error('--max-parallel requiere un entero >= 1');
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
/** Todos los tracks alcanzan ARMED; solo hasta `maxParallel` (menos los ya
|
|
40
|
+
* activos) pasan a arrancar. El resto queda `waiting` — el wrapper excedente
|
|
41
|
+
* espera sin arrancar su loop de watch/fingerprint hasta que un slot libere. */
|
|
42
|
+
function scheduleTracks(tracks, active, maxParallel) {
|
|
43
|
+
if (!Number.isInteger(maxParallel) || maxParallel < 1)
|
|
44
|
+
throw new Error('maxParallel debe ser entero >= 1');
|
|
45
|
+
const capacity = Math.max(0, maxParallel - active.size);
|
|
46
|
+
const candidates = tracks.filter((t) => !active.has(t));
|
|
47
|
+
return { start: candidates.slice(0, capacity), waiting: candidates.slice(capacity) };
|
|
48
|
+
}
|
|
49
|
+
/** Busca `docs/research/r5/fingerprint-budget.json` subiendo desde `startDir`
|
|
50
|
+
* (por defecto este archivo), sin asumir una profundidad fija de directorios.
|
|
51
|
+
* `startDir` es parametrizable solo para poder testear los caminos de fallo
|
|
52
|
+
* de `loadDefaultParallelism` con un árbol de directorios controlado —
|
|
53
|
+
* ver docs/research/r5/benchmark-fingerprint.mjs para el estado real de
|
|
54
|
+
* distribución de este artefacto (hoy NO viaja en el npm publish). */
|
|
55
|
+
function findBudgetArtifact(startDir) {
|
|
56
|
+
let dir = startDir;
|
|
57
|
+
for (let i = 0; i < 8; i += 1) {
|
|
58
|
+
const candidate = path_1.default.join(dir, 'docs', 'research', 'r5', 'fingerprint-budget.json');
|
|
59
|
+
if (fs_1.default.existsSync(candidate))
|
|
60
|
+
return candidate;
|
|
61
|
+
const parent = path_1.default.dirname(dir);
|
|
62
|
+
if (parent === dir)
|
|
63
|
+
break;
|
|
64
|
+
dir = parent;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/** `derivedDefault` del artefacto, si se puede resolver y es válido. Si el
|
|
69
|
+
* artefacto no resuelve, tiene JSON corrupto, o `derivedDefault` falta / no
|
|
70
|
+
* es entero / es < 1, cae a serial (1) — nunca crashea `awm watch` por esto.
|
|
71
|
+
* `startDir` default = este archivo; solo se sobreescribe en tests. */
|
|
72
|
+
function loadDefaultParallelism(startDir = __dirname) {
|
|
73
|
+
const artifactPath = findBudgetArtifact(startDir);
|
|
74
|
+
if (artifactPath === null)
|
|
75
|
+
return 1;
|
|
76
|
+
try {
|
|
77
|
+
const data = JSON.parse(fs_1.default.readFileSync(artifactPath, 'utf8'));
|
|
78
|
+
if (Number.isInteger(data.derivedDefault) && data.derivedDefault >= 1)
|
|
79
|
+
return data.derivedDefault;
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.planDigest = planDigest;
|
|
7
|
+
exports.assertTrackTask = assertTrackTask;
|
|
8
|
+
exports.resolveAuthenticatedContext = resolveAuthenticatedContext;
|
|
9
|
+
exports.resolveCommandContext = resolveCommandContext;
|
|
10
|
+
// Resolucion de contexto autenticado (R2.6, R9.4, R9.5): un track SOLO se
|
|
11
|
+
// autentica leyendo el journal del PLAN que su propio descriptor declara —
|
|
12
|
+
// jamas enumera ni abre journals hermanos (R2.7). La lectura es siempre
|
|
13
|
+
// read-only al momento de necesitarse (R9.5): nada aqui espeja ni cachea
|
|
14
|
+
// estado ajeno.
|
|
15
|
+
const fs_1 = __importDefault(require("fs"));
|
|
16
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
17
|
+
const paths_1 = require("../paths");
|
|
18
|
+
const descriptor_1 = require("./descriptor");
|
|
19
|
+
const store_1 = require("../journal/store");
|
|
20
|
+
function planDigest(source) {
|
|
21
|
+
return crypto_1.default.createHash('sha256').update(source.replace(/\r\n/g, '\n')).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
/** R2.3/R2.4: un track jamas ejecuta una tarea fuera de su asignacion, y un
|
|
24
|
+
* planDigest divergente bloquea ANTES de mirar taskIds — el plan cambio
|
|
25
|
+
* bajo los pies del track, la asignacion entera dejo de ser confiable. */
|
|
26
|
+
function assertTrackTask(ctx, taskId, currentPlanDigest) {
|
|
27
|
+
if (ctx.planDigest !== currentPlanDigest)
|
|
28
|
+
throw new Error('BLOCKED: planDigest divergente');
|
|
29
|
+
if (!ctx.taskIds.includes(taskId))
|
|
30
|
+
throw new Error(`task ${taskId} fuera de la asignación ${ctx.trackId}`);
|
|
31
|
+
}
|
|
32
|
+
/** Autentica un cwd contra el journal del plan que su descriptor declara
|
|
33
|
+
* (R2.6, R9.4). Recibe los JournalState YA leidos (localState del propio
|
|
34
|
+
* cwd, planState del journal del plan) — la funcion en si es pura, para que
|
|
35
|
+
* la autenticacion sea testeable sin tocar disco; el I/O real vive en
|
|
36
|
+
* `resolveCommandContext` mas abajo. */
|
|
37
|
+
function resolveAuthenticatedContext(cwd, localState, planState = localState) {
|
|
38
|
+
const root = fs_1.default.realpathSync(cwd);
|
|
39
|
+
const descriptor = (0, descriptor_1.readDescriptor)(root);
|
|
40
|
+
if (descriptor === null)
|
|
41
|
+
return { mode: 'plan', repoRoot: root, journal: localState };
|
|
42
|
+
if (localState.trackContext === undefined)
|
|
43
|
+
throw new Error('descriptor presente sin trackContext');
|
|
44
|
+
const planRoot = fs_1.default.realpathSync(descriptor.planRoot);
|
|
45
|
+
const ref = planState.tracks?.find((candidate) => candidate.trackId === descriptor.trackId);
|
|
46
|
+
if (planState.journalId !== descriptor.planJournalId || localState.trackContext.planJournalId !== descriptor.planJournalId) {
|
|
47
|
+
throw new Error('planJournalId no coincide');
|
|
48
|
+
}
|
|
49
|
+
if (ref === undefined)
|
|
50
|
+
throw new Error('TrackRef ausente en journal del plan');
|
|
51
|
+
if (ref.fencingToken !== descriptor.fencingToken)
|
|
52
|
+
throw new Error('fencingToken no coincide');
|
|
53
|
+
// Identidad de filesystem, no de string: en Windows `process.cwd()` y el path guardado
|
|
54
|
+
// en el journal pueden ser dos grafías de la misma carpeta (8.3 vs nombre largo).
|
|
55
|
+
if (!(0, paths_1.sameExistingPath)(ref.worktreePath, root) || (0, paths_1.sameExistingPath)(planRoot, root))
|
|
56
|
+
throw new Error('realpath de track no coincide');
|
|
57
|
+
if (localState.trackContext.trackId !== descriptor.trackId)
|
|
58
|
+
throw new Error('trackId no coincide');
|
|
59
|
+
return { mode: 'track', repoRoot: root, descriptor, trackContext: localState.trackContext };
|
|
60
|
+
}
|
|
61
|
+
/** Guard de entrada para los verbos `awm job`/`awm watch` (R9.4). Sin
|
|
62
|
+
* descriptor => modo plan de siempre, SIN leer ningun journal — el caso
|
|
63
|
+
* comun (todo repo sin tracks) no paga ningun costo ni riesgo nuevo; la
|
|
64
|
+
* logica propia de cada verbo sigue leyendo el journal como ya lo hacia.
|
|
65
|
+
* Con descriptor presente, lee el journal local y el del plan (SOLO ese,
|
|
66
|
+
* R2.7/R9.5) y delega la autenticacion real a resolveAuthenticatedContext;
|
|
67
|
+
* cualquier fallo se homogeniza bajo un mensaje que SIEMPRE contiene
|
|
68
|
+
* "cwd no autenticado", para que el CLI lo reporte con un patron unico. */
|
|
69
|
+
function resolveCommandContext(repoRoot, branch) {
|
|
70
|
+
const root = fs_1.default.realpathSync(repoRoot);
|
|
71
|
+
const descriptor = (0, descriptor_1.readDescriptor)(root);
|
|
72
|
+
if (descriptor === null)
|
|
73
|
+
return { mode: 'plan' };
|
|
74
|
+
try {
|
|
75
|
+
const local = (0, store_1.readJournal)(root, branch);
|
|
76
|
+
if (local.corrupt || local.state === null)
|
|
77
|
+
throw new Error('journal local corrupto o ausente');
|
|
78
|
+
const plan = (0, store_1.readJournal)(descriptor.planRoot, descriptor.planBranch);
|
|
79
|
+
if (plan.corrupt || plan.state === null)
|
|
80
|
+
throw new Error('journal del plan corrupto o ausente');
|
|
81
|
+
const authenticated = resolveAuthenticatedContext(root, local.state, plan.state);
|
|
82
|
+
if (authenticated.mode !== 'track')
|
|
83
|
+
throw new Error('descriptor presente pero no resolvio a modo track');
|
|
84
|
+
return { mode: 'track', context: authenticated };
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new Error(`cwd no autenticado: ${error.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.descriptorPath = void 0;
|
|
7
|
+
exports.writeDescriptor = writeDescriptor;
|
|
8
|
+
exports.readDescriptor = readDescriptor;
|
|
9
|
+
// Descriptor local de un worktree de track (R2.5): NUNCA versionado, vive
|
|
10
|
+
// bajo .awm/ igual que el journal. Es la unica prueba local de pertenencia a
|
|
11
|
+
// un plan — el resto de la autenticacion (R2.6) se hace en context.ts contra
|
|
12
|
+
// el TrackRef del journal del plan, jamas confiando solo en este archivo.
|
|
13
|
+
const fs_1 = __importDefault(require("fs"));
|
|
14
|
+
const path_1 = __importDefault(require("path"));
|
|
15
|
+
const atomic_file_1 = require("../atomic-file");
|
|
16
|
+
const descriptorPath = (trackRoot) => path_1.default.join(trackRoot, '.awm', 'track.json');
|
|
17
|
+
exports.descriptorPath = descriptorPath;
|
|
18
|
+
/** 0600 + escritura durable (mismo contrato que el journal, R1.2): un
|
|
19
|
+
* descriptor a medio escribir jamas debe autenticar un cwd. */
|
|
20
|
+
function writeDescriptor(trackRoot, value) {
|
|
21
|
+
if (value.schema !== 1 || !path_1.default.isAbsolute(value.planRoot) || value.trackId.length === 0
|
|
22
|
+
|| value.planJournalId.length === 0 || value.fencingToken.length < 32) {
|
|
23
|
+
throw new Error('descriptor de track inválido');
|
|
24
|
+
}
|
|
25
|
+
fs_1.default.mkdirSync(path_1.default.dirname((0, exports.descriptorPath)(trackRoot)), { recursive: true, mode: 0o700 });
|
|
26
|
+
(0, atomic_file_1.writeFileAtomicDurable)((0, exports.descriptorPath)(trackRoot), JSON.stringify({ ...value, planRoot: fs_1.default.realpathSync(value.planRoot) }, null, 2) + '\n', 0o600);
|
|
27
|
+
}
|
|
28
|
+
/** null = sin descriptor (modo plan, el caso comun). Forma invalida SIEMPRE
|
|
29
|
+
* lanza — un descriptor corrupto jamas autentica por omision (R1.6). */
|
|
30
|
+
function readDescriptor(trackRoot) {
|
|
31
|
+
const file = (0, exports.descriptorPath)(trackRoot);
|
|
32
|
+
if (!fs_1.default.existsSync(file))
|
|
33
|
+
return null;
|
|
34
|
+
const x = JSON.parse(fs_1.default.readFileSync(file, 'utf8'));
|
|
35
|
+
if (x.schema !== 1 || typeof x.planRoot !== 'string' || typeof x.planBranch !== 'string'
|
|
36
|
+
|| typeof x.trackId !== 'string' || typeof x.planJournalId !== 'string'
|
|
37
|
+
|| typeof x.fencingToken !== 'string')
|
|
38
|
+
throw new Error('track.json corrupto');
|
|
39
|
+
return x;
|
|
40
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.isAwmGitignored = isAwmGitignored;
|
|
7
|
+
exports.headSha = headSha;
|
|
8
|
+
exports.isWorktreeClean = isWorktreeClean;
|
|
9
|
+
exports.assertPlanHead = assertPlanHead;
|
|
10
|
+
exports.foreignPathExists = foreignPathExists;
|
|
11
|
+
exports.ownedWorktreeExists = ownedWorktreeExists;
|
|
12
|
+
exports.branchExists = branchExists;
|
|
13
|
+
exports.removeOwnedBranch = removeOwnedBranch;
|
|
14
|
+
exports.gitCheckTrackId = gitCheckTrackId;
|
|
15
|
+
exports.mergeBase = mergeBase;
|
|
16
|
+
exports.addOwnedWorktree = addOwnedWorktree;
|
|
17
|
+
exports.removeOwnedWorktree = removeOwnedWorktree;
|
|
18
|
+
exports.readMergeHead = readMergeHead;
|
|
19
|
+
exports.dirtyPaths = dirtyPaths;
|
|
20
|
+
exports.isAncestor = isAncestor;
|
|
21
|
+
exports.mergeFrozenTrack = mergeFrozenTrack;
|
|
22
|
+
exports.abortOwnedMerge = abortOwnedMerge;
|
|
23
|
+
exports.changedPaths = changedPaths;
|
|
24
|
+
const fs_1 = __importDefault(require("fs"));
|
|
25
|
+
const path_1 = __importDefault(require("path"));
|
|
26
|
+
const child_process_1 = require("child_process");
|
|
27
|
+
const process_1 = require("../journal/process");
|
|
28
|
+
const paths_1 = require("../paths");
|
|
29
|
+
const git = (repo, args) => (0, child_process_1.execFileSync)('git', args, { cwd: repo, encoding: 'utf8', stdio: process_1.EXEC_STDIO });
|
|
30
|
+
/** R4.1/C2: `.awm` debe estar demostrablemente ignorado o el journal del
|
|
31
|
+
* track podría terminar versionado. `git check-ignore` es una operación de
|
|
32
|
+
* working-tree/index — responde por el árbol REALMENTE checkeado en `dir`,
|
|
33
|
+
* nunca por un commit arbitrario. Por eso el caller (`defaultTrackRuntime`)
|
|
34
|
+
* DEBE pasar el path del worktree ya creado (checkeado en `baseSha`), no el
|
|
35
|
+
* repo del plan en su HEAD vivo: ese HEAD puede estar en un commit distinto
|
|
36
|
+
* de `baseSha` y dar una respuesta stale (post-review: bug crítico
|
|
37
|
+
* encontrado en la primera versión de esta función). `exit 0` = ignorado;
|
|
38
|
+
* `exit 1` = no ignorado; cualquier otro fallo tampoco prueba nada — todo lo
|
|
39
|
+
* que no sea éxito confirmado se trata como "no ignorado" (fail-closed). */
|
|
40
|
+
function isAwmGitignored(dir) {
|
|
41
|
+
try {
|
|
42
|
+
git(dir, ['check-ignore', '-q', '.awm/probe']);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function headSha(repo) {
|
|
50
|
+
return git(repo, ['rev-parse', 'HEAD']).trim();
|
|
51
|
+
}
|
|
52
|
+
/** R6.4 (Task 10): guard de LIMPIEZA del worktree/index, distinto del guard
|
|
53
|
+
* de OWNERSHIP (`changedPaths`/`assessActualOwnership`, que nunca consulta
|
|
54
|
+
* `git status` — ver el comentario grande del plan R5-T10 Step 6). `status
|
|
55
|
+
* --porcelain` vacío es la única prueba honesta de "nada sin commitear";
|
|
56
|
+
* cualquier fallo de git (repo corrupto, permisos) NO prueba limpieza —
|
|
57
|
+
* fail-closed, se trata como sucio. */
|
|
58
|
+
function isWorktreeClean(repo) {
|
|
59
|
+
try {
|
|
60
|
+
return git(repo, ['status', '--porcelain']).trim().length === 0;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** R5.8/R5.9/C7 (Task 10): comparación fail-closed de HEAD. "Un humano puede
|
|
67
|
+
* mutar el repo, pero la mutación se detecta y bloquea" — cualquier fallo de
|
|
68
|
+
* `git rev-parse` (repo desaparecido, corrupto) NUNCA se interpreta como
|
|
69
|
+
* "no cambió"; el silencio no es prueba, así que se bloquea igual que un
|
|
70
|
+
* drift confirmado. */
|
|
71
|
+
function assertPlanHead(repo, expectedSha) {
|
|
72
|
+
let actual;
|
|
73
|
+
try {
|
|
74
|
+
actual = headSha(repo);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw new Error(`assertPlanHead: HEAD de ${repo} indemostrable — BLOQUEADO (no se asume sin cambios): ${error.message}`);
|
|
78
|
+
}
|
|
79
|
+
if (actual !== expectedSha) {
|
|
80
|
+
throw new Error(`assertPlanHead: HEAD del plan cambió — esperado ${expectedSha}, actual ${actual}: BLOQUEADO`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** R4.6: nada se considera "nuestro" antes de que lo hayamos creado — si el
|
|
84
|
+
* destino ya existe y no está vacío, es por definición ajeno (todavía no
|
|
85
|
+
* intentamos crear nada ahí). Devuelve `true` también si no se puede
|
|
86
|
+
* *probar* que está vacío (fail-closed: silencio nunca es prueba de nada). */
|
|
87
|
+
function foreignPathExists(target) {
|
|
88
|
+
try {
|
|
89
|
+
return fs_1.default.existsSync(target) && fs_1.default.readdirSync(target).length > 0;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Task 9 (R4.2/R4.6/C11): true <=> `worktreePath` YA está registrado por
|
|
96
|
+
* `git worktree list` como worktree real, checked out exactamente en
|
|
97
|
+
* `branch` — el único caso legítimo de "destino no vacío pero nuestro":
|
|
98
|
+
* `branch`/`worktreePath` son deterministas (derivados 1:1 del trackId al
|
|
99
|
+
* declarar la cohorte, antes del primer intento de `create-worktree`), así
|
|
100
|
+
* que nada más los crea con ese nombre y ese path exactos. Distingue "esto
|
|
101
|
+
* es un intento propio interrumpido" de "esto es genuinamente ajeno" sin
|
|
102
|
+
* ningún estado persistido adicional al que ya vive en el `TrackRef`.
|
|
103
|
+
* Fail-closed (R4.6): cualquier fallo en preguntarle a git, o un
|
|
104
|
+
* `worktreePath` que ni siquiera se puede resolver, es "no probado" ->
|
|
105
|
+
* `false` — el caller sigue tratando el destino como potencialmente ajeno
|
|
106
|
+
* vía `foreignPathExists`, nunca lo adopta a ciegas. */
|
|
107
|
+
function ownedWorktreeExists(repo, worktreePath, branch) {
|
|
108
|
+
if (!fs_1.default.existsSync(worktreePath))
|
|
109
|
+
return false; // nada ahí todavía: no puede ser "nuestro" de un intento previo
|
|
110
|
+
let out;
|
|
111
|
+
try {
|
|
112
|
+
out = git(repo, ['worktree', 'list', '--porcelain']);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const wantedRef = `refs/heads/${branch}`;
|
|
118
|
+
for (const block of out.split('\n\n')) {
|
|
119
|
+
const lines = block.split('\n');
|
|
120
|
+
const wtLine = lines.find((l) => l.startsWith('worktree '));
|
|
121
|
+
const branchLine = lines.find((l) => l.startsWith('branch '));
|
|
122
|
+
if (wtLine === undefined || branchLine === undefined)
|
|
123
|
+
continue;
|
|
124
|
+
// Identidad de filesystem, NUNCA comparación de strings: git imprime los paths con
|
|
125
|
+
// separadores POSIX y el nombre largo, mientras el `worktreePath` del journal puede
|
|
126
|
+
// venir en 8.3 con backslashes. Son la misma carpeta escrita de dos formas.
|
|
127
|
+
if ((0, paths_1.sameExistingPath)(wtLine.slice('worktree '.length), worktreePath)
|
|
128
|
+
&& branchLine.slice('branch '.length) === wantedRef)
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
/** Task 13 (R4.10/C9): `true` <=> la branch local determinista existe. Nombre
|
|
134
|
+
* determinista (derivado 1:1 del trackId al declarar la cohorte), así que
|
|
135
|
+
* su mera existencia ya es prueba de ownership — a diferencia de un
|
|
136
|
+
* worktree (contenido de filesystem que SÍ podría ser ajeno), una branch es
|
|
137
|
+
* solo un ref; nada más crea una con ese nombre exacto. */
|
|
138
|
+
function branchExists(repo, branch) {
|
|
139
|
+
try {
|
|
140
|
+
git(repo, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Task 9 (paso BRANCH_REMOVED, Task 13 endurece el guard — R4.10/C9): borra
|
|
148
|
+
* la branch local determinista de un track SOLO después de que su worktree
|
|
149
|
+
* ya se quitó. `-d` (nunca `-D`, Step 4 del plan T13): un track que se
|
|
150
|
+
* aborta durante el teardown durable normalmente nunca divergió de su
|
|
151
|
+
* `baseSha` (si divergió, esos commits merecen quedar vivos hasta que un
|
|
152
|
+
* operador decida, no borrarse a la fuerza) — `-d` rehúsa por sí solo si no
|
|
153
|
+
* está mergeada, que es exactamente el comportamiento fail-closed que C9
|
|
154
|
+
* exige. Verifica explícitamente que la branch no siga checked out en
|
|
155
|
+
* ningún worktree ANTES de intentar el borrado (git también lo rehúsa
|
|
156
|
+
* nativamente, pero el chequeo explícito produce un mensaje diagnóstico
|
|
157
|
+
* claro y, sobre todo, trata un `git worktree list` indemostrable como
|
|
158
|
+
* bloqueo — nunca como "no está checked out"). Nunca lanza si la branch ya
|
|
159
|
+
* no existe — idempotente ante reintentos tras crash (R4.2). */
|
|
160
|
+
function removeOwnedBranch(repo, branch) {
|
|
161
|
+
if (!branchExists(repo, branch))
|
|
162
|
+
return; // ya no existe: nada que hacer
|
|
163
|
+
let out;
|
|
164
|
+
try {
|
|
165
|
+
out = git(repo, ['worktree', 'list', '--porcelain']);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
throw new Error(`no se pudo verificar si ${branch} sigue checked out — indemostrable: ${error.message}`);
|
|
169
|
+
}
|
|
170
|
+
if (out.split('\n').includes(`branch refs/heads/${branch}`)) {
|
|
171
|
+
throw new Error(`branch ${branch} sigue checked out en un worktree: no se borra`);
|
|
172
|
+
}
|
|
173
|
+
git(repo, ['branch', '-d', branch]);
|
|
174
|
+
}
|
|
175
|
+
function gitCheckTrackId(id) {
|
|
176
|
+
if (!id || id === '.' || id === '..' || id.startsWith('-') || id.includes('/') || id.includes('\\'))
|
|
177
|
+
return false;
|
|
178
|
+
try {
|
|
179
|
+
git(process.cwd(), ['check-ref-format', '--branch', id]);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function mergeBase(repo, left, right) {
|
|
187
|
+
return git(repo, ['merge-base', left, right]).trim();
|
|
188
|
+
}
|
|
189
|
+
/** R4.1/R4.6: crea el worktree del track SOLO si el destino está
|
|
190
|
+
* demostrablemente vacío. El caller (`tracks.ts`) es responsable de haber
|
|
191
|
+
* verificado `foreignPathExists`/`isAwmGitignored` ANTES de llamar esto —
|
|
192
|
+
* esta función es la única frontera que efectivamente ejecuta `git worktree
|
|
193
|
+
* add`, y nunca adopta ni sobreescribe contenido preexistente. */
|
|
194
|
+
function addOwnedWorktree(repo, ref, baseSha) {
|
|
195
|
+
const parent = path_1.default.dirname(ref.worktreePath);
|
|
196
|
+
fs_1.default.mkdirSync(parent, { recursive: true });
|
|
197
|
+
if (fs_1.default.existsSync(ref.worktreePath) && fs_1.default.readdirSync(ref.worktreePath).length > 0) {
|
|
198
|
+
throw new Error(`destino no vacío: ${ref.worktreePath}`);
|
|
199
|
+
}
|
|
200
|
+
git(repo, ['worktree', 'add', '-b', ref.branch, ref.worktreePath, baseSha]);
|
|
201
|
+
}
|
|
202
|
+
/** El worktree es NUESTRO — o recién creado en la misma llamada (C2, ej.
|
|
203
|
+
* `.awm` no ignorado en `baseSha`), o probado por ownership completo antes
|
|
204
|
+
* de llegar acá (Task 13, ver `worktreeOwnershipProven` en `teardown.ts`) —
|
|
205
|
+
* esto jamás borra algo ajeno (R4.6). Task 13 (R4.10/C9, Step 4 del plan)
|
|
206
|
+
* endurece el guard: YA NO fuerza (`--force` eliminado) — un worktree sucio
|
|
207
|
+
* bloquea, nombrando los paths sin commitear, en vez de descartar trabajo
|
|
208
|
+
* real sin dejar rastro. `dirtyPaths` sobre el WORKTREE mismo (no sobre
|
|
209
|
+
* `repo`): cada worktree tiene su propio árbol de trabajo/index, así que
|
|
210
|
+
* `git status` corrido ahí es la única fuente honesta de su propia
|
|
211
|
+
* limpieza. */
|
|
212
|
+
function removeOwnedWorktree(repo, worktreePath) {
|
|
213
|
+
const dirty = dirtyPaths(worktreePath);
|
|
214
|
+
if (dirty.length > 0) {
|
|
215
|
+
throw new Error(`worktree sucio, no se remueve sin --force: ${[...dirty].sort().join(', ')}`);
|
|
216
|
+
}
|
|
217
|
+
git(repo, ['worktree', 'remove', worktreePath]);
|
|
218
|
+
}
|
|
219
|
+
/** R6.2/R6.6-R6.9/C7 (Task 11): lee el `MERGE_HEAD` REAL del repo — no
|
|
220
|
+
* asume nada de un plain `.git/MERGE_HEAD` porque el plan del cohorte
|
|
221
|
+
* siempre opera sobre el repo PRINCIPAL (nunca un worktree secundario acá),
|
|
222
|
+
* pero `--git-path` es la única forma honesta de resolverlo sin asumir la
|
|
223
|
+
* estructura de `.git` (podría ser un dir o, en teoría, otra cosa).
|
|
224
|
+
* `null` <=> demostrablemente SIN merge en curso (el archivo no existe);
|
|
225
|
+
* cualquier fallo en RESOLVER el path, o el archivo presente pero
|
|
226
|
+
* ilegible, es indemostrable y BLOQUEA (fail-closed) — nunca se interpreta
|
|
227
|
+
* silencio como "no hay merge". */
|
|
228
|
+
function readMergeHead(repo) {
|
|
229
|
+
let gitPath;
|
|
230
|
+
try {
|
|
231
|
+
gitPath = git(repo, ['rev-parse', '--git-path', 'MERGE_HEAD']).trim();
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
throw new Error(`readMergeHead: no se pudo resolver el path de MERGE_HEAD en ${repo} — indemostrable: ${error.message}`);
|
|
235
|
+
}
|
|
236
|
+
const abs = path_1.default.isAbsolute(gitPath) ? gitPath : path_1.default.join(repo, gitPath);
|
|
237
|
+
if (!fs_1.default.existsSync(abs))
|
|
238
|
+
return null;
|
|
239
|
+
try {
|
|
240
|
+
return fs_1.default.readFileSync(abs, 'utf8').trim();
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
throw new Error(`readMergeHead: MERGE_HEAD existe en ${abs} pero es ilegible — indemostrable: ${error.message}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** R6.4/R6.5 (Task 11, reutilizado por `abortOwnedMerge`): mismo criterio
|
|
247
|
+
* fail-closed que `isWorktreeClean` pero devolviendo los paths (nunca se
|
|
248
|
+
* descartan — R6.5). Un fallo de `git status` NUNCA prueba limpieza: un
|
|
249
|
+
* centinela no vacío obliga a cualquier caller a tratar el árbol como
|
|
250
|
+
* sucio en vez de asumir silenciosamente cero paths sucios. */
|
|
251
|
+
function dirtyPaths(repo) {
|
|
252
|
+
let out;
|
|
253
|
+
try {
|
|
254
|
+
out = git(repo, ['status', '--porcelain']);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return ['<estado indemostrable: git status falló>'];
|
|
258
|
+
}
|
|
259
|
+
return out.split('\n').filter((line) => line.length > 0).map((line) => line.slice(3));
|
|
260
|
+
}
|
|
261
|
+
/** R6.6/R6.7 (Task 11): `true` <=> `ancestor` es alcanzable desde
|
|
262
|
+
* `descendant` — usado SOLO cuando `readMergeHead` ya probó que no hay
|
|
263
|
+
* merge en curso, para distinguir "todavía no se intentó" de "ya se
|
|
264
|
+
* mergeó" (un merge `--no-ff` exitoso siempre deja al track como ancestro
|
|
265
|
+
* del nuevo HEAD). Fail-closed hacia `false`: cualquier fallo de git (SHA
|
|
266
|
+
* inválido, repo corrupto) nunca se interpreta como ancestría probada —
|
|
267
|
+
* eso empujaría a `decideJoinReconciliation` hacia `accept-merge` sin
|
|
268
|
+
* evidencia real. */
|
|
269
|
+
function isAncestor(repo, ancestor, descendant) {
|
|
270
|
+
try {
|
|
271
|
+
git(repo, ['merge-base', '--is-ancestor', ancestor, descendant]);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** R6.2/R6.3/C7 (Task 11, Step 4 del plan — snippet reproducido exacto):
|
|
279
|
+
* único punto que ejecuta el merge real `no-ff` de un track ya `FROZEN`
|
|
280
|
+
* sobre la rama del plan. `assertPlanHead` ANTES de mutar nada (TOCTOU,
|
|
281
|
+
* C7): un HEAD movido bajo nuestros pies bloquea en vez de mergear a
|
|
282
|
+
* ciegas. Si `git merge` falla (conflicto real, u otra causa), la
|
|
283
|
+
* excepción se propaga tal cual — el caller (`runMergeTrack` en
|
|
284
|
+
* `watch/tracks.ts`) NUNCA interpreta la excepción por sí misma: relee el
|
|
285
|
+
* estado real del repo después para decidir qué pasó de verdad. */
|
|
286
|
+
function mergeFrozenTrack(repo, intent) {
|
|
287
|
+
assertPlanHead(repo, intent.expectedPlanHeadSha);
|
|
288
|
+
git(repo, ['merge', '--no-ff', '--no-edit', intent.expectedTrackHeadSha]);
|
|
289
|
+
}
|
|
290
|
+
/** R6.2/R6.3/C7 (Task 11, Step 4 del plan — snippet exacto): aborta
|
|
291
|
+
* ÚNICAMENTE un merge cuyo `MERGE_HEAD` observado coincide con el
|
|
292
|
+
* `expectedTrackHeadSha` del intent propio — jamás un `MERGE_HEAD` ajeno
|
|
293
|
+
* (protege contra abortar el merge de otro proceso). Verifica, tras el
|
|
294
|
+
* abort, que el árbol quedó demostrablemente limpio (sin `MERGE_HEAD`, sin
|
|
295
|
+
* paths sucios); si no, lanza en vez de reportar éxito sobre un estado que
|
|
296
|
+
* no se pudo probar recuperado. */
|
|
297
|
+
function abortOwnedMerge(repo, intent) {
|
|
298
|
+
const observed = readMergeHead(repo);
|
|
299
|
+
if (observed !== intent.expectedTrackHeadSha)
|
|
300
|
+
throw new Error('no se aborta MERGE_HEAD ajeno');
|
|
301
|
+
git(repo, ['merge', '--abort']);
|
|
302
|
+
if (readMergeHead(repo) !== null || dirtyPaths(repo).length > 0)
|
|
303
|
+
throw new Error('merge --abort no restauró árbol limpio');
|
|
304
|
+
}
|
|
305
|
+
function changedPaths(repo, base, head) {
|
|
306
|
+
const fields = git(repo, ['diff', '--name-status', '-z', '--find-renames', base, head]).split('\0');
|
|
307
|
+
const out = [];
|
|
308
|
+
for (let i = 0; i < fields.length && fields[i] !== '';) {
|
|
309
|
+
const status = fields[i++];
|
|
310
|
+
if (status.startsWith('R') || status.startsWith('C')) {
|
|
311
|
+
out.push({ status, oldPath: fields[i++], path: fields[i++] });
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
out.push({ status, path: fields[i++] });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|