@amenophis1er/foreman 0.1.15 → 0.1.16
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/package.json +1 -1
- package/src/gitwork.test.ts +39 -1
- package/src/gitwork.ts +66 -0
- package/src/orchestrator.ts +20 -9
- package/src/server.ts +86 -12
- package/src/services.test.ts +46 -1
- package/src/services.ts +62 -3
- package/src/types.ts +1 -1
- package/ui/dist/assets/index-DOVnExqF.js +68 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-CnCv2QiH.js +0 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amenophis1er/foreman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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/gitwork.test.ts
CHANGED
|
@@ -4,7 +4,8 @@ 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, renameMissionBranch
|
|
7
|
+
import { closeMissionBranch, defaultBranch, ensureMissionBranch, gitInfo, missionBranchName, remoteHasBranch, resolvePrBase, startMissionBranch, renameMissionBranch, dirtyPaths,
|
|
8
|
+
} from './gitwork.js';
|
|
8
9
|
|
|
9
10
|
const sh = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null' } }).toString();
|
|
10
11
|
|
|
@@ -108,3 +109,40 @@ test('renameMissionBranch takes the run title once there is one, and leaves a br
|
|
|
108
109
|
sh(dir, 'checkout', '-q', 'main');
|
|
109
110
|
assert.equal(await renameMissionBranch(dir, renamed!, 'Another title', '1788713434983-226123af'), null);
|
|
110
111
|
});
|
|
112
|
+
|
|
113
|
+
test('dirtyPaths names the uncommitted work a mission branch would carry', async () => {
|
|
114
|
+
const dir = await repo();
|
|
115
|
+
assert.deepEqual(await dirtyPaths(dir), [], 'a clean checkout carries nothing');
|
|
116
|
+
|
|
117
|
+
await writeFile(path.join(dir, 'README.md'), 'hello, edited\n');
|
|
118
|
+
await writeFile(path.join(dir, 'scratch.txt'), 'untracked\n');
|
|
119
|
+
const dirty = await dirtyPaths(dir);
|
|
120
|
+
assert.deepEqual(dirty.sort(), ['README.md', 'scratch.txt'], 'tracked edits and untracked files both count');
|
|
121
|
+
|
|
122
|
+
// The cap is for a message, not for the truth of it.
|
|
123
|
+
assert.equal((await dirtyPaths(dir, 1)).length, 1);
|
|
124
|
+
// Not a repository at all: nothing to report, and no throw.
|
|
125
|
+
assert.deepEqual(await dirtyPaths(os.tmpdir()), []);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a pull request targets the default branch when the mission was branched from a local-only branch', async () => {
|
|
129
|
+
const dir = await repo();
|
|
130
|
+
// A bare origin, the way a real clone has one.
|
|
131
|
+
const origin = await mkdtemp(path.join(os.tmpdir(), 'gitwork-origin-'));
|
|
132
|
+
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { stdio: 'pipe' });
|
|
133
|
+
sh(dir, 'remote', 'add', 'origin', origin);
|
|
134
|
+
sh(dir, 'push', '-q', '-u', 'origin', 'main');
|
|
135
|
+
|
|
136
|
+
assert.equal(await defaultBranch(dir), 'main');
|
|
137
|
+
assert.equal(await remoteHasBranch(dir, 'main'), true);
|
|
138
|
+
assert.equal(await remoteHasBranch(dir, 'foreman/earlier-1234'), false);
|
|
139
|
+
|
|
140
|
+
// Branched from main: the base is real and is left alone.
|
|
141
|
+
assert.deepEqual(await resolvePrBase(dir, 'main'), { base: 'main', fellBack: false });
|
|
142
|
+
|
|
143
|
+
// The case from the field: the checkout was left on the previous mission's
|
|
144
|
+
// branch, so this mission recorded that as its base and it exists nowhere
|
|
145
|
+
// but here. gh would fail on it; the default branch is the honest target.
|
|
146
|
+
sh(dir, 'checkout', '-q', '-b', 'foreman/earlier-1234');
|
|
147
|
+
assert.deepEqual(await resolvePrBase(dir, 'foreman/earlier-1234'), { base: 'main', fellBack: true });
|
|
148
|
+
});
|
package/src/gitwork.ts
CHANGED
|
@@ -85,6 +85,22 @@ export function missionBranchName(mission: string, runId: string): string {
|
|
|
85
85
|
* Uncommitted changes come along, as `checkout -b` always does. Resolves to
|
|
86
86
|
* the record for the run, or to one sentence on why it could not.
|
|
87
87
|
*/
|
|
88
|
+
/**
|
|
89
|
+
* The paths with uncommitted changes, tracked or untracked, capped for a
|
|
90
|
+
* message. `checkout -b` carries all of them onto the mission's branch, and
|
|
91
|
+
* the closing commit sweeps whatever is still uncommitted into the mission's
|
|
92
|
+
* own commit — so this is what a human stands to have committed under a
|
|
93
|
+
* mission's name without noticing.
|
|
94
|
+
*/
|
|
95
|
+
export async function dirtyPaths(folder: string, limit = 8): Promise<string[]> {
|
|
96
|
+
try {
|
|
97
|
+
const out = await git(['status', '--porcelain', '--untracked-files=normal'], folder);
|
|
98
|
+
return out.split('\n').map((l) => l.slice(3).trim()).filter(Boolean).slice(0, limit);
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
88
104
|
export async function startMissionBranch(folder: string, mission: string, runId: string): Promise<MissionGit | { error: string }> {
|
|
89
105
|
const info = await gitInfo(folder);
|
|
90
106
|
if (!info.repo) return { error: 'not a git repository' };
|
|
@@ -245,6 +261,56 @@ export function pullRequestState(folder: string, url: string): Promise<{ state:
|
|
|
245
261
|
});
|
|
246
262
|
}
|
|
247
263
|
|
|
264
|
+
/**
|
|
265
|
+
* The repository's default branch as origin sees it, from the remote HEAD git
|
|
266
|
+
* recorded at clone time, then from the usual names, and `main` as the last
|
|
267
|
+
* word. Local only: no network, so it works offline and cannot hang.
|
|
268
|
+
*/
|
|
269
|
+
export async function defaultBranch(folder: string): Promise<string> {
|
|
270
|
+
try {
|
|
271
|
+
const ref = (await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], folder)).trim();
|
|
272
|
+
const name = ref.replace(/^origin\//, '');
|
|
273
|
+
if (name) return name;
|
|
274
|
+
} catch { /* no remote HEAD recorded */ }
|
|
275
|
+
for (const name of ['main', 'master']) {
|
|
276
|
+
try {
|
|
277
|
+
await git(['rev-parse', '--verify', `refs/remotes/origin/${name}`], folder);
|
|
278
|
+
return name;
|
|
279
|
+
} catch { /* not this one */ }
|
|
280
|
+
}
|
|
281
|
+
return 'main';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Does origin have this branch? Asks the remote, and says no if it cannot ask. */
|
|
285
|
+
export async function remoteHasBranch(folder: string, branch: string): Promise<boolean> {
|
|
286
|
+
try {
|
|
287
|
+
const out = await git(['ls-remote', '--heads', 'origin', branch], folder, 10_000);
|
|
288
|
+
return out.trim().length > 0;
|
|
289
|
+
} catch {
|
|
290
|
+
// Offline or unauthenticated: fall back to what the last fetch recorded.
|
|
291
|
+
try {
|
|
292
|
+
await git(['rev-parse', '--verify', `refs/remotes/origin/${branch}`], folder);
|
|
293
|
+
return true;
|
|
294
|
+
} catch { return false; }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* What a pull request from this mission should actually target.
|
|
300
|
+
*
|
|
301
|
+
* A finished mission leaves the checkout on its own branch, so the next
|
|
302
|
+
* mission is branched from *that* — and records it as its base. That base
|
|
303
|
+
* lives only on this machine, so `gh pr create --base foreman/…` fails and a
|
|
304
|
+
* compare URL built from it 404s. When the recorded base is not on the
|
|
305
|
+
* remote, the default branch is the honest target, and the caller says so
|
|
306
|
+
* rather than quietly retargeting the request.
|
|
307
|
+
*/
|
|
308
|
+
export async function resolvePrBase(folder: string, recorded: string): Promise<{ base: string; fellBack: boolean }> {
|
|
309
|
+
if (recorded && await remoteHasBranch(folder, recorded)) return { base: recorded, fellBack: false };
|
|
310
|
+
const base = await defaultBranch(folder);
|
|
311
|
+
return { base, fellBack: base !== recorded };
|
|
312
|
+
}
|
|
313
|
+
|
|
248
314
|
/** `gh pr create`, as the user. Resolves to the PR's URL, or to why not. */
|
|
249
315
|
export function createPullRequest(folder: string, opts: { base: string; branch: string; title: string; body: string }): Promise<{ url?: string; error?: string }> {
|
|
250
316
|
return new Promise((resolve) => {
|
package/src/orchestrator.ts
CHANGED
|
@@ -644,6 +644,14 @@ export type Emitter = (event: string, data: unknown) => void;
|
|
|
644
644
|
/** Persists updated run metadata (fire-and-forget from the run's viewpoint). */
|
|
645
645
|
export type MetaSink = (meta: RunMeta) => void;
|
|
646
646
|
|
|
647
|
+
/**
|
|
648
|
+
* Turns a worker gets before the SDK stops it. Raised from 60: five workers
|
|
649
|
+
* in four missions hit the cap mid-task on legitimate, brief-sized work
|
|
650
|
+
* (a hundred TypeScript errors in one package; a test-suite port), and each
|
|
651
|
+
* time the director paid to respawn one that re-read the same files.
|
|
652
|
+
*/
|
|
653
|
+
const WORKER_MAX_TURNS = 100;
|
|
654
|
+
|
|
647
655
|
export const DIRECTOR_CHARTER = `
|
|
648
656
|
You are the FOREMAN DIRECTOR. You run a mission autonomously inside one folder by
|
|
649
657
|
directing worker agents. Non-negotiable rules, in priority order:
|
|
@@ -720,6 +728,16 @@ directing worker agents. Non-negotiable rules, in priority order:
|
|
|
720
728
|
(a dev server, a static preview), call mcp__foreman__expose_service with
|
|
721
729
|
its port and put the URL it returns in your report — the human can open
|
|
722
730
|
it from their phone. Keep that server up until the mission ends.
|
|
731
|
+
CLEAN UP WHAT YOU STARTED: before you finish, stop every server, watcher or
|
|
732
|
+
background process the crew started that you are not deliberately leaving
|
|
733
|
+
for the human, and name in your report any you left up and on which port.
|
|
734
|
+
A process that outlives the mission holds its port until somebody hunts it
|
|
735
|
+
down by hand.
|
|
736
|
+
SIZE THE TASK TO THE WORKER: a worker is stopped after ${WORKER_MAX_TURNS}
|
|
737
|
+
turns of its own. It gets one continuation to finish what it was doing, and
|
|
738
|
+
after that the task comes back to you half-done. So give a worker a task it
|
|
739
|
+
can finish in that many steps — split the big ones — rather than one brief
|
|
740
|
+
that has to be rescued twice.
|
|
723
741
|
4. REPORT WHAT YOU SEE. Judge the work as a competent professional would, not
|
|
724
742
|
only against the letter of the acceptance criteria. If you observe a defect
|
|
725
743
|
the criteria did not name — tap targets too small to use, unreadable
|
|
@@ -773,13 +791,6 @@ const USAGE_LIMIT_RE = /out of usage credits|usage limit reached|upgrade to incr
|
|
|
773
791
|
/** One worker's outcome, as runWorker hands it back. */
|
|
774
792
|
interface WorkerOutcome { report: string; isError: boolean }
|
|
775
793
|
|
|
776
|
-
/**
|
|
777
|
-
* Turns a worker gets before the SDK stops it. Raised from 60: five workers
|
|
778
|
-
* in four missions hit the cap mid-task on legitimate, brief-sized work
|
|
779
|
-
* (a hundred TypeScript errors in one package; a test-suite port), and each
|
|
780
|
-
* time the director paid to respawn one that re-read the same files.
|
|
781
|
-
*/
|
|
782
|
-
const WORKER_MAX_TURNS = 100;
|
|
783
794
|
/** What a worker stopped by the cap is told when its session is picked back up. */
|
|
784
795
|
const WORKER_CONTINUE_PROMPT =
|
|
785
796
|
'You were stopped by the turn cap, not by a failure. Your session and your files are as you left them; ' +
|
|
@@ -914,7 +925,7 @@ export class MissionRun {
|
|
|
914
925
|
* has no `expose_service` to offer.
|
|
915
926
|
*/
|
|
916
927
|
private readonly host: {
|
|
917
|
-
exposeService?: (runId: string, port: number, label: string) => Promise<{ ok: true; url: string; path: string } | { ok: false; reason: string }>;
|
|
928
|
+
exposeService?: (runId: string, port: number, label: string) => Promise<{ ok: true; url: string; path: string; pid?: number } | { ok: false; reason: string }>;
|
|
918
929
|
/** The browser channel detected at dispatch (src/browser.ts); Chrome when the host says nothing. */
|
|
919
930
|
browserChannel?: string;
|
|
920
931
|
} = {},
|
|
@@ -2457,7 +2468,7 @@ export class MissionRun {
|
|
|
2457
2468
|
if (!fn) return { content: [{ type: 'text' as const, text: 'Exposing services is not available in this run.' }] };
|
|
2458
2469
|
const r = await fn(this.meta.id, port, (label ?? '').trim() || `port ${port}`);
|
|
2459
2470
|
if (!r.ok) return { content: [{ type: 'text' as const, text: `Not exposed: ${r.reason}` }] };
|
|
2460
|
-
const entry = { port, label: (label ?? '').trim() || `port ${port}`, path: r.path, since: Date.now() };
|
|
2471
|
+
const entry = { port, label: (label ?? '').trim() || `port ${port}`, path: r.path, since: Date.now(), pid: r.pid };
|
|
2461
2472
|
this.meta.services = [...(this.meta.services ?? []).filter((s) => s.port !== port), entry];
|
|
2462
2473
|
this.saveMeta(this.meta);
|
|
2463
2474
|
this.emit('service_exposed', { ...entry, url: r.url });
|
package/src/server.ts
CHANGED
|
@@ -73,10 +73,10 @@ import { budgetAnchor, modelRecords, projectRecord, recordLine } from './track-r
|
|
|
73
73
|
import { reconcileRole } from './role-provider.js';
|
|
74
74
|
import { detectBrowser, installChromium } from './browser.js';
|
|
75
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
|
+
import { closeMissionBranch, compareUrl, createPullRequest, dirtyPaths, ensureMissionBranch, ghReady, gitInfo, resolvePrBase, missionBranchName, prDraft, pullRequestState, pushBranch, renameMissionBranch, startMissionBranch, type GitInfo } from './gitwork.js';
|
|
77
77
|
import { detectTailscale, tailnetUrl } from './tailscale.js';
|
|
78
78
|
import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
|
|
79
|
-
import { ServiceRegistry, portOpen, servicesHandler } from './services.js';
|
|
79
|
+
import { ServiceRegistry, listeningPid, portOpen, servicesHandler, stopService } from './services.js';
|
|
80
80
|
import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
|
|
81
81
|
import {
|
|
82
82
|
DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
|
|
@@ -1578,8 +1578,11 @@ async function driveRun(
|
|
|
1578
1578
|
exposeService: async (runId, port, label) => {
|
|
1579
1579
|
if (servicesDown) return { ok: false, reason: servicesDown };
|
|
1580
1580
|
if (!(await portOpen(port))) return { ok: false, reason: `nothing is listening on 127.0.0.1:${port} — start the server first` };
|
|
1581
|
-
|
|
1582
|
-
|
|
1581
|
+
// Whoever holds the port right now is what the crew just started, and
|
|
1582
|
+
// the only process a later stop is allowed to touch.
|
|
1583
|
+
const pid = await listeningPid(port);
|
|
1584
|
+
const svc = services.register(runId, port, label, pid ?? undefined);
|
|
1585
|
+
return { ok: true, url: `${await servicesBase()}${svc.path}`, path: svc.path, pid: pid ?? undefined };
|
|
1583
1586
|
},
|
|
1584
1587
|
});
|
|
1585
1588
|
activeByProject.set(projectId, run);
|
|
@@ -1673,7 +1676,7 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1673
1676
|
gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
|
|
1674
1677
|
budgetWarnAt: (() => {
|
|
1675
1678
|
const raw = Number(p.budgetWarnAt ?? g.budgetWarnAt);
|
|
1676
|
-
return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw :
|
|
1679
|
+
return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 60;
|
|
1677
1680
|
})(),
|
|
1678
1681
|
};
|
|
1679
1682
|
}
|
|
@@ -1744,6 +1747,7 @@ async function startRun(
|
|
|
1744
1747
|
// touches anything — so the deck's baseline, taken at the director's first
|
|
1745
1748
|
// turn, is the branch point, and the diff is exactly the mission.
|
|
1746
1749
|
if (settings.gitBranchPerMission) {
|
|
1750
|
+
const carried = await dirtyPaths(folder);
|
|
1747
1751
|
const g = await startMissionBranch(folder, mission, meta.id);
|
|
1748
1752
|
const emit = makeEmitter(meta.id, projectId);
|
|
1749
1753
|
if ('error' in g) {
|
|
@@ -1753,6 +1757,14 @@ async function startRun(
|
|
|
1753
1757
|
await store.writeMeta(meta).catch(() => {});
|
|
1754
1758
|
gitInfoCache.delete(folder);
|
|
1755
1759
|
emit('git_branch', { branch: g.branch, base: g.base, text: `On branch ${g.branch}, made from ${g.base}. Foreman commits the mission's work here when it ends; merging and pushing stay yours.` });
|
|
1760
|
+
// Whoever started this may not have been at the keyboard to be asked.
|
|
1761
|
+
if (carried.length) {
|
|
1762
|
+
emit('git_note', {
|
|
1763
|
+
text: `The checkout had uncommitted changes when this mission started, and they came along to ${g.branch}: `
|
|
1764
|
+
+ `${carried.slice(0, 5).join(', ')}${carried.length > 5 ? `, and ${carried.length - 5} more` : ''}. `
|
|
1765
|
+
+ "They will be part of the mission's closing commit unless you take them off the branch first.",
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1756
1768
|
}
|
|
1757
1769
|
}
|
|
1758
1770
|
await driveRun(projectId, meta);
|
|
@@ -1953,6 +1965,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1953
1965
|
const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
|
|
1954
1966
|
const prMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr$/);
|
|
1955
1967
|
const prStateMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr\/state$/);
|
|
1968
|
+
const runServicesMatch = url.pathname.match(/^\/runs\/([^/]+)\/services$/);
|
|
1969
|
+
const stopServiceMatch = url.pathname.match(/^\/runs\/([^/]+)\/services\/(\d{1,5})\/stop$/);
|
|
1956
1970
|
const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
|
|
1957
1971
|
const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
|
|
1958
1972
|
|
|
@@ -2238,7 +2252,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2238
2252
|
} else if (req.method === 'POST' && url.pathname === '/run') {
|
|
2239
2253
|
const {
|
|
2240
2254
|
projectId, mission, budgetUsd, directorModel, workerModel, browserTools,
|
|
2241
|
-
directorProviderId, workerProviderId,
|
|
2255
|
+
directorProviderId, workerProviderId, allowDirty,
|
|
2242
2256
|
} = await readBody(req);
|
|
2243
2257
|
if (typeof projectId !== 'string' || typeof mission !== 'string' || !mission.trim()) {
|
|
2244
2258
|
return json(res, 400, { error: 'projectId and mission are required' });
|
|
@@ -2246,6 +2260,29 @@ const server = http.createServer(async (req, res) => {
|
|
|
2246
2260
|
const project = await store.getProject(projectId);
|
|
2247
2261
|
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
2248
2262
|
await mkdir(project.folder, { recursive: true });
|
|
2263
|
+
// A mission on its own branch takes the working tree with it: `checkout
|
|
2264
|
+
// -b` carries uncommitted work onto the mission branch, and the closing
|
|
2265
|
+
// commit sweeps whatever is left into the mission's own commit, under
|
|
2266
|
+
// the mission's name. So a dirty checkout is refused here rather than
|
|
2267
|
+
// quietly absorbed — with an override, because it is the human's tree
|
|
2268
|
+
// and their call. Only this route refuses: a mission started from the
|
|
2269
|
+
// phone has nobody standing at the keyboard to answer, and gets the
|
|
2270
|
+
// warning in its transcript instead.
|
|
2271
|
+
{
|
|
2272
|
+
const s = await effectiveSettings(projectId);
|
|
2273
|
+
if (s.gitBranchPerMission && allowDirty !== true) {
|
|
2274
|
+
const info = await gitInfo(project.folder);
|
|
2275
|
+
if (info.repo && info.dirty) {
|
|
2276
|
+
const files = await dirtyPaths(project.folder);
|
|
2277
|
+
return json(res, 409, {
|
|
2278
|
+
error: `This checkout has uncommitted changes on ${info.branch ?? 'HEAD'}. `
|
|
2279
|
+
+ 'They would follow the mission onto its branch and be committed with its work. '
|
|
2280
|
+
+ 'Commit or stash them first, or start anyway.',
|
|
2281
|
+
code: 'dirty-checkout', branch: info.branch ?? null, files,
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2249
2286
|
// Reservation is the last step before dispatch — no awaits in between.
|
|
2250
2287
|
if (!reserveProject(projectId)) {
|
|
2251
2288
|
return json(res, 409, { error: 'this project already has an active mission' });
|
|
@@ -2673,6 +2710,33 @@ const server = http.createServer(async (req, res) => {
|
|
|
2673
2710
|
broadcastChat(FLEET_CHAT_ID, 'chat_cleared', {});
|
|
2674
2711
|
json(res, 200, { ok: true });
|
|
2675
2712
|
|
|
2713
|
+
} else if (runServicesMatch && req.method === 'GET') {
|
|
2714
|
+
// What this run exposed, and what of it is still up. Asked after a run
|
|
2715
|
+
// ends as much as during it: a finished mission leaves no agent behind
|
|
2716
|
+
// to tidy, so the human needs somewhere to see the leftovers.
|
|
2717
|
+
const meta = await store.readMeta(runServicesMatch[1]).catch(() => null);
|
|
2718
|
+
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
2719
|
+
const list = await Promise.all((meta.services ?? []).map(async (s) => {
|
|
2720
|
+
const holder = await listeningPid(s.port);
|
|
2721
|
+
return {
|
|
2722
|
+
port: s.port, label: s.label, since: s.since, path: s.path,
|
|
2723
|
+
listening: holder !== null,
|
|
2724
|
+
// Only a process Foreman watched start is one it will offer to stop.
|
|
2725
|
+
ours: holder !== null && s.pid !== undefined && holder === s.pid,
|
|
2726
|
+
};
|
|
2727
|
+
}));
|
|
2728
|
+
json(res, 200, { runId: meta.id, status: meta.status, services: list });
|
|
2729
|
+
|
|
2730
|
+
} else if (stopServiceMatch && req.method === 'POST') {
|
|
2731
|
+
const meta = await store.readMeta(stopServiceMatch[1]).catch(() => null);
|
|
2732
|
+
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
2733
|
+
const port = Number(stopServiceMatch[2]);
|
|
2734
|
+
const svc = (meta.services ?? []).find((s) => s.port === port);
|
|
2735
|
+
if (!svc) return json(res, 404, { error: 'this run did not expose that port' });
|
|
2736
|
+
const r = await stopService(port, svc.pid);
|
|
2737
|
+
if (!r.ok) return json(res, 409, { error: r.reason });
|
|
2738
|
+
json(res, 200, { ok: true, how: r.how });
|
|
2739
|
+
|
|
2676
2740
|
} else if (prStateMatch && req.method === 'GET') {
|
|
2677
2741
|
// What became of the run's pull request. A final answer (merged,
|
|
2678
2742
|
// closed) is written to the run so it is not asked again; open is.
|
|
@@ -2695,9 +2759,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
2695
2759
|
if (!info.remote) return json(res, 409, { error: 'the repository has no origin remote to push to' });
|
|
2696
2760
|
const doc = await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null);
|
|
2697
2761
|
const gh = await ghReady();
|
|
2762
|
+
// Where the request can actually go, which is not always where the
|
|
2763
|
+
// mission was branched from — see resolvePrBase.
|
|
2764
|
+
const target = await resolvePrBase(meta.folder, meta.git.base);
|
|
2698
2765
|
json(res, 200, {
|
|
2699
|
-
...prDraft(meta, doc), branch: meta.git.branch, base:
|
|
2700
|
-
|
|
2766
|
+
...prDraft(meta, doc), branch: meta.git.branch, base: target.base, commits: meta.git.commits ?? null,
|
|
2767
|
+
branchedFrom: meta.git.base, baseFellBack: target.fellBack,
|
|
2768
|
+
remote: info.remote, compareUrl: compareUrl(info.remote, target.base, meta.git.branch),
|
|
2701
2769
|
gh, pr: meta.git.pr ?? null, onBranch: info.branch === meta.git.branch, dirty: Boolean(info.dirty),
|
|
2702
2770
|
});
|
|
2703
2771
|
|
|
@@ -2725,18 +2793,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
2725
2793
|
let url: string | undefined;
|
|
2726
2794
|
let method: 'gh' | 'compare' = 'compare';
|
|
2727
2795
|
let note: string | undefined;
|
|
2796
|
+
const target = await resolvePrBase(meta.folder, meta.git.base);
|
|
2728
2797
|
if (gh.present && gh.authed && /github\.com/.test(info.remote)) {
|
|
2729
|
-
const r = await createPullRequest(meta.folder, { base:
|
|
2798
|
+
const r = await createPullRequest(meta.folder, { base: target.base, branch: meta.git.branch, title: t, body: b });
|
|
2730
2799
|
if (r.url) { url = r.url; method = 'gh'; } else note = r.error;
|
|
2731
2800
|
}
|
|
2732
|
-
if (!url) url = compareUrl(info.remote,
|
|
2801
|
+
if (!url) url = compareUrl(info.remote, target.base, meta.git.branch) ?? undefined;
|
|
2733
2802
|
meta.git = { ...meta.git, pr: method === 'gh' ? url : meta.git.pr };
|
|
2734
2803
|
await store.writeMeta(meta).catch(() => {});
|
|
2735
2804
|
gitInfoCache.delete(meta.folder);
|
|
2736
2805
|
const text = method === 'gh'
|
|
2737
2806
|
? `Pushed ${meta.git.branch} and opened a pull request: ${url}`
|
|
2738
2807
|
: `Pushed ${meta.git.branch}.${note ? ` ${note}.` : ''} Finish the pull request in the browser: ${url ?? 'open the repository'}`;
|
|
2739
|
-
emit('pull_request', {
|
|
2808
|
+
emit('pull_request', {
|
|
2809
|
+
branch: meta.git.branch, base: target.base, url, method,
|
|
2810
|
+
text: target.fellBack
|
|
2811
|
+
? `${text} (targeting ${target.base}: this mission was branched from ${meta.git.base}, which is not on the remote)`
|
|
2812
|
+
: text,
|
|
2813
|
+
});
|
|
2740
2814
|
json(res, 200, { ok: true, url, method, pushed: true, note });
|
|
2741
2815
|
|
|
2742
2816
|
} else if (req.method === 'POST' && url.pathname === '/steer') {
|
|
@@ -2958,7 +3032,7 @@ void closeOrphanedChatTurns().then((ids) => {
|
|
|
2958
3032
|
// Services declared by earlier runs are still worth proxying if their
|
|
2959
3033
|
// processes outlived the run; the registry is rebuilt from what was saved.
|
|
2960
3034
|
void store.listRuns().then((runs) => {
|
|
2961
|
-
for (const r of runs) for (const s of (r as { services?: Array<{ port: number; label: string }> }).services ?? []) services.register(r.id, s.port, s.label);
|
|
3035
|
+
for (const r of runs) for (const s of (r as { services?: Array<{ port: number; label: string; pid?: number }> }).services ?? []) services.register(r.id, s.port, s.label, s.pid);
|
|
2962
3036
|
}).catch(() => {});
|
|
2963
3037
|
|
|
2964
3038
|
if (BIND === 'all') {
|
package/src/services.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import http from 'node:http';
|
|
4
|
-
import { ServiceRegistry, parseServicePath, portOpen, proxyToService, servicePath, servicesHandler } from './services.js';
|
|
4
|
+
import { ServiceRegistry, listeningPid, parseServicePath, portOpen, proxyToService, servicePath, servicesHandler, stopService } from './services.js';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
5
6
|
import { requestAllowed } from './guard.js';
|
|
6
7
|
|
|
7
8
|
test('service paths round-trip and reject junk', () => {
|
|
@@ -86,3 +87,47 @@ test('the services port serves only /svc/ — everything else is a 404', async (
|
|
|
86
87
|
server.close();
|
|
87
88
|
}
|
|
88
89
|
});
|
|
90
|
+
|
|
91
|
+
/** A child process holding a port, the way a crew's dev server does. */
|
|
92
|
+
async function server(): Promise<{ pid: number; port: number; kill: () => void }> {
|
|
93
|
+
const child = spawn(process.execPath, ['-e',
|
|
94
|
+
"const s=require('http').createServer((_,r)=>r.end('ok'));s.listen(0,'127.0.0.1',()=>console.log(s.address().port));"],
|
|
95
|
+
{ stdio: ['ignore', 'pipe', 'ignore'] });
|
|
96
|
+
const port = await new Promise<number>((resolve, reject) => {
|
|
97
|
+
child.stdout.once('data', (b) => resolve(Number(String(b).trim())));
|
|
98
|
+
child.once('error', reject);
|
|
99
|
+
setTimeout(() => reject(new Error('the test server never reported a port')), 5000);
|
|
100
|
+
});
|
|
101
|
+
return { pid: child.pid as number, port, kill: () => { try { child.kill('SIGKILL'); } catch { /* gone */ } } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test('listeningPid names the process holding a port, and nobody for a free one', async () => {
|
|
105
|
+
const s = await server();
|
|
106
|
+
try {
|
|
107
|
+
assert.equal(await listeningPid(s.port), s.pid);
|
|
108
|
+
} finally { s.kill(); }
|
|
109
|
+
// Once it is gone the port is nobody's.
|
|
110
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
111
|
+
assert.equal(await listeningPid(s.port), null);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('stopService stops the recorded process, and refuses every other case', async () => {
|
|
115
|
+
const s = await server();
|
|
116
|
+
try {
|
|
117
|
+
assert.deepEqual(await stopService(s.port, undefined), {
|
|
118
|
+
ok: false, reason: 'Foreman did not record which process this was, so it will not kill anything.',
|
|
119
|
+
}, 'no recorded pid means no killing');
|
|
120
|
+
|
|
121
|
+
const wrong = await stopService(s.port, s.pid + 100000);
|
|
122
|
+
assert.equal(wrong.ok, false, 'a port held by someone else is left alone');
|
|
123
|
+
assert.match((wrong as { reason: string }).reason, /not the/);
|
|
124
|
+
assert.equal(await portOpen(s.port), true, 'and the refusal really did leave it running');
|
|
125
|
+
|
|
126
|
+
const stopped = await stopService(s.port, s.pid);
|
|
127
|
+
assert.equal(stopped.ok, true);
|
|
128
|
+
assert.equal(await portOpen(s.port), false);
|
|
129
|
+
|
|
130
|
+
const gone = await stopService(s.port, s.pid);
|
|
131
|
+
assert.equal(gone.ok, false, 'stopping twice is not an error worth pretending about');
|
|
132
|
+
} finally { s.kill(); }
|
|
133
|
+
});
|
package/src/services.ts
CHANGED
|
@@ -16,11 +16,18 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import http from 'node:http';
|
|
18
18
|
import net from 'node:net';
|
|
19
|
+
import { execFile } from 'node:child_process';
|
|
19
20
|
import type { GuardVerdict } from './guard.js';
|
|
20
21
|
|
|
21
22
|
export interface ExposedService {
|
|
22
23
|
port: number;
|
|
23
24
|
label: string;
|
|
25
|
+
/**
|
|
26
|
+
* The process listening on the port when the crew exposed it. Recorded so a
|
|
27
|
+
* stop later can prove it is still the same process — a port is a slot
|
|
28
|
+
* anyone can take, and Foreman must never kill something it did not start.
|
|
29
|
+
*/
|
|
30
|
+
pid?: number;
|
|
24
31
|
/** `/svc/<runId>/<port>/` — the path under the services origin, not Foreman's. */
|
|
25
32
|
path: string;
|
|
26
33
|
since: number;
|
|
@@ -53,14 +60,66 @@ export function portOpen(port: number, timeoutMs = 800): Promise<boolean> {
|
|
|
53
60
|
});
|
|
54
61
|
}
|
|
55
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The pid of whatever is listening on a local port, via lsof. Null when
|
|
65
|
+
* nothing is, or when lsof is unavailable — in which case Foreman simply
|
|
66
|
+
* never offers to stop the service, which is the safe direction.
|
|
67
|
+
*/
|
|
68
|
+
export async function listeningPid(port: number): Promise<number | null> {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
execFile('lsof', ['-nP', '-t', `-iTCP:${port}`, '-sTCP:LISTEN'], { timeout: 3000 }, (err, stdout) => {
|
|
71
|
+
if (err) return resolve(null);
|
|
72
|
+
const pid = Number(String(stdout).split('\n')[0]?.trim());
|
|
73
|
+
resolve(Number.isInteger(pid) && pid > 0 ? pid : null);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Is this process still alive? Signal 0 asks without sending anything. */
|
|
79
|
+
function alive(pid: number): boolean {
|
|
80
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type StopResult =
|
|
84
|
+
| { ok: true; how: 'term' | 'kill' }
|
|
85
|
+
| { ok: false; reason: string };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Stops a service the crew started, and only that one. The pid recorded when
|
|
89
|
+
* it was exposed must still be the process holding the port: if something
|
|
90
|
+
* else took the port since, or the recorded process is gone, this refuses
|
|
91
|
+
* rather than guessing. SIGTERM first, so a dev server gets to clean up.
|
|
92
|
+
*/
|
|
93
|
+
export async function stopService(port: number, expectedPid: number | undefined, graceMs = 4000): Promise<StopResult> {
|
|
94
|
+
if (!expectedPid) return { ok: false, reason: 'Foreman did not record which process this was, so it will not kill anything.' };
|
|
95
|
+
const holder = await listeningPid(port);
|
|
96
|
+
if (holder === null) return { ok: false, reason: `nothing is listening on 127.0.0.1:${port} any more` };
|
|
97
|
+
if (holder !== expectedPid) {
|
|
98
|
+
return { ok: false, reason: `port ${port} is held by process ${holder} now, not the ${expectedPid} this run started — leaving it alone` };
|
|
99
|
+
}
|
|
100
|
+
try { process.kill(expectedPid, 'SIGTERM'); } catch (err) {
|
|
101
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
102
|
+
}
|
|
103
|
+
const deadline = Date.now() + graceMs;
|
|
104
|
+
while (Date.now() < deadline) {
|
|
105
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
106
|
+
if (!alive(expectedPid)) return { ok: true, how: 'term' };
|
|
107
|
+
}
|
|
108
|
+
try { process.kill(expectedPid, 'SIGKILL'); } catch { /* already gone */ }
|
|
109
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
110
|
+
return alive(expectedPid)
|
|
111
|
+
? { ok: false, reason: `process ${expectedPid} did not stop` }
|
|
112
|
+
: { ok: true, how: 'kill' };
|
|
113
|
+
}
|
|
114
|
+
|
|
56
115
|
export class ServiceRegistry {
|
|
57
116
|
private byRun = new Map<string, ExposedService[]>();
|
|
58
117
|
|
|
59
|
-
register(runId: string, port: number, label: string): ExposedService {
|
|
118
|
+
register(runId: string, port: number, label: string, pid?: number): ExposedService {
|
|
60
119
|
const list = this.byRun.get(runId) ?? [];
|
|
61
120
|
const existing = list.find((s) => s.port === port);
|
|
62
|
-
if (existing) { existing.label = label || existing.label; return existing; }
|
|
63
|
-
const svc: ExposedService = { port, label: label || `port ${port}`, path: servicePath(runId, port), since: Date.now() };
|
|
121
|
+
if (existing) { existing.label = label || existing.label; if (pid) existing.pid = pid; return existing; }
|
|
122
|
+
const svc: ExposedService = { port, label: label || `port ${port}`, path: servicePath(runId, port), since: Date.now(), pid };
|
|
64
123
|
this.byRun.set(runId, [...list, svc]);
|
|
65
124
|
return svc;
|
|
66
125
|
}
|
package/src/types.ts
CHANGED
|
@@ -315,7 +315,7 @@ export interface RunMeta {
|
|
|
315
315
|
*/
|
|
316
316
|
allowedRoots?: string[];
|
|
317
317
|
/** Dev servers the crew exposed through Foreman's proxy (see services.ts). */
|
|
318
|
-
services?: Array<{ port: number; label: string; path: string; since: number }>;
|
|
318
|
+
services?: Array<{ port: number; label: string; path: string; since: number; pid?: number }>;
|
|
319
319
|
/**
|
|
320
320
|
* How long an approval card or director question may wait for the human
|
|
321
321
|
* before it is resolved with its unattended default (deny / "decide
|