@amenophis1er/foreman 0.1.6 → 0.1.8
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/bin/foreman.mjs +2 -1
- package/package.json +1 -1
- package/src/browser.ts +188 -0
- package/src/cli.ts +19 -1
- package/src/clone.test.ts +35 -0
- package/src/clone.ts +129 -0
- package/src/completion.test.ts +30 -0
- package/src/completion.ts +113 -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 +104 -0
- package/src/orchestrator.ts +109 -47
- package/src/planner.ts +7 -2
- 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 +569 -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 +17 -0
- package/ui/dist/assets/index-h0osI3Nn.js +68 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-btSPOnoZ.js +0 -67
package/src/server.ts
CHANGED
|
@@ -24,9 +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
|
|
31
|
+
* GET /search?q= Runs across the fleet matching title, brief, project or folder
|
|
27
32
|
* POST /fleet/chat One turn with the fleet planner {text} → {text, costUsd}
|
|
28
33
|
* POST /fleet/stop Stop the fleet planner reply in flight
|
|
29
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
|
|
30
38
|
* POST /steer Send an operator note to a running director {runId, text}
|
|
31
39
|
* POST /interrupt Interrupt a run {runId}
|
|
32
40
|
* GET /runs?projectId= Persisted run summaries, newest first
|
|
@@ -46,7 +54,7 @@
|
|
|
46
54
|
import http from 'node:http';
|
|
47
55
|
import crypto from 'node:crypto';
|
|
48
56
|
import os from 'node:os';
|
|
49
|
-
import { mkdir, readFile, readdir, stat } from 'node:fs/promises';
|
|
57
|
+
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
50
58
|
import path from 'node:path';
|
|
51
59
|
import { fileURLToPath } from 'node:url';
|
|
52
60
|
import { MissionRun } from './orchestrator.js';
|
|
@@ -56,10 +64,18 @@ import {
|
|
|
56
64
|
dropPendingAsk,
|
|
57
65
|
} from './planner.js';
|
|
58
66
|
import { DEFAULT_TOOL_POLICY } from './policy.js';
|
|
67
|
+
import { pathPermitted, requestAllowed } from './guard.js';
|
|
68
|
+
import { BodyError, bodyLimitFor, parseBody } from './http-body.js';
|
|
59
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';
|
|
60
76
|
import { detectTailscale, tailnetUrl } from './tailscale.js';
|
|
61
77
|
import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
|
|
62
|
-
import { ServiceRegistry,
|
|
78
|
+
import { ServiceRegistry, portOpen, servicesHandler } from './services.js';
|
|
63
79
|
import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
|
|
64
80
|
import {
|
|
65
81
|
DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
|
|
@@ -228,6 +244,8 @@ interface ModelOption {
|
|
|
228
244
|
providerLabel: string;
|
|
229
245
|
cost?: number;
|
|
230
246
|
note?: string;
|
|
247
|
+
/** Its track record on this machine, from the run ledger; absent until it has one. */
|
|
248
|
+
record?: string;
|
|
231
249
|
/** What spending on this model is: priced, free, or real-but-unquantified. */
|
|
232
250
|
costBasis: CostBasis;
|
|
233
251
|
/** @deprecated Mirrors `costBasis === 'priced'` for older clients. */
|
|
@@ -248,6 +266,15 @@ async function availableModels(project: Project | null): Promise<{
|
|
|
248
266
|
reachable: boolean;
|
|
249
267
|
}> {
|
|
250
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
|
+
};
|
|
251
278
|
|
|
252
279
|
// Anthropic, via whichever Claude Code install or key the server resolves.
|
|
253
280
|
// Always offered: it is the default, and the shipped configuration.
|
|
@@ -325,7 +352,7 @@ async function availableModels(project: Project | null): Promise<{
|
|
|
325
352
|
count: out.filter((x) => x.providerLabel === m.providerLabel).length,
|
|
326
353
|
}));
|
|
327
354
|
|
|
328
|
-
return { models: out, groups, reachable: true };
|
|
355
|
+
return { models: out.map(withRecord), groups, reachable: true };
|
|
329
356
|
}
|
|
330
357
|
|
|
331
358
|
/**
|
|
@@ -346,6 +373,26 @@ function providerForRole(meta: RunMeta, roleProviderId?: string): ProviderRef {
|
|
|
346
373
|
return own;
|
|
347
374
|
}
|
|
348
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
|
+
|
|
349
396
|
/** Whether a project's provider has a key on file. Never the key itself. */
|
|
350
397
|
async function providerHasKeyOf(p: Project): Promise<boolean> {
|
|
351
398
|
const ref = providerOf(p);
|
|
@@ -368,7 +415,6 @@ function toPath(v: unknown): string | undefined {
|
|
|
368
415
|
return typeof v === 'string' && v.trim() ? v.trim() : undefined;
|
|
369
416
|
}
|
|
370
417
|
|
|
371
|
-
/** Parses a model choice: a known alias or a full claude-* id; else inherit. */
|
|
372
418
|
/**
|
|
373
419
|
* A model id from any provider.
|
|
374
420
|
*
|
|
@@ -419,6 +465,19 @@ async function locateFolders(name: string): Promise<string[]> {
|
|
|
419
465
|
}
|
|
420
466
|
|
|
421
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
|
+
})();
|
|
422
481
|
/**
|
|
423
482
|
* Where to listen. Default: loopback, plus the tailnet address when this
|
|
424
483
|
* machine is on one — never every interface, since there is no login.
|
|
@@ -451,12 +510,30 @@ const authPromise = detectAuth();
|
|
|
451
510
|
const activeByProject = new Map<string, MissionRun | null>();
|
|
452
511
|
const sseClients = new Set<http.ServerResponse>();
|
|
453
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
|
+
|
|
454
532
|
function activeRuns(): MissionRun[] {
|
|
455
533
|
// Filter out reservation placeholders (see reserveProject).
|
|
456
534
|
return [...activeByProject.values()].filter((r): r is MissionRun => Boolean(r));
|
|
457
535
|
}
|
|
458
536
|
|
|
459
|
-
/** Broadcasts an enveloped frame to live clients and persists the bare event. */
|
|
460
537
|
/**
|
|
461
538
|
* What happened in the fleet lately, in one line each, for the front desk.
|
|
462
539
|
*
|
|
@@ -559,10 +636,6 @@ function ledgerKeyFor(meta: RunMeta): string {
|
|
|
559
636
|
return `${meta.id}.${meta.resumes ?? 0}`;
|
|
560
637
|
}
|
|
561
638
|
|
|
562
|
-
/**
|
|
563
|
-
* Chat frames carry `chat: true` and no run id, so a UI following the same
|
|
564
|
-
* stream can tell a planning conversation from a mission without guessing.
|
|
565
|
-
*/
|
|
566
639
|
/**
|
|
567
640
|
* A chat event for open tabs only, not the log: the log it would describe
|
|
568
641
|
* is the one being thrown away. Used when a conversation is cleared, so a
|
|
@@ -735,6 +808,55 @@ async function findProject(ref: string): Promise<Project | null> {
|
|
|
735
808
|
?? null;
|
|
736
809
|
}
|
|
737
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
|
+
|
|
738
860
|
// ---------------------------------------------------------------------------
|
|
739
861
|
// The fleet planner — the front desk
|
|
740
862
|
// ---------------------------------------------------------------------------
|
|
@@ -850,6 +972,8 @@ const fleetHost: FleetHost = {
|
|
|
850
972
|
if (words.error) lines.push(`it stopped with: ${clipText(words.error, 300)}`);
|
|
851
973
|
}
|
|
852
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` : ''}`);
|
|
853
977
|
const meta = await store.readChatMeta(project.id).catch(() => null);
|
|
854
978
|
if (meta?.proposal) lines.push(`a proposal is waiting for Start or Discard: "${clipText(firstLine(meta.proposal.mission), 100)}" cap $${meta.proposal.budgetUsd}`);
|
|
855
979
|
if (chatTurns.has(project.id)) lines.push('its planner is replying right now');
|
|
@@ -888,6 +1012,11 @@ const fleetHost: FleetHost = {
|
|
|
888
1012
|
},
|
|
889
1013
|
|
|
890
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
|
+
}
|
|
891
1020
|
const folder = expandHome(folderIn.trim());
|
|
892
1021
|
if (!path.isAbsolute(folder)) return `A folder to link must be an absolute path (or ~/…), not "${folderIn}".`;
|
|
893
1022
|
const st = await stat(folder).catch(() => null);
|
|
@@ -949,7 +1078,7 @@ async function driveFleetTurn(text: string, via: 'telegram' | 'http'): Promise<{
|
|
|
949
1078
|
try {
|
|
950
1079
|
const resolved = await resolveProvider(providerOf({}), store.root);
|
|
951
1080
|
emit('chat_turn', { state: 'thinking', model, provider: resolved.label, costBasis: resolved.costBasis });
|
|
952
|
-
const problem = providerProblem(resolved);
|
|
1081
|
+
const problem = providerProblem(resolved) ?? await signedInProblem(resolved);
|
|
953
1082
|
if (problem) {
|
|
954
1083
|
emit('chat_error', { error: `provider unavailable — ${problem}` });
|
|
955
1084
|
return { text: '', costUsd: 0, error: `provider unavailable — ${problem}` };
|
|
@@ -963,7 +1092,7 @@ async function driveFleetTurn(text: string, via: 'telegram' | 'http'): Promise<{
|
|
|
963
1092
|
const result = await runFleetTurn({
|
|
964
1093
|
sessionId: meta.sessionId, text, model, cwd, host: fleetHost, via,
|
|
965
1094
|
news: fleetNews(since), sinceMs: Date.now() - since,
|
|
966
|
-
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 })),
|
|
967
1096
|
agentEnv: await agentEnvFor(resolved, `chat:${FLEET_CHAT_ID}`),
|
|
968
1097
|
emit, abort,
|
|
969
1098
|
});
|
|
@@ -1067,6 +1196,13 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
|
|
|
1067
1196
|
return say(`<b>Running · ${live.length}</b>\n${lines.join('\n')}${planning.length ? `\n\n<b>Planning</b>\n${planning.join('\n')}` : ''}`);
|
|
1068
1197
|
}
|
|
1069
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
|
+
}
|
|
1070
1206
|
const settings = await store.readSettings().catch(() => ({ global: {}, projects: {} }));
|
|
1071
1207
|
const root = projectsRoot((settings.global as Record<string, unknown>).projectsRoot);
|
|
1072
1208
|
const name = slug(cmd.name);
|
|
@@ -1137,11 +1273,6 @@ async function chatMetaOf(projectId: string): Promise<ChatMeta> {
|
|
|
1137
1273
|
?? { projectId, costUsd: 0, createdAt: now, updatedAt: now };
|
|
1138
1274
|
}
|
|
1139
1275
|
|
|
1140
|
-
/**
|
|
1141
|
-
* Runs one planning turn: the human's message goes into the log first (so a
|
|
1142
|
-
* reload mid-turn still shows what was asked), then the planner's reply
|
|
1143
|
-
* streams out through the same envelope machinery as a mission.
|
|
1144
|
-
*/
|
|
1145
1276
|
/**
|
|
1146
1277
|
* `shown` is what the transcript records as the human's message when it
|
|
1147
1278
|
* differs from what the planner is sent (a fork's seed). `via: 'telegram'`
|
|
@@ -1181,7 +1312,7 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
|
|
|
1181
1312
|
provider: resolved.label,
|
|
1182
1313
|
costBasis: resolved.costBasis,
|
|
1183
1314
|
});
|
|
1184
|
-
const problem = providerProblem(resolved);
|
|
1315
|
+
const problem = providerProblem(resolved) ?? await signedInProblem(resolved);
|
|
1185
1316
|
if (problem) {
|
|
1186
1317
|
emit('chat_error', { error: `provider unavailable — ${problem}` });
|
|
1187
1318
|
return;
|
|
@@ -1196,8 +1327,9 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
|
|
|
1196
1327
|
projectId: project.id,
|
|
1197
1328
|
models: models.map((m) => ({
|
|
1198
1329
|
id: m.id, label: m.label, providerId: m.providerId,
|
|
1199
|
-
providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note,
|
|
1330
|
+
providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note, record: m.record,
|
|
1200
1331
|
})),
|
|
1332
|
+
anchor: await budgetAnchorFor(project).catch(() => ''),
|
|
1201
1333
|
sessionId: meta.sessionId,
|
|
1202
1334
|
folder: project.folder,
|
|
1203
1335
|
text,
|
|
@@ -1243,18 +1375,25 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
|
|
|
1243
1375
|
}
|
|
1244
1376
|
|
|
1245
1377
|
/**
|
|
1246
|
-
* A mission has started, so the
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1249
|
-
* 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.
|
|
1250
1387
|
*/
|
|
1251
1388
|
async function consumeProposal(projectId: string, runId: string, mission: string): Promise<void> {
|
|
1252
1389
|
const meta = await store.readChatMeta(projectId).catch(() => null);
|
|
1253
1390
|
if (!meta) return;
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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', {});
|
|
1258
1397
|
}
|
|
1259
1398
|
|
|
1260
1399
|
/**
|
|
@@ -1268,6 +1407,28 @@ function reserveProject(projectId: string): boolean {
|
|
|
1268
1407
|
return true;
|
|
1269
1408
|
}
|
|
1270
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
|
+
|
|
1271
1432
|
/** Runs a mission to completion. The project must already be reserved. */
|
|
1272
1433
|
async function driveRun(
|
|
1273
1434
|
projectId: string, meta: RunMeta, resume?: { sessionId?: string },
|
|
@@ -1278,7 +1439,7 @@ async function driveRun(
|
|
|
1278
1439
|
// A run that cannot resolve a credential must not start: dispatching anyway
|
|
1279
1440
|
// would fall back to whatever the environment happens to hold.
|
|
1280
1441
|
const resolved = await resolveProvider(providerOf(meta), store.root);
|
|
1281
|
-
const problem = providerProblem(resolved);
|
|
1442
|
+
const problem = providerProblem(resolved) ?? await signedInProblem(resolved);
|
|
1282
1443
|
if (problem) {
|
|
1283
1444
|
meta.status = 'error';
|
|
1284
1445
|
meta.endedAt = Date.now();
|
|
@@ -1288,6 +1449,29 @@ async function driveRun(
|
|
|
1288
1449
|
activeByProject.delete(projectId);
|
|
1289
1450
|
return;
|
|
1290
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
|
+
}
|
|
1291
1475
|
let agentEnv;
|
|
1292
1476
|
let roleBasis = resolved.costBasis;
|
|
1293
1477
|
let prices: { director?: ModelPrice; worker?: ModelPrice } = {};
|
|
@@ -1372,11 +1556,14 @@ async function driveRun(
|
|
|
1372
1556
|
// A dev server behind Foreman's address. Declared ports only, and only
|
|
1373
1557
|
// ones something is listening on — an agent cannot reserve a path for a
|
|
1374
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,
|
|
1375
1562
|
exposeService: async (runId, port, label) => {
|
|
1563
|
+
if (servicesDown) return { ok: false, reason: servicesDown };
|
|
1376
1564
|
if (!(await portOpen(port))) return { ok: false, reason: `nothing is listening on 127.0.0.1:${port} — start the server first` };
|
|
1377
1565
|
const svc = services.register(runId, port, label);
|
|
1378
|
-
|
|
1379
|
-
return { ok: true, url: `${base.replace(/\/+$/, '')}${svc.path}`, path: svc.path };
|
|
1566
|
+
return { ok: true, url: `${await servicesBase()}${svc.path}`, path: svc.path };
|
|
1380
1567
|
},
|
|
1381
1568
|
});
|
|
1382
1569
|
activeByProject.set(projectId, run);
|
|
@@ -1408,6 +1595,23 @@ async function driveRun(
|
|
|
1408
1595
|
// However the run ended, it no longer needs its gateways.
|
|
1409
1596
|
releaseGateways(meta.id);
|
|
1410
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
|
+
}
|
|
1411
1615
|
}
|
|
1412
1616
|
}
|
|
1413
1617
|
|
|
@@ -1417,6 +1621,8 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1417
1621
|
directorModel: ModelChoice; workerModel: ModelChoice; plannerModel: ModelChoice;
|
|
1418
1622
|
/** Provider serving each role, when Settings pinned one with the model. */
|
|
1419
1623
|
directorProviderId?: string; workerProviderId?: string;
|
|
1624
|
+
/** In a repository, each mission runs on a branch of its own (default on). */
|
|
1625
|
+
gitBranchPerMission: boolean;
|
|
1420
1626
|
}> {
|
|
1421
1627
|
const s = await store.readSettings()
|
|
1422
1628
|
.catch(() => ({ global: {}, projects: {} as Record<string, object> }));
|
|
@@ -1437,9 +1643,26 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1437
1643
|
plannerModel: modelChoice(p.plannerModel ?? g.plannerModel),
|
|
1438
1644
|
directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
|
|
1439
1645
|
workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
|
|
1646
|
+
gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
|
|
1440
1647
|
};
|
|
1441
1648
|
}
|
|
1442
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
|
+
|
|
1443
1666
|
async function startRun(
|
|
1444
1667
|
projectId: string, folder: string, mission: string, budgetUsd: number,
|
|
1445
1668
|
directorModel: ModelChoice, workerModel: ModelChoice, browserTools: boolean,
|
|
@@ -1476,6 +1699,21 @@ async function startRun(
|
|
|
1476
1699
|
return;
|
|
1477
1700
|
}
|
|
1478
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
|
+
}
|
|
1479
1717
|
await driveRun(projectId, meta);
|
|
1480
1718
|
}
|
|
1481
1719
|
|
|
@@ -1513,6 +1751,11 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1513
1751
|
meta.status = 'running';
|
|
1514
1752
|
meta.endedAt = undefined;
|
|
1515
1753
|
meta.resumes = (meta.resumes ?? 0) + 1;
|
|
1754
|
+
if (meta.git) {
|
|
1755
|
+
const back = await ensureMissionBranch(meta.folder, meta.git.branch);
|
|
1756
|
+
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.` });
|
|
1757
|
+
gitInfoCache.delete(meta.folder);
|
|
1758
|
+
}
|
|
1516
1759
|
await store.writeMeta(meta).catch((err) => {
|
|
1517
1760
|
console.error(`failed to persist resume of ${meta.id}:`, err);
|
|
1518
1761
|
});
|
|
@@ -1557,13 +1800,52 @@ async function serveStatic(pathname: string, res: http.ServerResponse): Promise<
|
|
|
1557
1800
|
// Request helpers
|
|
1558
1801
|
// ---------------------------------------------------------------------------
|
|
1559
1802
|
|
|
1803
|
+
/**
|
|
1804
|
+
* The request body as an object, refusing to buffer more than the route's cap.
|
|
1805
|
+
*
|
|
1806
|
+
* Both failures throw a `BodyError`, which the handler's outer catch turns
|
|
1807
|
+
* into the client's own status instead of a 500 that reads like a Foreman bug:
|
|
1808
|
+
* 413 when the body outgrows the cap (see `src/http-body.ts` for why there is
|
|
1809
|
+
* one), 400 when what arrived is not JSON. Content-Length is consulted first
|
|
1810
|
+
* so an oversized upload is refused before a single byte is read.
|
|
1811
|
+
*/
|
|
1560
1812
|
async function readBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
|
|
1813
|
+
const pathname = new URL(req.url ?? '/', `http://localhost:${PORT}`).pathname;
|
|
1814
|
+
const limit = bodyLimitFor(req.method, pathname);
|
|
1815
|
+
const declared = Number(req.headers['content-length']);
|
|
1816
|
+
if (Number.isFinite(declared) && declared > limit) throw new BodyError(413, 'request body too large');
|
|
1561
1817
|
const chunks: Buffer[] = [];
|
|
1562
|
-
|
|
1818
|
+
let seen = 0;
|
|
1819
|
+
for await (const c of req) {
|
|
1820
|
+
const buf = c as Buffer;
|
|
1821
|
+
seen += buf.length;
|
|
1822
|
+
if (seen > limit) throw new BodyError(413, 'request body too large');
|
|
1823
|
+
chunks.push(buf);
|
|
1824
|
+
}
|
|
1563
1825
|
if (!chunks.length) return {};
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1826
|
+
return parseBody(Buffer.concat(chunks));
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* The folders the folder picker may look inside.
|
|
1831
|
+
*
|
|
1832
|
+
* `/browse` and `/mkdir` exist to let the human point at a project, and they
|
|
1833
|
+
* took any absolute path — which made them a directory listing and a
|
|
1834
|
+
* `mkdir -p` for the whole filesystem, reachable from a page that got past
|
|
1835
|
+
* nothing but a URL. The answer is the same one the permission cards use:
|
|
1836
|
+
* name the places a project could plausibly live — the home directory, the
|
|
1837
|
+
* configured projects root (it may be on another volume), and every folder
|
|
1838
|
+
* already linked as a project — and refuse the rest. Read afresh per request
|
|
1839
|
+
* because linking a project is what widens the set.
|
|
1840
|
+
*/
|
|
1841
|
+
async function browseRoots(): Promise<string[]> {
|
|
1842
|
+
const settings = await store.readSettings().catch(() => ({ global: {} as Record<string, unknown> }));
|
|
1843
|
+
const roots = [
|
|
1844
|
+
os.homedir(),
|
|
1845
|
+
projectsRoot((settings.global as Record<string, unknown>).projectsRoot),
|
|
1846
|
+
];
|
|
1847
|
+
for (const p of await store.listProjects().catch(() => [])) roots.push(p.folder);
|
|
1848
|
+
return roots;
|
|
1567
1849
|
}
|
|
1568
1850
|
|
|
1569
1851
|
function json(res: http.ServerResponse, code: number, body: unknown): void {
|
|
@@ -1576,40 +1858,31 @@ function json(res: http.ServerResponse, code: number, body: unknown): void {
|
|
|
1576
1858
|
// ---------------------------------------------------------------------------
|
|
1577
1859
|
|
|
1578
1860
|
const server = http.createServer(async (req, res) => {
|
|
1861
|
+
// Before any route, including the service proxy: is this request one we
|
|
1862
|
+
// should be answering at all? Foreman has no login, so a browser that can
|
|
1863
|
+
// reach the port can otherwise act as the operator — a page on any domain
|
|
1864
|
+
// can point that name at 127.0.0.1 (DNS rebinding) or simply POST at
|
|
1865
|
+
// localhost from whatever tab the operator has open. See src/guard.ts.
|
|
1866
|
+
const verdict = requestAllowed(req, { port: PORT, tailnet, bindAll: BIND === 'all' });
|
|
1867
|
+
if (!verdict.ok) { json(res, verdict.status, { error: verdict.error }); return; }
|
|
1579
1868
|
const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
// absolute-path assets (`/app.js`) against Foreman's root; those arrive as
|
|
1583
|
-
// sub-resource requests carrying the service page as Referer, and are
|
|
1584
|
-
// routed to the same service. Documents never are — a typed URL is Foreman's.
|
|
1585
|
-
{
|
|
1586
|
-
const svc = parseServicePath(url.pathname);
|
|
1587
|
-
if (svc) {
|
|
1588
|
-
if (!services.has(svc.runId, svc.port)) { json(res, 404, { error: 'no such service' }); return; }
|
|
1589
|
-
proxyToService(req, res, svc.port, svc.rest, url.search, servicePath(svc.runId, svc.port));
|
|
1590
|
-
return;
|
|
1591
|
-
}
|
|
1592
|
-
const ref = req.headers.referer;
|
|
1593
|
-
const dest = String(req.headers['sec-fetch-dest'] ?? '');
|
|
1594
|
-
if (ref && dest && dest !== 'document' && dest !== 'empty' && !url.pathname.startsWith(SVC_PREFIX)) {
|
|
1595
|
-
try {
|
|
1596
|
-
const via = parseServicePath(new URL(ref).pathname);
|
|
1597
|
-
if (via && services.has(via.runId, via.port)) {
|
|
1598
|
-
proxyToService(req, res, via.port, url.pathname, url.search, servicePath(via.runId, via.port));
|
|
1599
|
-
return;
|
|
1600
|
-
}
|
|
1601
|
-
} catch { /* not a URL we can read — fall through to Foreman's own routes */ }
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1869
|
+
// Note: /svc/… is not handled here. Exposed dev servers live on
|
|
1870
|
+
// SERVICES_PORT, on their own origin — see servicesServer below.
|
|
1604
1871
|
const runEventsMatch = url.pathname.match(/^\/runs\/([^/]+)\/events$/);
|
|
1605
1872
|
// The deck: what a run changed and what it produced. Read-only by design —
|
|
1606
1873
|
// a stated non-goal — and handled before the chain because it owns two paths
|
|
1607
1874
|
// under /runs/{id}/ that nothing else claims.
|
|
1608
|
-
if (await handleDeckRoute(req, res, url, async (id) => {
|
|
1875
|
+
if (await handleDeckRoute(req, res, url, async (scope, id) => {
|
|
1876
|
+
if (scope === 'projects') {
|
|
1877
|
+
const project = await store.getProject(id).catch(() => null);
|
|
1878
|
+
return project ? { folder: project.folder } : null;
|
|
1879
|
+
}
|
|
1609
1880
|
const m = await store.readMeta(id).catch(() => null);
|
|
1610
1881
|
return m ? { folder: m.folder } : null;
|
|
1611
1882
|
})) return;
|
|
1612
1883
|
const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
|
|
1884
|
+
const prMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr$/);
|
|
1885
|
+
const prStateMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr\/state$/);
|
|
1613
1886
|
const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
|
|
1614
1887
|
const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
|
|
1615
1888
|
|
|
@@ -1631,6 +1904,57 @@ const server = http.createServer(async (req, res) => {
|
|
|
1631
1904
|
sseClients.add(res);
|
|
1632
1905
|
req.on('close', () => sseClients.delete(res));
|
|
1633
1906
|
|
|
1907
|
+
} else if (req.method === 'GET' && url.pathname === '/doctor') {
|
|
1908
|
+
// What `foreman doctor` prints, as data, for the dashboard's setup card.
|
|
1909
|
+
// Computed fresh each time — the point is to reflect a login or an
|
|
1910
|
+
// install the person just did. The port and dashboard checks are left
|
|
1911
|
+
// out: whoever is reading this in the dashboard already knows both.
|
|
1912
|
+
const [checks, auth, ollama, codexAuth, notify] = await Promise.all([
|
|
1913
|
+
preflight({ port: PORT, servicesPort: SERVICES_PORT, foremanHome: store.root, distDir: DIST_DIR, tailnet }),
|
|
1914
|
+
detectAuth(),
|
|
1915
|
+
discoverOllama(1200),
|
|
1916
|
+
readCodexAuth(codexHome()).catch(() => null),
|
|
1917
|
+
notifySettings().catch(() => null),
|
|
1918
|
+
]);
|
|
1919
|
+
const codexCount = codexAuth ? (await codexModels(codexHome()).catch(() => [])).length : 0;
|
|
1920
|
+
json(res, 200, {
|
|
1921
|
+
checks: checks.filter((c) => !c.name.startsWith('Port') && c.name !== 'Dashboard'),
|
|
1922
|
+
// The same facts, shaped for the first-run pages: one object per step.
|
|
1923
|
+
auth: { mode: auth.mode, source: auth.source, account: auth.account ?? null },
|
|
1924
|
+
ollama: ollama ? { host: ollamaHost(), models: ollama.length, local: ollama.filter((m) => !m.remote).length } : null,
|
|
1925
|
+
codex: codexAuth ? { models: codexCount } : null,
|
|
1926
|
+
tailnet: tailnet ? { dnsName: tailnet.dnsName ?? null, ip: tailnet.ip, url: tailnetUrl(tailnet, PORT), https: Boolean(tailnet.httpsPort) } : null,
|
|
1927
|
+
phone: notify?.telegramChatId ? { label: notify.telegramChatLabel ?? 'linked', bot: notify.telegramBot ?? null } : notify?.telegramBot ? { label: null, bot: notify.telegramBot } : null,
|
|
1928
|
+
browser: checks.find((c) => c.name === 'Browser') ?? null,
|
|
1929
|
+
version: currentVersion(),
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
} else if (req.method === 'POST' && url.pathname === '/setup/browser') {
|
|
1933
|
+
// Install Playwright's Chromium, as the user, into Playwright's cache.
|
|
1934
|
+
// The one install action the setup page has: no root, no system
|
|
1935
|
+
// packages, and the crew's browser tool can drive the result.
|
|
1936
|
+
const running = [...installJobs.values()].find((j) => j.state === 'running');
|
|
1937
|
+
if (running) return json(res, 202, { id: running.id });
|
|
1938
|
+
const job: InstallJob = { id: crypto.randomBytes(6).toString('hex'), what: 'chromium', startedAt: Date.now(), state: 'running', progress: 'Starting…' };
|
|
1939
|
+
installJobs.set(job.id, job);
|
|
1940
|
+
void installChromium((l) => { job.progress = l; }).then((failed) => {
|
|
1941
|
+
if (failed) { job.state = 'error'; job.error = failed; } else { job.state = 'done'; job.progress = 'Installed'; }
|
|
1942
|
+
setTimeout(() => installJobs.delete(job.id), 10 * 60_000).unref();
|
|
1943
|
+
});
|
|
1944
|
+
json(res, 202, { id: job.id });
|
|
1945
|
+
|
|
1946
|
+
} else if (req.method === 'GET' && url.pathname.startsWith('/setup/browser/')) {
|
|
1947
|
+
const job = installJobs.get(url.pathname.split('/').pop()!);
|
|
1948
|
+
if (!job) return json(res, 404, { error: 'no such install' });
|
|
1949
|
+
json(res, 200, job);
|
|
1950
|
+
|
|
1951
|
+
} else if (req.method === 'POST' && url.pathname === '/setup/done') {
|
|
1952
|
+
// The first-run pages were seen through (or skipped). Remembered in
|
|
1953
|
+
// settings so they do not come back; `#/setup` reopens them on purpose.
|
|
1954
|
+
const current = await store.readSettings();
|
|
1955
|
+
await store.writeSettings({ ...current, global: { ...current.global, setupDoneAt: Date.now() } });
|
|
1956
|
+
json(res, 200, { ok: true });
|
|
1957
|
+
|
|
1634
1958
|
} else if (req.method === 'GET' && url.pathname === '/settings') {
|
|
1635
1959
|
json(res, 200, await store.readSettings());
|
|
1636
1960
|
|
|
@@ -1664,9 +1988,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1664
1988
|
json(res, 200, await availableModels(project));
|
|
1665
1989
|
|
|
1666
1990
|
} else if (req.method === 'GET' && url.pathname === '/projects') {
|
|
1667
|
-
const [projects, allRuns, auth] = await Promise.all([
|
|
1991
|
+
const [projects, allRuns, auth, settingsFile] = await Promise.all([
|
|
1668
1992
|
store.listProjects(), store.listRuns(), authPromise,
|
|
1993
|
+
store.readSettings().catch(() => ({ global: {} as Record<string, unknown>, projects: {} })),
|
|
1669
1994
|
]);
|
|
1995
|
+
// A first run: nothing linked, nothing ever run, the setup pages not
|
|
1996
|
+
// yet seen. An install upgraded from before the pages existed has
|
|
1997
|
+
// projects, so it never sees them uninvited.
|
|
1998
|
+
const firstRun = !projects.length && !allRuns.length && !(settingsFile.global as Record<string, unknown>).setupDoneAt;
|
|
1670
1999
|
const cards = await Promise.all(projects.map(async (p) => {
|
|
1671
2000
|
const run = activeByProject.get(p.id);
|
|
1672
2001
|
const plannerAsk = pendingChatQuestion(p.id);
|
|
@@ -1680,6 +2009,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1680
2009
|
// Whether a key is on file, never the key. Settings renders
|
|
1681
2010
|
// "stored / not stored" from this and nothing more.
|
|
1682
2011
|
providerHasKey: await providerHasKeyOf(p),
|
|
2012
|
+
// Which branch the folder is on, and whether it is dirty — the
|
|
2013
|
+
// header's pill, and what "a branch per mission" starts from.
|
|
2014
|
+
git: await gitInfoCached(p.folder),
|
|
1683
2015
|
activeRun: run ? { ...run.meta } : null,
|
|
1684
2016
|
lastRun: lastRun && {
|
|
1685
2017
|
id: lastRun.id,
|
|
@@ -1719,6 +2051,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1719
2051
|
json(res, 200, {
|
|
1720
2052
|
// Billing mode travels with every fleet poll so the UI can state it
|
|
1721
2053
|
// plainly wherever money is about to be spent.
|
|
2054
|
+
firstRun,
|
|
1722
2055
|
authMode: auth.mode,
|
|
1723
2056
|
authSource: auth.source,
|
|
1724
2057
|
authAccount: auth.account ?? null,
|
|
@@ -2193,6 +2526,62 @@ const server = http.createServer(async (req, res) => {
|
|
|
2193
2526
|
if (!ok) return json(res, 404, { error: 'no pending question with that id' });
|
|
2194
2527
|
json(res, 200, { ok: true });
|
|
2195
2528
|
|
|
2529
|
+
} else if (req.method === 'GET' && url.pathname === '/projects/clone/where') {
|
|
2530
|
+
// Where a URL would land, before anyone clicks: the dialog shows the
|
|
2531
|
+
// path as you type, and the same check refuses what the clone would.
|
|
2532
|
+
const repo = (url.searchParams.get('url') ?? '').trim();
|
|
2533
|
+
if (!repo) return json(res, 200, { dest: null });
|
|
2534
|
+
const where = await cloneDestination(repo);
|
|
2535
|
+
json(res, 200, where.ok ? { dest: where.dest, name: where.ref.name, host: where.ref.host } : { dest: null, error: where.error });
|
|
2536
|
+
|
|
2537
|
+
} else if (req.method === 'POST' && url.pathname === '/projects/clone') {
|
|
2538
|
+
// Start a clone under the projects root; the picker polls the job.
|
|
2539
|
+
const { url: repo, branch } = await readBody(req);
|
|
2540
|
+
if (typeof repo !== 'string' || !repo.trim()) return json(res, 400, { error: 'url is required' });
|
|
2541
|
+
const where = await cloneDestination(repo.trim());
|
|
2542
|
+
if (!where.ok) return json(res, 400, { error: where.error });
|
|
2543
|
+
const job: CloneJob = { id: crypto.randomBytes(6).toString('hex'), url: where.ref.url, dest: where.dest, startedAt: Date.now(), state: 'running', progress: 'Starting git…' };
|
|
2544
|
+
cloneJobs.set(job.id, job);
|
|
2545
|
+
void (async () => {
|
|
2546
|
+
const failed = await cloneRepo({ ref: where.ref, dest: where.dest, branch: typeof branch === 'string' && branch.trim() ? branch.trim() : undefined, onProgress: (l) => { job.progress = l; } });
|
|
2547
|
+
if (failed) {
|
|
2548
|
+
await rm(where.dest, { recursive: true, force: true }).catch(() => {});
|
|
2549
|
+
job.state = 'error'; job.error = failed;
|
|
2550
|
+
} else {
|
|
2551
|
+
const project = await store.addProject(where.dest);
|
|
2552
|
+
projectsCache.set(project.id, { name: project.name });
|
|
2553
|
+
job.state = 'done'; job.projectId = project.id; job.progress = 'Done';
|
|
2554
|
+
}
|
|
2555
|
+
// Finished jobs linger long enough to be read, then go.
|
|
2556
|
+
setTimeout(() => cloneJobs.delete(job.id), 10 * 60_000).unref();
|
|
2557
|
+
})();
|
|
2558
|
+
json(res, 202, { id: job.id, dest: job.dest });
|
|
2559
|
+
|
|
2560
|
+
} else if (req.method === 'GET' && /^\/projects\/clone\/[a-f0-9]{12}$/.test(url.pathname)) {
|
|
2561
|
+
const job = cloneJobs.get(url.pathname.split('/').pop()!);
|
|
2562
|
+
if (!job) return json(res, 404, { error: 'no such clone (Foreman may have restarted; check the projects root)' });
|
|
2563
|
+
json(res, 200, { state: job.state, progress: job.progress, projectId: job.projectId, error: job.error, dest: job.dest });
|
|
2564
|
+
|
|
2565
|
+
} else if (req.method === 'GET' && url.pathname === '/search') {
|
|
2566
|
+
// Every run across the fleet whose title, brief, project or folder
|
|
2567
|
+
// says the words. Run records are small and already on disk; no index.
|
|
2568
|
+
const q = (url.searchParams.get('q') ?? '').trim().toLowerCase();
|
|
2569
|
+
if (q.length < 2) return json(res, 200, { runs: [] });
|
|
2570
|
+
const [runs, projects] = await Promise.all([store.listRuns(), store.listProjects()]);
|
|
2571
|
+
const byFolder = new Map(projects.map((p) => [p.folder, p]));
|
|
2572
|
+
const hits = runs.filter((r) => {
|
|
2573
|
+
const project = byFolder.get(r.folder);
|
|
2574
|
+
return [r.title, r.mission, r.folder, project?.name].some((f) => f?.toLowerCase().includes(q));
|
|
2575
|
+
}).sort((a, b) => b.createdAt - a.createdAt).slice(0, 30).map((r) => {
|
|
2576
|
+
const project = byFolder.get(r.folder);
|
|
2577
|
+
return {
|
|
2578
|
+
id: r.id, projectId: r.projectId ?? project?.id ?? null, projectName: project?.name ?? path.basename(r.folder),
|
|
2579
|
+
folder: r.folder, title: r.title, mission: firstLine(r.mission), status: r.status,
|
|
2580
|
+
createdAt: r.createdAt, endedAt: r.endedAt, costUsd: r.costUsd, costBasis: costBasisOf(r),
|
|
2581
|
+
};
|
|
2582
|
+
});
|
|
2583
|
+
json(res, 200, { runs: hits });
|
|
2584
|
+
|
|
2196
2585
|
} else if (req.method === 'POST' && url.pathname === '/fleet/chat') {
|
|
2197
2586
|
// One turn at the front desk, answered in the response. The same
|
|
2198
2587
|
// session the phone uses, so a conversation can move between them.
|
|
@@ -2214,6 +2603,72 @@ const server = http.createServer(async (req, res) => {
|
|
|
2214
2603
|
broadcastChat(FLEET_CHAT_ID, 'chat_cleared', {});
|
|
2215
2604
|
json(res, 200, { ok: true });
|
|
2216
2605
|
|
|
2606
|
+
} else if (prStateMatch && req.method === 'GET') {
|
|
2607
|
+
// What became of the run's pull request. A final answer (merged,
|
|
2608
|
+
// closed) is written to the run so it is not asked again; open is.
|
|
2609
|
+
const meta = await store.readMeta(prStateMatch[1]).catch(() => null);
|
|
2610
|
+
if (!meta?.git?.pr) return json(res, 404, { error: 'no pull request on this run' });
|
|
2611
|
+
if (meta.git.prState) return json(res, 200, { url: meta.git.pr, state: meta.git.prState, cached: true });
|
|
2612
|
+
const s = await pullRequestState(meta.folder, meta.git.pr);
|
|
2613
|
+
if (s && s.state !== 'open') {
|
|
2614
|
+
meta.git = { ...meta.git, prState: s.state };
|
|
2615
|
+
await store.writeMeta(meta).catch(() => {});
|
|
2616
|
+
}
|
|
2617
|
+
json(res, 200, { url: meta.git.pr, state: s?.state ?? null, mergedAt: s?.mergedAt, number: s?.number });
|
|
2618
|
+
|
|
2619
|
+
} else if (prMatch && req.method === 'GET') {
|
|
2620
|
+
// The pull request as Foreman would draft it, for the sheet to edit.
|
|
2621
|
+
const meta = await store.readMeta(prMatch[1]).catch(() => null);
|
|
2622
|
+
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
2623
|
+
if (!meta.git) return json(res, 409, { error: 'this run had no branch of its own' });
|
|
2624
|
+
const info = await gitInfo(meta.folder);
|
|
2625
|
+
if (!info.remote) return json(res, 409, { error: 'the repository has no origin remote to push to' });
|
|
2626
|
+
const doc = await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null);
|
|
2627
|
+
const gh = await ghReady();
|
|
2628
|
+
json(res, 200, {
|
|
2629
|
+
...prDraft(meta, doc), branch: meta.git.branch, base: meta.git.base, commits: meta.git.commits ?? null,
|
|
2630
|
+
remote: info.remote, compareUrl: compareUrl(info.remote, meta.git.base, meta.git.branch),
|
|
2631
|
+
gh, pr: meta.git.pr ?? null, onBranch: info.branch === meta.git.branch, dirty: Boolean(info.dirty),
|
|
2632
|
+
});
|
|
2633
|
+
|
|
2634
|
+
} else if (prMatch && req.method === 'POST') {
|
|
2635
|
+
// The one outward-facing act: push the mission's branch and open the
|
|
2636
|
+
// pull request — as the user, with their git and gh, on their click.
|
|
2637
|
+
// Never from an agent, never from the phone.
|
|
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
|
+
if (meta.status === 'running') return json(res, 409, { error: 'the mission is still running' });
|
|
2642
|
+
const { title, body } = await readBody(req);
|
|
2643
|
+
const t = typeof title === 'string' && title.trim() ? title.trim().slice(0, 200) : null;
|
|
2644
|
+
if (!t) return json(res, 400, { error: 'a title is required' });
|
|
2645
|
+
const b = typeof body === 'string' ? body : '';
|
|
2646
|
+
const info = await gitInfo(meta.folder);
|
|
2647
|
+
if (!info.remote) return json(res, 409, { error: 'the repository has no origin remote to push to' });
|
|
2648
|
+
const emit = makeEmitter(meta.id, meta.projectId ?? prMatch[1]);
|
|
2649
|
+
const pushed = await pushBranch(meta.folder, meta.git.branch);
|
|
2650
|
+
if (pushed) {
|
|
2651
|
+
emit('pull_request', { branch: meta.git.branch, error: pushed, text: `Push of ${meta.git.branch} failed: ${pushed}` });
|
|
2652
|
+
return json(res, 502, { error: pushed });
|
|
2653
|
+
}
|
|
2654
|
+
const gh = await ghReady();
|
|
2655
|
+
let url: string | undefined;
|
|
2656
|
+
let method: 'gh' | 'compare' = 'compare';
|
|
2657
|
+
let note: string | undefined;
|
|
2658
|
+
if (gh.present && gh.authed && /github\.com/.test(info.remote)) {
|
|
2659
|
+
const r = await createPullRequest(meta.folder, { base: meta.git.base, branch: meta.git.branch, title: t, body: b });
|
|
2660
|
+
if (r.url) { url = r.url; method = 'gh'; } else note = r.error;
|
|
2661
|
+
}
|
|
2662
|
+
if (!url) url = compareUrl(info.remote, meta.git.base, meta.git.branch) ?? undefined;
|
|
2663
|
+
meta.git = { ...meta.git, pr: method === 'gh' ? url : meta.git.pr };
|
|
2664
|
+
await store.writeMeta(meta).catch(() => {});
|
|
2665
|
+
gitInfoCache.delete(meta.folder);
|
|
2666
|
+
const text = method === 'gh'
|
|
2667
|
+
? `Pushed ${meta.git.branch} and opened a pull request: ${url}`
|
|
2668
|
+
: `Pushed ${meta.git.branch}.${note ? ` ${note}.` : ''} Finish the pull request in the browser: ${url ?? 'open the repository'}`;
|
|
2669
|
+
emit('pull_request', { branch: meta.git.branch, base: meta.git.base, url, method, text });
|
|
2670
|
+
json(res, 200, { ok: true, url, method, pushed: true, note });
|
|
2671
|
+
|
|
2217
2672
|
} else if (req.method === 'POST' && url.pathname === '/steer') {
|
|
2218
2673
|
const { runId, text } = await readBody(req);
|
|
2219
2674
|
const trimmed = typeof text === 'string' ? text.trim() : '';
|
|
@@ -2294,6 +2749,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
2294
2749
|
if (!events) return json(res, 404, { error: 'unknown run' });
|
|
2295
2750
|
json(res, 200, { events });
|
|
2296
2751
|
|
|
2752
|
+
} else if (req.method === 'GET' && /^\/projects\/[^/]+\/memory$/.test(url.pathname)) {
|
|
2753
|
+
// What the project "believes": .foreman/MEMORY.md, read-only here.
|
|
2754
|
+
const project = await store.getProject(url.pathname.split('/')[2]).catch(() => null);
|
|
2755
|
+
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
2756
|
+
json(res, 200, await readMemory(project.folder));
|
|
2757
|
+
|
|
2297
2758
|
} else if (req.method === 'GET' && url.pathname === '/missiondoc') {
|
|
2298
2759
|
const runId = url.searchParams.get('run');
|
|
2299
2760
|
if (!runId) return json(res, 400, { error: 'run parameter is required' });
|
|
@@ -2306,6 +2767,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
2306
2767
|
} else if (req.method === 'GET' && url.pathname === '/browse') {
|
|
2307
2768
|
const requested = url.searchParams.get('path') || os.homedir();
|
|
2308
2769
|
const dir = path.resolve(requested);
|
|
2770
|
+
if (!pathPermitted(dir, await browseRoots())) {
|
|
2771
|
+
return json(res, 400, { error: 'path outside the folders Foreman may browse' });
|
|
2772
|
+
}
|
|
2309
2773
|
const st = await stat(dir).catch(() => null);
|
|
2310
2774
|
if (!st?.isDirectory()) return json(res, 400, { error: `not a directory: ${dir}` });
|
|
2311
2775
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -2324,6 +2788,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2324
2788
|
if (typeof name !== 'string' || !name.trim() || /[/\\]/.test(name) || name.trim().startsWith('.')) {
|
|
2325
2789
|
return json(res, 400, { error: 'invalid folder name' });
|
|
2326
2790
|
}
|
|
2791
|
+
// The parent is what is being written into, so the parent is what has
|
|
2792
|
+
// to be inside the allowed folders.
|
|
2793
|
+
if (!pathPermitted(parent, await browseRoots())) {
|
|
2794
|
+
return json(res, 400, { error: 'path outside the folders Foreman may browse' });
|
|
2795
|
+
}
|
|
2327
2796
|
const parentSt = await stat(parent).catch(() => null);
|
|
2328
2797
|
if (!parentSt?.isDirectory()) return json(res, 400, { error: `not a directory: ${parent}` });
|
|
2329
2798
|
const created = path.join(parent, name.trim());
|
|
@@ -2339,12 +2808,42 @@ const server = http.createServer(async (req, res) => {
|
|
|
2339
2808
|
json(res, 404, { error: 'not found' });
|
|
2340
2809
|
}
|
|
2341
2810
|
} catch (err) {
|
|
2811
|
+
if (err instanceof BodyError) {
|
|
2812
|
+
// The answer goes out before the stream is torn down: destroying the
|
|
2813
|
+
// request first makes the client see a connection reset instead of the
|
|
2814
|
+
// 413 that explains it. `res.end` has already queued the bytes by the
|
|
2815
|
+
// time `req.destroy` stops the sender from writing more.
|
|
2816
|
+
json(res, err.status, { error: err.message });
|
|
2817
|
+
if (!req.readableEnded) req.destroy();
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2342
2820
|
json(res, 500, { error: String(err) });
|
|
2343
2821
|
}
|
|
2344
2822
|
});
|
|
2345
2823
|
|
|
2824
|
+
/**
|
|
2825
|
+
* The second origin: nothing but the crew's exposed dev servers, guarded the
|
|
2826
|
+
* same way as the dashboard. Kept separate so a page an agent wrote cannot
|
|
2827
|
+
* reach a single Foreman route from the browser — see src/services.ts.
|
|
2828
|
+
*/
|
|
2829
|
+
const servicesServer = http.createServer(servicesHandler({
|
|
2830
|
+
registry: services,
|
|
2831
|
+
allowed: (req) => requestAllowed(req, { port: SERVICES_PORT, tailnet, bindAll: BIND === 'all' }),
|
|
2832
|
+
}));
|
|
2833
|
+
/**
|
|
2834
|
+
* Why the services port is not listening, when it is not. A taken port must
|
|
2835
|
+
* not take the dashboard down with it (an unhandled 'error' on a server is a
|
|
2836
|
+
* crash): it is logged, preflight has already warned, and expose_service
|
|
2837
|
+
* tells the crew plainly instead of handing out a URL that answers nothing.
|
|
2838
|
+
*/
|
|
2839
|
+
let servicesDown: string | null = null;
|
|
2840
|
+
servicesServer.on('error', (err: NodeJS.ErrnoException) => {
|
|
2841
|
+
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`;
|
|
2842
|
+
console.warn(`[services] ${servicesDown}`);
|
|
2843
|
+
});
|
|
2844
|
+
|
|
2346
2845
|
// Fail loudly on a misconfigured install before anything else happens.
|
|
2347
|
-
if (!reportPreflight(await preflight({ port: PORT, foremanHome: store.root, distDir: DIST_DIR, tailnet }))) {
|
|
2846
|
+
if (!reportPreflight(await preflight({ port: PORT, servicesPort: SERVICES_PORT, foremanHome: store.root, distDir: DIST_DIR, tailnet }))) {
|
|
2348
2847
|
process.exit(1);
|
|
2349
2848
|
}
|
|
2350
2849
|
|
|
@@ -2389,11 +2888,13 @@ void store.listRuns().then((runs) => {
|
|
|
2389
2888
|
}).catch(() => {});
|
|
2390
2889
|
|
|
2391
2890
|
if (BIND === 'all') {
|
|
2392
|
-
server.listen(PORT, () => console.log(`Foreman listening on http://0.0.0.0:${PORT} (FOREMAN_BIND=all)`));
|
|
2891
|
+
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}`));
|
|
2892
|
+
servicesServer.listen(SERVICES_PORT);
|
|
2393
2893
|
} else {
|
|
2394
2894
|
server.listen(PORT, '127.0.0.1', () => {
|
|
2395
|
-
console.log(`Foreman listening on http://localhost:${PORT}${tailnet ? ` · ${tailnetUrl(tailnet, PORT)}` : ''}`);
|
|
2895
|
+
console.log(`Foreman listening on http://localhost:${PORT}${tailnet ? ` · ${tailnetUrl(tailnet, PORT)}` : ''} · services on http://localhost:${SERVICES_PORT}`);
|
|
2396
2896
|
});
|
|
2897
|
+
servicesServer.listen(SERVICES_PORT, '127.0.0.1');
|
|
2397
2898
|
if (tailnet) {
|
|
2398
2899
|
// A second listener on the tailnet address, feeding the same handler.
|
|
2399
2900
|
// Not 0.0.0.0: the café Wi-Fi is not the tailnet.
|
|
@@ -2401,7 +2902,12 @@ if (BIND === 'all') {
|
|
|
2401
2902
|
const viaTailnet = http.createServer(onRequest);
|
|
2402
2903
|
viaTailnet.on('error', (err) => console.warn(`[tailscale] could not listen on ${tailnet.ip}:${PORT} — ${err.message}`));
|
|
2403
2904
|
viaTailnet.listen(PORT, tailnet.ip);
|
|
2404
|
-
|
|
2905
|
+
// The services port needs the same reach: the human opens those previews
|
|
2906
|
+
// from their phone, over the tailnet, like everything else.
|
|
2907
|
+
const svcViaTailnet = http.createServer(servicesServer.listeners('request')[0] as http.RequestListener);
|
|
2908
|
+
svcViaTailnet.on('error', (err) => console.warn(`[tailscale] could not listen on ${tailnet.ip}:${SERVICES_PORT} — ${err.message}`));
|
|
2909
|
+
svcViaTailnet.listen(SERVICES_PORT, tailnet.ip);
|
|
2910
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => { viaTailnet.close(); svcViaTailnet.close(); });
|
|
2405
2911
|
}
|
|
2406
2912
|
}
|
|
2407
2913
|
|