@amenophis1er/foreman 0.1.13 → 0.1.14
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 +6 -4
- package/package.json +1 -1
- package/src/deck.ts +4 -2
- package/src/gitwork.test.ts +16 -1
- package/src/gitwork.ts +27 -1
- package/src/mcp.test.ts +56 -6
- package/src/mcp.ts +91 -12
- package/src/orchestrator.ts +28 -1
- package/src/server.ts +53 -5
- package/src/snapshot.test.ts +68 -0
- package/src/snapshot.ts +116 -0
- package/src/store.test.ts +16 -0
- package/src/store.ts +17 -0
- package/src/types.ts +9 -0
- package/ui/dist/assets/{index--dvfX3Mo.js → index-Ct_GJThl.js} +9 -9
- package/ui/dist/index.html +1 -1
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a finished run said, kept with the run.
|
|
3
|
+
*
|
|
4
|
+
* `.foreman/MISSION.md` is one file per project folder and every mission
|
|
5
|
+
* writes it, and the deck is a live diff of the folder against the run's
|
|
6
|
+
* baseline. Both were read fresh whenever a run was looked at — so the moment
|
|
7
|
+
* a newer mission started in the same project, every older run's page, its
|
|
8
|
+
* `run_report` and its resume were reading the new mission's doc and a diff
|
|
9
|
+
* that kept growing with later work. A resume of an older interrupted run
|
|
10
|
+
* handed its director the wrong mission outright.
|
|
11
|
+
*
|
|
12
|
+
* So a run's record freezes when the run ends: its MISSION.md and a summary
|
|
13
|
+
* of its deck are copied into `~/.foreman/runs/<id>/`. Finished runs are read
|
|
14
|
+
* from the copy; a running run still reads the live folder. On resume the
|
|
15
|
+
* copy goes back into the folder first, so the director picks up its own
|
|
16
|
+
* mission. Diffs in the frozen deck are capped so a record stays a record,
|
|
17
|
+
* not an archive of the repository.
|
|
18
|
+
*/
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { mkdir, readFile, writeFile, rename, stat, unlink } from 'node:fs/promises';
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
import { deckFor, type Deck } from './deck.js';
|
|
23
|
+
|
|
24
|
+
export const SNAPSHOT_DOC = 'MISSION.md';
|
|
25
|
+
export const SNAPSHOT_DECK = 'deck.json';
|
|
26
|
+
/** A single file's diff kept in the frozen deck; longer ones are cut with a note. */
|
|
27
|
+
const DIFF_CAP = 20_000;
|
|
28
|
+
|
|
29
|
+
export interface FrozenDeck extends Deck {
|
|
30
|
+
/** When the run ended and this was taken. */
|
|
31
|
+
frozenAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const missionDocPath = (folder: string) => path.join(folder, '.foreman', 'MISSION.md');
|
|
35
|
+
|
|
36
|
+
async function writeAtomic(file: string, body: string): Promise<void> {
|
|
37
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
38
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
39
|
+
await writeFile(tmp, body);
|
|
40
|
+
await rename(tmp, file);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Copies the run's mission doc and deck into `runDir`. Never throws; a missing doc is simply not copied. */
|
|
44
|
+
export async function snapshotRun(runDir: string, folder: string, runId: string, now = Date.now()): Promise<{ doc: boolean; deck: boolean }> {
|
|
45
|
+
let doc = false; let deck = false;
|
|
46
|
+
const text = await readFile(missionDocPath(folder), 'utf8').catch(() => null);
|
|
47
|
+
if (text !== null) {
|
|
48
|
+
await writeAtomic(path.join(runDir, SNAPSHOT_DOC), text).then(() => { doc = true; }).catch(() => {});
|
|
49
|
+
}
|
|
50
|
+
const live = await deckFor(folder, runId).catch(() => null);
|
|
51
|
+
if (live) {
|
|
52
|
+
const frozen: FrozenDeck = {
|
|
53
|
+
...live,
|
|
54
|
+
frozenAt: now,
|
|
55
|
+
files: live.files.map((f) => (f.diff && f.diff.length > DIFF_CAP
|
|
56
|
+
? { ...f, diff: `${f.diff.slice(0, DIFF_CAP)}\n… diff cut at ${DIFF_CAP} characters in the run's record`, truncated: true }
|
|
57
|
+
: f)),
|
|
58
|
+
note: [live.note, 'As the folder stood when the run ended.'].filter(Boolean).join(' '),
|
|
59
|
+
};
|
|
60
|
+
await writeAtomic(path.join(runDir, SNAPSHOT_DECK), JSON.stringify(frozen)).then(() => { deck = true; }).catch(() => {});
|
|
61
|
+
}
|
|
62
|
+
return { doc, deck };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The frozen doc, or null when this run has none (older runs, or nothing was written). */
|
|
66
|
+
export async function frozenMissionDoc(runDir: string): Promise<string | null> {
|
|
67
|
+
return readFile(path.join(runDir, SNAPSHOT_DOC), 'utf8').catch(() => null);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The frozen deck, or null. */
|
|
71
|
+
export async function frozenDeck(runDir: string): Promise<FrozenDeck | null> {
|
|
72
|
+
const raw = await readFile(path.join(runDir, SNAPSHOT_DECK), 'utf8').catch(() => null);
|
|
73
|
+
if (!raw) return null;
|
|
74
|
+
try { return JSON.parse(raw) as FrozenDeck; } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Puts the run's own mission doc back into the folder before a resume, so the
|
|
79
|
+
* director reads its mission and not whichever one ran last. Returns what
|
|
80
|
+
* happened, for the transcript: `restored` when the folder's doc differed,
|
|
81
|
+
* `same` when it already matched, `none` when this run has no copy.
|
|
82
|
+
*/
|
|
83
|
+
export async function restoreMissionDoc(runDir: string, folder: string): Promise<'restored' | 'same' | 'none'> {
|
|
84
|
+
const frozen = await frozenMissionDoc(runDir);
|
|
85
|
+
if (frozen === null) return 'none';
|
|
86
|
+
const current = await readFile(missionDocPath(folder), 'utf8').catch(() => null);
|
|
87
|
+
if (current === frozen) return 'same';
|
|
88
|
+
await writeAtomic(missionDocPath(folder), frozen);
|
|
89
|
+
return 'restored';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A new run starts with no mission doc in the folder. The previous run's
|
|
94
|
+
* MISSION.md would otherwise sit there until the new director rewrites it,
|
|
95
|
+
* and everything reading the folder — run_status, the fleet card, the rail —
|
|
96
|
+
* showed the old DONE WHEN, ticked, on a run that had just begun. The old doc
|
|
97
|
+
* is kept: copied into the previous run's record when that run has no copy
|
|
98
|
+
* yet (runs from before records were frozen), then removed from the folder.
|
|
99
|
+
*/
|
|
100
|
+
export async function parkMissionDoc(folder: string, previousRunDir: string | null): Promise<'parked' | 'none'> {
|
|
101
|
+
const file = missionDocPath(folder);
|
|
102
|
+
const text = await readFile(file, 'utf8').catch(() => null);
|
|
103
|
+
if (text === null) return 'none';
|
|
104
|
+
if (previousRunDir && (await frozenMissionDoc(previousRunDir)) === null) {
|
|
105
|
+
await writeAtomic(path.join(previousRunDir, SNAPSHOT_DOC), text).catch(() => {});
|
|
106
|
+
}
|
|
107
|
+
await unlink(file).catch(() => {});
|
|
108
|
+
return 'parked';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Does the run have a frozen record at all? */
|
|
112
|
+
export async function hasSnapshot(runDir: string): Promise<boolean> {
|
|
113
|
+
const doc = await stat(path.join(runDir, SNAPSHOT_DOC)).then(() => true, () => false);
|
|
114
|
+
if (doc) return true;
|
|
115
|
+
return stat(path.join(runDir, SNAPSHOT_DECK)).then(() => true, () => false);
|
|
116
|
+
}
|
package/src/store.test.ts
CHANGED
|
@@ -135,3 +135,19 @@ test('archiveChat retires a conversation: gone from the project, kept on disk, a
|
|
|
135
135
|
await store.archiveChat('p-abc', 'again');
|
|
136
136
|
await rm(root, { recursive: true, force: true });
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
test('sweepOrphans leaves a running run alone while another live process owns it', async () => {
|
|
141
|
+
const { store, root } = await tmpStore();
|
|
142
|
+
const mine = newRunId(1000); const theirs = newRunId(2000); const dead = newRunId(3000);
|
|
143
|
+
await store.createRun(meta(mine, { ownerPid: process.pid }));
|
|
144
|
+
// The test runner's parent is alive and is not us.
|
|
145
|
+
await store.createRun(meta(theirs, { ownerPid: process.ppid }));
|
|
146
|
+
await store.createRun(meta(dead, { ownerPid: 2 ** 22 - 7 }));
|
|
147
|
+
const swept = await store.sweepOrphans();
|
|
148
|
+
assert.ok(swept.includes(mine), 'our own record from a previous life is swept');
|
|
149
|
+
assert.ok(swept.includes(dead), 'a dead owner is an orphan');
|
|
150
|
+
assert.ok(!swept.includes(theirs), 'a live foreign owner keeps its run');
|
|
151
|
+
assert.equal((await store.readMeta(theirs))!.status, 'running');
|
|
152
|
+
await rm(root, { recursive: true, force: true });
|
|
153
|
+
});
|
package/src/store.ts
CHANGED
|
@@ -34,6 +34,14 @@ export function newRunId(now = Date.now()): string {
|
|
|
34
34
|
return `${String(now).padStart(13, '0')}-${crypto.randomBytes(4).toString('hex')}`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** Is a process with this pid alive? Signal 0 checks without sending anything. */
|
|
38
|
+
export function processAlive(pid: number): boolean {
|
|
39
|
+
try { process.kill(pid, 0); return true; } catch (err) {
|
|
40
|
+
// EPERM means it exists but is not ours to signal — still alive.
|
|
41
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
export class RunStore {
|
|
38
46
|
/** Absolute data root; exposed so startup checks can report and test it. */
|
|
39
47
|
readonly root: string;
|
|
@@ -182,6 +190,11 @@ export class RunStore {
|
|
|
182
190
|
return path.join(this.runsDir, runId);
|
|
183
191
|
}
|
|
184
192
|
|
|
193
|
+
/** The run's own directory, for the record a run keeps beside its meta and log (see snapshot.ts). */
|
|
194
|
+
runDirectory(runId: string): string {
|
|
195
|
+
return this.runDir(runId);
|
|
196
|
+
}
|
|
197
|
+
|
|
185
198
|
/** Creates the run directory and writes initial metadata. */
|
|
186
199
|
async createRun(meta: RunMeta): Promise<void> {
|
|
187
200
|
await mkdir(this.runDir(meta.id), { recursive: true });
|
|
@@ -351,6 +364,10 @@ export class RunStore {
|
|
|
351
364
|
const swept: string[] = [];
|
|
352
365
|
for (const meta of await this.listRuns()) {
|
|
353
366
|
if (meta.status !== 'running') continue;
|
|
367
|
+
// Another Foreman may be driving this run right now — the installed
|
|
368
|
+
// service while a dev server starts on the same data directory. Its
|
|
369
|
+
// record is not ours to close; only a run whose owner is gone is orphaned.
|
|
370
|
+
if (meta.ownerPid && meta.ownerPid !== process.pid && processAlive(meta.ownerPid)) continue;
|
|
354
371
|
const ended: RunMeta = { ...meta, status: 'interrupted', endedAt: Date.now() };
|
|
355
372
|
await this.append(meta.id, {
|
|
356
373
|
ts: Date.now(),
|
package/src/types.ts
CHANGED
|
@@ -331,6 +331,15 @@ export interface RunMeta {
|
|
|
331
331
|
* a resume clears it. Absent when the run ended for any other reason.
|
|
332
332
|
*/
|
|
333
333
|
stopReason?: 'budget' | 'turns' | 'time' | 'tokens';
|
|
334
|
+
/** When the run's record was frozen (MISSION.md and deck copied beside it); absent on older runs. */
|
|
335
|
+
snapshotAt?: number;
|
|
336
|
+
/**
|
|
337
|
+
* The server process driving this run. The startup sweep that marks
|
|
338
|
+
* abandoned runs interrupted leaves a run alone while its owner is alive —
|
|
339
|
+
* a second Foreman on the same data directory (a dev server beside the
|
|
340
|
+
* installed one) once swept a live mission's record out from under it.
|
|
341
|
+
*/
|
|
342
|
+
ownerPid?: number;
|
|
334
343
|
folder: string;
|
|
335
344
|
mission: string;
|
|
336
345
|
budgetUsd: number;
|