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,185 @@
|
|
|
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.IntegrationLockBlockedError = exports.decideJoinReconciliation = void 0;
|
|
7
|
+
exports.validateJoinReadiness = validateJoinReadiness;
|
|
8
|
+
exports.planJoinOrder = planJoinOrder;
|
|
9
|
+
exports.acquireIntegrationLock = acquireIntegrationLock;
|
|
10
|
+
exports.releaseIntegrationLock = releaseIntegrationLock;
|
|
11
|
+
exports.stopControllerGenerationConfirmed = stopControllerGenerationConfirmed;
|
|
12
|
+
// R5.2/R5.8/R5.9/R6.3/R6.4/R6.5/C7 (Task 10): quiescencia del plan y
|
|
13
|
+
// precondiciones de join. Dos familias de funciones acá:
|
|
14
|
+
// - `validateJoinReadiness`/`planJoinOrder`: PURAS, sin I/O — misma
|
|
15
|
+
// autoridad única que `protocol.ts`, pero para el momento previo al
|
|
16
|
+
// primer merge (Task 11 las consume; acá solo se construyen y prueban).
|
|
17
|
+
// - `acquireIntegrationLock`/`releaseIntegrationLock`/
|
|
18
|
+
// `stopControllerGenerationConfirmed`: efectos reales, modelados 1:1
|
|
19
|
+
// sobre `watch/lock.ts` (`acquireLock`/`releaseLock`/`LockBlockedError`)
|
|
20
|
+
// y sobre el patrón de terminación con identidad confirmada que
|
|
21
|
+
// `supervisor.ts` ya usa en su camino COMPLETE — nunca un kill crudo.
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
const path_1 = __importDefault(require("path"));
|
|
24
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
25
|
+
const store_1 = require("../journal/store");
|
|
26
|
+
const paths_1 = require("../journal/paths");
|
|
27
|
+
const process_1 = require("../journal/process");
|
|
28
|
+
const atomic_file_1 = require("../atomic-file");
|
|
29
|
+
const types_1 = require("../journal/types");
|
|
30
|
+
// R6.8/R6.9/C7 (Task 11): `decideJoinReconciliation` YA vive en `protocol.ts`
|
|
31
|
+
// desde Task 1 (`reconcileProtocol` la llama para su handler de
|
|
32
|
+
// `join-observation`) — regla de autoridad única: esta task NO crea una
|
|
33
|
+
// segunda copia acá. `join-reconcile.test.ts` prueba la matriz completa
|
|
34
|
+
// IMPORTANDO este re-export, así que cualquier fix que la matriz descubra
|
|
35
|
+
// tiene que vivir en `protocol.ts` (y volver a correr la exploración de
|
|
36
|
+
// Task 1 en el mismo commit) para que el test del gate siga significando algo.
|
|
37
|
+
var protocol_1 = require("./protocol");
|
|
38
|
+
Object.defineProperty(exports, "decideJoinReconciliation", { enumerable: true, get: function () { return protocol_1.decideJoinReconciliation; } });
|
|
39
|
+
/** Autoridad única de precondiciones de join: TODO hecho indemostrable o
|
|
40
|
+
* adverso bloquea — nunca se descarta un dirty path del mensaje (R6.5), y
|
|
41
|
+
* el HEAD congelado se compara byte a byte contra el HEAD real observado
|
|
42
|
+
* (protege contra el track mutando su propio worktree después de FROZEN). */
|
|
43
|
+
function validateJoinReadiness(x) {
|
|
44
|
+
const reasons = [];
|
|
45
|
+
if (x.actualHeadSha !== x.frozenHeadSha)
|
|
46
|
+
reasons.push(`HEAD cambió: esperado ${x.frozenHeadSha}, actual ${x.actualHeadSha}`);
|
|
47
|
+
if (x.dirtyPaths.length > 0)
|
|
48
|
+
reasons.push(`worktree sucio: ${[...x.dirtyPaths].sort().join(', ')}`);
|
|
49
|
+
if (!x.gatePass)
|
|
50
|
+
reasons.push('gate local rojo');
|
|
51
|
+
if (x.liveJobs > 0)
|
|
52
|
+
reasons.push(`${x.liveJobs} jobs vivos`);
|
|
53
|
+
if (x.supervisorAlive)
|
|
54
|
+
reasons.push('supervisor de track vivo');
|
|
55
|
+
if (x.lockExists)
|
|
56
|
+
reasons.push('lock de track retenido');
|
|
57
|
+
return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
|
|
58
|
+
}
|
|
59
|
+
/** Ownership REAL (post-hoc, desde commits ya congelados — ver
|
|
60
|
+
* `assessActualOwnership` en `ownership.ts`), no la declaración de T4: un
|
|
61
|
+
* solo track con `outsideOwnership` no vacío serializa el ORDEN de los
|
|
62
|
+
* joins restantes (R5.8/R5.9) — nunca revierte merges ya hechos. Un
|
|
63
|
+
* `globalClasses` no vacío además invalida el paralelismo de la cohorte
|
|
64
|
+
* entera (C5): `parallelInvalidatedBy` nombra `<trackId>:<clase>` para cada
|
|
65
|
+
* hallazgo, para que el consumidor (T10 Step 6 en `tracks.ts`) lo persista
|
|
66
|
+
* como `cohortParallelInvalidatedBy` y ningún `awm watch` futuro lo pierda. */
|
|
67
|
+
function planJoinOrder(trackIds, assessments) {
|
|
68
|
+
const violations = {};
|
|
69
|
+
const parallelInvalidatedBy = [];
|
|
70
|
+
let serial = false;
|
|
71
|
+
for (const trackId of trackIds) {
|
|
72
|
+
const a = assessments[trackId];
|
|
73
|
+
if (a === undefined)
|
|
74
|
+
continue;
|
|
75
|
+
if (a.outsideOwnership.length > 0) {
|
|
76
|
+
violations[trackId] = [...a.outsideOwnership];
|
|
77
|
+
serial = true;
|
|
78
|
+
}
|
|
79
|
+
if (a.globalClasses.length > 0) {
|
|
80
|
+
serial = true;
|
|
81
|
+
for (const cls of a.globalClasses)
|
|
82
|
+
parallelInvalidatedBy.push(`${trackId}:${cls}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { mode: serial ? 'serial-joins' : 'parallel-joins', order: [...trackIds], violations, parallelInvalidatedBy };
|
|
86
|
+
}
|
|
87
|
+
// --- Lock de integración (R5.8/R5.9/C7) -------------------------------------
|
|
88
|
+
/** Identidad indemostrable en el lock: BLOQUEAR con error distinto — mismo
|
|
89
|
+
* criterio que `LockBlockedError` de `watch/lock.ts`, nunca se reclama lo
|
|
90
|
+
* que no se puede probar muerto. */
|
|
91
|
+
class IntegrationLockBlockedError extends Error {
|
|
92
|
+
constructor(message) { super(message); this.name = 'IntegrationLockBlockedError'; }
|
|
93
|
+
}
|
|
94
|
+
exports.IntegrationLockBlockedError = IntegrationLockBlockedError;
|
|
95
|
+
function isWellFormedIntegrationLockBody(x) {
|
|
96
|
+
return (0, types_1.isWellFormedProcessRef)(x)
|
|
97
|
+
&& typeof x.planJournalId === 'string'
|
|
98
|
+
&& typeof x.expectedPlanHeadSha === 'string';
|
|
99
|
+
}
|
|
100
|
+
/** Modelado 1:1 sobre `acquireLock` (`watch/lock.ts`): creación EXCLUSIVA
|
|
101
|
+
* real (`wx`), 0600, fsync de archivo + directorio, identidad completa
|
|
102
|
+
* (`ProcessRef`) más el contexto de integración (`planJournalId` +
|
|
103
|
+
* `expectedPlanHeadSha`) para que cualquier lector externo pueda auditar
|
|
104
|
+
* QUÉ mutación se autorizó, no solo QUIÉN la retiene. */
|
|
105
|
+
function acquireIntegrationLock(planRoot, ctx) {
|
|
106
|
+
const lp = (0, paths_1.integrationLockPath)(planRoot);
|
|
107
|
+
fs_1.default.mkdirSync(path_1.default.dirname(lp), { recursive: true, mode: 0o700 });
|
|
108
|
+
const self = (0, process_1.captureSelfRef)(crypto_1.default.randomBytes(8).toString('hex'));
|
|
109
|
+
const body = { ...self, ...ctx };
|
|
110
|
+
const serialized = JSON.stringify(body, null, 2) + '\n';
|
|
111
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
112
|
+
try {
|
|
113
|
+
const fd = fs_1.default.openSync(lp, 'wx', 0o600);
|
|
114
|
+
try {
|
|
115
|
+
fs_1.default.writeFileSync(fd, serialized);
|
|
116
|
+
fs_1.default.fsyncSync(fd);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
fs_1.default.closeSync(fd);
|
|
120
|
+
}
|
|
121
|
+
(0, atomic_file_1.fsyncDirSync)(path_1.default.dirname(lp));
|
|
122
|
+
return { ref: self, path: lp };
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error.code !== 'EEXIST')
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
let prior;
|
|
129
|
+
try {
|
|
130
|
+
prior = JSON.parse(fs_1.default.readFileSync(lp, 'utf8'));
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new IntegrationLockBlockedError(`integration.lock ilegible en ${lp}: identidad indemostrable — BLOQUEADO (no se reclama)`);
|
|
134
|
+
}
|
|
135
|
+
if (!isWellFormedIntegrationLockBody(prior)) {
|
|
136
|
+
throw new IntegrationLockBlockedError(`integration.lock con shape inválido en ${lp}: identidad indemostrable — BLOQUEADO (no se reclama)`);
|
|
137
|
+
}
|
|
138
|
+
if ((0, process_1.refIsAlive)(prior)) {
|
|
139
|
+
throw new Error(`integración activa (pid ${prior.pid}) sobre este plan`);
|
|
140
|
+
}
|
|
141
|
+
process.stderr.write('awm watch: integration.lock previo con identidad muerta probada — reclamando\n');
|
|
142
|
+
fs_1.default.rmSync(lp, { force: true });
|
|
143
|
+
}
|
|
144
|
+
throw new Error('no se pudo adquirir integration.lock tras reintento único (carrera persistente)');
|
|
145
|
+
}
|
|
146
|
+
function releaseIntegrationLock(handle) {
|
|
147
|
+
try {
|
|
148
|
+
const onDisk = JSON.parse(fs_1.default.readFileSync(handle.path, 'utf8'));
|
|
149
|
+
if (onDisk.spawnNonce === handle.ref.spawnNonce)
|
|
150
|
+
fs_1.default.rmSync(handle.path);
|
|
151
|
+
}
|
|
152
|
+
catch { /* ya ausente o ilegible: no tocar lo que no es nuestro */ }
|
|
153
|
+
}
|
|
154
|
+
// --- Pausa de la generación del plan (R5.8/C7) ------------------------------
|
|
155
|
+
/** "Ningún controller administrado sigue corriendo durante el lease" (Step 5
|
|
156
|
+
* del plan R5-T10): a diferencia de `resolveGeneration` (generations.ts,
|
|
157
|
+
* gobierna reemplazo POR STALL, con backoff/adapter), esto es una pausa
|
|
158
|
+
* ADMINISTRATIVA incondicional antes de integrar — termina, con identidad
|
|
159
|
+
* confirmada, cualquier generación del PLAN todavía activa. Sin generación
|
|
160
|
+
* activa, es un no-op (ya está pausado). Cualquier fallo en confirmar la
|
|
161
|
+
* terminación lanza — jamás se adquiere el lock de integración con un
|
|
162
|
+
* controller potencialmente vivo. */
|
|
163
|
+
async function stopControllerGenerationConfirmed(planRoot, branch, grace) {
|
|
164
|
+
const r = (0, store_1.readJournal)(planRoot, branch);
|
|
165
|
+
if (r.corrupt || r.state === null)
|
|
166
|
+
throw new Error('journal corrupto: no se puede pausar la generación del plan (R1.6)');
|
|
167
|
+
const s = r.state;
|
|
168
|
+
// Misma definición que `activeGeneration` (`watch/generations.ts`),
|
|
169
|
+
// reimplementada inline: `core/tracks` no depende de `commands/watch`
|
|
170
|
+
// (dirección de capas), y esto es una sola línea sin lógica propia que
|
|
171
|
+
// proteger de una duplicación real.
|
|
172
|
+
const gen = s.generations.find((g) => g.state === 'active' || g.state === 'controller-suspected-stall');
|
|
173
|
+
if (gen === undefined)
|
|
174
|
+
return;
|
|
175
|
+
for (const ref of [gen.processRef, gen.wrapperRef]) {
|
|
176
|
+
if (ref === undefined || (0, process_1.groupIsGone)(ref.processGroup))
|
|
177
|
+
continue;
|
|
178
|
+
const confirmed = await (0, process_1.terminateGroupConfirmed)(ref, grace);
|
|
179
|
+
if (!confirmed)
|
|
180
|
+
throw new Error(`no se pudo confirmar la terminación de la generación ${gen.n} del plan antes de integrar (C7)`);
|
|
181
|
+
}
|
|
182
|
+
gen.state = 'terminated';
|
|
183
|
+
(0, store_1.writeJournal)(planRoot, branch, s);
|
|
184
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'plan-generation-stopped', n: gen.n });
|
|
185
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
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.ownershipPrefix = ownershipPrefix;
|
|
7
|
+
exports.canonicalResource = canonicalResource;
|
|
8
|
+
exports.assessDeclaredIndependence = assessDeclaredIndependence;
|
|
9
|
+
exports.assessActualOwnership = assessActualOwnership;
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const GLOBAL = [
|
|
12
|
+
{ kind: 'lockfile', re: /(^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock)$/i },
|
|
13
|
+
{ kind: 'manifest', re: /(^|\/)(?:package\.json|Cargo\.toml|pyproject\.toml)$/i },
|
|
14
|
+
{ kind: 'migration', re: /(^|\/)(?:migrations?|schema)\//i },
|
|
15
|
+
{ kind: 'snapshot', re: /(?:^|\/)(__snapshots__\/|[^/]+\.snap$)/i },
|
|
16
|
+
{ kind: 'generated', re: /(^|\/)(?:dist|generated|coverage)\//i },
|
|
17
|
+
];
|
|
18
|
+
const canon = (p) => path_1.default.posix.normalize(p.replaceAll('\\', '/')).replace(/^\.\//, '');
|
|
19
|
+
const key = (p) => canon(p).toLocaleLowerCase('en-US');
|
|
20
|
+
const GLOB_CHARS = /[*?[\]{}]/;
|
|
21
|
+
/**
|
|
22
|
+
* Formas soportadas: `dir/sub/file.ts`, `dir/`, `dir/*`, `dir/**`. Cualquier otro glob
|
|
23
|
+
* (intermedio, `?`, clases, llaves) se rechaza: un patrón que no sabemos expandir no puede
|
|
24
|
+
* usarse para AFIRMAR que dos tracks no se pisan.
|
|
25
|
+
*/
|
|
26
|
+
function ownershipPrefix(owner) {
|
|
27
|
+
const trimmed = canon(owner).replace(/\/(?:\*\*|\*)$/, '/');
|
|
28
|
+
if (GLOB_CHARS.test(trimmed))
|
|
29
|
+
throw new Error(`ownership no soporta este glob: ${owner}`);
|
|
30
|
+
return key(trimmed);
|
|
31
|
+
}
|
|
32
|
+
const covers = (owner, actual) => {
|
|
33
|
+
const o = ownershipPrefix(owner);
|
|
34
|
+
const a = key(actual);
|
|
35
|
+
return o.endsWith('/') ? a === o.slice(0, -1) || a.startsWith(o) : a === o;
|
|
36
|
+
};
|
|
37
|
+
/** Un patrón inexpandible cuenta como intersección: nunca habilita paralelismo por ignorancia. */
|
|
38
|
+
const intersects = (left, right) => {
|
|
39
|
+
try {
|
|
40
|
+
return covers(left, right) || covers(right, left);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
/** Simétrico del anterior: un patrón inexpandible nunca prueba propiedad. */
|
|
47
|
+
const proves = (owner, actual) => {
|
|
48
|
+
try {
|
|
49
|
+
return covers(owner, actual);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
function canonicalResource(raw) {
|
|
56
|
+
const match = raw.match(/^([a-z][a-z0-9-]*):(.+)$/i);
|
|
57
|
+
if (match === null || match[2].trim().length === 0)
|
|
58
|
+
throw new Error(`recurso debe usar <clase>:<valor>: ${raw}`);
|
|
59
|
+
// Case-fold el valor igual que `key()` hace con paths: sin esto, `db:Dev`
|
|
60
|
+
// y `db:dev` producen strings distintos y el Set.has() de
|
|
61
|
+
// assessDeclaredIndependence no detecta la colision (fail-open).
|
|
62
|
+
return `${match[1].toLowerCase()}:${match[2].trim().toLocaleLowerCase('en-US')}`;
|
|
63
|
+
}
|
|
64
|
+
function assessDeclaredIndependence(tracks) {
|
|
65
|
+
const reasons = new Set();
|
|
66
|
+
for (const t of tracks) {
|
|
67
|
+
for (const owner of t.ownership) {
|
|
68
|
+
try {
|
|
69
|
+
ownershipPrefix(owner);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
reasons.add(`unsupported-glob:${canon(owner)}`);
|
|
73
|
+
}
|
|
74
|
+
const global = GLOBAL.find((g) => g.re.test(canon(owner)));
|
|
75
|
+
if (global !== undefined)
|
|
76
|
+
reasons.add(`global:${global.kind}:${canon(owner)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (let i = 0; i < tracks.length; i++)
|
|
80
|
+
for (let j = i + 1; j < tracks.length; j++) {
|
|
81
|
+
for (const left of tracks[i].ownership)
|
|
82
|
+
for (const right of tracks[j].ownership) {
|
|
83
|
+
if (intersects(left, right))
|
|
84
|
+
reasons.add(`path:${canon(right)}`);
|
|
85
|
+
}
|
|
86
|
+
const rightResources = new Set(tracks[j].sharedResources.map(canonicalResource));
|
|
87
|
+
for (const resource of tracks[i].sharedResources.map(canonicalResource)) {
|
|
88
|
+
if (rightResources.has(resource))
|
|
89
|
+
reasons.add(`resource:${resource}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { parallel: reasons.size === 0, reasons: [...reasons].sort() };
|
|
93
|
+
}
|
|
94
|
+
function assessActualOwnership(track, changes) {
|
|
95
|
+
const actual = changes.flatMap((c) => c.oldPath === undefined ? [c.path] : [c.oldPath, c.path]);
|
|
96
|
+
return {
|
|
97
|
+
outsideOwnership: actual.filter((p) => !track.ownership.some((o) => proves(o, p))).sort(),
|
|
98
|
+
globalClasses: actual.flatMap((p) => GLOBAL.filter((g) => g.re.test(canon(p))).map((g) => `${g.kind}:${canon(p)}`)).sort(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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.parseTrackPlan = parseTrackPlan;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const taskHeading = /^### Task ([^:]+):/;
|
|
9
|
+
const trackLine = /^\*\*Track:\*\*\s*(.*)$/;
|
|
10
|
+
const fileLine = /^- (?:Create|Modify|Test|Delete):\s+`([^`]+)`/;
|
|
11
|
+
const EXACT_MATCH_ERROR = 'membresía y filas de ## Tracks requieren coincidencia exacta';
|
|
12
|
+
function jsonStrings(label, raw) {
|
|
13
|
+
if (raw === undefined)
|
|
14
|
+
throw new Error(`${label} es obligatorio para paralelismo`);
|
|
15
|
+
let value;
|
|
16
|
+
try {
|
|
17
|
+
value = JSON.parse(raw);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new Error(`${label} debe ser JSON string[]`);
|
|
21
|
+
}
|
|
22
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((x) => typeof x !== 'string' || x.length === 0)) {
|
|
23
|
+
throw new Error(`${label} debe ser JSON string[] no vacío`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function canonicalFile(raw) {
|
|
28
|
+
const withoutLines = raw.replace(/:\d+(?:-\d+)?$/, '');
|
|
29
|
+
const posix = withoutLines.replaceAll('\\', '/');
|
|
30
|
+
const normalized = path_1.default.posix.normalize(posix);
|
|
31
|
+
if (path_1.default.posix.isAbsolute(normalized) || normalized === '..' || normalized.startsWith('../')) {
|
|
32
|
+
throw new Error(`Files path fuera del repo: ${raw}`);
|
|
33
|
+
}
|
|
34
|
+
return normalized.replace(/^\.\//, '');
|
|
35
|
+
}
|
|
36
|
+
function parseTrackPlan(source, checkRef) {
|
|
37
|
+
const lines = source.replace(/\r\n/g, '\n').split('\n');
|
|
38
|
+
const hasMembership = lines.some((line) => trackLine.test(line));
|
|
39
|
+
const tracksHeading = lines.findIndex((line) => line.trim() === '## Tracks');
|
|
40
|
+
if (!hasMembership && tracksHeading < 0)
|
|
41
|
+
return { mode: 'serial', reason: 'no-tracks' };
|
|
42
|
+
if (!hasMembership || tracksHeading < 0)
|
|
43
|
+
throw new Error('Track membership y ## Tracks deben coexistir');
|
|
44
|
+
const argvRaw = lines.find((l) => l.startsWith('**Integration argv:**'))?.slice('**Integration argv:**'.length).trim();
|
|
45
|
+
const pathsRaw = lines.find((l) => l.startsWith('**Integration paths:**'))?.slice('**Integration paths:**'.length).trim();
|
|
46
|
+
const integration = { argv: jsonStrings('Integration argv', argvRaw), paths: jsonStrings('Integration paths', pathsRaw) };
|
|
47
|
+
const declared = new Map();
|
|
48
|
+
for (const line of lines.slice(tracksHeading + 1)) {
|
|
49
|
+
if (line.startsWith('## '))
|
|
50
|
+
break;
|
|
51
|
+
const cells = line.split('|').slice(1, -1).map((x) => x.trim());
|
|
52
|
+
if (cells.length !== 3 || cells[0] === 'Track' || /^[-:]+$/.test(cells[0]))
|
|
53
|
+
continue;
|
|
54
|
+
const [id, dependsRaw, resourcesRaw] = cells;
|
|
55
|
+
if (!id || id === '.' || id === '..' || id.startsWith('-') || id.includes('/') || id.includes('\\') || !checkRef(id)) {
|
|
56
|
+
throw new Error(`track id inválido: ${JSON.stringify(id)}`);
|
|
57
|
+
}
|
|
58
|
+
if (declared.has(id))
|
|
59
|
+
throw new Error(`track duplicado: ${id}`);
|
|
60
|
+
const resources = resourcesRaw === '[]' ? [] : resourcesRaw.length === 0 ? null : resourcesRaw.split(',').map((x) => x.trim());
|
|
61
|
+
const dependsOn = dependsRaw === 'none' ? [] : dependsRaw.split(',').map((x) => x.trim());
|
|
62
|
+
declared.set(id, { trackId: id, taskIds: [], ownership: [], dependsOn, sharedResources: resources ?? [] });
|
|
63
|
+
if (resources === null)
|
|
64
|
+
return { mode: 'serial', reason: `shared-resources-missing:${id}` };
|
|
65
|
+
}
|
|
66
|
+
let taskId = null;
|
|
67
|
+
let member = null;
|
|
68
|
+
let pendingFiles = [];
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const heading = line.match(taskHeading);
|
|
71
|
+
if (heading !== null) {
|
|
72
|
+
if (taskId !== null && pendingFiles.length > 0 && member === null)
|
|
73
|
+
throw new Error(`task ${taskId} tiene Files pero no Track`);
|
|
74
|
+
taskId = heading[1].trim();
|
|
75
|
+
member = null;
|
|
76
|
+
pendingFiles = [];
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const membership = line.match(trackLine);
|
|
80
|
+
if (membership !== null) {
|
|
81
|
+
if (taskId === null || member !== null)
|
|
82
|
+
throw new Error('cada task admite exactamente un Track');
|
|
83
|
+
member = membership[1].trim();
|
|
84
|
+
const track = declared.get(member);
|
|
85
|
+
if (track === undefined)
|
|
86
|
+
throw new Error(EXACT_MATCH_ERROR);
|
|
87
|
+
track.taskIds.push(taskId);
|
|
88
|
+
track.ownership.push(...pendingFiles);
|
|
89
|
+
}
|
|
90
|
+
const file = line.match(fileLine);
|
|
91
|
+
if (file !== null) {
|
|
92
|
+
const canonical = canonicalFile(file[1]);
|
|
93
|
+
if (member === null)
|
|
94
|
+
pendingFiles.push(canonical);
|
|
95
|
+
else
|
|
96
|
+
declared.get(member).ownership.push(canonical);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// La última task del documento no tiene un `### Task` siguiente que dispare el chequeo del loop.
|
|
100
|
+
if (taskId !== null && pendingFiles.length > 0 && member === null)
|
|
101
|
+
throw new Error(`task ${taskId} tiene Files pero no Track`);
|
|
102
|
+
const members = new Set([...declared.values()].flatMap((t) => t.taskIds.length > 0 ? [t.trackId] : []));
|
|
103
|
+
if (members.size !== declared.size)
|
|
104
|
+
throw new Error(EXACT_MATCH_ERROR);
|
|
105
|
+
if ([...declared.values()].some((t) => t.dependsOn.length > 0))
|
|
106
|
+
return { mode: 'serial', reason: 'track-dependency' };
|
|
107
|
+
return { mode: 'parallel-candidate', tracks: Object.fromEntries(declared), integration };
|
|
108
|
+
}
|