agentic-workflow-manager 3.3.1 → 3.5.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/dist/src/commands/job/exec-wrapper.js +136 -0
- package/dist/src/commands/job/export.js +94 -0
- package/dist/src/commands/job/gate.js +118 -0
- package/dist/src/commands/job/heartbeat.js +15 -0
- package/dist/src/commands/job/index.js +246 -0
- package/dist/src/commands/job/query.js +37 -0
- package/dist/src/commands/job/reap.js +24 -0
- package/dist/src/commands/job/reconcile.js +112 -0
- package/dist/src/commands/job/request.js +27 -0
- package/dist/src/commands/ledger/index.js +1 -1
- package/dist/src/commands/watch/apply.js +352 -0
- package/dist/src/commands/watch/generations.js +249 -0
- package/dist/src/commands/watch/index.js +49 -0
- package/dist/src/commands/watch/init.js +72 -0
- package/dist/src/commands/watch/lock.js +89 -0
- package/dist/src/commands/watch/runner.js +191 -0
- package/dist/src/commands/watch/supervisor.js +266 -0
- package/dist/src/core/atomic-file.js +31 -0
- package/dist/src/core/export/pack.js +7 -1
- package/dist/src/core/export/transform.js +51 -1
- package/dist/src/core/journal/adapter.js +27 -0
- package/dist/src/core/journal/fingerprint.js +80 -0
- package/dist/src/core/journal/paths.js +56 -0
- package/dist/src/core/journal/process.js +284 -0
- package/dist/src/core/journal/redact.js +142 -0
- package/dist/src/core/journal/requests.js +132 -0
- package/dist/src/core/journal/store.js +107 -0
- package/dist/src/core/journal/types.js +165 -0
- package/dist/src/core/ledger/cluster.js +138 -0
- package/dist/src/core/ledger/store.js +20 -11
- package/dist/src/index.js +4 -0
- package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
- package/dist/tests/commands/job/export.test.js +76 -0
- package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
- package/dist/tests/commands/job/reap-cli.test.js +101 -0
- package/dist/tests/commands/job/verbs.test.js +56 -0
- package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
- package/dist/tests/commands/watch/apply.test.js +397 -0
- package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
- package/dist/tests/commands/watch/generations.test.js +115 -0
- package/dist/tests/commands/watch/integration.test.js +124 -0
- package/dist/tests/commands/watch/lock.test.js +60 -0
- package/dist/tests/commands/watch/runner.test.js +239 -0
- package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
- package/dist/tests/commands/watch/watch-init.test.js +43 -0
- package/dist/tests/core/atomic-file-durable.test.js +42 -0
- package/dist/tests/core/export/engine.test.js +7 -2
- package/dist/tests/core/export/transform.test.js +77 -0
- package/dist/tests/core/journal/adapter.test.js +27 -0
- package/dist/tests/core/journal/fingerprint.test.js +164 -0
- package/dist/tests/core/journal/paths.test.js +35 -0
- package/dist/tests/core/journal/process.test.js +213 -0
- package/dist/tests/core/journal/redact.test.js +59 -0
- package/dist/tests/core/journal/requests.test.js +134 -0
- package/dist/tests/core/journal/store.test.js +88 -0
- package/dist/tests/core/journal/types.test.js +78 -0
- package/dist/tests/core/ledger/cluster.test.js +240 -0
- package/dist/tests/core/ledger/store.test.js +42 -7
- package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
- package/package.json +1 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Supervisor = exports.DEFAULT_SUPERVISOR_CONFIG = void 0;
|
|
4
|
+
exports.runSupervisorLoop = runSupervisorLoop;
|
|
5
|
+
// Loop foreground (R4.4/R4.5): tick = apply -> collect/spawn -> stall -> gate.
|
|
6
|
+
// COMPLETE exige gate verde (que exige cero vivos): drenaje ANTES de declarar.
|
|
7
|
+
// Custodia BLOCKED: el loop sigue, el lock NO se libera, nada se mata.
|
|
8
|
+
const store_1 = require("../../core/journal/store");
|
|
9
|
+
const fingerprint_1 = require("../../core/journal/fingerprint");
|
|
10
|
+
const adapter_1 = require("../../core/journal/adapter");
|
|
11
|
+
const process_1 = require("../../core/journal/process");
|
|
12
|
+
const gate_1 = require("../job/gate");
|
|
13
|
+
const lock_1 = require("./lock");
|
|
14
|
+
const apply_1 = require("./apply");
|
|
15
|
+
const runner_1 = require("./runner");
|
|
16
|
+
const generations_1 = require("./generations");
|
|
17
|
+
exports.DEFAULT_SUPERVISOR_CONFIG = {
|
|
18
|
+
provider: 'codex',
|
|
19
|
+
heartbeatTimeoutMs: 5 * 60000, // R4.2 default 5 min
|
|
20
|
+
activityWindowMs: 10 * 60000, // R4.2 ventana propia adicional
|
|
21
|
+
tickMs: 5000,
|
|
22
|
+
termGraceMs: 30000, // R4.2b flush 30 s
|
|
23
|
+
killGraceMs: 5000,
|
|
24
|
+
reconcileGraceMs: 10000,
|
|
25
|
+
jobStallObservationMs: 5 * 60000, // R3.5 default: mismo orden de magnitud que heartbeatTimeoutMs, concern independiente
|
|
26
|
+
};
|
|
27
|
+
const LIVE = ['received', 'spawn-intent', 'claimed', 'running', 'cancel-requested'];
|
|
28
|
+
class Supervisor {
|
|
29
|
+
repoRoot;
|
|
30
|
+
branch;
|
|
31
|
+
cfg;
|
|
32
|
+
spawner;
|
|
33
|
+
backoff = new generations_1.Backoff();
|
|
34
|
+
relaunchNotBefore = 0;
|
|
35
|
+
lastActivity = null;
|
|
36
|
+
lastGenerationToken = null;
|
|
37
|
+
constructor(repoRoot, branch, cfg, spawner) {
|
|
38
|
+
this.repoRoot = repoRoot;
|
|
39
|
+
this.branch = branch;
|
|
40
|
+
this.cfg = cfg;
|
|
41
|
+
this.spawner = spawner;
|
|
42
|
+
}
|
|
43
|
+
fingerprintNow = (argv, paths, cwd) => {
|
|
44
|
+
try {
|
|
45
|
+
return (0, fingerprint_1.computeFingerprint)(this.repoRoot, argv, paths, cwd).fingerprint;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
} // no recomputable => el gate NO certifica (fail-closed)
|
|
50
|
+
};
|
|
51
|
+
ensureController(resumePrompt) {
|
|
52
|
+
if (Date.now() < this.relaunchNotBefore)
|
|
53
|
+
return 'deferred';
|
|
54
|
+
if (this.backoff.exhausted()) {
|
|
55
|
+
(0, generations_1.enterCustody)(this.repoRoot, this.branch, 'tope de intentos de launch/relaunch por hora alcanzado (R4.3)');
|
|
56
|
+
return 'custody';
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
(0, generations_1.ensureControllerGeneration)(this.repoRoot, this.branch, this.cfg.provider, resumePrompt, this.spawner, this.cfg.reconcileGraceMs);
|
|
60
|
+
return 'ok';
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
this.backoff.recordRelaunch();
|
|
64
|
+
const delayMs = this.backoff.nextMs();
|
|
65
|
+
this.relaunchNotBefore = Date.now() + delayMs;
|
|
66
|
+
(0, store_1.appendEvent)(this.repoRoot, this.branch, {
|
|
67
|
+
kind: 'controller-launch-failed', detail: error.message, retryAfterMs: delayMs,
|
|
68
|
+
});
|
|
69
|
+
if (this.backoff.exhausted()) {
|
|
70
|
+
(0, generations_1.enterCustody)(this.repoRoot, this.branch, 'tope de intentos de launch/relaunch por hora alcanzado (R4.3)');
|
|
71
|
+
return 'custody';
|
|
72
|
+
}
|
|
73
|
+
return 'deferred';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async tick() {
|
|
77
|
+
const before = (0, store_1.readJournal)(this.repoRoot, this.branch);
|
|
78
|
+
if (before.corrupt || before.state === null)
|
|
79
|
+
throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
|
|
80
|
+
(0, lock_1.verifyBranchInvariant)(this.repoRoot, before.state.branch);
|
|
81
|
+
if (before.state.cycle.status === 'COMPLETE')
|
|
82
|
+
return 'complete';
|
|
83
|
+
const pending = before.state?.cycle.nextAction;
|
|
84
|
+
const resumePrompt = pending !== undefined ? `el next_action ${pending.actionId} del journal` : 'el plan del ciclo desde el journal';
|
|
85
|
+
if (this.ensureController(resumePrompt) === 'custody')
|
|
86
|
+
return 'custody';
|
|
87
|
+
const r0 = (0, store_1.readJournal)(this.repoRoot, this.branch);
|
|
88
|
+
if (r0.corrupt || r0.state === null)
|
|
89
|
+
throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
|
|
90
|
+
const gen = (0, generations_1.activeGeneration)(r0.state);
|
|
91
|
+
(0, apply_1.consumePendingRequests)(this.repoRoot, this.branch, gen?.token ?? null);
|
|
92
|
+
const afterRequests = (0, store_1.readJournal)(this.repoRoot, this.branch);
|
|
93
|
+
if (afterRequests.state !== null && (0, generations_1.activeGeneration)(afterRequests.state) === undefined
|
|
94
|
+
&& afterRequests.state.generations.length > 0 && afterRequests.state.cycle.status === 'IN_PROGRESS') {
|
|
95
|
+
(0, generations_1.beginGeneration)(this.repoRoot, this.branch);
|
|
96
|
+
if (this.ensureController(resumePrompt) === 'custody')
|
|
97
|
+
return 'custody';
|
|
98
|
+
}
|
|
99
|
+
(0, runner_1.runnerTick)(this.repoRoot, this.branch, this.spawner, { reconcileGraceMs: this.cfg.reconcileGraceMs, stallObservationMs: this.cfg.jobStallObservationMs });
|
|
100
|
+
const custody = await this.superviseController();
|
|
101
|
+
if (custody)
|
|
102
|
+
return 'custody';
|
|
103
|
+
const r = (0, store_1.readJournal)(this.repoRoot, this.branch);
|
|
104
|
+
const gate = (0, gate_1.computeGate)(r.state, r.corrupt, this.fingerprintNow);
|
|
105
|
+
const liveJobs = r.state === null ? 1 : Object.values(r.state.jobs).filter((j) => LIVE.includes(j.executionState)).length;
|
|
106
|
+
if (gate.pass && liveJobs === 0) { // gate verde YA implica cero vivos; doble cinturon (R4.5)
|
|
107
|
+
const s = r.state;
|
|
108
|
+
for (const generation of s.generations) {
|
|
109
|
+
for (const ref of [generation.processRef, generation.wrapperRef]) {
|
|
110
|
+
// Los tests pueden ejecutar el wrapper in-process; nunca
|
|
111
|
+
// enviar una senial al propio supervisor.
|
|
112
|
+
if (ref?.pid === process.pid)
|
|
113
|
+
continue;
|
|
114
|
+
if (ref === undefined || (0, process_1.groupIsGone)(ref.processGroup))
|
|
115
|
+
continue;
|
|
116
|
+
const confirmed = await (0, process_1.terminateGroupConfirmed)(ref, { termGraceMs: this.cfg.termGraceMs, killGraceMs: this.cfg.killGraceMs });
|
|
117
|
+
if (!confirmed) {
|
|
118
|
+
(0, generations_1.enterCustody)(this.repoRoot, this.branch, `no se pudo terminar con identidad confirmada la generacion ${generation.n} antes de COMPLETE`);
|
|
119
|
+
return 'custody';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
generation.state = 'terminated';
|
|
123
|
+
}
|
|
124
|
+
s.cycle.status = 'COMPLETE';
|
|
125
|
+
s.cycle.completedAt = new Date().toISOString();
|
|
126
|
+
(0, store_1.writeJournal)(this.repoRoot, this.branch, s);
|
|
127
|
+
(0, store_1.appendEvent)(this.repoRoot, this.branch, { kind: 'cycle-complete' });
|
|
128
|
+
return 'complete';
|
|
129
|
+
}
|
|
130
|
+
return 'continue';
|
|
131
|
+
}
|
|
132
|
+
/** true => custodia (el caller NO libera lock ni sale). */
|
|
133
|
+
async superviseController() {
|
|
134
|
+
const r = (0, store_1.readJournal)(this.repoRoot, this.branch);
|
|
135
|
+
if (r.corrupt || r.state === null)
|
|
136
|
+
throw new Error('journal corrupto (R1.6)');
|
|
137
|
+
const s = r.state;
|
|
138
|
+
const gen = (0, generations_1.activeGeneration)(s);
|
|
139
|
+
if (gen?.processRef === undefined)
|
|
140
|
+
return false; // sin controlador propio: nada que supervisar
|
|
141
|
+
if (this.lastGenerationToken !== gen.token) {
|
|
142
|
+
this.lastGenerationToken = gen.token;
|
|
143
|
+
this.lastActivity = null;
|
|
144
|
+
}
|
|
145
|
+
const adapter = (0, adapter_1.adapterFor)(this.cfg.provider);
|
|
146
|
+
const heartbeatAgeMs = Date.now() - Date.parse(s.controllerHeartbeatAt ?? gen.launchedAt);
|
|
147
|
+
const snap = adapter.activity(gen.processRef);
|
|
148
|
+
const key = JSON.stringify(snap);
|
|
149
|
+
if (this.lastActivity === null || this.lastActivity.key !== key) {
|
|
150
|
+
this.lastActivity = { key, changedAt: Date.now() };
|
|
151
|
+
}
|
|
152
|
+
const activityFrozenMs = Date.now() - this.lastActivity.changedAt;
|
|
153
|
+
const decision = (0, generations_1.decideStall)({ heartbeatAgeMs, activityFrozenMs, safeToReplace: adapter.safeToReplace(gen.processRef) }, { heartbeatTimeoutMs: this.cfg.heartbeatTimeoutMs, activityWindowMs: this.cfg.activityWindowMs });
|
|
154
|
+
if (decision === 'healthy') {
|
|
155
|
+
this.backoff.reset();
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
if (decision === 'suspected-stall-observe') {
|
|
159
|
+
if (gen.state !== 'controller-suspected-stall') {
|
|
160
|
+
gen.state = 'controller-suspected-stall'; // SOLO observacion (R4.2)
|
|
161
|
+
(0, store_1.writeJournal)(this.repoRoot, this.branch, s);
|
|
162
|
+
(0, store_1.appendEvent)(this.repoRoot, this.branch, { kind: 'controller-suspected-stall', n: gen.n });
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
if (decision === 'custody-blocked') {
|
|
167
|
+
(0, generations_1.enterCustody)(this.repoRoot, this.branch, 'doble senial de stall sin safeToReplace positivo del adapter (R4.2b)');
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
// resolve-generation
|
|
171
|
+
const resolved = await (0, generations_1.resolveGeneration)(this.repoRoot, this.branch, adapter, { termGraceMs: this.cfg.termGraceMs, killGraceMs: this.cfg.killGraceMs });
|
|
172
|
+
if (resolved === 'custody-blocked')
|
|
173
|
+
return true;
|
|
174
|
+
if (this.backoff.exhausted()) {
|
|
175
|
+
(0, generations_1.enterCustody)(this.repoRoot, this.branch, 'tope de relanzamientos por hora alcanzado (R4.3)');
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
if (Date.now() < this.relaunchNotBefore)
|
|
179
|
+
return false; // esperando backoff, auditando
|
|
180
|
+
(0, generations_1.beginGeneration)(this.repoRoot, this.branch);
|
|
181
|
+
const nextAction = (0, store_1.readJournal)(this.repoRoot, this.branch).state.cycle.nextAction;
|
|
182
|
+
const prompt = nextAction !== undefined ? `el next_action ${nextAction.actionId} del journal` : 'el plan del ciclo desde el journal';
|
|
183
|
+
const launched = this.ensureController(prompt);
|
|
184
|
+
if (launched === 'custody')
|
|
185
|
+
return true;
|
|
186
|
+
if (launched === 'ok') {
|
|
187
|
+
this.backoff.recordRelaunch();
|
|
188
|
+
this.relaunchNotBefore = Date.now() + this.backoff.nextMs();
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
exports.Supervisor = Supervisor;
|
|
194
|
+
/** Foreground, visible, terminable (R2.4): sin daemons. SIGINT/SIGTERM libera
|
|
195
|
+
* el lock y sale; COMPLETE => auto-exit liberando lock y terminando la
|
|
196
|
+
* generacion propia (cero huerfanos). */
|
|
197
|
+
async function runSupervisorLoop(repoRoot, branch, cfg, spawner = (0, runner_1.defaultWrapperSpawner)()) {
|
|
198
|
+
const r = (0, store_1.readJournal)(repoRoot, branch);
|
|
199
|
+
if (r.corrupt || r.state === null)
|
|
200
|
+
throw new Error('journal ausente o corrupto: corre `awm watch --init` primero');
|
|
201
|
+
(0, lock_1.verifyBranchInvariant)(repoRoot, r.state.branch);
|
|
202
|
+
if (r.state.cycle.status === 'COMPLETE')
|
|
203
|
+
return;
|
|
204
|
+
const handle = (0, lock_1.acquireLock)(repoRoot);
|
|
205
|
+
let shutdownRequested = false;
|
|
206
|
+
let wakeSleep = null;
|
|
207
|
+
let safeToRelease = false;
|
|
208
|
+
const onSignal = () => { shutdownRequested = true; wakeSleep?.(); };
|
|
209
|
+
process.on('SIGINT', onSignal);
|
|
210
|
+
process.on('SIGTERM', onSignal);
|
|
211
|
+
const sup = new Supervisor(repoRoot, branch, cfg, spawner);
|
|
212
|
+
try {
|
|
213
|
+
const s0 = (0, store_1.readJournal)(repoRoot, branch).state;
|
|
214
|
+
if ((0, generations_1.activeGeneration)(s0) === undefined) {
|
|
215
|
+
(0, generations_1.beginGeneration)(repoRoot, branch);
|
|
216
|
+
}
|
|
217
|
+
for (;;) {
|
|
218
|
+
if (shutdownRequested)
|
|
219
|
+
break;
|
|
220
|
+
const out = await sup.tick();
|
|
221
|
+
if (out === 'complete')
|
|
222
|
+
break;
|
|
223
|
+
// 'custody': NO liberar lock, NO salir — seguir auditando (R4.5)
|
|
224
|
+
await new Promise((resolve) => {
|
|
225
|
+
let settled = false;
|
|
226
|
+
const finish = () => { if (!settled) {
|
|
227
|
+
settled = true;
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
wakeSleep = null;
|
|
230
|
+
resolve();
|
|
231
|
+
} };
|
|
232
|
+
const timer = setTimeout(finish, cfg.tickMs);
|
|
233
|
+
wakeSleep = finish;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
// En shutdown explicito, drenar ownership ANTES de liberar el lock. En
|
|
237
|
+
// COMPLETE, tick() ya hizo exactamente esta confirmacion antes de
|
|
238
|
+
// persistir el estado terminal; el loop solo verifica el invariante.
|
|
239
|
+
(0, generations_1.collectControllerGeneration)(repoRoot, branch);
|
|
240
|
+
const sEnd = (0, store_1.readJournal)(repoRoot, branch).state;
|
|
241
|
+
for (const g of sEnd.generations) {
|
|
242
|
+
if ((0, generations_1.controllerGenerationHasUnresolvedClaim)(repoRoot, branch, g)) {
|
|
243
|
+
throw new Error(`ownership retenido: generacion ${g.n} tiene claim sin identidad ni resultado`);
|
|
244
|
+
}
|
|
245
|
+
for (const ref of [g.processRef, g.wrapperRef]) {
|
|
246
|
+
if (ref?.pid === process.pid)
|
|
247
|
+
continue;
|
|
248
|
+
if (ref === undefined || (0, process_1.groupIsGone)(ref.processGroup))
|
|
249
|
+
continue;
|
|
250
|
+
const confirmed = await (0, process_1.terminateGroupConfirmed)(ref, { termGraceMs: cfg.termGraceMs, killGraceMs: cfg.killGraceMs });
|
|
251
|
+
if (!confirmed)
|
|
252
|
+
throw new Error(`ownership retenido: generacion ${g.n} sigue viva o su identidad es indemostrable`);
|
|
253
|
+
}
|
|
254
|
+
g.state = 'terminated';
|
|
255
|
+
}
|
|
256
|
+
if (shutdownRequested)
|
|
257
|
+
(0, store_1.writeJournal)(repoRoot, branch, sEnd);
|
|
258
|
+
safeToRelease = true;
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
process.removeListener('SIGINT', onSignal);
|
|
262
|
+
process.removeListener('SIGTERM', onSignal);
|
|
263
|
+
if (safeToRelease)
|
|
264
|
+
(0, lock_1.releaseLock)(repoRoot, handle);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.writeFileAtomic = writeFileAtomic;
|
|
7
|
+
exports.fsyncDirSync = fsyncDirSync;
|
|
8
|
+
exports.writeFileAtomicDurable = writeFileAtomicDurable;
|
|
7
9
|
const fs_1 = __importDefault(require("fs"));
|
|
8
10
|
const path_1 = __importDefault(require("path"));
|
|
9
11
|
const crypto_1 = __importDefault(require("crypto"));
|
|
@@ -70,3 +72,32 @@ function writeFileAtomic(file, content, mode = 0o644) {
|
|
|
70
72
|
throw error;
|
|
71
73
|
}
|
|
72
74
|
}
|
|
75
|
+
/** fsync del directorio contenedor: garantiza que una ENTRADA creada/renombrada
|
|
76
|
+
* sobrevive un crash del OS. Falla LANZANDO — la durabilidad de la transición
|
|
77
|
+
* es parte del contrato, nunca un best-effort silencioso (design R1.2,
|
|
78
|
+
* bloqueador 4 de la review del plan). */
|
|
79
|
+
function fsyncDirSync(dir) {
|
|
80
|
+
let dirFd;
|
|
81
|
+
try {
|
|
82
|
+
dirFd = fs_1.default.openSync(dir, 'r');
|
|
83
|
+
fs_1.default.fsyncSync(dirFd);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
throw new Error(`fsync de directorio fallo para ${dir}: ${error.message}`);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
if (dirFd !== undefined) {
|
|
90
|
+
try {
|
|
91
|
+
fs_1.default.closeSync(dirFd);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// best-effort SOLO el close: el fsync ya ocurrio o ya lanzo.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** writeFileAtomic + fsync del directorio contenedor tras el rename. */
|
|
100
|
+
function writeFileAtomicDurable(file, content, mode = 0o644) {
|
|
101
|
+
writeFileAtomic(file, content, mode);
|
|
102
|
+
fsyncDirSync(path_1.default.dirname(file));
|
|
103
|
+
}
|
|
@@ -13,6 +13,12 @@ exports.packSkill = packSkill;
|
|
|
13
13
|
const fs_1 = __importDefault(require("fs"));
|
|
14
14
|
const path_1 = __importDefault(require("path"));
|
|
15
15
|
const child_process_1 = require("child_process");
|
|
16
|
+
// stdio explicito: sin esto, spawnSync relayea el stderr del `zip` hijo hacia
|
|
17
|
+
// el stderr del proceso llamante (default `inheritStderr` de Node cuando no
|
|
18
|
+
// se pasa `stdio`) — si ese fd fuera un pipe roto/destruido, el relay mismo
|
|
19
|
+
// dispara un EPIPE no catcheable que crashea al llamante (mismo bug de raiz
|
|
20
|
+
// que motivo EXEC_STDIO en core/journal/process.ts).
|
|
21
|
+
const EXEC_STDIO = ['ignore', 'pipe', 'pipe'];
|
|
16
22
|
/** Refuses symlinks anywhere in the tree — copying/zipping them could dereference
|
|
17
23
|
* into content outside the registry (info-leak) or embed a broken/unexpected
|
|
18
24
|
* link for the recipient. Exported artifacts are plain files only. */
|
|
@@ -28,7 +34,7 @@ function assertNoSymlinks(dir) {
|
|
|
28
34
|
}
|
|
29
35
|
/** Capa 1: binario `zip` del sistema. ENOENT → missing (capa 2: carpeta). */
|
|
30
36
|
const defaultZip = (cwd, zipName, folderName) => {
|
|
31
|
-
const r = (0, child_process_1.spawnSync)('zip', ['-r', '-q', zipName, folderName], { cwd });
|
|
37
|
+
const r = (0, child_process_1.spawnSync)('zip', ['-r', '-q', zipName, folderName], { cwd, stdio: EXEC_STDIO });
|
|
32
38
|
if (r.error && r.error.code === 'ENOENT') {
|
|
33
39
|
return { ok: false, missing: true };
|
|
34
40
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.DEFERENCE_LINE = void 0;
|
|
4
|
+
exports.stripIntraRegistryPaths = stripIntraRegistryPaths;
|
|
4
5
|
exports.claudeAiTransform = claudeAiTransform;
|
|
5
6
|
// cli/src/core/export/transform.ts
|
|
6
7
|
//
|
|
@@ -9,6 +10,53 @@ exports.claudeAiTransform = claudeAiTransform;
|
|
|
9
10
|
// línea) — sin parser YAML a propósito (YAGNI, cero deps).
|
|
10
11
|
const DEFERENCE_LINE = (skillName) => `In environments with AWM installed (Claude Code), defer to the registry's ${skillName} skill — this port is for environments without filesystem access.`;
|
|
11
12
|
exports.DEFERENCE_LINE = DEFERENCE_LINE;
|
|
13
|
+
// Paths intra-registry: resuelven en Claude Code (donde el registry está en
|
|
14
|
+
// disco) y nunca en claude.ai, donde solo se sube la skill portable. Se limpian
|
|
15
|
+
// en el artefacto exportado en vez de editar el SKILL.md canónico, que en Claude
|
|
16
|
+
// Code sí los necesita.
|
|
17
|
+
const SKILL_NAME = '[a-z0-9][a-z0-9-]*';
|
|
18
|
+
const REF_FILE = '[A-Za-z0-9._-]+';
|
|
19
|
+
const PATH_SRC = '(?:skills\\/' + SKILL_NAME + '\\/references\\/' + REF_FILE + '\\.md'
|
|
20
|
+
+ '|skills\\/' + SKILL_NAME + '\\/SKILL\\.md)';
|
|
21
|
+
/** Reconoce, en una sola pasada sobre el body ORIGINAL, tanto el caso
|
|
22
|
+
* "paréntesis cuyo único contenido es un path" (grupo 1) como el path suelto
|
|
23
|
+
* en cualquier otra posición (grupo 2). Una sola pasada evita un bug real de
|
|
24
|
+
* splicing encontrado en code review: si se borrara el paréntesis en una
|
|
25
|
+
* pasada separada, un path inmediatamente siguiente podría quedar pegado a
|
|
26
|
+
* texto que antes terminaba en `/` (el cierre de una URL, p. ej.), y el guard
|
|
27
|
+
* de "embebido en URL" (que mira el carácter previo) confundiría eso con un
|
|
28
|
+
* path genuinamente embebido. Matcheando todo en una sola pasada contra el
|
|
29
|
+
* string original, cada offset que llega a `isEmbeddedInUrl` es siempre real,
|
|
30
|
+
* nunca un artefacto de un borrado previo. */
|
|
31
|
+
const PATH_OR_DROPPED_PAREN = new RegExp('([ \\t]*\\((?:see[ \\t]+)?`?' + PATH_SRC + '`?\\))' + '|' + '(`?' + PATH_SRC + '`?)', 'g');
|
|
32
|
+
/** Un path precedido por `/` es el final de una URL o de un path más largo (un
|
|
33
|
+
* enlace a GitHub, por ejemplo). Esas referencias SÍ resuelven para quien lee la
|
|
34
|
+
* skill en claude.ai, así que no se tocan. */
|
|
35
|
+
function isEmbeddedInUrl(haystack, matchStart, matched) {
|
|
36
|
+
const pathStart = matchStart + matched.indexOf('skills/');
|
|
37
|
+
return pathStart > 0 && haystack[pathStart - 1] === '/';
|
|
38
|
+
}
|
|
39
|
+
const PATH_MATCHER = new RegExp('^skills\\/(' + SKILL_NAME + ')\\/references\\/(' + REF_FILE + ')\\.md$'
|
|
40
|
+
+ '|^skills\\/(' + SKILL_NAME + ')\\/SKILL\\.md$');
|
|
41
|
+
function pathlessForm(p) {
|
|
42
|
+
const m = PATH_MATCHER.exec(p);
|
|
43
|
+
if (!m)
|
|
44
|
+
throw new Error(`unreachable: "${p}" matched PATH_SRC but not PATH_MATCHER — the two must stay in sync`);
|
|
45
|
+
const [, refSkill, refFile, skillOnlyName] = m;
|
|
46
|
+
if (skillOnlyName !== undefined)
|
|
47
|
+
return `the \`${skillOnlyName}\` skill`;
|
|
48
|
+
return `the \`${refSkill}\` skill's ${refFile.replace(/-/g, ' ')} reference`;
|
|
49
|
+
}
|
|
50
|
+
function stripIntraRegistryPaths(body) {
|
|
51
|
+
return body.replace(PATH_OR_DROPPED_PAREN, (match, parenForm, bareForm, offset) => {
|
|
52
|
+
if (isEmbeddedInUrl(body, offset, match))
|
|
53
|
+
return match;
|
|
54
|
+
if (parenForm !== undefined)
|
|
55
|
+
return '';
|
|
56
|
+
const path = bareForm.replace(/^`|`$/g, '');
|
|
57
|
+
return pathlessForm(path);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
12
60
|
function claudeAiTransform(skillMd, skillName) {
|
|
13
61
|
// \r?\n-tolerant, same rationale as readArtifactDescription in discovery.ts:
|
|
14
62
|
// SKILL.md files may be CRLF-terminated and that's still valid frontmatter.
|
|
@@ -53,5 +101,7 @@ function claudeAiTransform(skillMd, skillName) {
|
|
|
53
101
|
? `${value.slice(0, -1)} ${deference.replace(/'/g, "''")}'`
|
|
54
102
|
: `${value} ${deference}`;
|
|
55
103
|
fmLines[descIdx] = `description: ${newValue}`;
|
|
56
|
-
|
|
104
|
+
// Solo el body: el frontmatter ya se editó arriba y sus campos no son prosa
|
|
105
|
+
// navegable (R2.4).
|
|
106
|
+
return `---\n${fmLines.join('\n')}\n---\n${stripIntraRegistryPaths(body)}`;
|
|
57
107
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.adapterFor = adapterFor;
|
|
4
|
+
const process_1 = require("./process");
|
|
5
|
+
const RESUME_PROMPT_PREFIX = 'Sos el orquestador SDD de este repo. Corre `awm job reconcile` y ejecuta ';
|
|
6
|
+
function baseSafeToReplace(ref) {
|
|
7
|
+
return (0, process_1.refIsAlive)(ref) ? 'indeterminate' : 'safe';
|
|
8
|
+
}
|
|
9
|
+
const codexAdapter = {
|
|
10
|
+
provider: 'codex',
|
|
11
|
+
launchArgv: (resumePrompt) => ['codex', 'exec', `${RESUME_PROMPT_PREFIX}${resumePrompt}`],
|
|
12
|
+
activity: process_1.activitySnapshot,
|
|
13
|
+
safeToReplace: baseSafeToReplace,
|
|
14
|
+
};
|
|
15
|
+
const claudeAdapter = {
|
|
16
|
+
provider: 'claude-code',
|
|
17
|
+
launchArgv: (resumePrompt) => ['claude', '-p', `${RESUME_PROMPT_PREFIX}${resumePrompt}`],
|
|
18
|
+
activity: process_1.activitySnapshot,
|
|
19
|
+
safeToReplace: baseSafeToReplace,
|
|
20
|
+
};
|
|
21
|
+
function adapterFor(provider) {
|
|
22
|
+
if (provider === 'codex')
|
|
23
|
+
return codexAdapter;
|
|
24
|
+
if (provider === 'claude-code')
|
|
25
|
+
return claudeAdapter;
|
|
26
|
+
throw new Error(`provider desconocido: ${provider} (validos: codex, claude-code)`);
|
|
27
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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.resolveWorkingDirectory = resolveWorkingDirectory;
|
|
7
|
+
exports.computeFingerprint = computeFingerprint;
|
|
8
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const child_process_1 = require("child_process");
|
|
12
|
+
const process_1 = require("./process");
|
|
13
|
+
function sha(parts) {
|
|
14
|
+
return crypto_1.default.createHash('sha256').update(parts.join('\0')).digest('hex');
|
|
15
|
+
}
|
|
16
|
+
// Sin ceiling de maxBuffer: repos grandes (`ls-files` / `ls-files --stage` en
|
|
17
|
+
// miles de archivos) pueden superar el default de Node (1MB) y abortar con ENOBUFS.
|
|
18
|
+
// stdio explicito (ver EXEC_STDIO en process.ts): sin esto, execFileSync relayea
|
|
19
|
+
// el stderr de git hacia el stderr DEL SUPERVISOR — si ese fd es un pipe roto, el
|
|
20
|
+
// relay dispara un EPIPE no catcheable que crashea el proceso ENTERO (este helper
|
|
21
|
+
// backea computeFingerprint, invocado en CADA tick via FingerprintNow/computeGate).
|
|
22
|
+
function git(cwd, args) {
|
|
23
|
+
return (0, child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8', maxBuffer: Infinity, stdio: process_1.EXEC_STDIO });
|
|
24
|
+
}
|
|
25
|
+
function resolveWorkingDirectory(repoRoot, cwdRel) {
|
|
26
|
+
if (typeof cwdRel !== 'string' || cwdRel.length === 0)
|
|
27
|
+
throw new Error('cwd relativo requerido');
|
|
28
|
+
const relative = path_1.default.normalize(cwdRel);
|
|
29
|
+
if (path_1.default.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path_1.default.sep}`)) {
|
|
30
|
+
throw new Error(`cwd fuera del repo: ${JSON.stringify(cwdRel)}`);
|
|
31
|
+
}
|
|
32
|
+
const root = fs_1.default.realpathSync(repoRoot);
|
|
33
|
+
let cursor = root;
|
|
34
|
+
for (const segment of relative.split(path_1.default.sep).filter((part) => part !== '.')) {
|
|
35
|
+
cursor = path_1.default.join(cursor, segment);
|
|
36
|
+
const stat = fs_1.default.lstatSync(cursor);
|
|
37
|
+
if (stat.isSymbolicLink())
|
|
38
|
+
throw new Error(`cwd contiene symlink no permitido: ${JSON.stringify(cwdRel)}`);
|
|
39
|
+
}
|
|
40
|
+
const absolute = fs_1.default.realpathSync(path_1.default.join(root, relative));
|
|
41
|
+
if (absolute !== root && !absolute.startsWith(`${root}${path_1.default.sep}`))
|
|
42
|
+
throw new Error(`cwd fuera del repo: ${JSON.stringify(cwdRel)}`);
|
|
43
|
+
if (!fs_1.default.statSync(absolute).isDirectory())
|
|
44
|
+
throw new Error(`cwd no es directorio: ${JSON.stringify(cwdRel)}`);
|
|
45
|
+
return { relative: relative.split(path_1.default.sep).join('/'), absolute };
|
|
46
|
+
}
|
|
47
|
+
/** El journal jamás invalida evidencia: .awm/ queda fuera de toda expansión. */
|
|
48
|
+
const EXCLUDE_JOURNAL = ':(exclude).awm';
|
|
49
|
+
/** Componentes SEPARADOS (design R3.4, bloqueador 7 de la review):
|
|
50
|
+
* argv exacto + cwd relativo REAL + HEAD + índice real (`ls-files --stage`
|
|
51
|
+
* hasheado) + digest de contenido por archivo tracked/untracked/deleted. */
|
|
52
|
+
function computeFingerprint(repoRoot, argv, pathGlobs, cwdRel) {
|
|
53
|
+
if (!Array.isArray(argv) || argv.length === 0)
|
|
54
|
+
throw new Error('argv vacio');
|
|
55
|
+
const cwdNorm = resolveWorkingDirectory(repoRoot, cwdRel).relative;
|
|
56
|
+
const commandDigest = sha(argv);
|
|
57
|
+
const head = git(repoRoot, ['rev-parse', 'HEAD']).trim();
|
|
58
|
+
const pathspecs = pathGlobs.length > 0 ? pathGlobs : ['.'];
|
|
59
|
+
// Índice REAL: modos + blobs + stages + paths — un cambio staged-only con
|
|
60
|
+
// worktree idéntico produce salida distinta aquí. Sin -z a propósito: esta
|
|
61
|
+
// salida se hashea completa como texto opaco, nunca se separa en paths
|
|
62
|
+
// individuales, así que el quoting de core.quotePath es inofensivo aquí
|
|
63
|
+
// (a diferencia de expandedPaths abajo, cuyos paths SÍ se re-extraen para
|
|
64
|
+
// pasarlos a `hash-object` — por eso ese caso sí necesita -z).
|
|
65
|
+
const indexRaw = git(repoRoot, ['ls-files', '--stage', '--', ...pathspecs, EXCLUDE_JOURNAL]);
|
|
66
|
+
const indexDigest = sha([indexRaw]);
|
|
67
|
+
const expandedPaths = git(repoRoot, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...pathspecs, EXCLUDE_JOURNAL])
|
|
68
|
+
.split('\0').filter(Boolean).sort();
|
|
69
|
+
const perFile = expandedPaths.map((p) => {
|
|
70
|
+
try {
|
|
71
|
+
return `${p}:${git(repoRoot, ['hash-object', '--', p]).trim()}`;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return `${p}:deleted`; // listado pero ilegible/borrado del worktree: cuenta como cambio
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
const declaredPaths = pathGlobs.length > 0 ? pathGlobs : ['.'];
|
|
78
|
+
const fingerprint = sha([commandDigest, `cwd:${cwdNorm}`, `paths:${JSON.stringify(declaredPaths)}`, `head:${head}`, `index:${indexDigest}`, ...perFile]);
|
|
79
|
+
return { fingerprint, commandDigest, expandedPaths };
|
|
80
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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.branchSlug = branchSlug;
|
|
7
|
+
exports.journalDir = journalDir;
|
|
8
|
+
exports.supervisorLockPath = supervisorLockPath;
|
|
9
|
+
exports.statePath = statePath;
|
|
10
|
+
exports.requestsDir = requestsDir;
|
|
11
|
+
exports.acksDir = acksDir;
|
|
12
|
+
exports.logsDir = logsDir;
|
|
13
|
+
exports.eventsPath = eventsPath;
|
|
14
|
+
exports.exportDir = exportDir;
|
|
15
|
+
const fs_1 = __importDefault(require("fs"));
|
|
16
|
+
const path_1 = __importDefault(require("path"));
|
|
17
|
+
function branchSlug(branch) {
|
|
18
|
+
if (!branch || branch === '.' || branch.includes('..')) {
|
|
19
|
+
throw new Error(`branch inválida para slug: ${JSON.stringify(branch)}`);
|
|
20
|
+
}
|
|
21
|
+
// Escapa PRIMERO el propio caracter de escape (_), despues / y \ — así
|
|
22
|
+
// ningún guion bajo literal sobrevive sin escapar, lo que hace la
|
|
23
|
+
// codificación biyectiva: dos ramas distintas nunca pueden colisionar
|
|
24
|
+
// (bloqueador encontrado en code-quality review de Task 3).
|
|
25
|
+
return branch
|
|
26
|
+
.replace(/_/g, '_5F')
|
|
27
|
+
.replace(/\//g, '_2F')
|
|
28
|
+
.replace(/\\/g, '_5C');
|
|
29
|
+
}
|
|
30
|
+
function journalDir(repoRoot, branch) {
|
|
31
|
+
return path_1.default.join(repoRoot, '.awm', 'journal', branchSlug(branch));
|
|
32
|
+
}
|
|
33
|
+
/** Lock único por worktree FÍSICO: clavado por realpath, fuera del dir de rama
|
|
34
|
+
* (design R1.1, bloqueante v5-5: dos ramas jamás toman locks distintos sobre
|
|
35
|
+
* el mismo árbol). */
|
|
36
|
+
function supervisorLockPath(repoRoot) {
|
|
37
|
+
return path_1.default.join(fs_1.default.realpathSync(repoRoot), '.awm', 'journal', 'supervisor.lock');
|
|
38
|
+
}
|
|
39
|
+
function statePath(repoRoot, branch) {
|
|
40
|
+
return path_1.default.join(journalDir(repoRoot, branch), 'state.json');
|
|
41
|
+
}
|
|
42
|
+
function requestsDir(repoRoot, branch) {
|
|
43
|
+
return path_1.default.join(journalDir(repoRoot, branch), 'requests');
|
|
44
|
+
}
|
|
45
|
+
function acksDir(repoRoot, branch) {
|
|
46
|
+
return path_1.default.join(journalDir(repoRoot, branch), 'acks');
|
|
47
|
+
}
|
|
48
|
+
function logsDir(repoRoot, branch) {
|
|
49
|
+
return path_1.default.join(journalDir(repoRoot, branch), 'logs');
|
|
50
|
+
}
|
|
51
|
+
function eventsPath(repoRoot, branch) {
|
|
52
|
+
return path_1.default.join(journalDir(repoRoot, branch), 'events.jsonl');
|
|
53
|
+
}
|
|
54
|
+
function exportDir(repoRoot, branch) {
|
|
55
|
+
return path_1.default.join(journalDir(repoRoot, branch), 'export');
|
|
56
|
+
}
|