agentic-workflow-manager 9.6.0 → 9.7.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 +2 -0
- package/dist/src/commands/context/index.js +3 -1
- package/dist/src/commands/track/index.js +3 -7
- package/dist/src/commands/watch/apply.js +15 -0
- package/dist/src/commands/watch/tracks.js +56 -7
- package/dist/src/core/discovery.js +79 -4
- package/dist/src/core/journal/types.js +1 -0
- package/dist/src/core/orchestrators.js +34 -10
- package/dist/src/core/text.js +7 -0
- package/dist/src/core/tracks/git.js +15 -3
- package/dist/src/core/tracks/protocol.js +26 -6
- package/dist/tests/commands/context.test.js +9 -1
- package/dist/tests/commands/hooks/install.test.js +4 -0
- package/dist/tests/commands/hooks/resync.test.js +2 -0
- package/dist/tests/commands/track/verbs.test.js +6 -0
- package/dist/tests/commands/watch/apply.test.js +46 -9
- package/dist/tests/commands/watch/track-remove-request.test.js +165 -0
- package/dist/tests/commands/watch/track-teardown-crash.test.js +27 -0
- package/dist/tests/core/context/orchestrator.test.js +2 -0
- package/dist/tests/core/discovery-multiroot.test.js +5 -0
- package/dist/tests/core/discovery-parent-race.test.js +95 -0
- package/dist/tests/core/discovery-safe-read.test.js +115 -0
- package/dist/tests/core/discovery.test.js +28 -1
- package/dist/tests/core/journal/types.test.js +11 -0
- package/dist/tests/core/orchestrators.test.js +171 -14
- package/dist/tests/core/text.test.js +10 -0
- package/dist/tests/core/tracks/git.test.js +7 -0
- package/dist/tests/core/tracks/protocol.test.js +103 -0
- package/dist/tests/integration/context-orchestrators-e2e.test.js +57 -7
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
package/README.md
CHANGED
|
@@ -69,6 +69,8 @@ and one JSON argv integration command. Start or resume with `awm watch`; inspect
|
|
|
69
69
|
`awm track status`. Track workers commit only their assigned files and request
|
|
70
70
|
`awm track join`. The plan supervisor freezes and merges tracks, runs global QA once on the
|
|
71
71
|
final HEAD, executes the canonical integration job once, then applies the interlock.
|
|
72
|
+
`awm track remove` requests a safe cancellation of the complete cohort; only demonstrably
|
|
73
|
+
owned resources are removed before returning to serial execution.
|
|
72
74
|
|
|
73
75
|
Any missing declaration, overlap, global file class, unavailable worktree, or failed
|
|
74
76
|
preparation degrades to serial with an event naming the cause. `BLOCKED` means ownership or
|
|
@@ -15,7 +15,9 @@ function runContextOrchestrators(collected, opts) {
|
|
|
15
15
|
// un usuario que tipee el nombre declarado exacto (con un caracter que el saneo
|
|
16
16
|
// quita, p.ej. `_` o un byte de control) se lleva un falso "not composed" (R3.5/R3.6).
|
|
17
17
|
const verifyNormalized = (0, text_1.sanitizeDeclaredField)(opts.verify);
|
|
18
|
-
|
|
18
|
+
// A dropped raw identity must not borrow a retained declaration's sanitized name.
|
|
19
|
+
const found = !collected.droppedNames.includes(opts.verify)
|
|
20
|
+
&& composed.some((o) => o.name === verifyNormalized);
|
|
19
21
|
if (!found) {
|
|
20
22
|
const available = composed.map((o) => o.name).join(', ') || '(none)';
|
|
21
23
|
return {
|
|
@@ -104,14 +104,10 @@ function registerTrackCommand(program) {
|
|
|
104
104
|
}
|
|
105
105
|
process.stdout.write(JSON.stringify({ requestId: r.requestId, idempotencyKey: r.idempotencyKey, qaHeadSha: r.qaHeadSha }, null, 2) + '\n');
|
|
106
106
|
});
|
|
107
|
-
//
|
|
108
|
-
//
|
|
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.
|
|
107
|
+
// Request-only: el supervisor persiste la intención, cancela la cohorte completa y
|
|
108
|
+
// reutiliza el teardown fail-closed antes de volver a ejecución serial.
|
|
113
109
|
track.command('remove')
|
|
114
|
-
.description('
|
|
110
|
+
.description('emite track-teardown-request — cancela la cohorte y solicita teardown seguro antes del fallback serial')
|
|
115
111
|
.requiredOption('--generation <token>', 'token de la generacion vigente')
|
|
116
112
|
.argument('<trackId>')
|
|
117
113
|
.action((trackId, opts) => {
|
|
@@ -250,6 +250,21 @@ function applyRequestToState(s, env, digest, repoRoot) {
|
|
|
250
250
|
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: trackId });
|
|
251
251
|
return;
|
|
252
252
|
}
|
|
253
|
+
if (env.kind === 'track-teardown-request') {
|
|
254
|
+
const p = env.payload;
|
|
255
|
+
if (typeof p.trackId !== 'string' || p.trackId.length === 0)
|
|
256
|
+
throw new Error('track-teardown-request requiere trackId');
|
|
257
|
+
const trackId = p.trackId;
|
|
258
|
+
const ref = s.tracks?.find((t) => t.trackId === trackId);
|
|
259
|
+
if (ref === undefined)
|
|
260
|
+
throw new Error(`track-teardown-request: track desconocido: ${trackId}`);
|
|
261
|
+
if (s.tracks === undefined || s.tracks.length < 2 || s.cohortPhase === undefined) {
|
|
262
|
+
throw new Error('track-teardown-request requiere una cohorte válida de al menos dos tracks');
|
|
263
|
+
}
|
|
264
|
+
ref.teardownRequested = true;
|
|
265
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: trackId });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
253
268
|
if (env.kind === 'track-freeze-request') {
|
|
254
269
|
// R5.2/R6.3 (Task 10): request administrativa CROSS-JOURNAL — el
|
|
255
270
|
// supervisor del PLAN la emite directamente al `requestsDir` de ESTE
|
|
@@ -135,13 +135,22 @@ function applyProtocolToState(state, protocol) {
|
|
|
135
135
|
// `expectedPlanHeadSha` incorrecto.
|
|
136
136
|
if (protocol.planHeadSha !== undefined)
|
|
137
137
|
next.cohortPlanHeadSha = protocol.planHeadSha;
|
|
138
|
-
// R7/C3 (Task 12):
|
|
139
|
-
// que
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (protocol.
|
|
144
|
-
next.
|
|
138
|
+
// R7/C3 (Task 12): los valores finales normalmente solo avanzan, salvo
|
|
139
|
+
// que fallback abandone explícitamente esa ruta. En ese caso el reducer
|
|
140
|
+
// ya invalidó su evidencia y el mirror durable DEBE borrarla también;
|
|
141
|
+
// conservarla permitiría que un restart reconstruyera un protocolo que
|
|
142
|
+
// contradice los tracks en teardown.
|
|
143
|
+
if (protocol.cohortPhase === 'FALLBACK_PENDING') {
|
|
144
|
+
next.globalQaHeadSha = undefined;
|
|
145
|
+
next.finalIntegrationJobId = undefined;
|
|
146
|
+
next.qaFinalizeRequested = undefined;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
if (protocol.globalQaHeadSha !== undefined)
|
|
150
|
+
next.globalQaHeadSha = protocol.globalQaHeadSha;
|
|
151
|
+
if (protocol.finalIntegrationJobId !== undefined)
|
|
152
|
+
next.finalIntegrationJobId = protocol.finalIntegrationJobId;
|
|
153
|
+
}
|
|
145
154
|
if (protocol.fallbackReason !== undefined)
|
|
146
155
|
next.cohortFallbackReason = protocol.fallbackReason;
|
|
147
156
|
next.tracks = (next.tracks ?? []).map((ref) => {
|
|
@@ -818,6 +827,46 @@ async function reconcileTracks(planRoot, branch, state, runtime, maxParallel) {
|
|
|
818
827
|
if (s.tracks === undefined || s.tracks.length < 2 || s.cohortPhase === undefined) {
|
|
819
828
|
return { state: s, effectExecuted: null };
|
|
820
829
|
}
|
|
830
|
+
// La request de teardown se consume en dos pasos durables, igual que
|
|
831
|
+
// join: `apply.ts` solo deja el marcador y este reconciler traduce la
|
|
832
|
+
// intención al reducer. BLOCKED, SERIAL y COMPLETE no admiten nueva
|
|
833
|
+
// transición: limpiar únicamente el marcador preserva el estado seguro.
|
|
834
|
+
const pendingTeardown = (s.tracks ?? []).find((track) => track.teardownRequested === true);
|
|
835
|
+
if (pendingTeardown !== undefined) {
|
|
836
|
+
const moot = ['BLOCKED', 'SERIAL', 'COMPLETE'].includes(s.cohortPhase);
|
|
837
|
+
if (moot) {
|
|
838
|
+
const next = structuredClone(s);
|
|
839
|
+
for (const track of next.tracks ?? [])
|
|
840
|
+
if (track.trackId === pendingTeardown.trackId)
|
|
841
|
+
track.teardownRequested = undefined;
|
|
842
|
+
s = persist(planRoot, branch, next);
|
|
843
|
+
(0, store_1.appendEvent)(planRoot, branch, {
|
|
844
|
+
kind: 'track-teardown-moot', trackId: pendingTeardown.trackId, phase: s.cohortPhase,
|
|
845
|
+
});
|
|
846
|
+
return { state: s, effectExecuted: null };
|
|
847
|
+
}
|
|
848
|
+
const protocolBefore = toProtocol(s, maxParallel);
|
|
849
|
+
const observed = (0, protocol_1.reconcileProtocol)(protocolBefore, { kind: 'teardown-requested', trackId: pendingTeardown.trackId });
|
|
850
|
+
const next = applyProtocolToState(s, observed);
|
|
851
|
+
for (const track of next.tracks ?? [])
|
|
852
|
+
if (track.trackId === pendingTeardown.trackId)
|
|
853
|
+
track.teardownRequested = undefined;
|
|
854
|
+
s = persist(planRoot, branch, next);
|
|
855
|
+
// El lock pertenece al proceso vivo que alcanzó la integración.
|
|
856
|
+
// Al abandonar FINAL_INTERLOCK debe soltarse en este mismo proceso;
|
|
857
|
+
// tras un crash el lease muerto se reclama por identidad al iniciar.
|
|
858
|
+
if (protocolBefore.cohortPhase === 'FINAL_INTERLOCK' && s.cohortPhase === 'FALLBACK_PENDING') {
|
|
859
|
+
runtime.releaseIntegrationLockIfHeld();
|
|
860
|
+
}
|
|
861
|
+
(0, store_1.appendEvent)(planRoot, branch, {
|
|
862
|
+
kind: 'track-teardown-observed', trackId: pendingTeardown.trackId,
|
|
863
|
+
phase: s.tracks?.find((t) => t.trackId === pendingTeardown.trackId)?.phase,
|
|
864
|
+
});
|
|
865
|
+
// El marcador es una frontera durable por sí mismo. No se consume
|
|
866
|
+
// otro marcador ni se entra a begin-teardown hasta la siguiente
|
|
867
|
+
// llamada, para que un crash conserve el resto de las requests.
|
|
868
|
+
return { state: s, effectExecuted: null };
|
|
869
|
+
}
|
|
821
870
|
// El pedido de join del controller (`joinRequested`, marcado declarativamente al
|
|
822
871
|
// consumir `track-join-request`) se convierte ACÁ en la observación que el reducer
|
|
823
872
|
// entiende. La regla de si eso mueve la fase vive en `reconcileProtocol` (solo
|
|
@@ -34,6 +34,73 @@ function readArtifactDescription(filePath) {
|
|
|
34
34
|
return '';
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
function inspectSkillDirectory(parent, expected) {
|
|
38
|
+
const stat = fs_1.default.lstatSync(parent, { bigint: true });
|
|
39
|
+
if (stat.isSymbolicLink())
|
|
40
|
+
throw new Error(`${parent}: must not be a symbolic link`);
|
|
41
|
+
if (!stat.isDirectory())
|
|
42
|
+
throw new Error(`${parent}: must be a directory`);
|
|
43
|
+
if (typeof stat.dev !== 'bigint' || typeof stat.ino !== 'bigint' || stat.dev < 0n || stat.ino <= 0n) {
|
|
44
|
+
throw new Error(`${parent}: directory identity is unobservable`);
|
|
45
|
+
}
|
|
46
|
+
if (expected && (stat.dev !== expected.dev || stat.ino !== expected.ino)) {
|
|
47
|
+
throw new Error(`${parent}: directory identity changed after inspection`);
|
|
48
|
+
}
|
|
49
|
+
return stat;
|
|
50
|
+
}
|
|
51
|
+
/** CTX-CONSTITUTION-052: bind the inspected leaf to its observable parent chain. */
|
|
52
|
+
function readSkillDescription(file) {
|
|
53
|
+
const absolute = path_1.default.resolve(file);
|
|
54
|
+
const parents = [];
|
|
55
|
+
for (let parent = path_1.default.dirname(absolute);; parent = path_1.default.dirname(parent)) {
|
|
56
|
+
parents.unshift(parent);
|
|
57
|
+
if (parent === path_1.default.dirname(parent))
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
const inspectedParents = parents.map(parent => ({ path: parent, stat: inspectSkillDirectory(parent) }));
|
|
61
|
+
const validateParents = () => {
|
|
62
|
+
for (const parent of inspectedParents)
|
|
63
|
+
inspectSkillDirectory(parent.path, parent.stat);
|
|
64
|
+
};
|
|
65
|
+
let inspected;
|
|
66
|
+
try {
|
|
67
|
+
inspected = fs_1.default.lstatSync(file, { bigint: true });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error?.code === 'ENOENT')
|
|
71
|
+
return null;
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
// A parent swapped before leaf lstat must not establish a new trusted identity.
|
|
75
|
+
validateParents();
|
|
76
|
+
if (inspected.isSymbolicLink())
|
|
77
|
+
throw new Error('must not be a symbolic link');
|
|
78
|
+
if (!inspected.isFile())
|
|
79
|
+
throw new Error('must be a regular file');
|
|
80
|
+
if (typeof inspected.dev !== 'bigint' || typeof inspected.ino !== 'bigint'
|
|
81
|
+
|| typeof inspected.size !== 'bigint' || inspected.dev < 0n
|
|
82
|
+
|| inspected.ino <= 0n || inspected.size < 0n) {
|
|
83
|
+
throw new Error('file identity or size is unobservable');
|
|
84
|
+
}
|
|
85
|
+
const flags = fs_1.default.constants.O_RDONLY | (fs_1.default.constants.O_NOFOLLOW ?? 0) | (fs_1.default.constants.O_NONBLOCK ?? 0);
|
|
86
|
+
const fd = fs_1.default.openSync(file, flags);
|
|
87
|
+
try {
|
|
88
|
+
const opened = fs_1.default.fstatSync(fd, { bigint: true });
|
|
89
|
+
if (!opened.isFile())
|
|
90
|
+
throw new Error('must be a regular file');
|
|
91
|
+
if (opened.dev !== inspected.dev || opened.ino !== inspected.ino || opened.size !== inspected.size) {
|
|
92
|
+
throw new Error('file identity or size changed after inspection');
|
|
93
|
+
}
|
|
94
|
+
// Reject substitutions observable after open, while retaining descriptor authority.
|
|
95
|
+
validateParents();
|
|
96
|
+
const raw = fs_1.default.readFileSync(fd, 'utf-8');
|
|
97
|
+
const frontmatter = (0, frontmatter_2.matchFrontmatterBlock)(raw);
|
|
98
|
+
return frontmatter === null ? '' : (0, frontmatter_2.readFrontmatterDescription)(frontmatter);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
fs_1.default.closeSync(fd);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
37
104
|
function collisionError(kind, name, first, second) {
|
|
38
105
|
return new Error(`Artifact name collision: ${kind} "${name}" exists in both ${first} and ${second}. ` +
|
|
39
106
|
`Remove or rename one of them, or declare "${name}" in "overrides" of the later registry's awm-registry.json.`);
|
|
@@ -54,7 +121,7 @@ function mergeEntry(kind, byName, entry, rootOverrides) {
|
|
|
54
121
|
}
|
|
55
122
|
/**
|
|
56
123
|
* Scans skills directories across all provided content roots and returns all valid skills.
|
|
57
|
-
* A valid skill is a directory that contains a SKILL.md file.
|
|
124
|
+
* A valid skill is a directory that contains a regular, non-symlink SKILL.md file.
|
|
58
125
|
* Throws on name collision across roots unless the later root declares the name in its awm-registry.json overrides.
|
|
59
126
|
*/
|
|
60
127
|
function discoverSkills(roots = (0, registries_1.contentRoots)()) {
|
|
@@ -68,12 +135,20 @@ function discoverSkills(roots = (0, registries_1.contentRoots)()) {
|
|
|
68
135
|
if (!entry.isDirectory())
|
|
69
136
|
continue;
|
|
70
137
|
const skillPath = path_1.default.join(dir, entry.name);
|
|
71
|
-
|
|
72
|
-
|
|
138
|
+
const skillFile = path_1.default.join(skillPath, 'SKILL.md');
|
|
139
|
+
let description;
|
|
140
|
+
try {
|
|
141
|
+
description = readSkillDescription(skillFile);
|
|
142
|
+
if (description === null)
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
throw new Error(`${skillFile}: cannot read (${e instanceof Error ? e.message : String(e)})`);
|
|
147
|
+
}
|
|
73
148
|
mergeEntry('skill', byName, {
|
|
74
149
|
name: entry.name,
|
|
75
150
|
path: skillPath,
|
|
76
|
-
description
|
|
151
|
+
description,
|
|
77
152
|
}, overrides);
|
|
78
153
|
}
|
|
79
154
|
}
|
|
@@ -246,6 +246,7 @@ function isWellFormedTrackRef(x) {
|
|
|
246
246
|
&& (x.supervisorProcessRef === undefined || isWellFormedProcessRef(x.supervisorProcessRef))
|
|
247
247
|
&& (x.joinIntent === undefined || isWellFormedJoinIntent(x.joinIntent))
|
|
248
248
|
&& (x.teardownIntent === undefined || isWellFormedTeardownIntent(x.teardownIntent))
|
|
249
|
+
&& (x.teardownRequested === undefined || typeof x.teardownRequested === 'boolean')
|
|
249
250
|
&& (x.joinedCommitSha === undefined || typeof x.joinedCommitSha === 'string')
|
|
250
251
|
&& (x.blockedReason === undefined || typeof x.blockedReason === 'string');
|
|
251
252
|
}
|
|
@@ -17,6 +17,7 @@ exports.collectAndWarn = collectAndWarn;
|
|
|
17
17
|
const fs_1 = __importDefault(require("fs"));
|
|
18
18
|
const path_1 = __importDefault(require("path"));
|
|
19
19
|
const registries_1 = require("./registries");
|
|
20
|
+
const discovery_1 = require("./discovery");
|
|
20
21
|
const text_1 = require("./text");
|
|
21
22
|
const ALLOWED_FIELDS = ['name', 'appliesWhen', 'terminatesTo'];
|
|
22
23
|
// These fields are semantically short (a short identity, a short trigger condition, a
|
|
@@ -24,6 +25,24 @@ const ALLOWED_FIELDS = ['name', 'appliesWhen', 'terminatesTo'];
|
|
|
24
25
|
// untrusted input whose fields flow straight into the AI-provider context payload, so an
|
|
25
26
|
// unbounded string here would let a crafted registry bloat/DoS that context.
|
|
26
27
|
const MAX_FIELD_LENGTH = 500;
|
|
28
|
+
function discoverDeclaredSkillNames() {
|
|
29
|
+
let names = new Set();
|
|
30
|
+
const diagnostics = [];
|
|
31
|
+
const acceptedRoots = [];
|
|
32
|
+
for (const root of (0, registries_1.contentRoots)()) {
|
|
33
|
+
try {
|
|
34
|
+
// Admit the entire root only after global collision/override resolution succeeds.
|
|
35
|
+
const skills = (0, discovery_1.discoverSkills)([...acceptedRoots, root]);
|
|
36
|
+
names = new Set(skills.map((skill) => skill.name));
|
|
37
|
+
acceptedRoots.push(root);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
41
|
+
diagnostics.push(`${(0, text_1.sanitizeDiagnosticText)(root)}: orchestrator skill discovery unavailable (${(0, text_1.sanitizeDiagnosticText)(message)})`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { names, diagnostics };
|
|
45
|
+
}
|
|
27
46
|
function readDeclaredOrchestrators(root) {
|
|
28
47
|
const file = path_1.default.join(root, registries_1.REGISTRY_MANIFEST_NAME);
|
|
29
48
|
// Shares the same trust boundary as readRegistryManifest (registries.ts): a manifest
|
|
@@ -35,7 +54,7 @@ function readDeclaredOrchestrators(root) {
|
|
|
35
54
|
exists = (0, registries_1.assertRegularRegistryFile)(file);
|
|
36
55
|
}
|
|
37
56
|
catch (e) {
|
|
38
|
-
return { orchestrators: [], diagnostics: [`${file}: ${e instanceof Error ? e.message : String(e)}`] };
|
|
57
|
+
return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: ${e instanceof Error ? e.message : String(e)}`)] };
|
|
39
58
|
}
|
|
40
59
|
if (!exists)
|
|
41
60
|
return { orchestrators: [], diagnostics: [] };
|
|
@@ -44,28 +63,27 @@ function readDeclaredOrchestrators(root) {
|
|
|
44
63
|
contents = fs_1.default.readFileSync(file, 'utf-8');
|
|
45
64
|
}
|
|
46
65
|
catch (e) {
|
|
47
|
-
return { orchestrators: [], diagnostics: [`${file}: cannot read manifest (${e instanceof Error ? e.message : String(e)})`] };
|
|
66
|
+
return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: cannot read manifest (${e instanceof Error ? e.message : String(e)})`)] };
|
|
48
67
|
}
|
|
49
68
|
let raw;
|
|
50
69
|
try {
|
|
51
70
|
raw = JSON.parse(contents);
|
|
52
71
|
}
|
|
53
72
|
catch (e) {
|
|
54
|
-
return { orchestrators: [], diagnostics: [`${file}: manifest is not valid JSON (${e instanceof Error ? e.message : String(e)})`] };
|
|
73
|
+
return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: manifest is not valid JSON (${e instanceof Error ? e.message : String(e)})`)] };
|
|
55
74
|
}
|
|
56
75
|
const decl = raw?.orchestrator;
|
|
57
76
|
if (decl === undefined)
|
|
58
77
|
return { orchestrators: [], diagnostics: [] };
|
|
59
78
|
if (typeof decl !== 'object' || decl === null || Array.isArray(decl)) {
|
|
60
|
-
return { orchestrators: [], diagnostics: [`${file}: "orchestrator" must be an object`] };
|
|
79
|
+
return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: "orchestrator" must be an object`)] };
|
|
61
80
|
}
|
|
62
81
|
const problems = [];
|
|
63
82
|
const entries = decl;
|
|
64
83
|
for (const key of Object.keys(entries)) {
|
|
65
84
|
if (!ALLOWED_FIELDS.includes(key)) {
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// control characters, which would otherwise let a crafted key forge extra log lines.
|
|
85
|
+
// Quote keys unambiguously; sanitize the complete diagnostic below because
|
|
86
|
+
// JSON.stringify preserves Unicode line separators and C1 controls.
|
|
69
87
|
problems.push(`unknown field ${JSON.stringify(key)} — the contract admits only ${ALLOWED_FIELDS.join(', ')}`);
|
|
70
88
|
}
|
|
71
89
|
}
|
|
@@ -79,7 +97,7 @@ function readDeclaredOrchestrators(root) {
|
|
|
79
97
|
}
|
|
80
98
|
}
|
|
81
99
|
if (problems.length > 0) {
|
|
82
|
-
return { orchestrators: [], diagnostics: [`${file}: invalid "orchestrator" declaration — ${problems.join('; ')}`] };
|
|
100
|
+
return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: invalid "orchestrator" declaration — ${problems.join('; ')}`)] };
|
|
83
101
|
}
|
|
84
102
|
return {
|
|
85
103
|
orchestrators: [{
|
|
@@ -126,13 +144,19 @@ function readDeclaredOrchestrators(root) {
|
|
|
126
144
|
*/
|
|
127
145
|
function collectDeclaredOrchestrators() {
|
|
128
146
|
const declared = [];
|
|
129
|
-
const
|
|
147
|
+
const droppedNames = [];
|
|
148
|
+
const { names: availableSkillNames, diagnostics } = discoverDeclaredSkillNames();
|
|
130
149
|
const seenNames = new Set();
|
|
131
150
|
const seenSanitizedNames = new Set();
|
|
132
151
|
for (const reg of (0, registries_1.listRegistries)()) {
|
|
133
152
|
const r = readDeclaredOrchestrators(reg.contentRoot);
|
|
134
153
|
for (const orch of r.orchestrators) {
|
|
135
154
|
const file = path_1.default.join(reg.contentRoot, registries_1.REGISTRY_MANIFEST_NAME);
|
|
155
|
+
if (!availableSkillNames.has(orch.name)) {
|
|
156
|
+
droppedNames.push(orch.name);
|
|
157
|
+
diagnostics.push(`${(0, text_1.sanitizeDiagnosticText)(file)}: orchestrator declaration dropped because skill "${(0, text_1.sanitizeDiagnosticText)(orch.name)}" is not discoverable in configured safe registries`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
136
160
|
if (seenNames.has(orch.name)) {
|
|
137
161
|
diagnostics.push(`${file}: orchestrator "${(0, text_1.stripControlChars)(orch.name)}" duplicates one already declared by an earlier registry — shadowed duplicate dropped`);
|
|
138
162
|
continue;
|
|
@@ -149,7 +173,7 @@ function collectDeclaredOrchestrators() {
|
|
|
149
173
|
}
|
|
150
174
|
diagnostics.push(...r.diagnostics);
|
|
151
175
|
}
|
|
152
|
-
return { declared, diagnostics };
|
|
176
|
+
return { declared, diagnostics, droppedNames };
|
|
153
177
|
}
|
|
154
178
|
/** Recolecta declarados y emite sus diagnosticos como warnings. Punto unico usado por
|
|
155
179
|
* `InjectionOrchestrator.inputFor`/`statusInputFor` y por `commands/hooks/claude.ts`
|
package/dist/src/core/text.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.stripControlChars = stripControlChars;
|
|
4
|
+
exports.sanitizeDiagnosticText = sanitizeDiagnosticText;
|
|
4
5
|
exports.sanitizeDeclaredField = sanitizeDeclaredField;
|
|
5
6
|
// cli/src/core/text.ts
|
|
6
7
|
/** Neutraliza bytes de control C0 (incluyendo ESC `\x1b`) y DEL de texto que
|
|
@@ -16,6 +17,12 @@ function stripControlChars(text) {
|
|
|
16
17
|
// eslint-disable-next-line no-control-regex -- necesitamos matchear C0 deliberadamente
|
|
17
18
|
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
18
19
|
}
|
|
20
|
+
/** Keeps an untrusted diagnostic field on one terminal line, without changing
|
|
21
|
+
* the whitespace policy for multiline public text or composed markdown. */
|
|
22
|
+
function sanitizeDiagnosticText(text) {
|
|
23
|
+
// eslint-disable-next-line no-control-regex -- remove all C0/C1 controls, including terminal escape bytes
|
|
24
|
+
return text.replace(/\r\n|[\r\n\t\u2028\u2029]/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '');
|
|
25
|
+
}
|
|
19
26
|
/**
|
|
20
27
|
* Neutraliza marcado markdown (colapsa `\r?\n` a un espacio y quita
|
|
21
28
|
* `` ` * _ # < > ``) de contenido no confiable antes de interpolarlo en un
|
|
@@ -136,13 +136,25 @@ function ownedWorktreeExists(repo, worktreePath, branch) {
|
|
|
136
136
|
* worktree (contenido de filesystem que SÍ podría ser ajeno), una branch es
|
|
137
137
|
* solo un ref; nada más crea una con ese nombre exacto. */
|
|
138
138
|
function branchExists(repo, branch) {
|
|
139
|
+
// `show-ref --quiet` puede devolver 1 tanto para una ref ausente como
|
|
140
|
+
// para un repositorio que ni siquiera pudo abrirse. Primero probamos que
|
|
141
|
+
// git puede identificar el repositorio; solo después status 1 significa
|
|
142
|
+
// honestamente "la branch no existe".
|
|
143
|
+
try {
|
|
144
|
+
git(repo, ['rev-parse', '--git-dir']);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
throw new Error(`branchExists: repo de ${branch} indemostrable: ${error.message}`);
|
|
148
|
+
}
|
|
139
149
|
try {
|
|
140
150
|
git(repo, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
141
|
-
return true;
|
|
142
151
|
}
|
|
143
|
-
catch {
|
|
144
|
-
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error.status === 1)
|
|
154
|
+
return false;
|
|
155
|
+
throw new Error(`branchExists: existencia de ${branch} indemostrable: ${error.message}`);
|
|
145
156
|
}
|
|
157
|
+
return true;
|
|
146
158
|
}
|
|
147
159
|
/** Task 9 (paso BRANCH_REMOVED, Task 13 endurece el guard — R4.10/C9): borra
|
|
148
160
|
* la branch local determinista de un track SOLO después de que su worktree
|
|
@@ -269,7 +269,25 @@ function nextProtocolEffect(s) {
|
|
|
269
269
|
}
|
|
270
270
|
function reconcileProtocol(s, observation) {
|
|
271
271
|
const out = structuredClone(s);
|
|
272
|
-
if (observation.kind === '
|
|
272
|
+
if (observation.kind === 'teardown-requested') {
|
|
273
|
+
const requested = out.tracks[observation.trackId];
|
|
274
|
+
if (requested === undefined)
|
|
275
|
+
throw new Error(`track desconocido: ${observation.trackId}`);
|
|
276
|
+
if (out.cohortPhase !== 'BLOCKED' && out.cohortPhase !== 'SERIAL' && out.cohortPhase !== 'COMPLETE'
|
|
277
|
+
&& out.cohortPhase !== 'FALLBACK_PENDING') {
|
|
278
|
+
out.cohortPhase = 'FALLBACK_PENDING';
|
|
279
|
+
out.fallbackReason = `controller-requested:${observation.trackId}`;
|
|
280
|
+
// La evidencia de QA/integración describe la ruta de finalización
|
|
281
|
+
// que acabamos de abandonar. Debe desaparecer ANTES de mover los
|
|
282
|
+
// tracks a teardown: de otro modo el invariante de integración
|
|
283
|
+
// final ve evidence viva junto a tracks no mergeados y rechaza la
|
|
284
|
+
// transición segura hacia fallback.
|
|
285
|
+
out.globalQaHeadSha = undefined;
|
|
286
|
+
out.finalIntegrationJobId = undefined;
|
|
287
|
+
markTeardownRequested(out.tracks);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else if (observation.kind === 'prepare-failed') {
|
|
273
291
|
out.cohortPhase = 'FALLBACK_PENDING';
|
|
274
292
|
out.fallbackReason = `prepare-failed:${observation.trackId}`;
|
|
275
293
|
markTeardownRequested(out.tracks);
|
|
@@ -295,10 +313,11 @@ function reconcileProtocol(s, observation) {
|
|
|
295
313
|
// `decideTeardown` documenta para BLOCKED: solo el operador lo
|
|
296
314
|
// saca de acá.
|
|
297
315
|
const t = out.tracks[observation.trackId];
|
|
298
|
-
if (t
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
316
|
+
if (t === undefined)
|
|
317
|
+
throw new Error(`track desconocido: ${observation.trackId}`);
|
|
318
|
+
t.phase = 'BLOCKED';
|
|
319
|
+
t.blockedReason = observation.detail;
|
|
320
|
+
out.cohortPhase = 'BLOCKED';
|
|
302
321
|
}
|
|
303
322
|
// effect-failed fuera de PREPARING/begin-teardown: sin transición definida todavía (no ejercitado por T1).
|
|
304
323
|
}
|
|
@@ -351,6 +370,7 @@ function reconcileProtocol(s, observation) {
|
|
|
351
370
|
if (decision === 'block-foreign') {
|
|
352
371
|
t.phase = 'BLOCKED';
|
|
353
372
|
t.blockedReason = observation.foreignSupervisor ? 'identidad de supervisor ajena' : 'worktree preexistente ajeno';
|
|
373
|
+
out.cohortPhase = 'BLOCKED';
|
|
354
374
|
}
|
|
355
375
|
else if (decision === 'accept-supervisor-stopped') {
|
|
356
376
|
t.phase = 'SUPERVISOR_STOPPED';
|
|
@@ -432,7 +452,7 @@ function reconcileProtocol(s, observation) {
|
|
|
432
452
|
}
|
|
433
453
|
function observeProtocolEffect(s, effect, observation) {
|
|
434
454
|
if (observation.kind === 'effect-failed' || observation.kind === 'prepare-failed' || observation.kind === 'join-observation'
|
|
435
|
-
|| observation.kind === 'freeze-observation' || observation.kind === 'join-requested'
|
|
455
|
+
|| observation.kind === 'freeze-observation' || observation.kind === 'join-requested' || observation.kind === 'teardown-requested'
|
|
436
456
|
|| observation.kind === 'supervisor-observed' || observation.kind === 'worktree-observed'
|
|
437
457
|
|| observation.kind === 'teardown-observation'
|
|
438
458
|
|| observation.kind === 'global-qa-pass'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const context_1 = require("../../src/commands/context");
|
|
4
|
-
const collected = (declared, diagnostics = []) => ({ declared, diagnostics });
|
|
4
|
+
const collected = (declared, diagnostics = []) => ({ declared, diagnostics, droppedNames: [] });
|
|
5
5
|
const uno = { name: 'mi-proceso', appliesWhen: 'cuando hay una tarea', terminatesTo: 'development-process' };
|
|
6
6
|
describe('awm context orchestrators', () => {
|
|
7
7
|
it('lista los orquestadores compuestos', () => {
|
|
@@ -55,6 +55,14 @@ describe('awm context orchestrators', () => {
|
|
|
55
55
|
const r = (0, context_1.runContextOrchestrators)(collected([uno]), { json: false, verify: 'de-verdad-no-existe' });
|
|
56
56
|
expect(r.code).toBe(2);
|
|
57
57
|
});
|
|
58
|
+
it('--verify rejects a dropped raw identity that sanitizes to a retained name', () => {
|
|
59
|
+
const input = {
|
|
60
|
+
...collected([{ name: 'foobar', appliesWhen: 'x', terminatesTo: 'y' }]),
|
|
61
|
+
droppedNames: ['foo_bar'],
|
|
62
|
+
};
|
|
63
|
+
expect((0, context_1.runContextOrchestrators)(input, { json: false, verify: 'foo_bar' }).code).toBe(2);
|
|
64
|
+
expect((0, context_1.runContextOrchestrators)(input, { json: false, verify: 'foobar' }).code).toBe(0);
|
|
65
|
+
});
|
|
58
66
|
it('sanea bytes de control antes de escribir a la terminal', () => {
|
|
59
67
|
const hostil = { name: 'x\x1b[31m', appliesWhen: 'w\x07', terminatesTo: 't\x00' };
|
|
60
68
|
const r = (0, context_1.runContextOrchestrators)(collected([hostil]), { json: false });
|
|
@@ -186,6 +186,8 @@ describe('installHook (happy path + merge)', () => {
|
|
|
186
186
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
|
|
187
187
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
|
|
188
188
|
fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
189
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso'), { recursive: true });
|
|
190
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso', 'SKILL.md'), '---\nname: mi-proceso\n---\n');
|
|
189
191
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
|
|
190
192
|
installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
|
|
191
193
|
const content = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm', 'hooks', 'using-awm.md'), 'utf-8');
|
|
@@ -246,6 +248,8 @@ describe('installHook (happy path + merge)', () => {
|
|
|
246
248
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
|
|
247
249
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
|
|
248
250
|
fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
251
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills', 'proceso-valido'), { recursive: true });
|
|
252
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills', 'proceso-valido', 'SKILL.md'), '---\nname: proceso-valido\n---\n');
|
|
249
253
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'proceso-valido', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
|
|
250
254
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
|
251
255
|
let result;
|
|
@@ -101,6 +101,8 @@ describe('resyncInstalledHooks', () => {
|
|
|
101
101
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
|
|
102
102
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
|
|
103
103
|
fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
104
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso'), { recursive: true });
|
|
105
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso', 'SKILL.md'), '---\nname: mi-proceso\n---\n');
|
|
104
106
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
|
|
105
107
|
const { installHook } = require('../../../src/commands/hooks/install');
|
|
106
108
|
installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
|
|
@@ -103,6 +103,12 @@ describe('awm track — verbos mutantes son request-only (R6.1)', () => {
|
|
|
103
103
|
expect(git(repo, ['rev-parse', 'HEAD'])).toBe(beforeHead);
|
|
104
104
|
expect(fs_1.default.readFileSync((0, paths_1.statePath)(repo, branch), 'utf8')).toBe(beforeState);
|
|
105
105
|
});
|
|
106
|
+
test('remove se anuncia como cancelación request-only soportada (TR-REQ-10)', async () => {
|
|
107
|
+
const out = await runCli(repo, ['track', 'remove', '--help']);
|
|
108
|
+
expect(out.exitCode).toBe(0);
|
|
109
|
+
expect(out.out).toContain('cancela la cohorte');
|
|
110
|
+
expect(out.out).not.toContain('NO IMPLEMENTADO');
|
|
111
|
+
});
|
|
106
112
|
// `finalize` es PLAN-scoped y sin <trackId>, así que no entra en las matrices de
|
|
107
113
|
// add/join/remove — pero comparte con ellas la propiedad que define a la superficie:
|
|
108
114
|
// emite una request y no toca Git ni state.json.
|
|
@@ -126,7 +126,7 @@ describe('aplicacion transaccional de requests', () => {
|
|
|
126
126
|
expect(Object.keys((0, store_1.readJournal)(repo, 'rama').state.jobs)).toHaveLength(1); // sin duplicados
|
|
127
127
|
});
|
|
128
128
|
test('register-entity CREA task con VerificationPlan y ReviewObligations; cycle-plan y dispatch (R1.4/R1.4b)', () => {
|
|
129
|
-
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
129
|
+
const request = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
130
130
|
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
|
|
131
131
|
payload: {
|
|
132
132
|
entity: 'task', taskId: 'T1', title: 'implementar',
|
|
@@ -513,20 +513,57 @@ describe('aplicacion transaccional de requests', () => {
|
|
|
513
513
|
expect(out.rejectedInvalid).toBe(1);
|
|
514
514
|
expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
|
|
515
515
|
});
|
|
516
|
-
test('
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: 'teardown-
|
|
516
|
+
test('track-teardown-request persiste intención sin mover fase ni cohorte', () => {
|
|
517
|
+
const s0 = (0, store_1.readJournal)(repo, 'rama').state;
|
|
518
|
+
s0.tracks = [{ ...trackRef('alpha'), phase: 'ACTIVE' }, { ...trackRef('beta'), phase: 'ACTIVE' }];
|
|
519
|
+
s0.cohortPhase = 'JOINING';
|
|
520
|
+
(0, store_1.writeJournal)(repo, 'rama', s0);
|
|
521
|
+
const request = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
522
|
+
kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: 'teardown-alpha',
|
|
523
523
|
payload: { trackId: 'alpha' },
|
|
524
524
|
});
|
|
525
|
+
expect((0, apply_1.consumePendingRequests)(repo, 'rama', 'g1').applied).toBe(1);
|
|
526
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
527
|
+
expect(state.tracks[0].teardownRequested).toBe(true);
|
|
528
|
+
expect(state.tracks[0].phase).toBe('ACTIVE');
|
|
529
|
+
expect(state.cohortPhase).toBe('JOINING');
|
|
530
|
+
expect(state.appliedRequests[request.requestId]).toMatchObject({ outcome: 'applied', resultRef: 'alpha' });
|
|
531
|
+
});
|
|
532
|
+
test.each([
|
|
533
|
+
['ausente', {}],
|
|
534
|
+
['vacio', { trackId: '' }],
|
|
535
|
+
['malformado', { trackId: 42 }],
|
|
536
|
+
['desconocido', { trackId: 'no-existe' }],
|
|
537
|
+
])('track-teardown-request %s se rechaza visiblemente sin mutar el track', (_case, payload) => {
|
|
538
|
+
const s0 = (0, store_1.readJournal)(repo, 'rama').state;
|
|
539
|
+
s0.tracks = [{ ...trackRef('alpha'), phase: 'ACTIVE' }, { ...trackRef('beta'), phase: 'ACTIVE' }];
|
|
540
|
+
s0.cohortPhase = 'JOINING';
|
|
541
|
+
(0, store_1.writeJournal)(repo, 'rama', s0);
|
|
542
|
+
const r = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
543
|
+
kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: `teardown-${_case}`,
|
|
544
|
+
payload,
|
|
545
|
+
});
|
|
525
546
|
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
526
547
|
expect(out.applied).toBe(0);
|
|
527
548
|
expect(out.rejectedInvalid).toBe(1);
|
|
528
549
|
expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
|
|
529
|
-
|
|
550
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
551
|
+
expect(state.tracks).toEqual([{ ...trackRef('alpha'), phase: 'ACTIVE' }, { ...trackRef('beta'), phase: 'ACTIVE' }]);
|
|
552
|
+
expect(state.cohortPhase).toBe('JOINING');
|
|
553
|
+
});
|
|
554
|
+
test('track-teardown-request se rechaza si la cohorte no puede reconciliarse', () => {
|
|
555
|
+
const s0 = (0, store_1.readJournal)(repo, 'rama').state;
|
|
556
|
+
s0.tracks = [{ ...trackRef('alpha'), phase: 'ACTIVE' }];
|
|
557
|
+
s0.cohortPhase = 'JOINING';
|
|
558
|
+
(0, store_1.writeJournal)(repo, 'rama', s0);
|
|
559
|
+
const request = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
560
|
+
kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: 'teardown-invalid-cohort',
|
|
561
|
+
payload: { trackId: 'alpha' },
|
|
562
|
+
});
|
|
563
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
564
|
+
expect(out.rejectedInvalid).toBe(1);
|
|
565
|
+
expect(fs_1.default.existsSync(`${request.file}.rejected`)).toBe(true);
|
|
566
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.tracks[0].teardownRequested).toBeUndefined();
|
|
530
567
|
});
|
|
531
568
|
test('track-finalize-request persiste el autoreporte del controller (qaFinalizeRequested) — puramente declarativo (R7.2)', () => {
|
|
532
569
|
(0, requests_1.emitRequest)(repo, 'rama', {
|