@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/README.md
CHANGED
|
@@ -230,7 +230,7 @@ result.
|
|
|
230
230
|
```sh
|
|
231
231
|
npm ci && npm run setup # dependencies, then the dashboard build
|
|
232
232
|
npm start # serves http://localhost:4177
|
|
233
|
-
npm test #
|
|
233
|
+
npm test # 379 tests, node:test
|
|
234
234
|
npm run typecheck # server and dashboard
|
|
235
235
|
npm run dev # API + Vite together
|
|
236
236
|
scripts/dev-restart.sh # restarts the server only when nothing would be lost
|
|
@@ -247,10 +247,12 @@ codex mcp add foreman -- foreman mcp
|
|
|
247
247
|
agy mcp add foreman -- foreman mcp
|
|
248
248
|
```
|
|
249
249
|
|
|
250
|
-
Tools: `fleet_status`, `list_runs`, `run_status` (with `wait_seconds
|
|
251
|
-
that
|
|
250
|
+
Tools: `fleet_status`, `list_runs`, `run_status` (with `wait_seconds` and
|
|
251
|
+
`until`: one call that blocks until the run changes, finishes, or needs you),
|
|
252
|
+
`run_report` (a finished run in one call: the director's report, DONE WHEN,
|
|
253
|
+
changed files, branch and pull request), `run_transcript`, `mission_doc`,
|
|
252
254
|
`project_memory`, `search_runs`, `doctor`, `link_project` (folder or Git URL),
|
|
253
|
-
`start_mission`, `steer`. It talks to the running server at `FOREMAN_URL`
|
|
255
|
+
`start_mission`, `steer`. Start, wait until finished, read the report: three calls. It talks to the running server at `FOREMAN_URL`
|
|
254
256
|
(default `http://localhost:4177`) and has no logic of its own.
|
|
255
257
|
|
|
256
258
|
Deliberately absent: approving or denying, answering the director's questions,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amenophis1er/foreman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/deck.ts
CHANGED
|
@@ -847,7 +847,7 @@ function sendJson(res: ServerResponse, code: number, body: unknown): void {
|
|
|
847
847
|
*/
|
|
848
848
|
export async function handleDeckRoute(
|
|
849
849
|
req: IncomingMessage, res: ServerResponse, url: URL,
|
|
850
|
-
lookup: (scope: 'runs' | 'projects', id: string) => Promise<{ folder: string } | null>,
|
|
850
|
+
lookup: (scope: 'runs' | 'projects', id: string) => Promise<{ folder: string; deck?: Deck } | null>,
|
|
851
851
|
): Promise<boolean> {
|
|
852
852
|
// The same jail and the same viewer serve two scopes: a run (its deck,
|
|
853
853
|
// relative to a baseline) and a project (its tree as it stands, no baseline).
|
|
@@ -863,7 +863,9 @@ export async function handleDeckRoute(
|
|
|
863
863
|
if (!run) { sendJson(res, 404, { error: 'not found' }); return true; }
|
|
864
864
|
|
|
865
865
|
if (what === 'deck') {
|
|
866
|
-
|
|
866
|
+
// A finished run's deck is the one frozen when it ended (snapshot.ts);
|
|
867
|
+
// the caller hands it over. A running run is diffed live.
|
|
868
|
+
sendJson(res, 200, run.deck ?? await deckFor(run.folder, runId));
|
|
867
869
|
return true;
|
|
868
870
|
}
|
|
869
871
|
if (what === 'tree') {
|
package/src/gitwork.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
7
|
-
import { closeMissionBranch, ensureMissionBranch, gitInfo, missionBranchName, startMissionBranch } from './gitwork.js';
|
|
7
|
+
import { closeMissionBranch, ensureMissionBranch, gitInfo, missionBranchName, startMissionBranch, renameMissionBranch } from './gitwork.js';
|
|
8
8
|
|
|
9
9
|
const sh = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null' } }).toString();
|
|
10
10
|
|
|
@@ -93,3 +93,18 @@ test('prDraft: the run title, the brief, the boxes as the mission left them, and
|
|
|
93
93
|
assert.match(d.body, /## Done when\n\n- \[x\] footer\.html exists\n- \[ \] linked from index/);
|
|
94
94
|
assert.match(d.body, /branch `foreman\/add-a-footer-ab12` from `main` · spend \$0\.42/);
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
test('renameMissionBranch takes the run title once there is one, and leaves a branch that moved on', async () => {
|
|
99
|
+
const dir = await repo();
|
|
100
|
+
const g = await startMissionBranch(dir, 'Repo: this folder is a git worktree. Do the thing.', '1788713434983-226123af');
|
|
101
|
+
assert.ok(!('error' in g));
|
|
102
|
+
assert.equal(g.branch, 'foreman/repo-this-folder-is-a-git-23af');
|
|
103
|
+
const renamed = await renameMissionBranch(dir, g.branch, 'Studio data sanitization and null handling', '1788713434983-226123af');
|
|
104
|
+
assert.equal(renamed, 'foreman/studio-data-sanitization-and-null-23af');
|
|
105
|
+
assert.equal(sh(dir, 'rev-parse', '--abbrev-ref', 'HEAD').trim(), renamed);
|
|
106
|
+
// Same name again: nothing to do. Not on the branch any more: left alone.
|
|
107
|
+
assert.equal(await renameMissionBranch(dir, renamed!, 'Studio data sanitization and null handling', '1788713434983-226123af'), null);
|
|
108
|
+
sh(dir, 'checkout', '-q', 'main');
|
|
109
|
+
assert.equal(await renameMissionBranch(dir, renamed!, 'Another title', '1788713434983-226123af'), null);
|
|
110
|
+
});
|
package/src/gitwork.ts
CHANGED
|
@@ -70,7 +70,12 @@ export async function gitInfo(folder: string): Promise<GitInfo> {
|
|
|
70
70
|
/** `foreman/<first words of the brief>-<id tail>`: readable in `git branch`, unique per run. */
|
|
71
71
|
export function missionBranchName(mission: string, runId: string): string {
|
|
72
72
|
const first = mission.split('\n').find((l) => l.trim())?.trim() ?? 'mission';
|
|
73
|
-
|
|
73
|
+
// Whole words up to six and forty characters: a name cut mid-word
|
|
74
|
+
// ("null-handli") reads worse than a shorter one.
|
|
75
|
+
const words = first.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').filter(Boolean).slice(0, 6);
|
|
76
|
+
let slug = '';
|
|
77
|
+
for (const w of words) { const next = slug ? `${slug}-${w}` : w; if (next.length > 40) break; slug = next; }
|
|
78
|
+
slug = slug || words[0]?.slice(0, 40) || 'mission';
|
|
74
79
|
const tail = runId.replace(/[^a-z0-9]/gi, '').slice(-4).toLowerCase();
|
|
75
80
|
return `foreman/${slug}-${tail}`;
|
|
76
81
|
}
|
|
@@ -92,6 +97,27 @@ export async function startMissionBranch(folder: string, mission: string, runId:
|
|
|
92
97
|
return { branch, base: info.branch ?? 'HEAD', baseHead: info.head ?? null };
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The branch takes the run's title once there is one. Branches are created
|
|
102
|
+
* before the title exists (the title is a model call that lands seconds
|
|
103
|
+
* later), so they started from the brief's first words — and briefs that all
|
|
104
|
+
* open with the same boilerplate gave every run the same name. Renamed in
|
|
105
|
+
* place, only while nothing has been committed on it and it is still checked
|
|
106
|
+
* out. Returns the new name, or null when it was left as it was.
|
|
107
|
+
*/
|
|
108
|
+
export async function renameMissionBranch(folder: string, from: string, title: string, runId: string): Promise<string | null> {
|
|
109
|
+
const to = missionBranchName(title, runId);
|
|
110
|
+
if (to === from) return null;
|
|
111
|
+
const info = await gitInfo(folder);
|
|
112
|
+
if (!info.repo || info.branch !== from) return null;
|
|
113
|
+
try {
|
|
114
|
+
await git(['branch', '-m', from, to], folder);
|
|
115
|
+
return to;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
95
121
|
/**
|
|
96
122
|
* Back on the mission's branch for a resume. Returns null when already or
|
|
97
123
|
* now there, else why not — a dirty tree that would be clobbered, typically.
|
package/src/mcp.test.ts
CHANGED
|
@@ -54,12 +54,12 @@ test('run_status: crew, DONE WHEN from the mission doc, needs, and no wait on a
|
|
|
54
54
|
assert.match(r.text, /stopped at its budget cap/);
|
|
55
55
|
assert.match(r.text, /DONE WHEN 1\/2\n open: docs updated/);
|
|
56
56
|
assert.match(r.text, /worker-1 · done/);
|
|
57
|
-
assert.match(r.text,
|
|
57
|
+
assert.match(r.text, /^finished$/m);
|
|
58
58
|
assert.ok(!calls.some((c) => c.path.startsWith('/events')), 'a finished run is never waited on');
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
test('run_status with a wait
|
|
62
|
-
const sse = new ReadableStream<Uint8Array>({
|
|
61
|
+
test('run_status with a wait returns when the run\'s picture changes, not on a bare event', async () => {
|
|
62
|
+
const sse = () => new ReadableStream<Uint8Array>({
|
|
63
63
|
start(c) {
|
|
64
64
|
const enc = new TextEncoder();
|
|
65
65
|
c.enqueue(enc.encode(': connected\n\n'));
|
|
@@ -67,18 +67,68 @@ test('run_status with a wait subscribes to /events and returns on the first even
|
|
|
67
67
|
c.enqueue(enc.encode('event: worker_started\ndata: {"runId":"r1","projectId":"p1","data":{"id":"worker-2"}}\n\n'));
|
|
68
68
|
},
|
|
69
69
|
});
|
|
70
|
+
let rounds = 0;
|
|
70
71
|
const { fetchImpl } = fakeServer({
|
|
71
|
-
|
|
72
|
+
// First round: an event, but the same run — no change. Second: a new worker.
|
|
73
|
+
'GET /runs': () => ({ runs: [run(rounds >= 2 ? { workers: [{ id: 'worker-1', status: 'done', costUsd: 0.4, task: 'write tests' }, { id: 'worker-2', status: 'running', costUsd: 0, task: 'more' }] } : {})] }),
|
|
72
74
|
'GET /projects': { projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: run(), needs: [{ kind: 'perm', id: 'a1', runId: 'r1', text: 'director wants Bash — rm -rf dist' }] }] },
|
|
73
75
|
'GET /missiondoc': { doc: '' },
|
|
74
|
-
'GET /events': new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }),
|
|
76
|
+
'GET /events': () => { rounds += 1; return new Response(sse(), { status: 200, headers: { 'content-type': 'text/event-stream' } }); },
|
|
75
77
|
});
|
|
76
78
|
const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_status').run({ runId: 'r1', wait_seconds: 5 });
|
|
77
79
|
assert.match(r.text, /changed: yes/);
|
|
80
|
+
assert.ok(rounds >= 2, 'the first event changed nothing visible, so it kept waiting');
|
|
81
|
+
assert.match(r.text, /worker-2 · running/);
|
|
78
82
|
assert.match(r.text, /NEEDS YOU \(1\) — only a human can answer/);
|
|
79
83
|
assert.match(r.text, /\[perm\] director wants Bash/);
|
|
80
84
|
});
|
|
81
85
|
|
|
86
|
+
test('run_status until=finished waits through events that are not the end, and stops at the end', async () => {
|
|
87
|
+
// Two rounds: a cost tick (not finished) then the run turns done.
|
|
88
|
+
let rounds = 0;
|
|
89
|
+
const sseOnce = () => new ReadableStream<Uint8Array>({ start(c) { c.enqueue(new TextEncoder().encode('event: cost\ndata: {"runId":"r1","projectId":"p1","data":{}}\n\n')); } });
|
|
90
|
+
const { fetchImpl, calls } = fakeServer({
|
|
91
|
+
'GET /runs': () => ({ runs: [run({ status: rounds >= 2 ? 'done' : 'running' })] }),
|
|
92
|
+
'GET /projects': { projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: null, needs: [] }] },
|
|
93
|
+
'GET /missiondoc': { doc: '' },
|
|
94
|
+
'GET /events': () => { rounds += 1; return new Response(sseOnce(), { status: 200 }); },
|
|
95
|
+
});
|
|
96
|
+
const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_status').run({ runId: 'r1', wait_seconds: 10, until: 'finished' });
|
|
97
|
+
assert.match(r.text, /r1 · done/);
|
|
98
|
+
assert.match(r.text, /^finished$/m);
|
|
99
|
+
assert.ok(calls.filter((c) => c.path.startsWith('/events')).length >= 2, 'kept waiting past the first event');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('run_status until=needs_you returns when an approval appears', async () => {
|
|
103
|
+
let asked = false;
|
|
104
|
+
const { fetchImpl } = fakeServer({
|
|
105
|
+
'GET /runs': { runs: [run()] },
|
|
106
|
+
'GET /projects': () => ({ projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: run(), needs: asked ? [{ kind: 'perm', id: 'a1', runId: 'r1', text: 'director wants Bash' }] : [] }] }),
|
|
107
|
+
'GET /missiondoc': { doc: '' },
|
|
108
|
+
'GET /events': () => { asked = true; return new Response(new ReadableStream<Uint8Array>({ start(c) { c.enqueue(new TextEncoder().encode('event: permission_request\ndata: {"runId":"r1","projectId":"p1","data":{}}\n\n')); } }), { status: 200 }); },
|
|
109
|
+
});
|
|
110
|
+
const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_status').run({ runId: 'r1', wait_seconds: 5, until: 'needs_you' });
|
|
111
|
+
assert.match(r.text, /^needs you$/m);
|
|
112
|
+
assert.match(r.text, /\[perm\] director wants Bash/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('run_report: the director\'s last words, DONE WHEN, changed files and the branch in one call', async () => {
|
|
116
|
+
const say = (agent: string, text: string) => ({ ts: 1, event: 'message', data: { agent, msg: { type: 'assistant', message: { content: [{ type: 'text', text }] } } } });
|
|
117
|
+
const { fetchImpl } = fakeServer({
|
|
118
|
+
'GET /runs': { runs: [run({ status: 'done', git: { branch: 'foreman/fix-r1', base: 'main', commits: 1, pr: 'https://github.com/o/r/pull/9', prState: 'merged' } })] },
|
|
119
|
+
'GET /runs/r1/events': { events: [say('director', 'Starting.'), say('worker-1', 'A long worker message that must not be mistaken for the report.'), say('director', 'All done: tests pass, docs updated, nothing left undone in this mission.')] },
|
|
120
|
+
'GET /missiondoc': { doc: '- [x] tests pass\n- [x] docs updated\n' },
|
|
121
|
+
'GET /runs/r1/deck': { baseline: { kind: 'git' }, files: [{ path: 'src/a.ts', status: 'modified', additions: 10, deletions: 2 }, { path: 'old.txt', status: 'modified', additions: 1, deletions: 1, preexisting: true }], artifacts: [{ path: 'shot.png', kind: 'image' }], totals: { files: 2, additions: 11, deletions: 3 } },
|
|
122
|
+
});
|
|
123
|
+
const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_report').run({ runId: 'r1' });
|
|
124
|
+
assert.match(r.text, /branch foreman\/fix-r1 from main · 1 commit · PR https:\/\/github.com\/o\/r\/pull\/9 \(merged\)/);
|
|
125
|
+
assert.match(r.text, /DONE WHEN 2\/2/);
|
|
126
|
+
assert.match(r.text, /changed: 1 file · \+11 −3 · 1 screenshot · 1 already dirty before the run/);
|
|
127
|
+
assert.match(r.text, /modified src\/a\.ts \+10 −2/);
|
|
128
|
+
assert.match(r.text, /Director's report:\nAll done: tests pass/);
|
|
129
|
+
assert.doesNotMatch(r.text, /worker message/);
|
|
130
|
+
});
|
|
131
|
+
|
|
82
132
|
test('waitForRunEvent gives up at the timeout when nothing arrives for the run', async () => {
|
|
83
133
|
const quiet = new ReadableStream<Uint8Array>({ start(c) { c.enqueue(new TextEncoder().encode(': connected\n\n')); } });
|
|
84
134
|
const { fetchImpl } = fakeServer({ 'GET /events': new Response(quiet, { status: 200 }) });
|
|
@@ -114,7 +164,7 @@ test('the tool set has no human-only actions', () => {
|
|
|
114
164
|
for (const forbidden of ['approve', 'deny', 'permission', 'answer', 'interrupt', 'resume', 'budget', 'pull_request', 'open_pr', 'settings', 'key']) {
|
|
115
165
|
assert.ok(!names.some((n) => n.split('_').includes(forbidden) || n === forbidden), `${forbidden} must not be a tool`);
|
|
116
166
|
}
|
|
117
|
-
assert.deepEqual(names, ['fleet_status', 'list_runs', 'run_status', 'run_transcript', 'mission_doc', 'project_memory', 'search_runs', 'doctor', 'link_project', 'start_mission', 'steer']);
|
|
167
|
+
assert.deepEqual(names, ['fleet_status', 'list_runs', 'run_status', 'run_report', 'run_transcript', 'mission_doc', 'project_memory', 'search_runs', 'doctor', 'link_project', 'start_mission', 'steer']);
|
|
118
168
|
});
|
|
119
169
|
|
|
120
170
|
test('a server that is not there is said in one sentence with the start command', async () => {
|
package/src/mcp.ts
CHANGED
|
@@ -222,22 +222,63 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
|
|
|
222
222
|
},
|
|
223
223
|
};
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* What a reader of run_status would notice changing. `until: 'any'` waits
|
|
227
|
+
* for THIS to move, not for the next event: a fresh run emits a cost frame
|
|
228
|
+
* per SDK message, and returning on those meant "changed: true" twice in a
|
|
229
|
+
* row with identical payloads — a client had to poll after all.
|
|
230
|
+
*/
|
|
231
|
+
async function digestOf(id: string): Promise<string> {
|
|
232
|
+
const r = await findRun(id);
|
|
233
|
+
const needs = await needsOf(id);
|
|
234
|
+
const doc = await get<{ doc: string }>(`/missiondoc?run=${encodeURIComponent(id)}`).then((d) => d.doc ?? '').catch(() => '');
|
|
235
|
+
const dw = doneWhen(doc);
|
|
236
|
+
return JSON.stringify([r.status, r.stopReason ?? null, (r.costUsd ?? 0).toFixed(2), (r.workers ?? []).map((w) => `${w.id}:${w.status}`), dw.done.length, dw.open.length, needs.map((n) => n.id), r.git?.commits ?? 0, r.git?.pr ?? null]);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** What is pending on a run right now, from the fleet payload. */
|
|
240
|
+
async function needsOf(id: string): Promise<Need[]> {
|
|
241
|
+
const projects = (await get<{ projects: ProjectCard[] }>('/projects')).projects;
|
|
242
|
+
const card = projects.find((p) => p.activeRun?.id === id);
|
|
243
|
+
return (card?.needs ?? []).filter((n) => !n.runId || n.runId === id);
|
|
244
|
+
}
|
|
245
|
+
|
|
225
246
|
const runStatus: ToolDef = {
|
|
226
247
|
name: 'run_status',
|
|
227
|
-
description: 'One run: status, spend against cap, crew and their states, DONE WHEN ticks, what needs a human, branch and pull request. With wait_seconds > 0 it
|
|
228
|
-
schema: {
|
|
229
|
-
|
|
248
|
+
description: 'One run: status, spend against cap, crew and their states, DONE WHEN ticks, what needs a human, branch and pull request. With wait_seconds > 0 it blocks until `until` is met — "any" change on the run, the run "finished", or it "needs_you" (a pending approval or question, or finished) — or until the timeout. Use it instead of polling; call again if it timed out.',
|
|
249
|
+
schema: {
|
|
250
|
+
runId: z.string(),
|
|
251
|
+
wait_seconds: z.number().int().min(0).max(maxWait).default(0),
|
|
252
|
+
until: z.enum(['any', 'finished', 'needs_you']).default('any'),
|
|
253
|
+
},
|
|
254
|
+
run: async ({ runId, wait_seconds, until }) => {
|
|
230
255
|
const id = String(runId);
|
|
256
|
+
const cond = String(until ?? 'any');
|
|
231
257
|
let changed: boolean | undefined;
|
|
232
258
|
if (Number(wait_seconds) > 0) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
259
|
+
// Wait in rounds: each round ends on the first event for the run,
|
|
260
|
+
// then the condition is checked against fresh state; an event that
|
|
261
|
+
// does not satisfy it (a cost tick, under "finished") starts another
|
|
262
|
+
// round with the time that is left. The clock is the outer bound.
|
|
263
|
+
const deadline = Date.now() + Number(wait_seconds) * 1000;
|
|
264
|
+
changed = false;
|
|
265
|
+
const before = await digestOf(id);
|
|
266
|
+
for (;;) {
|
|
267
|
+
const now = await findRun(id);
|
|
268
|
+
const satisfied = now.status !== 'running'
|
|
269
|
+
|| (cond === 'needs_you' && (await needsOf(id)).length > 0)
|
|
270
|
+
|| (cond === 'any' && changed);
|
|
271
|
+
if (satisfied) break;
|
|
272
|
+
const left = Math.ceil((deadline - Date.now()) / 1000);
|
|
273
|
+
if (left <= 0) break;
|
|
274
|
+
const got = await waitForRunEvent(base, id, left, f);
|
|
275
|
+
if (!got) break;
|
|
276
|
+
// An event arrived; only count it when the picture it paints differs.
|
|
277
|
+
if ((await digestOf(id)) !== before) changed = true;
|
|
278
|
+
}
|
|
236
279
|
}
|
|
237
280
|
const r = await findRun(id);
|
|
238
|
-
const
|
|
239
|
-
const card = projects.find((p) => p.activeRun?.id === id);
|
|
240
|
-
const needs = (card?.needs ?? []).filter((n) => !n.runId || n.runId === id);
|
|
281
|
+
const needs = await needsOf(id);
|
|
241
282
|
const doc = await get<{ doc: string }>(`/missiondoc?run=${encodeURIComponent(id)}`).then((d) => d.doc).catch(() => '');
|
|
242
283
|
const dw = doneWhen(doc);
|
|
243
284
|
const workers = (r.workers ?? []).map((w) => ` ${w.id} · ${w.status} · ${usd(w.costUsd)} · ${w.task.slice(0, 80)}`);
|
|
@@ -248,9 +289,47 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
|
|
|
248
289
|
dw.done.length + dw.open.length ? `DONE WHEN ${dw.done.length}/${dw.done.length + dw.open.length}${dw.open.length ? `\n open: ${dw.open.join('\n open: ')}` : ''}` : null,
|
|
249
290
|
workers.length ? `crew:\n${workers.join('\n')}` : null,
|
|
250
291
|
needs.length ? `NEEDS YOU (${needs.length}) — only a human can answer these, on the dashboard or the phone:\n${needs.map((n) => ` [${n.kind}] ${n.text}`).join('\n')}` : null,
|
|
251
|
-
changed !== undefined ? (changed ? 'changed: yes' : 'changed: no (timeout)') : null,
|
|
292
|
+
changed !== undefined ? (r.status !== 'running' ? 'finished' : needs.length && cond === 'needs_you' ? 'needs you' : changed ? 'changed: yes' : 'changed: no (timeout — call again)') : null,
|
|
293
|
+
].filter(Boolean);
|
|
294
|
+
return { text: lines.join('\n'), data: { run: r, doneWhen: dw, needs, changed, waitedFor: Number(wait_seconds) > 0 ? cond : undefined } };
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const runReport: ToolDef = {
|
|
299
|
+
name: 'run_report',
|
|
300
|
+
description: 'What a finished run produced, in one call: the director\'s final report, DONE WHEN ticks, the files it changed with +/− counts, the branch, commit and pull request, spend and crew. For a running run it reports the state so far.',
|
|
301
|
+
schema: { runId: z.string() },
|
|
302
|
+
run: async ({ runId }) => {
|
|
303
|
+
const id = String(runId);
|
|
304
|
+
const r = await findRun(id);
|
|
305
|
+
const [events, docRes, deck] = await Promise.all([
|
|
306
|
+
get<{ events: Array<{ ts: number; event: string; data: Record<string, unknown> }> }>(`/runs/${encodeURIComponent(id)}/events`).then((d) => d.events).catch(() => []),
|
|
307
|
+
get<{ doc: string }>(`/missiondoc?run=${encodeURIComponent(id)}`).catch(() => ({ doc: '' })),
|
|
308
|
+
get<{ files: Array<{ path: string; status: string; additions: number; deletions: number; preexisting?: boolean }>; artifacts: Array<{ path: string; kind: string }>; totals: { files: number; additions: number; deletions: number }; baseline: { kind: string } }>(`/runs/${encodeURIComponent(id)}/deck`).catch(() => null),
|
|
309
|
+
]);
|
|
310
|
+
// The director's last words: the final assistant text before the run ended.
|
|
311
|
+
let report = '';
|
|
312
|
+
for (const e of events) {
|
|
313
|
+
if (e.event !== 'message' || e.data?.agent !== 'director') continue;
|
|
314
|
+
const msg = e.data.msg as { type?: string; message?: { content?: Array<{ type: string; text?: string }> } } | undefined;
|
|
315
|
+
const text = msg?.type === 'assistant' ? msg.message?.content?.filter((c) => c.type === 'text' && c.text).map((c) => c.text).join('\n') : '';
|
|
316
|
+
if (text && text.trim().length > 40) report = text.trim();
|
|
317
|
+
}
|
|
318
|
+
const dw = doneWhen(docRes.doc);
|
|
319
|
+
const files = deck?.files ?? [];
|
|
320
|
+
const own = files.filter((x) => !x.preexisting);
|
|
321
|
+
const fileLines = own.slice(0, 40).map((x) => ` ${x.status.padEnd(8)} ${x.path} +${x.additions} −${x.deletions}`);
|
|
322
|
+
const images = (deck?.artifacts ?? []).filter((a) => a.kind === 'image').length;
|
|
323
|
+
const lines = [
|
|
324
|
+
runLine(r),
|
|
325
|
+
r.git ? `branch ${r.git.branch} from ${r.git.base}${r.git.commits ? ` · ${r.git.commits} commit${r.git.commits === 1 ? '' : 's'}` : ''}${r.git.pr ? ` · PR ${r.git.pr}${r.git.prState ? ` (${r.git.prState})` : ''}` : ' · no pull request yet (the human opens it from the run page)'}` : 'not a git repository',
|
|
326
|
+
`DONE WHEN ${dw.done.length}/${dw.done.length + dw.open.length}${dw.open.length ? ` · open: ${dw.open.join(' · ')}` : ''}`,
|
|
327
|
+
deck ? `changed: ${own.length} file${own.length === 1 ? '' : 's'} · +${deck.totals.additions} −${deck.totals.deletions}${images ? ` · ${images} screenshot${images === 1 ? '' : 's'}` : ''}${files.length > own.length ? ` · ${files.length - own.length} already dirty before the run` : ''}` : null,
|
|
328
|
+
fileLines.length ? fileLines.join('\n') + (own.length > 40 ? `\n … ${own.length - 40} more` : '') : null,
|
|
329
|
+
`crew: ${(r.workers ?? []).length} worker${(r.workers ?? []).length === 1 ? '' : 's'} · ${(r.workers ?? []).filter((w) => w.status === 'done').length} done`,
|
|
330
|
+
report ? `\nDirector's report:\n${report.slice(0, 4000)}` : '\nNo final report from the director yet.',
|
|
252
331
|
].filter(Boolean);
|
|
253
|
-
return { text: lines.join('\n'), data: { run: r, doneWhen: dw,
|
|
332
|
+
return { text: lines.join('\n'), data: { run: r, doneWhen: dw, files: own, totals: deck?.totals, report } };
|
|
254
333
|
},
|
|
255
334
|
};
|
|
256
335
|
|
|
@@ -377,7 +456,7 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
|
|
|
377
456
|
},
|
|
378
457
|
};
|
|
379
458
|
|
|
380
|
-
return [fleet, listRuns, runStatus, transcript, missionDoc, memory, search, doctor, link, start, steer];
|
|
459
|
+
return [fleet, listRuns, runStatus, runReport, transcript, missionDoc, memory, search, doctor, link, start, steer];
|
|
381
460
|
}
|
|
382
461
|
|
|
383
462
|
/** Runs the MCP server over stdio until the client goes away. Nothing may be written to stdout but the protocol. */
|
package/src/orchestrator.ts
CHANGED
|
@@ -604,6 +604,8 @@ type AgentRole = 'director' | 'worker';
|
|
|
604
604
|
* and which role's rates price the tokens.
|
|
605
605
|
*/
|
|
606
606
|
interface WorkerOverrides {
|
|
607
|
+
/** Set on the one continuation a worker gets after the turn cap. */
|
|
608
|
+
continued?: boolean;
|
|
607
609
|
env?: AgentEnv;
|
|
608
610
|
model?: string;
|
|
609
611
|
priceRole?: AgentRole;
|
|
@@ -746,6 +748,19 @@ const USAGE_LIMIT_RE = /out of usage credits|usage limit reached|upgrade to incr
|
|
|
746
748
|
/** One worker's outcome, as runWorker hands it back. */
|
|
747
749
|
interface WorkerOutcome { report: string; isError: boolean }
|
|
748
750
|
|
|
751
|
+
/**
|
|
752
|
+
* Turns a worker gets before the SDK stops it. Raised from 60: five workers
|
|
753
|
+
* in four missions hit the cap mid-task on legitimate, brief-sized work
|
|
754
|
+
* (a hundred TypeScript errors in one package; a test-suite port), and each
|
|
755
|
+
* time the director paid to respawn one that re-read the same files.
|
|
756
|
+
*/
|
|
757
|
+
const WORKER_MAX_TURNS = 100;
|
|
758
|
+
/** What a worker stopped by the cap is told when its session is picked back up. */
|
|
759
|
+
const WORKER_CONTINUE_PROMPT =
|
|
760
|
+
'You were stopped by the turn cap, not by a failure. Your session and your files are as you left them; ' +
|
|
761
|
+
'`git status` and `git diff` show your uncommitted work. Continue the same task from where you were — do not ' +
|
|
762
|
+
'start over or re-read what you already know — finish it, report with report_progress, and end your turn.';
|
|
763
|
+
|
|
749
764
|
/**
|
|
750
765
|
* A worker record plus the in-process handles that must never be persisted:
|
|
751
766
|
* the live query (for interrupts), the run promise (so nothing is left
|
|
@@ -1983,7 +1998,7 @@ export class MissionRun {
|
|
|
1983
1998
|
permissionMode: 'default',
|
|
1984
1999
|
resume: resumeSessionId,
|
|
1985
2000
|
model: overrides?.model || this.meta.workerModel,
|
|
1986
|
-
maxTurns:
|
|
2001
|
+
maxTurns: WORKER_MAX_TURNS,
|
|
1987
2002
|
systemPrompt: { type: 'preset', preset: 'claude_code', append: WORKER_CHARTER },
|
|
1988
2003
|
...(overrides?.env ?? this.agentEnv.worker),
|
|
1989
2004
|
// Built per worker: the report_progress handler closes over this id,
|
|
@@ -1996,6 +2011,7 @@ export class MissionRun {
|
|
|
1996
2011
|
w.q = q;
|
|
1997
2012
|
|
|
1998
2013
|
let report = '';
|
|
2014
|
+
let hitTurnCap = false;
|
|
1999
2015
|
let isError = false;
|
|
2000
2016
|
|
|
2001
2017
|
// The stall watchdog. A silent worker no longer blocks the director, but
|
|
@@ -2041,6 +2057,7 @@ export class MissionRun {
|
|
|
2041
2057
|
if (m.type === 'result') {
|
|
2042
2058
|
report = String(m.result ?? '');
|
|
2043
2059
|
isError = Boolean(m.is_error);
|
|
2060
|
+
hitTurnCap = m.subtype === 'error_max_turns' || /maximum number of turns/i.test(report);
|
|
2044
2061
|
this.noteUsageLimit(report);
|
|
2045
2062
|
// Same ordering rule as the director loop: the cost event carries
|
|
2046
2063
|
// usage, so usage has to be current before it is emitted.
|
|
@@ -2060,6 +2077,16 @@ export class MissionRun {
|
|
|
2060
2077
|
w.q = undefined;
|
|
2061
2078
|
}
|
|
2062
2079
|
|
|
2080
|
+
// A worker stopped by the turn cap mid-task is not a failed worker. Its
|
|
2081
|
+
// session is resumed once with a continue turn — the same context, the
|
|
2082
|
+
// same files — instead of handing the director an error it can only
|
|
2083
|
+
// answer by spawning a replacement that re-reads everything. Once: a
|
|
2084
|
+
// worker that burns two allowances is stuck, and that IS the director's.
|
|
2085
|
+
if (hitTurnCap && !overrides?.continued && w.sessionId && !stalled && !looping) {
|
|
2086
|
+
this.emit('worker_progress', { id: workerId, status: `reached the ${WORKER_MAX_TURNS}-turn cap mid-task; continuing the same session once` });
|
|
2087
|
+
return this.runWorker(workerId, WORKER_CONTINUE_PROMPT, w.sessionId, { ...overrides, continued: true });
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2063
2090
|
// Told to the director as a fact plus its options, not as an order: it is
|
|
2064
2091
|
// the agent with the context to know whether this needs a different
|
|
2065
2092
|
// approach, a different worker, or a human.
|
package/src/server.ts
CHANGED
|
@@ -72,7 +72,8 @@ import { readMemory } from './memory.js';
|
|
|
72
72
|
import { budgetAnchor, modelRecords, projectRecord, recordLine } from './track-record.js';
|
|
73
73
|
import { reconcileRole } from './role-provider.js';
|
|
74
74
|
import { detectBrowser, installChromium } from './browser.js';
|
|
75
|
-
import {
|
|
75
|
+
import { frozenDeck, frozenMissionDoc, parkMissionDoc, restoreMissionDoc, snapshotRun } from './snapshot.js';
|
|
76
|
+
import { closeMissionBranch, compareUrl, createPullRequest, ensureMissionBranch, ghReady, gitInfo, missionBranchName, prDraft, pullRequestState, pushBranch, renameMissionBranch, startMissionBranch, type GitInfo } from './gitwork.js';
|
|
76
77
|
import { detectTailscale, tailnetUrl } from './tailscale.js';
|
|
77
78
|
import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
|
|
78
79
|
import { ServiceRegistry, portOpen, servicesHandler } from './services.js';
|
|
@@ -593,6 +594,21 @@ function makeEmitter(runId: string, projectId: string) {
|
|
|
593
594
|
runLabelCache.set(runId, { ...runLabelCache.get(runId), mission: String(d.mission ?? '') });
|
|
594
595
|
} else if (event === 'run_titled') {
|
|
595
596
|
runLabelCache.set(runId, { ...runLabelCache.get(runId), title: String(d.title ?? '') });
|
|
597
|
+
// The branch was named from the brief before the title existed; now it
|
|
598
|
+
// can carry the title. Only while it is still the brief-derived name and
|
|
599
|
+
// nothing is committed on it — the mission's first turns.
|
|
600
|
+
const run = activeRuns().find((r) => r.meta.id === runId);
|
|
601
|
+
const title = String(d.title ?? '').trim();
|
|
602
|
+
if (run?.meta.git && title && run.meta.git.branch === missionBranchName(run.meta.mission, runId) && !(run.meta.git.commits)) {
|
|
603
|
+
void renameMissionBranch(run.meta.folder, run.meta.git.branch, title, runId).then(async (renamed) => {
|
|
604
|
+
if (!renamed || !run.meta.git) return;
|
|
605
|
+
const was = run.meta.git.branch;
|
|
606
|
+
run.meta.git = { ...run.meta.git, branch: renamed };
|
|
607
|
+
await store.writeMeta(run.meta).catch(() => {});
|
|
608
|
+
gitInfoCache.delete(run.meta.folder);
|
|
609
|
+
makeEmitter(runId, projectId)('git_branch', { branch: renamed, base: run.meta.git.base, text: `Branch renamed to ${renamed} (was ${was}) now that the run has a title.` });
|
|
610
|
+
});
|
|
611
|
+
}
|
|
596
612
|
}
|
|
597
613
|
if (!projectsCache.has(projectId)) {
|
|
598
614
|
void store.getProject(projectId).then((p) => { if (p) projectsCache.set(projectId, { name: p.name }); });
|
|
@@ -1595,6 +1611,15 @@ async function driveRun(
|
|
|
1595
1611
|
// However the run ended, it no longer needs its gateways.
|
|
1596
1612
|
releaseGateways(meta.id);
|
|
1597
1613
|
if (activeByProject.get(projectId) === run) activeByProject.delete(projectId);
|
|
1614
|
+
// Freeze the record: the mission doc and the deck as they stand at this
|
|
1615
|
+
// moment, beside the run's meta and log. Done first, before the branch
|
|
1616
|
+
// closes, so the record is what the crew left — a later mission in the
|
|
1617
|
+
// same folder rewrites MISSION.md and the live diff, never this copy.
|
|
1618
|
+
const frozen = await snapshotRun(store.runDirectory(meta.id), meta.folder, meta.id);
|
|
1619
|
+
if (frozen.doc || frozen.deck) {
|
|
1620
|
+
meta.snapshotAt = Date.now();
|
|
1621
|
+
await store.writeMeta(meta).catch(() => {});
|
|
1622
|
+
}
|
|
1598
1623
|
// A finished mission on its own branch closes with a commit of whatever
|
|
1599
1624
|
// the crew left uncommitted. Done only: an interrupted run resumes on
|
|
1600
1625
|
// the same branch and its tree, and an error is not a result to record.
|
|
@@ -1681,6 +1706,7 @@ async function startRun(
|
|
|
1681
1706
|
// choosing a model chooses where that role runs.
|
|
1682
1707
|
directorProviderId: roleProviders.director ?? settings.directorProviderId,
|
|
1683
1708
|
workerProviderId: roleProviders.worker ?? settings.workerProviderId,
|
|
1709
|
+
ownerPid: process.pid,
|
|
1684
1710
|
browserTools: browserTools || undefined,
|
|
1685
1711
|
toolPolicy: settings.toolPolicy,
|
|
1686
1712
|
autoAllowReadOnly: settings.autoAllowReadOnly,
|
|
@@ -1699,6 +1725,14 @@ async function startRun(
|
|
|
1699
1725
|
return;
|
|
1700
1726
|
}
|
|
1701
1727
|
await consumeProposal(projectId, meta.id, mission).catch(() => {});
|
|
1728
|
+
// The folder's MISSION.md belongs to whichever run wrote it. Parked into
|
|
1729
|
+
// that run's record (when it lacks one) and cleared, so this run's status
|
|
1730
|
+
// reads empty until its own director writes the plan — not 13/14 done.
|
|
1731
|
+
{
|
|
1732
|
+
const previous = (await store.listRuns().catch(() => [] as RunMeta[]))
|
|
1733
|
+
.find((r) => r.projectId === projectId && r.id !== meta.id && r.status !== 'running');
|
|
1734
|
+
await parkMissionDoc(folder, previous ? store.runDirectory(previous.id) : null).catch(() => {});
|
|
1735
|
+
}
|
|
1702
1736
|
// In a repository, the mission gets a branch of its own before the crew
|
|
1703
1737
|
// touches anything — so the deck's baseline, taken at the director's first
|
|
1704
1738
|
// turn, is the branch point, and the diff is exactly the mission.
|
|
@@ -1761,6 +1795,7 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1761
1795
|
meta.stopReason = undefined;
|
|
1762
1796
|
meta.status = 'running';
|
|
1763
1797
|
meta.endedAt = undefined;
|
|
1798
|
+
meta.ownerPid = process.pid;
|
|
1764
1799
|
meta.resumes = (meta.resumes ?? 0) + 1;
|
|
1765
1800
|
if (meta.budgetUsd !== budgetWas) {
|
|
1766
1801
|
makeEmitter(meta.id, projectId)('settings_changed', {
|
|
@@ -1773,6 +1808,15 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1773
1808
|
if (back) makeEmitter(meta.id, projectId)('git_note', { text: `Could not return to ${meta.git.branch} (${back}); the resumed mission runs on whatever is checked out.` });
|
|
1774
1809
|
gitInfoCache.delete(meta.folder);
|
|
1775
1810
|
}
|
|
1811
|
+
// The director resumes from MISSION.md. If another mission ran here since,
|
|
1812
|
+
// the folder's copy is that mission's; this run's own goes back first.
|
|
1813
|
+
const restored = await restoreMissionDoc(store.runDirectory(meta.id), meta.folder).catch(() => 'none' as const);
|
|
1814
|
+
if (restored === 'restored') {
|
|
1815
|
+
const emit = makeEmitter(meta.id, projectId);
|
|
1816
|
+
emit('mission_doc_restored', {
|
|
1817
|
+
text: 'Restored this run\'s own MISSION.md into the folder before resuming; a later mission had overwritten it.',
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1776
1820
|
await store.writeMeta(meta).catch((err) => {
|
|
1777
1821
|
console.error(`failed to persist resume of ${meta.id}:`, err);
|
|
1778
1822
|
});
|
|
@@ -1895,7 +1939,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1895
1939
|
return project ? { folder: project.folder } : null;
|
|
1896
1940
|
}
|
|
1897
1941
|
const m = await store.readMeta(id).catch(() => null);
|
|
1898
|
-
|
|
1942
|
+
if (!m) return null;
|
|
1943
|
+
const frozen = m.status !== 'running' ? await frozenDeck(store.runDirectory(m.id)) : null;
|
|
1944
|
+
return { folder: m.folder, ...(frozen ? { deck: frozen } : {}) };
|
|
1899
1945
|
})) return;
|
|
1900
1946
|
const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
|
|
1901
1947
|
const prMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr$/);
|
|
@@ -2779,9 +2825,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2779
2825
|
if (!runId) return json(res, 400, { error: 'run parameter is required' });
|
|
2780
2826
|
const meta = await store.readMeta(runId);
|
|
2781
2827
|
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2828
|
+
// A finished run answers with the doc as it ended; only a running run
|
|
2829
|
+
// reads the folder, which is the one mission running there right now.
|
|
2830
|
+
const frozen = meta.status !== 'running' ? await frozenMissionDoc(store.runDirectory(meta.id)) : null;
|
|
2831
|
+
const doc = frozen ?? await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null);
|
|
2832
|
+
json(res, 200, { doc, frozen: frozen !== null });
|
|
2785
2833
|
|
|
2786
2834
|
} else if (req.method === 'GET' && url.pathname === '/browse') {
|
|
2787
2835
|
const requested = url.searchParams.get('path') || os.homedir();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { frozenDeck, frozenMissionDoc, hasSnapshot, parkMissionDoc, restoreMissionDoc, snapshotRun } from './snapshot.js';
|
|
7
|
+
|
|
8
|
+
async function site() {
|
|
9
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'foreman-snap-'));
|
|
10
|
+
const folder = path.join(root, 'proj'); const runDir = path.join(root, 'runs', 'r1');
|
|
11
|
+
await mkdir(path.join(folder, '.foreman'), { recursive: true });
|
|
12
|
+
await mkdir(runDir, { recursive: true });
|
|
13
|
+
return { root, folder, runDir };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('snapshotRun freezes the mission doc and a deck beside the run, and reads them back', async () => {
|
|
17
|
+
const { root, folder, runDir } = await site();
|
|
18
|
+
await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Mission A\n- [x] done\n');
|
|
19
|
+
await writeFile(path.join(folder, 'a.txt'), 'hello\n');
|
|
20
|
+
const r = await snapshotRun(runDir, folder, 'r1', 1234);
|
|
21
|
+
assert.deepEqual(r, { doc: true, deck: true });
|
|
22
|
+
assert.equal(await frozenMissionDoc(runDir), '# Mission A\n- [x] done\n');
|
|
23
|
+
const deck = await frozenDeck(runDir);
|
|
24
|
+
assert.equal(deck?.frozenAt, 1234);
|
|
25
|
+
assert.match(deck?.note ?? '', /As the folder stood when the run ended/);
|
|
26
|
+
assert.equal(await hasSnapshot(runDir), true);
|
|
27
|
+
// A later mission overwrites the folder's doc; the record does not move.
|
|
28
|
+
await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Mission B\n');
|
|
29
|
+
assert.equal(await frozenMissionDoc(runDir), '# Mission A\n- [x] done\n');
|
|
30
|
+
await rm(root, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('restoreMissionDoc puts the run\'s own doc back, and says whether it had to', async () => {
|
|
34
|
+
const { root, folder, runDir } = await site();
|
|
35
|
+
assert.equal(await restoreMissionDoc(runDir, folder), 'none', 'no record yet');
|
|
36
|
+
await writeFile(path.join(runDir, 'MISSION.md'), '# Mission A\n');
|
|
37
|
+
await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Mission B\n');
|
|
38
|
+
assert.equal(await restoreMissionDoc(runDir, folder), 'restored');
|
|
39
|
+
assert.equal(await readFile(path.join(folder, '.foreman', 'MISSION.md'), 'utf8'), '# Mission A\n');
|
|
40
|
+
assert.equal(await restoreMissionDoc(runDir, folder), 'same');
|
|
41
|
+
await rm(root, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a run with no mission doc still freezes its deck; a missing folder freezes nothing and does not throw', async () => {
|
|
45
|
+
const { root, folder, runDir } = await site();
|
|
46
|
+
const r = await snapshotRun(runDir, folder, 'r1');
|
|
47
|
+
assert.equal(r.doc, false);
|
|
48
|
+
assert.equal(await hasSnapshot(runDir), r.deck);
|
|
49
|
+
const gone = await snapshotRun(path.join(root, 'runs', 'r2'), path.join(root, 'nowhere'), 'r2');
|
|
50
|
+
assert.equal(gone.doc, false);
|
|
51
|
+
assert.equal(await hasSnapshot(path.join(root, 'runs', 'r2')), gone.deck);
|
|
52
|
+
await rm(root, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
test('parkMissionDoc clears the folder for a new run and keeps the old doc with the run that wrote it', async () => {
|
|
57
|
+
const { root, folder, runDir } = await site();
|
|
58
|
+
assert.equal(await parkMissionDoc(folder, runDir), 'none', 'nothing to park');
|
|
59
|
+
await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Old mission\n- [x] all done\n');
|
|
60
|
+
assert.equal(await parkMissionDoc(folder, runDir), 'parked');
|
|
61
|
+
assert.equal(await readFile(path.join(folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null), null, 'the folder starts clean');
|
|
62
|
+
assert.equal(await frozenMissionDoc(runDir), '# Old mission\n- [x] all done\n', 'kept with the previous run');
|
|
63
|
+
// A previous run that already has its own copy is not overwritten by a later folder state.
|
|
64
|
+
await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Something else\n');
|
|
65
|
+
await parkMissionDoc(folder, runDir);
|
|
66
|
+
assert.equal(await frozenMissionDoc(runDir), '# Old mission\n- [x] all done\n');
|
|
67
|
+
await rm(root, { recursive: true, force: true });
|
|
68
|
+
});
|