@amenophis1er/foreman 0.1.7 → 0.1.9
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 +9 -3
- package/package.json +1 -1
- package/src/browser.ts +188 -0
- package/src/cli.ts +6 -1
- package/src/clone.test.ts +35 -0
- package/src/clone.ts +129 -0
- package/src/deck.test.ts +2 -2
- package/src/deck.ts +32 -6
- package/src/fleet-planner.ts +5 -4
- package/src/gitwork.test.ts +95 -0
- package/src/gitwork.ts +239 -0
- package/src/guard.test.ts +104 -0
- package/src/guard.ts +131 -0
- package/src/http-body.test.ts +42 -0
- package/src/http-body.ts +57 -0
- package/src/memory.test.ts +46 -0
- package/src/memory.ts +107 -0
- package/src/notify/commands.ts +1 -1
- package/src/notify/telegram.ts +1 -1
- package/src/notify.ts +5 -0
- package/src/orchestrator.test.ts +111 -0
- package/src/orchestrator.ts +121 -47
- package/src/planner.ts +7 -2
- package/src/policy.test.ts +25 -0
- package/src/policy.ts +53 -3
- package/src/preflight.ts +29 -43
- package/src/role-provider.test.ts +55 -0
- package/src/role-provider.ts +65 -0
- package/src/server.ts +567 -63
- package/src/services.test.ts +36 -1
- package/src/services.ts +60 -3
- package/src/store.test.ts +18 -0
- package/src/store.ts +16 -1
- package/src/track-record.test.ts +46 -0
- package/src/track-record.ts +149 -0
- package/src/types.ts +23 -0
- package/ui/dist/assets/index--dvfX3Mo.js +68 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-BPFc7O5V.js +0 -67
package/src/server.ts
CHANGED
|
@@ -24,10 +24,17 @@
|
|
|
24
24
|
* POST /runs/{id}/resume Resume an interrupted/failed run
|
|
25
25
|
* POST /permission Resolve an approval {id, behavior, message?}
|
|
26
26
|
* POST /answer Answer a director question {id, text}
|
|
27
|
+
* POST /projects/clone Clone a Git URL under the projects root and link it {url, branch?} → {id}; GET /projects/clone/{id} polls
|
|
28
|
+
* GET /projects/{id}/memory The project's memory (.foreman/MEMORY.md): text, updatedAt
|
|
29
|
+
* GET /projects/{id}/tree The project's files as they stand (read-only, jailed); …/artifact and …/preview as for runs
|
|
30
|
+
* GET /doctor The same checks `foreman doctor` runs, for the first-run card
|
|
27
31
|
* GET /search?q= Runs across the fleet matching title, brief, project or folder
|
|
28
32
|
* POST /fleet/chat One turn with the fleet planner {text} → {text, costUsd}
|
|
29
33
|
* POST /fleet/stop Stop the fleet planner reply in flight
|
|
30
34
|
* DELETE /fleet/chat Forget the fleet conversation
|
|
35
|
+
* GET /runs/{id}/pr The pull request Foreman would draft for a finished run on its own branch
|
|
36
|
+
* POST /runs/{id}/pr Push that branch and open the PR (gh) or hand back the compare URL {title, body}
|
|
37
|
+
* GET /runs/{id}/pr/state open | merged | closed, from gh; a final answer is remembered on the run
|
|
31
38
|
* POST /steer Send an operator note to a running director {runId, text}
|
|
32
39
|
* POST /interrupt Interrupt a run {runId}
|
|
33
40
|
* GET /runs?projectId= Persisted run summaries, newest first
|
|
@@ -47,7 +54,7 @@
|
|
|
47
54
|
import http from 'node:http';
|
|
48
55
|
import crypto from 'node:crypto';
|
|
49
56
|
import os from 'node:os';
|
|
50
|
-
import { mkdir, readFile, readdir, stat } from 'node:fs/promises';
|
|
57
|
+
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
51
58
|
import path from 'node:path';
|
|
52
59
|
import { fileURLToPath } from 'node:url';
|
|
53
60
|
import { MissionRun } from './orchestrator.js';
|
|
@@ -57,10 +64,18 @@ import {
|
|
|
57
64
|
dropPendingAsk,
|
|
58
65
|
} from './planner.js';
|
|
59
66
|
import { DEFAULT_TOOL_POLICY } from './policy.js';
|
|
67
|
+
import { pathPermitted, requestAllowed } from './guard.js';
|
|
68
|
+
import { BodyError, bodyLimitFor, parseBody } from './http-body.js';
|
|
60
69
|
import { saveAttachments } from './attachments.js';
|
|
70
|
+
import { cloneRepo, looksLikeRepoUrl, parseRepoUrl } from './clone.js';
|
|
71
|
+
import { readMemory } from './memory.js';
|
|
72
|
+
import { budgetAnchor, modelRecords, projectRecord, recordLine } from './track-record.js';
|
|
73
|
+
import { reconcileRole } from './role-provider.js';
|
|
74
|
+
import { detectBrowser, installChromium } from './browser.js';
|
|
75
|
+
import { closeMissionBranch, compareUrl, createPullRequest, ensureMissionBranch, ghReady, gitInfo, prDraft, pullRequestState, pushBranch, startMissionBranch, type GitInfo } from './gitwork.js';
|
|
61
76
|
import { detectTailscale, tailnetUrl } from './tailscale.js';
|
|
62
77
|
import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
|
|
63
|
-
import { ServiceRegistry,
|
|
78
|
+
import { ServiceRegistry, portOpen, servicesHandler } from './services.js';
|
|
64
79
|
import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
|
|
65
80
|
import {
|
|
66
81
|
DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
|
|
@@ -229,6 +244,8 @@ interface ModelOption {
|
|
|
229
244
|
providerLabel: string;
|
|
230
245
|
cost?: number;
|
|
231
246
|
note?: string;
|
|
247
|
+
/** Its track record on this machine, from the run ledger; absent until it has one. */
|
|
248
|
+
record?: string;
|
|
232
249
|
/** What spending on this model is: priced, free, or real-but-unquantified. */
|
|
233
250
|
costBasis: CostBasis;
|
|
234
251
|
/** @deprecated Mirrors `costBasis === 'priced'` for older clients. */
|
|
@@ -249,6 +266,15 @@ async function availableModels(project: Project | null): Promise<{
|
|
|
249
266
|
reachable: boolean;
|
|
250
267
|
}> {
|
|
251
268
|
const out: ModelOption[] = [];
|
|
269
|
+
// The ledger, read back: each model's record across every run on this
|
|
270
|
+
// machine, attached as one line so the picker and the planner can weigh
|
|
271
|
+
// "cheap" against "finished last time".
|
|
272
|
+
const records = modelRecords(await store.listRuns().catch(() => []));
|
|
273
|
+
const withRecord = (m: ModelOption): ModelOption => {
|
|
274
|
+
const r = records.get(m.id) ?? records.get(m.model) ?? records.get(m.label);
|
|
275
|
+
const line = recordLine(r);
|
|
276
|
+
return line ? { ...m, record: line } : m;
|
|
277
|
+
};
|
|
252
278
|
|
|
253
279
|
// Anthropic, via whichever Claude Code install or key the server resolves.
|
|
254
280
|
// Always offered: it is the default, and the shipped configuration.
|
|
@@ -326,7 +352,7 @@ async function availableModels(project: Project | null): Promise<{
|
|
|
326
352
|
count: out.filter((x) => x.providerLabel === m.providerLabel).length,
|
|
327
353
|
}));
|
|
328
354
|
|
|
329
|
-
return { models: out, groups, reachable: true };
|
|
355
|
+
return { models: out.map(withRecord), groups, reachable: true };
|
|
330
356
|
}
|
|
331
357
|
|
|
332
358
|
/**
|
|
@@ -347,6 +373,26 @@ function providerForRole(meta: RunMeta, roleProviderId?: string): ProviderRef {
|
|
|
347
373
|
return own;
|
|
348
374
|
}
|
|
349
375
|
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The one provider problem `resolveProvider` cannot see: a Claude Code
|
|
379
|
+
* provider whose install is not signed in. Resolution only names the config
|
|
380
|
+
* dir; whether anything is logged into it is a fact about the machine, read
|
|
381
|
+
* fresh here so a login done a minute ago counts. Said in Foreman's words,
|
|
382
|
+
* because the SDK's own — "Not logged in · Please run /login" — reads like a
|
|
383
|
+
* Foreman slash command and names nothing the person can do from here.
|
|
384
|
+
*/
|
|
385
|
+
async function signedInProblem(resolved: ResolvedProvider): Promise<string | null> {
|
|
386
|
+
if (resolved.kind !== 'claude-code') return null;
|
|
387
|
+
const signedIn = resolved.ownLogin
|
|
388
|
+
? await dirHasCredentials(resolved.configDir)
|
|
389
|
+
: (await detectAuth()).mode !== 'none';
|
|
390
|
+
if (signedIn) return null;
|
|
391
|
+
return 'Claude Code is not signed in on this machine, so nothing can run on it yet. ' +
|
|
392
|
+
'Sign in — run `claude` in a terminal, then `/login` — and restart Foreman; ' +
|
|
393
|
+
'or give this project a provider of its own (an Anthropic API key, Codex, or an OpenAI-compatible endpoint) in Settings → Provider.';
|
|
394
|
+
}
|
|
395
|
+
|
|
350
396
|
/** Whether a project's provider has a key on file. Never the key itself. */
|
|
351
397
|
async function providerHasKeyOf(p: Project): Promise<boolean> {
|
|
352
398
|
const ref = providerOf(p);
|
|
@@ -369,7 +415,6 @@ function toPath(v: unknown): string | undefined {
|
|
|
369
415
|
return typeof v === 'string' && v.trim() ? v.trim() : undefined;
|
|
370
416
|
}
|
|
371
417
|
|
|
372
|
-
/** Parses a model choice: a known alias or a full claude-* id; else inherit. */
|
|
373
418
|
/**
|
|
374
419
|
* A model id from any provider.
|
|
375
420
|
*
|
|
@@ -420,6 +465,19 @@ async function locateFolders(name: string): Promise<string[]> {
|
|
|
420
465
|
}
|
|
421
466
|
|
|
422
467
|
const PORT = Number(process.env.PORT ?? 4177);
|
|
468
|
+
/**
|
|
469
|
+
* Where the dev servers the crew exposed are proxied — a second port, and so a
|
|
470
|
+
* second origin. Those pages are written by an agent, and a browser gives a
|
|
471
|
+
* page the run of every API on its own origin: served from Foreman's port, an
|
|
472
|
+
* exposed preview could start missions, read the folder tree and spend the
|
|
473
|
+
* budget with a line of fetch(). On a port of its own the same-origin policy
|
|
474
|
+
* does that refusing for us. FOREMAN_SERVICES_PORT overrides; PORT + 1 by default.
|
|
475
|
+
*/
|
|
476
|
+
const SERVICES_PORT = (() => {
|
|
477
|
+
const raw = process.env.FOREMAN_SERVICES_PORT;
|
|
478
|
+
const n = Number(raw);
|
|
479
|
+
return raw && Number.isInteger(n) && n >= 1 && n <= 65535 ? n : PORT + 1;
|
|
480
|
+
})();
|
|
423
481
|
/**
|
|
424
482
|
* Where to listen. Default: loopback, plus the tailnet address when this
|
|
425
483
|
* machine is on one — never every interface, since there is no login.
|
|
@@ -452,12 +510,30 @@ const authPromise = detectAuth();
|
|
|
452
510
|
const activeByProject = new Map<string, MissionRun | null>();
|
|
453
511
|
const sseClients = new Set<http.ServerResponse>();
|
|
454
512
|
|
|
513
|
+
/**
|
|
514
|
+
* A comment frame down every open stream, often enough that nothing in the
|
|
515
|
+
* middle calls the connection dead.
|
|
516
|
+
*
|
|
517
|
+
* A quiet fleet produces no events for minutes at a time, and an idle stream
|
|
518
|
+
* is exactly what proxies (`tailscale serve`, anything else in front) and
|
|
519
|
+
* phone radios reclaim — the dashboard then sits there looking live while
|
|
520
|
+
* receiving nothing. `:` starts a comment in the SSE grammar, so this costs
|
|
521
|
+
* the client nothing to parse and never reaches an event handler. Unref'd:
|
|
522
|
+
* it must not be the reason the process stays up. Clients that have gone
|
|
523
|
+
* away are dropped here rather than waiting for a real event to notice.
|
|
524
|
+
*/
|
|
525
|
+
setInterval(() => {
|
|
526
|
+
for (const res of sseClients) {
|
|
527
|
+
if (res.destroyed || res.writableEnded) { sseClients.delete(res); continue; }
|
|
528
|
+
try { res.write(': ping\n\n'); } catch { sseClients.delete(res); }
|
|
529
|
+
}
|
|
530
|
+
}, 25_000).unref();
|
|
531
|
+
|
|
455
532
|
function activeRuns(): MissionRun[] {
|
|
456
533
|
// Filter out reservation placeholders (see reserveProject).
|
|
457
534
|
return [...activeByProject.values()].filter((r): r is MissionRun => Boolean(r));
|
|
458
535
|
}
|
|
459
536
|
|
|
460
|
-
/** Broadcasts an enveloped frame to live clients and persists the bare event. */
|
|
461
537
|
/**
|
|
462
538
|
* What happened in the fleet lately, in one line each, for the front desk.
|
|
463
539
|
*
|
|
@@ -560,10 +636,6 @@ function ledgerKeyFor(meta: RunMeta): string {
|
|
|
560
636
|
return `${meta.id}.${meta.resumes ?? 0}`;
|
|
561
637
|
}
|
|
562
638
|
|
|
563
|
-
/**
|
|
564
|
-
* Chat frames carry `chat: true` and no run id, so a UI following the same
|
|
565
|
-
* stream can tell a planning conversation from a mission without guessing.
|
|
566
|
-
*/
|
|
567
639
|
/**
|
|
568
640
|
* A chat event for open tabs only, not the log: the log it would describe
|
|
569
641
|
* is the one being thrown away. Used when a conversation is cleared, so a
|
|
@@ -736,6 +808,55 @@ async function findProject(ref: string): Promise<Project | null> {
|
|
|
736
808
|
?? null;
|
|
737
809
|
}
|
|
738
810
|
|
|
811
|
+
// ---------------------------------------------------------------------------
|
|
812
|
+
// Linking a repository: clone under the projects root, then link the folder
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
|
|
815
|
+
interface CloneJob {
|
|
816
|
+
id: string; url: string; dest: string; startedAt: number;
|
|
817
|
+
state: 'running' | 'done' | 'error'; progress: string; projectId?: string; error?: string;
|
|
818
|
+
}
|
|
819
|
+
/** Clones in flight or recently finished, for the picker to poll. Memory only; a restart forgets them (the folder stays). */
|
|
820
|
+
const cloneJobs = new Map<string, CloneJob>();
|
|
821
|
+
|
|
822
|
+
interface InstallJob { id: string; what: 'chromium'; startedAt: number; state: 'running' | 'done' | 'error'; progress: string; error?: string }
|
|
823
|
+
/** Browser installs started from the setup page; one at a time, polled like a clone. */
|
|
824
|
+
const installJobs = new Map<string, InstallJob>();
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Where a repository lands and whether it may: under the projects root, by
|
|
828
|
+
* its own name, never over something that is already there. Returns the
|
|
829
|
+
* destination or the reason it cannot be used.
|
|
830
|
+
*/
|
|
831
|
+
async function cloneDestination(url: string): Promise<{ ok: true; ref: NonNullable<ReturnType<typeof parseRepoUrl>>; dest: string } | { ok: false; error: string }> {
|
|
832
|
+
const ref = parseRepoUrl(url);
|
|
833
|
+
if (!ref) return { ok: false, error: `"${url}" is not a Git URL Foreman recognises. Try https://github.com/owner/repo, git@host:owner/repo.git, or owner/repo.` };
|
|
834
|
+
const settings = await store.readSettings().catch(() => ({ global: {}, projects: {} }));
|
|
835
|
+
const root = projectsRoot((settings.global as Record<string, unknown>).projectsRoot);
|
|
836
|
+
const dest = path.join(root, ref.name);
|
|
837
|
+
if (await stat(dest).catch(() => null)) {
|
|
838
|
+
const linked = (await store.listProjects()).find((p) => p.folder === dest);
|
|
839
|
+
return { ok: false, error: linked ? `${ref.name} is already here and linked as ${linked.name}.` : `${dest} already exists. Link that folder instead, or move it aside.` };
|
|
840
|
+
}
|
|
841
|
+
await mkdir(root, { recursive: true });
|
|
842
|
+
return { ok: true, ref, dest };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** Clone and link, start to finish. Used by the front desk and the phone, which wait; the picker uses the job table instead. */
|
|
846
|
+
async function cloneAndLink(url: string, branch?: string, onProgress?: (line: string) => void): Promise<{ project?: Project; error?: string }> {
|
|
847
|
+
const where = await cloneDestination(url);
|
|
848
|
+
if (!where.ok) return { error: where.error };
|
|
849
|
+
const failed = await cloneRepo({ ref: where.ref, dest: where.dest, branch, onProgress });
|
|
850
|
+
if (failed) {
|
|
851
|
+
// A half-clone is worse than none: git leaves the folder on failure.
|
|
852
|
+
await rm(where.dest, { recursive: true, force: true }).catch(() => {});
|
|
853
|
+
return { error: failed };
|
|
854
|
+
}
|
|
855
|
+
const project = await store.addProject(where.dest);
|
|
856
|
+
projectsCache.set(project.id, { name: project.name });
|
|
857
|
+
return { project };
|
|
858
|
+
}
|
|
859
|
+
|
|
739
860
|
// ---------------------------------------------------------------------------
|
|
740
861
|
// The fleet planner — the front desk
|
|
741
862
|
// ---------------------------------------------------------------------------
|
|
@@ -851,6 +972,8 @@ const fleetHost: FleetHost = {
|
|
|
851
972
|
if (words.error) lines.push(`it stopped with: ${clipText(words.error, 300)}`);
|
|
852
973
|
}
|
|
853
974
|
}
|
|
975
|
+
const rec = projectRecord((await store.listRuns().catch(() => [] as RunMeta[])).filter((r) => r.folder === project.folder));
|
|
976
|
+
if (rec.done >= 2 && rec.medianCostUsd !== undefined) lines.push(`track record: ${rec.done} of ${rec.runs} missions finished; a finished one here costs about $${rec.medianCostUsd.toFixed(2)}${rec.medianMinutes !== undefined ? ` and takes ~${rec.medianMinutes} min` : ''}${rec.capHits ? `; ${rec.capHits} hit the cap` : ''}`);
|
|
854
977
|
const meta = await store.readChatMeta(project.id).catch(() => null);
|
|
855
978
|
if (meta?.proposal) lines.push(`a proposal is waiting for Start or Discard: "${clipText(firstLine(meta.proposal.mission), 100)}" cap $${meta.proposal.budgetUsd}`);
|
|
856
979
|
if (chatTurns.has(project.id)) lines.push('its planner is replying right now');
|
|
@@ -889,6 +1012,11 @@ const fleetHost: FleetHost = {
|
|
|
889
1012
|
},
|
|
890
1013
|
|
|
891
1014
|
async linkProject(folderIn) {
|
|
1015
|
+
if (looksLikeRepoUrl(folderIn)) {
|
|
1016
|
+
const r = await cloneAndLink(folderIn.trim());
|
|
1017
|
+
if (r.error) return r.error;
|
|
1018
|
+
return `Cloned ${r.project!.name} into ${r.project!.folder} and linked it. Its planner can read it now (open_planning).`;
|
|
1019
|
+
}
|
|
892
1020
|
const folder = expandHome(folderIn.trim());
|
|
893
1021
|
if (!path.isAbsolute(folder)) return `A folder to link must be an absolute path (or ~/…), not "${folderIn}".`;
|
|
894
1022
|
const st = await stat(folder).catch(() => null);
|
|
@@ -950,7 +1078,7 @@ async function driveFleetTurn(text: string, via: 'telegram' | 'http'): Promise<{
|
|
|
950
1078
|
try {
|
|
951
1079
|
const resolved = await resolveProvider(providerOf({}), store.root);
|
|
952
1080
|
emit('chat_turn', { state: 'thinking', model, provider: resolved.label, costBasis: resolved.costBasis });
|
|
953
|
-
const problem = providerProblem(resolved);
|
|
1081
|
+
const problem = providerProblem(resolved) ?? await signedInProblem(resolved);
|
|
954
1082
|
if (problem) {
|
|
955
1083
|
emit('chat_error', { error: `provider unavailable — ${problem}` });
|
|
956
1084
|
return { text: '', costUsd: 0, error: `provider unavailable — ${problem}` };
|
|
@@ -964,7 +1092,7 @@ async function driveFleetTurn(text: string, via: 'telegram' | 'http'): Promise<{
|
|
|
964
1092
|
const result = await runFleetTurn({
|
|
965
1093
|
sessionId: meta.sessionId, text, model, cwd, host: fleetHost, via,
|
|
966
1094
|
news: fleetNews(since), sinceMs: Date.now() - since,
|
|
967
|
-
models: models.map((m) => ({ id: m.id, label: m.label, providerId: m.providerId, providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note })),
|
|
1095
|
+
models: models.map((m) => ({ id: m.id, label: m.label, providerId: m.providerId, providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note, record: m.record })),
|
|
968
1096
|
agentEnv: await agentEnvFor(resolved, `chat:${FLEET_CHAT_ID}`),
|
|
969
1097
|
emit, abort,
|
|
970
1098
|
});
|
|
@@ -1068,6 +1196,13 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
|
|
|
1068
1196
|
return say(`<b>Running · ${live.length}</b>\n${lines.join('\n')}${planning.length ? `\n\n<b>Planning</b>\n${planning.join('\n')}` : ''}`);
|
|
1069
1197
|
}
|
|
1070
1198
|
case 'new': {
|
|
1199
|
+
if (looksLikeRepoUrl(cmd.name)) {
|
|
1200
|
+
say(`Cloning <code>${escTg(cmd.name.trim())}</code>…`);
|
|
1201
|
+
const r = await cloneAndLink(cmd.name.trim());
|
|
1202
|
+
if (r.error) return say(`Could not clone: ${escTg(r.error)}`);
|
|
1203
|
+
lastPhonePlanning = r.project!.id; lastPhonePlanningAt = Date.now();
|
|
1204
|
+
return say(`Cloned <b>${escTg(r.project!.name)}</b> into <code>${escTg(r.project!.folder)}</code> and linked it.\nTell me what it should do next — just type it.`);
|
|
1205
|
+
}
|
|
1071
1206
|
const settings = await store.readSettings().catch(() => ({ global: {}, projects: {} }));
|
|
1072
1207
|
const root = projectsRoot((settings.global as Record<string, unknown>).projectsRoot);
|
|
1073
1208
|
const name = slug(cmd.name);
|
|
@@ -1138,11 +1273,6 @@ async function chatMetaOf(projectId: string): Promise<ChatMeta> {
|
|
|
1138
1273
|
?? { projectId, costUsd: 0, createdAt: now, updatedAt: now };
|
|
1139
1274
|
}
|
|
1140
1275
|
|
|
1141
|
-
/**
|
|
1142
|
-
* Runs one planning turn: the human's message goes into the log first (so a
|
|
1143
|
-
* reload mid-turn still shows what was asked), then the planner's reply
|
|
1144
|
-
* streams out through the same envelope machinery as a mission.
|
|
1145
|
-
*/
|
|
1146
1276
|
/**
|
|
1147
1277
|
* `shown` is what the transcript records as the human's message when it
|
|
1148
1278
|
* differs from what the planner is sent (a fork's seed). `via: 'telegram'`
|
|
@@ -1182,7 +1312,7 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
|
|
|
1182
1312
|
provider: resolved.label,
|
|
1183
1313
|
costBasis: resolved.costBasis,
|
|
1184
1314
|
});
|
|
1185
|
-
const problem = providerProblem(resolved);
|
|
1315
|
+
const problem = providerProblem(resolved) ?? await signedInProblem(resolved);
|
|
1186
1316
|
if (problem) {
|
|
1187
1317
|
emit('chat_error', { error: `provider unavailable — ${problem}` });
|
|
1188
1318
|
return;
|
|
@@ -1197,8 +1327,9 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
|
|
|
1197
1327
|
projectId: project.id,
|
|
1198
1328
|
models: models.map((m) => ({
|
|
1199
1329
|
id: m.id, label: m.label, providerId: m.providerId,
|
|
1200
|
-
providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note,
|
|
1330
|
+
providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note, record: m.record,
|
|
1201
1331
|
})),
|
|
1332
|
+
anchor: await budgetAnchorFor(project).catch(() => ''),
|
|
1202
1333
|
sessionId: meta.sessionId,
|
|
1203
1334
|
folder: project.folder,
|
|
1204
1335
|
text,
|
|
@@ -1244,18 +1375,25 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
|
|
|
1244
1375
|
}
|
|
1245
1376
|
|
|
1246
1377
|
/**
|
|
1247
|
-
* A mission has started, so the
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
1250
|
-
* at the moment the work began
|
|
1378
|
+
* A mission has started, so the conversation that led to it is finished.
|
|
1379
|
+
*
|
|
1380
|
+
* The handoff is recorded first — the chat is the story of how this mission
|
|
1381
|
+
* came to exist, and it should not simply stop at the moment the work began —
|
|
1382
|
+
* and then the whole conversation is archived, not left as the project's
|
|
1383
|
+
* front page. A project visited after a mission shows its runs and a blank
|
|
1384
|
+
* line, not a spent planning session scrolled to the bottom; the run is the
|
|
1385
|
+
* continuation, and "Plan the next step" forks a new conversation from it.
|
|
1386
|
+
* Open tabs are told, so they reset without a reload.
|
|
1251
1387
|
*/
|
|
1252
1388
|
async function consumeProposal(projectId: string, runId: string, mission: string): Promise<void> {
|
|
1253
1389
|
const meta = await store.readChatMeta(projectId).catch(() => null);
|
|
1254
1390
|
if (!meta) return;
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1391
|
+
const handoff = { runId, mission };
|
|
1392
|
+
await store.appendChat(projectId, { ts: Date.now(), event: 'mission_started', data: handoff }).catch(() => {});
|
|
1393
|
+
noteFleetEvent(projectId, 'mission_started', handoff);
|
|
1394
|
+
notifyHub.handle({ event: 'mission_started', runId: null, projectId, chat: true, data: handoff, ts: Date.now() });
|
|
1395
|
+
await store.archiveChat(projectId, runId).catch(() => {});
|
|
1396
|
+
broadcastChat(projectId, 'chat_cleared', {});
|
|
1259
1397
|
}
|
|
1260
1398
|
|
|
1261
1399
|
/**
|
|
@@ -1269,6 +1407,28 @@ function reserveProject(projectId: string): boolean {
|
|
|
1269
1407
|
return true;
|
|
1270
1408
|
}
|
|
1271
1409
|
|
|
1410
|
+
/**
|
|
1411
|
+
* Where an exposed service is reachable from, without the trailing slash — the
|
|
1412
|
+
* same address the human already uses for Foreman, but on SERVICES_PORT.
|
|
1413
|
+
*
|
|
1414
|
+
* The starting point is the URL the dashboard hands out (Settings, else the
|
|
1415
|
+
* tailnet, else localhost), because that is the one that actually reaches this
|
|
1416
|
+
* machine from wherever the human reads their notifications. Only the port
|
|
1417
|
+
* changes. An https URL cannot simply be re-pointed: it is `tailscale serve`
|
|
1418
|
+
* terminating TLS in front of the main port, and serve fronts that port alone,
|
|
1419
|
+
* so the honest answer is plain http straight at the tailnet address.
|
|
1420
|
+
*/
|
|
1421
|
+
async function servicesBase(): Promise<string> {
|
|
1422
|
+
const publicUrl = (await notifySettings().catch(() => null))?.publicUrl
|
|
1423
|
+
?? (tailnet ? tailnetUrl(tailnet, PORT) : `http://localhost:${PORT}`);
|
|
1424
|
+
const direct = () => `http://${tailnet ? (tailnet.dnsName ?? tailnet.ip) : 'localhost'}:${SERVICES_PORT}`;
|
|
1425
|
+
let u: URL;
|
|
1426
|
+
try { u = new URL(publicUrl); } catch { return direct(); }
|
|
1427
|
+
if (u.protocol !== 'http:') return direct();
|
|
1428
|
+
u.port = String(SERVICES_PORT);
|
|
1429
|
+
return u.origin;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1272
1432
|
/** Runs a mission to completion. The project must already be reserved. */
|
|
1273
1433
|
async function driveRun(
|
|
1274
1434
|
projectId: string, meta: RunMeta, resume?: { sessionId?: string },
|
|
@@ -1279,7 +1439,7 @@ async function driveRun(
|
|
|
1279
1439
|
// A run that cannot resolve a credential must not start: dispatching anyway
|
|
1280
1440
|
// would fall back to whatever the environment happens to hold.
|
|
1281
1441
|
const resolved = await resolveProvider(providerOf(meta), store.root);
|
|
1282
|
-
const problem = providerProblem(resolved);
|
|
1442
|
+
const problem = providerProblem(resolved) ?? await signedInProblem(resolved);
|
|
1283
1443
|
if (problem) {
|
|
1284
1444
|
meta.status = 'error';
|
|
1285
1445
|
meta.endedAt = Date.now();
|
|
@@ -1289,6 +1449,29 @@ async function driveRun(
|
|
|
1289
1449
|
activeByProject.delete(projectId);
|
|
1290
1450
|
return;
|
|
1291
1451
|
}
|
|
1452
|
+
// Each role's provider, checked against its model before anything is
|
|
1453
|
+
// resolved. Model and provider are picked together but stored apart, and
|
|
1454
|
+
// a stale pair — `fable` pinned to Codex by an older settings file — fails
|
|
1455
|
+
// on the first turn with an upstream 400 nobody chose. The model list is
|
|
1456
|
+
// the authority; a corrected role is written back and said in the
|
|
1457
|
+
// transcript, so the run reads the way it actually ran.
|
|
1458
|
+
{
|
|
1459
|
+
const project = await store.getProject(projectId).catch(() => null);
|
|
1460
|
+
const { models } = await availableModels(project).catch(() => ({ models: [] as ModelOption[] }));
|
|
1461
|
+
const known = models.map((m) => ({ id: m.id, providerId: m.providerId }));
|
|
1462
|
+
const director = reconcileRole('director', meta.directorModel, meta.directorProviderId, known);
|
|
1463
|
+
const worker = reconcileRole('workers', meta.workerModel, meta.workerProviderId, known);
|
|
1464
|
+
const notes = [director.note, worker.note].filter((n): n is string => Boolean(n));
|
|
1465
|
+
if (notes.length) {
|
|
1466
|
+
meta.directorProviderId = director.providerId;
|
|
1467
|
+
meta.workerProviderId = worker.providerId;
|
|
1468
|
+
await store.writeMeta(meta).catch(() => {});
|
|
1469
|
+
emit('models_changed', {
|
|
1470
|
+
text: `Provider corrected before dispatch — ${notes.join(' ')}`,
|
|
1471
|
+
directorModel: meta.directorModel, workerModel: meta.workerModel,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1292
1475
|
let agentEnv;
|
|
1293
1476
|
let roleBasis = resolved.costBasis;
|
|
1294
1477
|
let prices: { director?: ModelPrice; worker?: ModelPrice } = {};
|
|
@@ -1373,11 +1556,14 @@ async function driveRun(
|
|
|
1373
1556
|
// A dev server behind Foreman's address. Declared ports only, and only
|
|
1374
1557
|
// ones something is listening on — an agent cannot reserve a path for a
|
|
1375
1558
|
// server it has not started.
|
|
1559
|
+
// Detected per dispatch, so a browser installed from the setup page
|
|
1560
|
+
// serves the very next mission without a restart.
|
|
1561
|
+
browserChannel: (await detectBrowser())?.channel,
|
|
1376
1562
|
exposeService: async (runId, port, label) => {
|
|
1563
|
+
if (servicesDown) return { ok: false, reason: servicesDown };
|
|
1377
1564
|
if (!(await portOpen(port))) return { ok: false, reason: `nothing is listening on 127.0.0.1:${port} — start the server first` };
|
|
1378
1565
|
const svc = services.register(runId, port, label);
|
|
1379
|
-
|
|
1380
|
-
return { ok: true, url: `${base.replace(/\/+$/, '')}${svc.path}`, path: svc.path };
|
|
1566
|
+
return { ok: true, url: `${await servicesBase()}${svc.path}`, path: svc.path };
|
|
1381
1567
|
},
|
|
1382
1568
|
});
|
|
1383
1569
|
activeByProject.set(projectId, run);
|
|
@@ -1409,6 +1595,23 @@ async function driveRun(
|
|
|
1409
1595
|
// However the run ended, it no longer needs its gateways.
|
|
1410
1596
|
releaseGateways(meta.id);
|
|
1411
1597
|
if (activeByProject.get(projectId) === run) activeByProject.delete(projectId);
|
|
1598
|
+
// A finished mission on its own branch closes with a commit of whatever
|
|
1599
|
+
// the crew left uncommitted. Done only: an interrupted run resumes on
|
|
1600
|
+
// the same branch and its tree, and an error is not a result to record.
|
|
1601
|
+
if (meta.git && meta.status === 'done') {
|
|
1602
|
+
const label = meta.title || meta.mission.split('\n').find((l) => l.trim())?.trim().slice(0, 72) || meta.id;
|
|
1603
|
+
const closed = await closeMissionBranch(meta.folder, meta.git, `foreman: ${label}`);
|
|
1604
|
+
meta.git = { branch: closed.branch, base: closed.base, baseHead: closed.baseHead, commits: closed.commits, commit: closed.commit ?? meta.git.commit };
|
|
1605
|
+
await store.writeMeta(meta).catch(() => {});
|
|
1606
|
+
gitInfoCache.delete(meta.folder);
|
|
1607
|
+
const ahead = closed.commits ?? 0;
|
|
1608
|
+
emit('git_committed', {
|
|
1609
|
+
branch: closed.branch, base: closed.base, commits: ahead, commit: closed.commit, committed: closed.committed, error: closed.error,
|
|
1610
|
+
text: closed.error
|
|
1611
|
+
? `Could not commit the mission's work on ${closed.branch}: ${closed.error}`
|
|
1612
|
+
: `${closed.committed ? `Committed the mission's work as ${closed.commit}` : 'Nothing left to commit'} — ${closed.branch} is ${ahead} commit${ahead === 1 ? '' : 's'} ahead of ${closed.base}. Merge or open a pull request when you are ready; Foreman does neither.`,
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1412
1615
|
}
|
|
1413
1616
|
}
|
|
1414
1617
|
|
|
@@ -1418,6 +1621,8 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1418
1621
|
directorModel: ModelChoice; workerModel: ModelChoice; plannerModel: ModelChoice;
|
|
1419
1622
|
/** Provider serving each role, when Settings pinned one with the model. */
|
|
1420
1623
|
directorProviderId?: string; workerProviderId?: string;
|
|
1624
|
+
/** In a repository, each mission runs on a branch of its own (default on). */
|
|
1625
|
+
gitBranchPerMission: boolean;
|
|
1421
1626
|
}> {
|
|
1422
1627
|
const s = await store.readSettings()
|
|
1423
1628
|
.catch(() => ({ global: {}, projects: {} as Record<string, object> }));
|
|
@@ -1438,9 +1643,26 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1438
1643
|
plannerModel: modelChoice(p.plannerModel ?? g.plannerModel),
|
|
1439
1644
|
directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
|
|
1440
1645
|
workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
|
|
1646
|
+
gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
|
|
1441
1647
|
};
|
|
1442
1648
|
}
|
|
1443
1649
|
|
|
1650
|
+
/** What missions have cost in this project and across the fleet, as the planner's anchor. */
|
|
1651
|
+
async function budgetAnchorFor(project: Project): Promise<string> {
|
|
1652
|
+
const runs = await store.listRuns();
|
|
1653
|
+
return budgetAnchor(projectRecord(runs.filter((r) => r.folder === project.folder)), projectRecord(runs), project.name);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/** Git facts per folder, for the fleet poll — asked at most every few seconds per project. */
|
|
1657
|
+
const gitInfoCache = new Map<string, { at: number; value: GitInfo }>();
|
|
1658
|
+
async function gitInfoCached(folder: string): Promise<GitInfo> {
|
|
1659
|
+
const hit = gitInfoCache.get(folder);
|
|
1660
|
+
if (hit && Date.now() - hit.at < 5_000) return hit.value;
|
|
1661
|
+
const value = await gitInfo(folder);
|
|
1662
|
+
gitInfoCache.set(folder, { at: Date.now(), value });
|
|
1663
|
+
return value;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1444
1666
|
async function startRun(
|
|
1445
1667
|
projectId: string, folder: string, mission: string, budgetUsd: number,
|
|
1446
1668
|
directorModel: ModelChoice, workerModel: ModelChoice, browserTools: boolean,
|
|
@@ -1477,6 +1699,21 @@ async function startRun(
|
|
|
1477
1699
|
return;
|
|
1478
1700
|
}
|
|
1479
1701
|
await consumeProposal(projectId, meta.id, mission).catch(() => {});
|
|
1702
|
+
// In a repository, the mission gets a branch of its own before the crew
|
|
1703
|
+
// touches anything — so the deck's baseline, taken at the director's first
|
|
1704
|
+
// turn, is the branch point, and the diff is exactly the mission.
|
|
1705
|
+
if (settings.gitBranchPerMission) {
|
|
1706
|
+
const g = await startMissionBranch(folder, mission, meta.id);
|
|
1707
|
+
const emit = makeEmitter(meta.id, projectId);
|
|
1708
|
+
if ('error' in g) {
|
|
1709
|
+
if (g.error !== 'not a git repository') emit('git_note', { text: `Could not give this mission its own branch (${g.error}); running on the current branch.` });
|
|
1710
|
+
} else {
|
|
1711
|
+
meta.git = g;
|
|
1712
|
+
await store.writeMeta(meta).catch(() => {});
|
|
1713
|
+
gitInfoCache.delete(folder);
|
|
1714
|
+
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.` });
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1480
1717
|
await driveRun(projectId, meta);
|
|
1481
1718
|
}
|
|
1482
1719
|
|
|
@@ -1487,6 +1724,8 @@ async function startRun(
|
|
|
1487
1724
|
*/
|
|
1488
1725
|
async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
1489
1726
|
directorModel?: string; directorProviderId?: string; workerModel?: string; workerProviderId?: string;
|
|
1727
|
+
/** A new cap for the resumed attempt — the answer to "stopped at its budget". */
|
|
1728
|
+
budgetUsd?: number;
|
|
1490
1729
|
} = {}): Promise<void> {
|
|
1491
1730
|
const sessionId = meta.directorSessionId;
|
|
1492
1731
|
// Resume re-reads Settings for tool policy and auto-allow, so a policy
|
|
@@ -1511,9 +1750,29 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1511
1750
|
if (workerChanged) { meta.workerModel = modelChoice(pick.workerModel); meta.workerProviderId = pick.workerProviderId; }
|
|
1512
1751
|
meta.toolPolicy = settings.toolPolicy;
|
|
1513
1752
|
meta.autoAllowReadOnly = settings.autoAllowReadOnly;
|
|
1753
|
+
// A raised cap rides the resume: the run that stopped at $10 continues
|
|
1754
|
+
// under $15, and the transcript says who moved it and from where. Written
|
|
1755
|
+
// to the log as a settings change, which is what it is.
|
|
1756
|
+
const budgetWas = meta.budgetUsd;
|
|
1757
|
+
if (typeof pick.budgetUsd === 'number' && Number.isFinite(pick.budgetUsd) && pick.budgetUsd > 0 && pick.budgetUsd !== meta.budgetUsd) {
|
|
1758
|
+
meta.budgetUsd = pick.budgetUsd;
|
|
1759
|
+
}
|
|
1760
|
+
const stoppedAt = meta.stopReason;
|
|
1761
|
+
meta.stopReason = undefined;
|
|
1514
1762
|
meta.status = 'running';
|
|
1515
1763
|
meta.endedAt = undefined;
|
|
1516
1764
|
meta.resumes = (meta.resumes ?? 0) + 1;
|
|
1765
|
+
if (meta.budgetUsd !== budgetWas) {
|
|
1766
|
+
makeEmitter(meta.id, projectId)('settings_changed', {
|
|
1767
|
+
budgetUsd: meta.budgetUsd,
|
|
1768
|
+
changes: [`Budget ${meta.budgetUsd > budgetWas ? 'raised' : 'lowered'} to $${meta.budgetUsd.toFixed(2)} (was $${budgetWas.toFixed(2)}) before resume${stoppedAt === 'budget' ? ' — the last attempt stopped at its cap' : ''}.`],
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
if (meta.git) {
|
|
1772
|
+
const back = await ensureMissionBranch(meta.folder, meta.git.branch);
|
|
1773
|
+
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
|
+
gitInfoCache.delete(meta.folder);
|
|
1775
|
+
}
|
|
1517
1776
|
await store.writeMeta(meta).catch((err) => {
|
|
1518
1777
|
console.error(`failed to persist resume of ${meta.id}:`, err);
|
|
1519
1778
|
});
|
|
@@ -1558,13 +1817,52 @@ async function serveStatic(pathname: string, res: http.ServerResponse): Promise<
|
|
|
1558
1817
|
// Request helpers
|
|
1559
1818
|
// ---------------------------------------------------------------------------
|
|
1560
1819
|
|
|
1820
|
+
/**
|
|
1821
|
+
* The request body as an object, refusing to buffer more than the route's cap.
|
|
1822
|
+
*
|
|
1823
|
+
* Both failures throw a `BodyError`, which the handler's outer catch turns
|
|
1824
|
+
* into the client's own status instead of a 500 that reads like a Foreman bug:
|
|
1825
|
+
* 413 when the body outgrows the cap (see `src/http-body.ts` for why there is
|
|
1826
|
+
* one), 400 when what arrived is not JSON. Content-Length is consulted first
|
|
1827
|
+
* so an oversized upload is refused before a single byte is read.
|
|
1828
|
+
*/
|
|
1561
1829
|
async function readBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
|
|
1830
|
+
const pathname = new URL(req.url ?? '/', `http://localhost:${PORT}`).pathname;
|
|
1831
|
+
const limit = bodyLimitFor(req.method, pathname);
|
|
1832
|
+
const declared = Number(req.headers['content-length']);
|
|
1833
|
+
if (Number.isFinite(declared) && declared > limit) throw new BodyError(413, 'request body too large');
|
|
1562
1834
|
const chunks: Buffer[] = [];
|
|
1563
|
-
|
|
1835
|
+
let seen = 0;
|
|
1836
|
+
for await (const c of req) {
|
|
1837
|
+
const buf = c as Buffer;
|
|
1838
|
+
seen += buf.length;
|
|
1839
|
+
if (seen > limit) throw new BodyError(413, 'request body too large');
|
|
1840
|
+
chunks.push(buf);
|
|
1841
|
+
}
|
|
1564
1842
|
if (!chunks.length) return {};
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1843
|
+
return parseBody(Buffer.concat(chunks));
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* The folders the folder picker may look inside.
|
|
1848
|
+
*
|
|
1849
|
+
* `/browse` and `/mkdir` exist to let the human point at a project, and they
|
|
1850
|
+
* took any absolute path — which made them a directory listing and a
|
|
1851
|
+
* `mkdir -p` for the whole filesystem, reachable from a page that got past
|
|
1852
|
+
* nothing but a URL. The answer is the same one the permission cards use:
|
|
1853
|
+
* name the places a project could plausibly live — the home directory, the
|
|
1854
|
+
* configured projects root (it may be on another volume), and every folder
|
|
1855
|
+
* already linked as a project — and refuse the rest. Read afresh per request
|
|
1856
|
+
* because linking a project is what widens the set.
|
|
1857
|
+
*/
|
|
1858
|
+
async function browseRoots(): Promise<string[]> {
|
|
1859
|
+
const settings = await store.readSettings().catch(() => ({ global: {} as Record<string, unknown> }));
|
|
1860
|
+
const roots = [
|
|
1861
|
+
os.homedir(),
|
|
1862
|
+
projectsRoot((settings.global as Record<string, unknown>).projectsRoot),
|
|
1863
|
+
];
|
|
1864
|
+
for (const p of await store.listProjects().catch(() => [])) roots.push(p.folder);
|
|
1865
|
+
return roots;
|
|
1568
1866
|
}
|
|
1569
1867
|
|
|
1570
1868
|
function json(res: http.ServerResponse, code: number, body: unknown): void {
|
|
@@ -1577,40 +1875,31 @@ function json(res: http.ServerResponse, code: number, body: unknown): void {
|
|
|
1577
1875
|
// ---------------------------------------------------------------------------
|
|
1578
1876
|
|
|
1579
1877
|
const server = http.createServer(async (req, res) => {
|
|
1878
|
+
// Before any route, including the service proxy: is this request one we
|
|
1879
|
+
// should be answering at all? Foreman has no login, so a browser that can
|
|
1880
|
+
// reach the port can otherwise act as the operator — a page on any domain
|
|
1881
|
+
// can point that name at 127.0.0.1 (DNS rebinding) or simply POST at
|
|
1882
|
+
// localhost from whatever tab the operator has open. See src/guard.ts.
|
|
1883
|
+
const verdict = requestAllowed(req, { port: PORT, tailnet, bindAll: BIND === 'all' });
|
|
1884
|
+
if (!verdict.ok) { json(res, verdict.status, { error: verdict.error }); return; }
|
|
1580
1885
|
const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
|
|
1581
|
-
//
|
|
1582
|
-
//
|
|
1583
|
-
// absolute-path assets (`/app.js`) against Foreman's root; those arrive as
|
|
1584
|
-
// sub-resource requests carrying the service page as Referer, and are
|
|
1585
|
-
// routed to the same service. Documents never are — a typed URL is Foreman's.
|
|
1586
|
-
{
|
|
1587
|
-
const svc = parseServicePath(url.pathname);
|
|
1588
|
-
if (svc) {
|
|
1589
|
-
if (!services.has(svc.runId, svc.port)) { json(res, 404, { error: 'no such service' }); return; }
|
|
1590
|
-
proxyToService(req, res, svc.port, svc.rest, url.search, servicePath(svc.runId, svc.port));
|
|
1591
|
-
return;
|
|
1592
|
-
}
|
|
1593
|
-
const ref = req.headers.referer;
|
|
1594
|
-
const dest = String(req.headers['sec-fetch-dest'] ?? '');
|
|
1595
|
-
if (ref && dest && dest !== 'document' && dest !== 'empty' && !url.pathname.startsWith(SVC_PREFIX)) {
|
|
1596
|
-
try {
|
|
1597
|
-
const via = parseServicePath(new URL(ref).pathname);
|
|
1598
|
-
if (via && services.has(via.runId, via.port)) {
|
|
1599
|
-
proxyToService(req, res, via.port, url.pathname, url.search, servicePath(via.runId, via.port));
|
|
1600
|
-
return;
|
|
1601
|
-
}
|
|
1602
|
-
} catch { /* not a URL we can read — fall through to Foreman's own routes */ }
|
|
1603
|
-
}
|
|
1604
|
-
}
|
|
1886
|
+
// Note: /svc/… is not handled here. Exposed dev servers live on
|
|
1887
|
+
// SERVICES_PORT, on their own origin — see servicesServer below.
|
|
1605
1888
|
const runEventsMatch = url.pathname.match(/^\/runs\/([^/]+)\/events$/);
|
|
1606
1889
|
// The deck: what a run changed and what it produced. Read-only by design —
|
|
1607
1890
|
// a stated non-goal — and handled before the chain because it owns two paths
|
|
1608
1891
|
// under /runs/{id}/ that nothing else claims.
|
|
1609
|
-
if (await handleDeckRoute(req, res, url, async (id) => {
|
|
1892
|
+
if (await handleDeckRoute(req, res, url, async (scope, id) => {
|
|
1893
|
+
if (scope === 'projects') {
|
|
1894
|
+
const project = await store.getProject(id).catch(() => null);
|
|
1895
|
+
return project ? { folder: project.folder } : null;
|
|
1896
|
+
}
|
|
1610
1897
|
const m = await store.readMeta(id).catch(() => null);
|
|
1611
1898
|
return m ? { folder: m.folder } : null;
|
|
1612
1899
|
})) return;
|
|
1613
1900
|
const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
|
|
1901
|
+
const prMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr$/);
|
|
1902
|
+
const prStateMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr\/state$/);
|
|
1614
1903
|
const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
|
|
1615
1904
|
const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
|
|
1616
1905
|
|
|
@@ -1632,6 +1921,57 @@ const server = http.createServer(async (req, res) => {
|
|
|
1632
1921
|
sseClients.add(res);
|
|
1633
1922
|
req.on('close', () => sseClients.delete(res));
|
|
1634
1923
|
|
|
1924
|
+
} else if (req.method === 'GET' && url.pathname === '/doctor') {
|
|
1925
|
+
// What `foreman doctor` prints, as data, for the dashboard's setup card.
|
|
1926
|
+
// Computed fresh each time — the point is to reflect a login or an
|
|
1927
|
+
// install the person just did. The port and dashboard checks are left
|
|
1928
|
+
// out: whoever is reading this in the dashboard already knows both.
|
|
1929
|
+
const [checks, auth, ollama, codexAuth, notify] = await Promise.all([
|
|
1930
|
+
preflight({ port: PORT, servicesPort: SERVICES_PORT, foremanHome: store.root, distDir: DIST_DIR, tailnet }),
|
|
1931
|
+
detectAuth(),
|
|
1932
|
+
discoverOllama(1200),
|
|
1933
|
+
readCodexAuth(codexHome()).catch(() => null),
|
|
1934
|
+
notifySettings().catch(() => null),
|
|
1935
|
+
]);
|
|
1936
|
+
const codexCount = codexAuth ? (await codexModels(codexHome()).catch(() => [])).length : 0;
|
|
1937
|
+
json(res, 200, {
|
|
1938
|
+
checks: checks.filter((c) => !c.name.startsWith('Port') && c.name !== 'Dashboard'),
|
|
1939
|
+
// The same facts, shaped for the first-run pages: one object per step.
|
|
1940
|
+
auth: { mode: auth.mode, source: auth.source, account: auth.account ?? null },
|
|
1941
|
+
ollama: ollama ? { host: ollamaHost(), models: ollama.length, local: ollama.filter((m) => !m.remote).length } : null,
|
|
1942
|
+
codex: codexAuth ? { models: codexCount } : null,
|
|
1943
|
+
tailnet: tailnet ? { dnsName: tailnet.dnsName ?? null, ip: tailnet.ip, url: tailnetUrl(tailnet, PORT), https: Boolean(tailnet.httpsPort) } : null,
|
|
1944
|
+
phone: notify?.telegramChatId ? { label: notify.telegramChatLabel ?? 'linked', bot: notify.telegramBot ?? null } : notify?.telegramBot ? { label: null, bot: notify.telegramBot } : null,
|
|
1945
|
+
browser: checks.find((c) => c.name === 'Browser') ?? null,
|
|
1946
|
+
version: currentVersion(),
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
} else if (req.method === 'POST' && url.pathname === '/setup/browser') {
|
|
1950
|
+
// Install Playwright's Chromium, as the user, into Playwright's cache.
|
|
1951
|
+
// The one install action the setup page has: no root, no system
|
|
1952
|
+
// packages, and the crew's browser tool can drive the result.
|
|
1953
|
+
const running = [...installJobs.values()].find((j) => j.state === 'running');
|
|
1954
|
+
if (running) return json(res, 202, { id: running.id });
|
|
1955
|
+
const job: InstallJob = { id: crypto.randomBytes(6).toString('hex'), what: 'chromium', startedAt: Date.now(), state: 'running', progress: 'Starting…' };
|
|
1956
|
+
installJobs.set(job.id, job);
|
|
1957
|
+
void installChromium((l) => { job.progress = l; }).then((failed) => {
|
|
1958
|
+
if (failed) { job.state = 'error'; job.error = failed; } else { job.state = 'done'; job.progress = 'Installed'; }
|
|
1959
|
+
setTimeout(() => installJobs.delete(job.id), 10 * 60_000).unref();
|
|
1960
|
+
});
|
|
1961
|
+
json(res, 202, { id: job.id });
|
|
1962
|
+
|
|
1963
|
+
} else if (req.method === 'GET' && url.pathname.startsWith('/setup/browser/')) {
|
|
1964
|
+
const job = installJobs.get(url.pathname.split('/').pop()!);
|
|
1965
|
+
if (!job) return json(res, 404, { error: 'no such install' });
|
|
1966
|
+
json(res, 200, job);
|
|
1967
|
+
|
|
1968
|
+
} else if (req.method === 'POST' && url.pathname === '/setup/done') {
|
|
1969
|
+
// The first-run pages were seen through (or skipped). Remembered in
|
|
1970
|
+
// settings so they do not come back; `#/setup` reopens them on purpose.
|
|
1971
|
+
const current = await store.readSettings();
|
|
1972
|
+
await store.writeSettings({ ...current, global: { ...current.global, setupDoneAt: Date.now() } });
|
|
1973
|
+
json(res, 200, { ok: true });
|
|
1974
|
+
|
|
1635
1975
|
} else if (req.method === 'GET' && url.pathname === '/settings') {
|
|
1636
1976
|
json(res, 200, await store.readSettings());
|
|
1637
1977
|
|
|
@@ -1665,9 +2005,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1665
2005
|
json(res, 200, await availableModels(project));
|
|
1666
2006
|
|
|
1667
2007
|
} else if (req.method === 'GET' && url.pathname === '/projects') {
|
|
1668
|
-
const [projects, allRuns, auth] = await Promise.all([
|
|
2008
|
+
const [projects, allRuns, auth, settingsFile] = await Promise.all([
|
|
1669
2009
|
store.listProjects(), store.listRuns(), authPromise,
|
|
2010
|
+
store.readSettings().catch(() => ({ global: {} as Record<string, unknown>, projects: {} })),
|
|
1670
2011
|
]);
|
|
2012
|
+
// A first run: nothing linked, nothing ever run, the setup pages not
|
|
2013
|
+
// yet seen. An install upgraded from before the pages existed has
|
|
2014
|
+
// projects, so it never sees them uninvited.
|
|
2015
|
+
const firstRun = !projects.length && !allRuns.length && !(settingsFile.global as Record<string, unknown>).setupDoneAt;
|
|
1671
2016
|
const cards = await Promise.all(projects.map(async (p) => {
|
|
1672
2017
|
const run = activeByProject.get(p.id);
|
|
1673
2018
|
const plannerAsk = pendingChatQuestion(p.id);
|
|
@@ -1681,6 +2026,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1681
2026
|
// Whether a key is on file, never the key. Settings renders
|
|
1682
2027
|
// "stored / not stored" from this and nothing more.
|
|
1683
2028
|
providerHasKey: await providerHasKeyOf(p),
|
|
2029
|
+
// Which branch the folder is on, and whether it is dirty — the
|
|
2030
|
+
// header's pill, and what "a branch per mission" starts from.
|
|
2031
|
+
git: await gitInfoCached(p.folder),
|
|
1684
2032
|
activeRun: run ? { ...run.meta } : null,
|
|
1685
2033
|
lastRun: lastRun && {
|
|
1686
2034
|
id: lastRun.id,
|
|
@@ -1720,6 +2068,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1720
2068
|
json(res, 200, {
|
|
1721
2069
|
// Billing mode travels with every fleet poll so the UI can state it
|
|
1722
2070
|
// plainly wherever money is about to be spent.
|
|
2071
|
+
firstRun,
|
|
1723
2072
|
authMode: auth.mode,
|
|
1724
2073
|
authSource: auth.source,
|
|
1725
2074
|
authAccount: auth.account ?? null,
|
|
@@ -2194,6 +2543,42 @@ const server = http.createServer(async (req, res) => {
|
|
|
2194
2543
|
if (!ok) return json(res, 404, { error: 'no pending question with that id' });
|
|
2195
2544
|
json(res, 200, { ok: true });
|
|
2196
2545
|
|
|
2546
|
+
} else if (req.method === 'GET' && url.pathname === '/projects/clone/where') {
|
|
2547
|
+
// Where a URL would land, before anyone clicks: the dialog shows the
|
|
2548
|
+
// path as you type, and the same check refuses what the clone would.
|
|
2549
|
+
const repo = (url.searchParams.get('url') ?? '').trim();
|
|
2550
|
+
if (!repo) return json(res, 200, { dest: null });
|
|
2551
|
+
const where = await cloneDestination(repo);
|
|
2552
|
+
json(res, 200, where.ok ? { dest: where.dest, name: where.ref.name, host: where.ref.host } : { dest: null, error: where.error });
|
|
2553
|
+
|
|
2554
|
+
} else if (req.method === 'POST' && url.pathname === '/projects/clone') {
|
|
2555
|
+
// Start a clone under the projects root; the picker polls the job.
|
|
2556
|
+
const { url: repo, branch } = await readBody(req);
|
|
2557
|
+
if (typeof repo !== 'string' || !repo.trim()) return json(res, 400, { error: 'url is required' });
|
|
2558
|
+
const where = await cloneDestination(repo.trim());
|
|
2559
|
+
if (!where.ok) return json(res, 400, { error: where.error });
|
|
2560
|
+
const job: CloneJob = { id: crypto.randomBytes(6).toString('hex'), url: where.ref.url, dest: where.dest, startedAt: Date.now(), state: 'running', progress: 'Starting git…' };
|
|
2561
|
+
cloneJobs.set(job.id, job);
|
|
2562
|
+
void (async () => {
|
|
2563
|
+
const failed = await cloneRepo({ ref: where.ref, dest: where.dest, branch: typeof branch === 'string' && branch.trim() ? branch.trim() : undefined, onProgress: (l) => { job.progress = l; } });
|
|
2564
|
+
if (failed) {
|
|
2565
|
+
await rm(where.dest, { recursive: true, force: true }).catch(() => {});
|
|
2566
|
+
job.state = 'error'; job.error = failed;
|
|
2567
|
+
} else {
|
|
2568
|
+
const project = await store.addProject(where.dest);
|
|
2569
|
+
projectsCache.set(project.id, { name: project.name });
|
|
2570
|
+
job.state = 'done'; job.projectId = project.id; job.progress = 'Done';
|
|
2571
|
+
}
|
|
2572
|
+
// Finished jobs linger long enough to be read, then go.
|
|
2573
|
+
setTimeout(() => cloneJobs.delete(job.id), 10 * 60_000).unref();
|
|
2574
|
+
})();
|
|
2575
|
+
json(res, 202, { id: job.id, dest: job.dest });
|
|
2576
|
+
|
|
2577
|
+
} else if (req.method === 'GET' && /^\/projects\/clone\/[a-f0-9]{12}$/.test(url.pathname)) {
|
|
2578
|
+
const job = cloneJobs.get(url.pathname.split('/').pop()!);
|
|
2579
|
+
if (!job) return json(res, 404, { error: 'no such clone (Foreman may have restarted; check the projects root)' });
|
|
2580
|
+
json(res, 200, { state: job.state, progress: job.progress, projectId: job.projectId, error: job.error, dest: job.dest });
|
|
2581
|
+
|
|
2197
2582
|
} else if (req.method === 'GET' && url.pathname === '/search') {
|
|
2198
2583
|
// Every run across the fleet whose title, brief, project or folder
|
|
2199
2584
|
// says the words. Run records are small and already on disk; no index.
|
|
@@ -2235,6 +2620,72 @@ const server = http.createServer(async (req, res) => {
|
|
|
2235
2620
|
broadcastChat(FLEET_CHAT_ID, 'chat_cleared', {});
|
|
2236
2621
|
json(res, 200, { ok: true });
|
|
2237
2622
|
|
|
2623
|
+
} else if (prStateMatch && req.method === 'GET') {
|
|
2624
|
+
// What became of the run's pull request. A final answer (merged,
|
|
2625
|
+
// closed) is written to the run so it is not asked again; open is.
|
|
2626
|
+
const meta = await store.readMeta(prStateMatch[1]).catch(() => null);
|
|
2627
|
+
if (!meta?.git?.pr) return json(res, 404, { error: 'no pull request on this run' });
|
|
2628
|
+
if (meta.git.prState) return json(res, 200, { url: meta.git.pr, state: meta.git.prState, cached: true });
|
|
2629
|
+
const s = await pullRequestState(meta.folder, meta.git.pr);
|
|
2630
|
+
if (s && s.state !== 'open') {
|
|
2631
|
+
meta.git = { ...meta.git, prState: s.state };
|
|
2632
|
+
await store.writeMeta(meta).catch(() => {});
|
|
2633
|
+
}
|
|
2634
|
+
json(res, 200, { url: meta.git.pr, state: s?.state ?? null, mergedAt: s?.mergedAt, number: s?.number });
|
|
2635
|
+
|
|
2636
|
+
} else if (prMatch && req.method === 'GET') {
|
|
2637
|
+
// The pull request as Foreman would draft it, for the sheet to edit.
|
|
2638
|
+
const meta = await store.readMeta(prMatch[1]).catch(() => null);
|
|
2639
|
+
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
2640
|
+
if (!meta.git) return json(res, 409, { error: 'this run had no branch of its own' });
|
|
2641
|
+
const info = await gitInfo(meta.folder);
|
|
2642
|
+
if (!info.remote) return json(res, 409, { error: 'the repository has no origin remote to push to' });
|
|
2643
|
+
const doc = await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null);
|
|
2644
|
+
const gh = await ghReady();
|
|
2645
|
+
json(res, 200, {
|
|
2646
|
+
...prDraft(meta, doc), branch: meta.git.branch, base: meta.git.base, commits: meta.git.commits ?? null,
|
|
2647
|
+
remote: info.remote, compareUrl: compareUrl(info.remote, meta.git.base, meta.git.branch),
|
|
2648
|
+
gh, pr: meta.git.pr ?? null, onBranch: info.branch === meta.git.branch, dirty: Boolean(info.dirty),
|
|
2649
|
+
});
|
|
2650
|
+
|
|
2651
|
+
} else if (prMatch && req.method === 'POST') {
|
|
2652
|
+
// The one outward-facing act: push the mission's branch and open the
|
|
2653
|
+
// pull request — as the user, with their git and gh, on their click.
|
|
2654
|
+
// Never from an agent, never from the phone.
|
|
2655
|
+
const meta = await store.readMeta(prMatch[1]).catch(() => null);
|
|
2656
|
+
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
2657
|
+
if (!meta.git) return json(res, 409, { error: 'this run had no branch of its own' });
|
|
2658
|
+
if (meta.status === 'running') return json(res, 409, { error: 'the mission is still running' });
|
|
2659
|
+
const { title, body } = await readBody(req);
|
|
2660
|
+
const t = typeof title === 'string' && title.trim() ? title.trim().slice(0, 200) : null;
|
|
2661
|
+
if (!t) return json(res, 400, { error: 'a title is required' });
|
|
2662
|
+
const b = typeof body === 'string' ? body : '';
|
|
2663
|
+
const info = await gitInfo(meta.folder);
|
|
2664
|
+
if (!info.remote) return json(res, 409, { error: 'the repository has no origin remote to push to' });
|
|
2665
|
+
const emit = makeEmitter(meta.id, meta.projectId ?? prMatch[1]);
|
|
2666
|
+
const pushed = await pushBranch(meta.folder, meta.git.branch);
|
|
2667
|
+
if (pushed) {
|
|
2668
|
+
emit('pull_request', { branch: meta.git.branch, error: pushed, text: `Push of ${meta.git.branch} failed: ${pushed}` });
|
|
2669
|
+
return json(res, 502, { error: pushed });
|
|
2670
|
+
}
|
|
2671
|
+
const gh = await ghReady();
|
|
2672
|
+
let url: string | undefined;
|
|
2673
|
+
let method: 'gh' | 'compare' = 'compare';
|
|
2674
|
+
let note: string | undefined;
|
|
2675
|
+
if (gh.present && gh.authed && /github\.com/.test(info.remote)) {
|
|
2676
|
+
const r = await createPullRequest(meta.folder, { base: meta.git.base, branch: meta.git.branch, title: t, body: b });
|
|
2677
|
+
if (r.url) { url = r.url; method = 'gh'; } else note = r.error;
|
|
2678
|
+
}
|
|
2679
|
+
if (!url) url = compareUrl(info.remote, meta.git.base, meta.git.branch) ?? undefined;
|
|
2680
|
+
meta.git = { ...meta.git, pr: method === 'gh' ? url : meta.git.pr };
|
|
2681
|
+
await store.writeMeta(meta).catch(() => {});
|
|
2682
|
+
gitInfoCache.delete(meta.folder);
|
|
2683
|
+
const text = method === 'gh'
|
|
2684
|
+
? `Pushed ${meta.git.branch} and opened a pull request: ${url}`
|
|
2685
|
+
: `Pushed ${meta.git.branch}.${note ? ` ${note}.` : ''} Finish the pull request in the browser: ${url ?? 'open the repository'}`;
|
|
2686
|
+
emit('pull_request', { branch: meta.git.branch, base: meta.git.base, url, method, text });
|
|
2687
|
+
json(res, 200, { ok: true, url, method, pushed: true, note });
|
|
2688
|
+
|
|
2238
2689
|
} else if (req.method === 'POST' && url.pathname === '/steer') {
|
|
2239
2690
|
const { runId, text } = await readBody(req);
|
|
2240
2691
|
const trimmed = typeof text === 'string' ? text.trim() : '';
|
|
@@ -2306,6 +2757,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2306
2757
|
const overrides = {
|
|
2307
2758
|
directorModel: str(resumeBody.directorModel), directorProviderId: str(resumeBody.directorProviderId),
|
|
2308
2759
|
workerModel: str(resumeBody.workerModel), workerProviderId: str(resumeBody.workerProviderId),
|
|
2760
|
+
budgetUsd: typeof resumeBody.budgetUsd === 'number' && Number.isFinite(resumeBody.budgetUsd) && resumeBody.budgetUsd > 0
|
|
2761
|
+
? Math.round(resumeBody.budgetUsd * 100) / 100 : undefined,
|
|
2309
2762
|
};
|
|
2310
2763
|
void resumeRun(meta.projectId, meta, overrides);
|
|
2311
2764
|
json(res, 200, { ok: true });
|
|
@@ -2315,6 +2768,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
2315
2768
|
if (!events) return json(res, 404, { error: 'unknown run' });
|
|
2316
2769
|
json(res, 200, { events });
|
|
2317
2770
|
|
|
2771
|
+
} else if (req.method === 'GET' && /^\/projects\/[^/]+\/memory$/.test(url.pathname)) {
|
|
2772
|
+
// What the project "believes": .foreman/MEMORY.md, read-only here.
|
|
2773
|
+
const project = await store.getProject(url.pathname.split('/')[2]).catch(() => null);
|
|
2774
|
+
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
2775
|
+
json(res, 200, await readMemory(project.folder));
|
|
2776
|
+
|
|
2318
2777
|
} else if (req.method === 'GET' && url.pathname === '/missiondoc') {
|
|
2319
2778
|
const runId = url.searchParams.get('run');
|
|
2320
2779
|
if (!runId) return json(res, 400, { error: 'run parameter is required' });
|
|
@@ -2327,6 +2786,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
2327
2786
|
} else if (req.method === 'GET' && url.pathname === '/browse') {
|
|
2328
2787
|
const requested = url.searchParams.get('path') || os.homedir();
|
|
2329
2788
|
const dir = path.resolve(requested);
|
|
2789
|
+
if (!pathPermitted(dir, await browseRoots())) {
|
|
2790
|
+
return json(res, 400, { error: 'path outside the folders Foreman may browse' });
|
|
2791
|
+
}
|
|
2330
2792
|
const st = await stat(dir).catch(() => null);
|
|
2331
2793
|
if (!st?.isDirectory()) return json(res, 400, { error: `not a directory: ${dir}` });
|
|
2332
2794
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -2345,6 +2807,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2345
2807
|
if (typeof name !== 'string' || !name.trim() || /[/\\]/.test(name) || name.trim().startsWith('.')) {
|
|
2346
2808
|
return json(res, 400, { error: 'invalid folder name' });
|
|
2347
2809
|
}
|
|
2810
|
+
// The parent is what is being written into, so the parent is what has
|
|
2811
|
+
// to be inside the allowed folders.
|
|
2812
|
+
if (!pathPermitted(parent, await browseRoots())) {
|
|
2813
|
+
return json(res, 400, { error: 'path outside the folders Foreman may browse' });
|
|
2814
|
+
}
|
|
2348
2815
|
const parentSt = await stat(parent).catch(() => null);
|
|
2349
2816
|
if (!parentSt?.isDirectory()) return json(res, 400, { error: `not a directory: ${parent}` });
|
|
2350
2817
|
const created = path.join(parent, name.trim());
|
|
@@ -2360,12 +2827,42 @@ const server = http.createServer(async (req, res) => {
|
|
|
2360
2827
|
json(res, 404, { error: 'not found' });
|
|
2361
2828
|
}
|
|
2362
2829
|
} catch (err) {
|
|
2830
|
+
if (err instanceof BodyError) {
|
|
2831
|
+
// The answer goes out before the stream is torn down: destroying the
|
|
2832
|
+
// request first makes the client see a connection reset instead of the
|
|
2833
|
+
// 413 that explains it. `res.end` has already queued the bytes by the
|
|
2834
|
+
// time `req.destroy` stops the sender from writing more.
|
|
2835
|
+
json(res, err.status, { error: err.message });
|
|
2836
|
+
if (!req.readableEnded) req.destroy();
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2363
2839
|
json(res, 500, { error: String(err) });
|
|
2364
2840
|
}
|
|
2365
2841
|
});
|
|
2366
2842
|
|
|
2843
|
+
/**
|
|
2844
|
+
* The second origin: nothing but the crew's exposed dev servers, guarded the
|
|
2845
|
+
* same way as the dashboard. Kept separate so a page an agent wrote cannot
|
|
2846
|
+
* reach a single Foreman route from the browser — see src/services.ts.
|
|
2847
|
+
*/
|
|
2848
|
+
const servicesServer = http.createServer(servicesHandler({
|
|
2849
|
+
registry: services,
|
|
2850
|
+
allowed: (req) => requestAllowed(req, { port: SERVICES_PORT, tailnet, bindAll: BIND === 'all' }),
|
|
2851
|
+
}));
|
|
2852
|
+
/**
|
|
2853
|
+
* Why the services port is not listening, when it is not. A taken port must
|
|
2854
|
+
* not take the dashboard down with it (an unhandled 'error' on a server is a
|
|
2855
|
+
* crash): it is logged, preflight has already warned, and expose_service
|
|
2856
|
+
* tells the crew plainly instead of handing out a URL that answers nothing.
|
|
2857
|
+
*/
|
|
2858
|
+
let servicesDown: string | null = null;
|
|
2859
|
+
servicesServer.on('error', (err: NodeJS.ErrnoException) => {
|
|
2860
|
+
servicesDown = `Foreman's services port ${SERVICES_PORT} is not available (${err.code ?? err.message}); set FOREMAN_SERVICES_PORT to a free port and restart to expose dev servers`;
|
|
2861
|
+
console.warn(`[services] ${servicesDown}`);
|
|
2862
|
+
});
|
|
2863
|
+
|
|
2367
2864
|
// Fail loudly on a misconfigured install before anything else happens.
|
|
2368
|
-
if (!reportPreflight(await preflight({ port: PORT, foremanHome: store.root, distDir: DIST_DIR, tailnet }))) {
|
|
2865
|
+
if (!reportPreflight(await preflight({ port: PORT, servicesPort: SERVICES_PORT, foremanHome: store.root, distDir: DIST_DIR, tailnet }))) {
|
|
2369
2866
|
process.exit(1);
|
|
2370
2867
|
}
|
|
2371
2868
|
|
|
@@ -2410,11 +2907,13 @@ void store.listRuns().then((runs) => {
|
|
|
2410
2907
|
}).catch(() => {});
|
|
2411
2908
|
|
|
2412
2909
|
if (BIND === 'all') {
|
|
2413
|
-
server.listen(PORT, () => console.log(`Foreman listening on http://0.0.0.0:${PORT} (FOREMAN_BIND=all)`));
|
|
2910
|
+
server.listen(PORT, () => console.log(`Foreman listening on http://0.0.0.0:${PORT} (FOREMAN_BIND=all) · services on http://0.0.0.0:${SERVICES_PORT}`));
|
|
2911
|
+
servicesServer.listen(SERVICES_PORT);
|
|
2414
2912
|
} else {
|
|
2415
2913
|
server.listen(PORT, '127.0.0.1', () => {
|
|
2416
|
-
console.log(`Foreman listening on http://localhost:${PORT}${tailnet ? ` · ${tailnetUrl(tailnet, PORT)}` : ''}`);
|
|
2914
|
+
console.log(`Foreman listening on http://localhost:${PORT}${tailnet ? ` · ${tailnetUrl(tailnet, PORT)}` : ''} · services on http://localhost:${SERVICES_PORT}`);
|
|
2417
2915
|
});
|
|
2916
|
+
servicesServer.listen(SERVICES_PORT, '127.0.0.1');
|
|
2418
2917
|
if (tailnet) {
|
|
2419
2918
|
// A second listener on the tailnet address, feeding the same handler.
|
|
2420
2919
|
// Not 0.0.0.0: the café Wi-Fi is not the tailnet.
|
|
@@ -2422,7 +2921,12 @@ if (BIND === 'all') {
|
|
|
2422
2921
|
const viaTailnet = http.createServer(onRequest);
|
|
2423
2922
|
viaTailnet.on('error', (err) => console.warn(`[tailscale] could not listen on ${tailnet.ip}:${PORT} — ${err.message}`));
|
|
2424
2923
|
viaTailnet.listen(PORT, tailnet.ip);
|
|
2425
|
-
|
|
2924
|
+
// The services port needs the same reach: the human opens those previews
|
|
2925
|
+
// from their phone, over the tailnet, like everything else.
|
|
2926
|
+
const svcViaTailnet = http.createServer(servicesServer.listeners('request')[0] as http.RequestListener);
|
|
2927
|
+
svcViaTailnet.on('error', (err) => console.warn(`[tailscale] could not listen on ${tailnet.ip}:${SERVICES_PORT} — ${err.message}`));
|
|
2928
|
+
svcViaTailnet.listen(SERVICES_PORT, tailnet.ip);
|
|
2929
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => { viaTailnet.close(); svcViaTailnet.close(); });
|
|
2426
2930
|
}
|
|
2427
2931
|
}
|
|
2428
2932
|
|