@amenophis1er/foreman 0.1.15 → 0.1.17
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 +43 -3
- package/package.json +1 -1
- package/src/fleet-planner.test.ts +39 -1
- package/src/fleet-planner.ts +83 -3
- package/src/gitwork.test.ts +89 -2
- package/src/gitwork.ts +134 -0
- package/src/mcp.test.ts +38 -1
- package/src/mcp.ts +75 -3
- package/src/notify/commands.test.ts +2 -0
- package/src/notify/commands.ts +6 -0
- package/src/notify/telegram.ts +1 -0
- package/src/notify.test.ts +61 -0
- package/src/notify.ts +45 -1
- package/src/orchestrator.ts +20 -9
- package/src/schedule-guards.test.ts +236 -0
- package/src/schedule-guards.ts +149 -0
- package/src/schedule.test.ts +240 -0
- package/src/schedule.ts +343 -0
- package/src/server.ts +624 -17
- package/src/services.test.ts +46 -1
- package/src/services.ts +62 -3
- package/src/store.test.ts +81 -1
- package/src/store.ts +117 -3
- package/src/types.ts +64 -1
- package/ui/dist/assets/index-Bcc4KMtO.js +68 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-CnCv2QiH.js +0 -68
package/src/server.ts
CHANGED
|
@@ -50,6 +50,14 @@
|
|
|
50
50
|
* DELETE /chat?projectId= Forget the conversation and its session
|
|
51
51
|
* PUT /providers/{id}/key Store a provider's key {key}
|
|
52
52
|
* DELETE /providers/{id}/key Forget it
|
|
53
|
+
* GET /projects/{id}/schedules This project's schedules + this month's scheduled spend and its ceiling
|
|
54
|
+
* POST /projects/{id}/schedules Add one {name, brief, cadence, budgetUsd, …} → {schedule}
|
|
55
|
+
* PUT /schedules/{id} Change one (same fields, all optional) → {schedule}
|
|
56
|
+
* DELETE /schedules/{id} Forget one
|
|
57
|
+
* POST /schedules/{id}/pause Stop it firing until a human resumes it
|
|
58
|
+
* POST /schedules/{id}/resume Start it firing again, from now
|
|
59
|
+
* POST /schedules/{id}/run-now Start its mission at once (409 if the project is busy)
|
|
60
|
+
* GET /schedules/preview?cadence= The next three firings of a cadence {next:[ms]}
|
|
53
61
|
*/
|
|
54
62
|
import http from 'node:http';
|
|
55
63
|
import crypto from 'node:crypto';
|
|
@@ -73,14 +81,14 @@ import { budgetAnchor, modelRecords, projectRecord, recordLine } from './track-r
|
|
|
73
81
|
import { reconcileRole } from './role-provider.js';
|
|
74
82
|
import { detectBrowser, installChromium } from './browser.js';
|
|
75
83
|
import { frozenDeck, frozenMissionDoc, parkMissionDoc, restoreMissionDoc, snapshotRun } from './snapshot.js';
|
|
76
|
-
import { closeMissionBranch, compareUrl, createPullRequest, ensureMissionBranch, ghReady, gitInfo, missionBranchName, prDraft, pullRequestState, pushBranch, renameMissionBranch, startMissionBranch, type GitInfo } from './gitwork.js';
|
|
84
|
+
import { closeMissionBranch, compareUrl, createPullRequest, dirtyPaths, ensureMissionBranch, ghReady, gitInfo, resolvePrBase, worktreeGrant, worktreeParent, missionBranchName, prDraft, pullRequestState, pushBranch, renameMissionBranch, startMissionBranch, type GitInfo } from './gitwork.js';
|
|
77
85
|
import { detectTailscale, tailnetUrl } from './tailscale.js';
|
|
78
86
|
import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
|
|
79
|
-
import { ServiceRegistry, portOpen, servicesHandler } from './services.js';
|
|
87
|
+
import { ServiceRegistry, listeningPid, portOpen, servicesHandler, stopService } from './services.js';
|
|
80
88
|
import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
|
|
81
89
|
import {
|
|
82
90
|
DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
|
|
83
|
-
type FleetHost, type FleetProjectView,
|
|
91
|
+
type FleetHost, type FleetProjectView, type FleetScheduleView,
|
|
84
92
|
} from './fleet-planner.js';
|
|
85
93
|
import { escapeHtml as escTg } from './notify.js';
|
|
86
94
|
import { RunStore, newRunId } from './store.js';
|
|
@@ -107,8 +115,12 @@ import {
|
|
|
107
115
|
dirHasCredentials, detectAuth, hasKeychainCredentials, readAccount, type AuthMode,
|
|
108
116
|
} from './preflight.js';
|
|
109
117
|
import { combineBasis, costBasisOf } from './types.js';
|
|
118
|
+
import { describeCadence, nextRunAt, nextRuns, validateCadence, type Cadence } from './schedule.js';
|
|
119
|
+
import {
|
|
120
|
+
DEFAULT_SCHEDULED_MONTHLY_CAP_USD, afterRunOutcome, decideTicks, monthlyScheduledSpend,
|
|
121
|
+
} from './schedule-guards.js';
|
|
110
122
|
import type {
|
|
111
|
-
ChatMeta, CostBasis, ForemanEvent, MissionProposal, ModelChoice, Project, ProviderRef, RunMeta, ToolPolicy,
|
|
123
|
+
ChatMeta, CostBasis, ForemanEvent, MissionProposal, ModelChoice, Project, ProviderRef, RunMeta, Schedule, ToolPolicy,
|
|
112
124
|
} from './types.js';
|
|
113
125
|
|
|
114
126
|
/**
|
|
@@ -535,6 +547,24 @@ function activeRuns(): MissionRun[] {
|
|
|
535
547
|
return [...activeByProject.values()].filter((r): r is MissionRun => Boolean(r));
|
|
536
548
|
}
|
|
537
549
|
|
|
550
|
+
/**
|
|
551
|
+
* The metadata of every run this process is currently driving, by run id.
|
|
552
|
+
*
|
|
553
|
+
* The emitter has to answer "was this run started by a schedule?" at the
|
|
554
|
+
* instant it emits, and a run's `run_finished` can be emitted before the
|
|
555
|
+
* MissionRun object exists at all (a provider that will not resolve fails the
|
|
556
|
+
* run in driveRun's first few lines). Reading meta.json back there would be
|
|
557
|
+
* asynchronous, and the notification envelope has already gone out by then.
|
|
558
|
+
*/
|
|
559
|
+
const drivingRuns = new Map<string, RunMeta>();
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Schedule names by schedule id, so a notification can say which standing
|
|
563
|
+
* instruction started a run without a disk read on the emitter's path. Filled
|
|
564
|
+
* wherever schedules are loaded anyway — every ticker pass, and each dispatch.
|
|
565
|
+
*/
|
|
566
|
+
const scheduleNames = new Map<string, string>();
|
|
567
|
+
|
|
538
568
|
/**
|
|
539
569
|
* What happened in the fleet lately, in one line each, for the front desk.
|
|
540
570
|
*
|
|
@@ -610,6 +640,21 @@ function makeEmitter(runId: string, projectId: string) {
|
|
|
610
640
|
});
|
|
611
641
|
}
|
|
612
642
|
}
|
|
643
|
+
// The end of a scheduled run is also the schedule's news: the phone should
|
|
644
|
+
// say nobody pressed start, and the schedule's own failure bookkeeping has
|
|
645
|
+
// to move on. This is where the server learns a run ended — the
|
|
646
|
+
// orchestrator knows nothing about schedules and should not.
|
|
647
|
+
if (event === 'run_finished') {
|
|
648
|
+
const meta = drivingRuns.get(runId) ?? activeRuns().find((r) => r.meta.id === runId)?.meta;
|
|
649
|
+
if (meta?.scheduleId) {
|
|
650
|
+
d.scheduled = true;
|
|
651
|
+
const name = scheduleNames.get(meta.scheduleId);
|
|
652
|
+
if (name) d.scheduleName = name;
|
|
653
|
+
void recordScheduleOutcome(meta.scheduleId, meta).catch((err) => {
|
|
654
|
+
console.error(`failed to record outcome of schedule ${meta.scheduleId}:`, err);
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
613
658
|
if (!projectsCache.has(projectId)) {
|
|
614
659
|
void store.getProject(projectId).then((p) => { if (p) projectsCache.set(projectId, { name: p.name }); });
|
|
615
660
|
}
|
|
@@ -617,6 +662,24 @@ function makeEmitter(runId: string, projectId: string) {
|
|
|
617
662
|
};
|
|
618
663
|
}
|
|
619
664
|
|
|
665
|
+
/**
|
|
666
|
+
* An announcement about a schedule rather than about a run.
|
|
667
|
+
*
|
|
668
|
+
* A skipped or auto-paused schedule has no run to hang a line off — that is
|
|
669
|
+
* exactly what happened: nothing started. So the frame carries `runId: null`,
|
|
670
|
+
* goes nowhere near a run's event log, and otherwise travels the same three
|
|
671
|
+
* roads every other event does (open tabs, the front desk's news, the phone).
|
|
672
|
+
*/
|
|
673
|
+
function scheduleNotice(projectId: string) {
|
|
674
|
+
return (event: string, data: unknown): void => {
|
|
675
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify({ runId: null, projectId, data })}\n\n`;
|
|
676
|
+
for (const res of sseClients) res.write(frame);
|
|
677
|
+
const d = (data ?? {}) as Record<string, unknown>;
|
|
678
|
+
noteFleetEvent(projectId, event, d);
|
|
679
|
+
notifyHub.handle({ event, runId: null, projectId, data: d, ts: Date.now() });
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
620
683
|
/**
|
|
621
684
|
* Everything an agent needs to authenticate, including starting the provider's
|
|
622
685
|
* gateway if it has one.
|
|
@@ -805,12 +868,24 @@ notifyHub.onAnswer((a) => {
|
|
|
805
868
|
// brief, its budget, its models and its browser judgement.
|
|
806
869
|
void startRun(a.projectId, project.folder, prop.mission, prop.budgetUsd,
|
|
807
870
|
modelChoice(prop.directorModel), modelChoice(prop.workerModel), prop.browser === true,
|
|
808
|
-
providerOf(project), { director: prop.directorProviderId, worker: prop.workerProviderId }
|
|
871
|
+
providerOf(project), { director: prop.directorProviderId, worker: prop.workerProviderId },
|
|
872
|
+
{ startedBy: 'phone' });
|
|
809
873
|
void notifyHub.say(`Started <b>${escTg(project.name)}</b> as proposed, cap $${prop.budgetUsd}.`);
|
|
810
874
|
})();
|
|
811
875
|
}
|
|
812
876
|
});
|
|
813
877
|
|
|
878
|
+
/**
|
|
879
|
+
* A gap as a reader would say it: "in 15 h", "3 days ago". Used where a
|
|
880
|
+
* timestamp would make someone do arithmetic on their phone.
|
|
881
|
+
*/
|
|
882
|
+
function relativeTime(ms: number): string {
|
|
883
|
+
const s = Math.round(ms / 1000);
|
|
884
|
+
const a = Math.abs(s);
|
|
885
|
+
const span = a < 90 ? 'a minute' : a < 5400 ? `${Math.round(a / 60)} min` : a < 172800 ? `${Math.round(a / 3600)} h` : `${Math.round(a / 86400)} days`;
|
|
886
|
+
return s >= 0 ? `in ${span}` : `${span} ago`;
|
|
887
|
+
}
|
|
888
|
+
|
|
814
889
|
/** The project the phone last planned with; plain text continues it. */
|
|
815
890
|
let lastPhonePlanning: string | null = null;
|
|
816
891
|
|
|
@@ -959,6 +1034,41 @@ const fleetHost: FleetHost = {
|
|
|
959
1034
|
return out;
|
|
960
1035
|
},
|
|
961
1036
|
|
|
1037
|
+
/**
|
|
1038
|
+
* The standing schedules, with what they have already cost this month
|
|
1039
|
+
* against their project's ceiling — the number that decides whether the next
|
|
1040
|
+
* unattended firing happens at all, and the one nobody can see from a phone.
|
|
1041
|
+
*
|
|
1042
|
+
* Reading only, like every other verb here. An unknown reference is an
|
|
1043
|
+
* error rather than an empty list: "no schedules" and "you named a project
|
|
1044
|
+
* that does not exist" are different answers, and a typo must not read as
|
|
1045
|
+
* reassurance.
|
|
1046
|
+
*/
|
|
1047
|
+
async listSchedules(ref?: string): Promise<FleetScheduleView[]> {
|
|
1048
|
+
const project = ref ? await findProject(ref) : null;
|
|
1049
|
+
if (ref && !project) throw new Error(await noSuchProject(ref));
|
|
1050
|
+
const schedules = await store.listSchedules(project?.id);
|
|
1051
|
+
if (!schedules.length) return [];
|
|
1052
|
+
const runs = await store.listRuns().catch(() => [] as RunMeta[]);
|
|
1053
|
+
const now = new Date();
|
|
1054
|
+
const names = new Map((await store.listProjects()).map((p) => [p.id, p.name]));
|
|
1055
|
+
// One settings read and one spend count per project, not per schedule:
|
|
1056
|
+
// a project with six nightly schedules asks the same two questions six
|
|
1057
|
+
// times, and the answers cannot differ between them.
|
|
1058
|
+
const money = new Map<string, { monthSpendUsd: number; monthlyCapUsd: number }>();
|
|
1059
|
+
for (const projectId of new Set(schedules.map((s) => s.projectId))) {
|
|
1060
|
+
money.set(projectId, {
|
|
1061
|
+
monthSpendUsd: monthlyScheduledSpend(runs, projectId, now),
|
|
1062
|
+
monthlyCapUsd: (await effectiveSettings(projectId)).scheduledMonthlyCapUsd,
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
return schedules.map((schedule) => ({
|
|
1066
|
+
projectName: names.get(schedule.projectId) ?? schedule.projectId,
|
|
1067
|
+
schedule,
|
|
1068
|
+
...money.get(schedule.projectId)!,
|
|
1069
|
+
}));
|
|
1070
|
+
},
|
|
1071
|
+
|
|
962
1072
|
async projectDetail(ref) {
|
|
963
1073
|
const project = await findProject(ref);
|
|
964
1074
|
if (!project) return noSuchProject(ref);
|
|
@@ -1246,9 +1356,35 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
|
|
|
1246
1356
|
if (!reserveProject(project.id)) return say(`<b>${escTg(project.name)}</b> already has an active mission.`);
|
|
1247
1357
|
void startRun(project.id, project.folder, cmd.text, project.defaultBudgetUsd,
|
|
1248
1358
|
modelChoice(undefined), modelChoice(undefined), /screenshot|browser|render|console/i.test(cmd.text),
|
|
1249
|
-
providerOf(project));
|
|
1359
|
+
providerOf(project), {}, { startedBy: 'phone' });
|
|
1250
1360
|
return say(`Started a mission on <b>${escTg(project.name)}</b> with a $${project.defaultBudgetUsd} cap. I will tell you when it needs you or ends.`);
|
|
1251
1361
|
}
|
|
1362
|
+
case 'schedules': {
|
|
1363
|
+
// Reading only. A schedule is standing configuration — it decides
|
|
1364
|
+
// what a machine does while nobody is watching — and remote surfaces
|
|
1365
|
+
// never grant standing changes, so there is no create, edit, pause,
|
|
1366
|
+
// resume or run-now here. The reply says so rather than leaving
|
|
1367
|
+
// someone to discover it by trying.
|
|
1368
|
+
const project = cmd.project ? await findProject(cmd.project) : null;
|
|
1369
|
+
if (cmd.project && !project) return say(`No project called <b>${escTg(cmd.project)}</b>. /projects lists them.`);
|
|
1370
|
+
const schedules = await store.listSchedules(project?.id);
|
|
1371
|
+
const footer = 'Schedules are read-only from here — create, edit, pause and resume live in the dashboard.';
|
|
1372
|
+
if (!schedules.length) {
|
|
1373
|
+
return say(`No schedules${project ? ` on <b>${escTg(project.name)}</b>` : ''} yet.\n${footer}`);
|
|
1374
|
+
}
|
|
1375
|
+
const names = new Map((await store.listProjects()).map((p) => [p.id, p.name]));
|
|
1376
|
+
const lines = schedules.map((s) => {
|
|
1377
|
+
const state = s.pausedReason
|
|
1378
|
+
? `paused (${s.pausedReason === 'monthly-cap' ? 'monthly cap' : s.pausedReason})`
|
|
1379
|
+
: s.enabled ? 'enabled' : 'disabled';
|
|
1380
|
+
const next = s.pausedReason || !s.enabled || s.nextRunAt === null
|
|
1381
|
+
? 'no next run'
|
|
1382
|
+
: `next ${relativeTime(s.nextRunAt - Date.now())}`;
|
|
1383
|
+
const where = project ? '' : ` · ${escTg(names.get(s.projectId) ?? s.projectId)}`;
|
|
1384
|
+
return `• <b>${escTg(s.name)}</b>${where}\n ${escTg(describeCadence(s.cadence))} · ${next} · ${state}`;
|
|
1385
|
+
});
|
|
1386
|
+
return say(`<b>Schedules${project ? ` · ${escTg(project.name)}` : ''}</b>\n${lines.join('\n')}\n\n${footer}`);
|
|
1387
|
+
}
|
|
1252
1388
|
case 'stop': {
|
|
1253
1389
|
const project = cmd.project ? await findProject(cmd.project) : (lastPhonePlanning ? await store.getProject(lastPhonePlanning) : null);
|
|
1254
1390
|
const abort = project && chatAborts.get(project.id);
|
|
@@ -1451,6 +1587,9 @@ async function driveRun(
|
|
|
1451
1587
|
changes?: { directorChanged: boolean; workerChanged: boolean },
|
|
1452
1588
|
): Promise<void> {
|
|
1453
1589
|
const emit = makeEmitter(meta.id, projectId);
|
|
1590
|
+
// Registered before anything can fail, so even a run that dies on its
|
|
1591
|
+
// provider still tells the emitter which schedule it belonged to.
|
|
1592
|
+
drivingRuns.set(meta.id, meta);
|
|
1454
1593
|
// One resolution per run, from the provider frozen into the run's metadata.
|
|
1455
1594
|
// A run that cannot resolve a credential must not start: dispatching anyway
|
|
1456
1595
|
// would fall back to whatever the environment happens to hold.
|
|
@@ -1462,6 +1601,7 @@ async function driveRun(
|
|
|
1462
1601
|
await store.writeMeta(meta).catch(() => {});
|
|
1463
1602
|
emit('run_error', { error: `provider unavailable — ${problem}` });
|
|
1464
1603
|
emit('run_finished', { status: 'error', costUsd: meta.costUsd });
|
|
1604
|
+
drivingRuns.delete(meta.id);
|
|
1465
1605
|
activeByProject.delete(projectId);
|
|
1466
1606
|
return;
|
|
1467
1607
|
}
|
|
@@ -1543,6 +1683,7 @@ async function driveRun(
|
|
|
1543
1683
|
await store.writeMeta(meta).catch(() => {});
|
|
1544
1684
|
emit('run_error', { error: String(err instanceof Error ? err.message : err) });
|
|
1545
1685
|
emit('run_finished', { status: 'error', costUsd: meta.costUsd });
|
|
1686
|
+
drivingRuns.delete(meta.id);
|
|
1546
1687
|
activeByProject.delete(projectId);
|
|
1547
1688
|
return;
|
|
1548
1689
|
}
|
|
@@ -1578,8 +1719,11 @@ async function driveRun(
|
|
|
1578
1719
|
exposeService: async (runId, port, label) => {
|
|
1579
1720
|
if (servicesDown) return { ok: false, reason: servicesDown };
|
|
1580
1721
|
if (!(await portOpen(port))) return { ok: false, reason: `nothing is listening on 127.0.0.1:${port} — start the server first` };
|
|
1581
|
-
|
|
1582
|
-
|
|
1722
|
+
// Whoever holds the port right now is what the crew just started, and
|
|
1723
|
+
// the only process a later stop is allowed to touch.
|
|
1724
|
+
const pid = await listeningPid(port);
|
|
1725
|
+
const svc = services.register(runId, port, label, pid ?? undefined);
|
|
1726
|
+
return { ok: true, url: `${await servicesBase()}${svc.path}`, path: svc.path, pid: pid ?? undefined };
|
|
1583
1727
|
},
|
|
1584
1728
|
});
|
|
1585
1729
|
activeByProject.set(projectId, run);
|
|
@@ -1610,6 +1754,7 @@ async function driveRun(
|
|
|
1610
1754
|
} finally {
|
|
1611
1755
|
// However the run ended, it no longer needs its gateways.
|
|
1612
1756
|
releaseGateways(meta.id);
|
|
1757
|
+
drivingRuns.delete(meta.id);
|
|
1613
1758
|
if (activeByProject.get(projectId) === run) activeByProject.delete(projectId);
|
|
1614
1759
|
// Freeze the record: the mission doc and the deck as they stand at this
|
|
1615
1760
|
// moment, beside the run's meta and log. Done first, before the branch
|
|
@@ -1648,8 +1793,12 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1648
1793
|
directorProviderId?: string; workerProviderId?: string;
|
|
1649
1794
|
/** In a repository, each mission runs on a branch of its own (default on). */
|
|
1650
1795
|
gitBranchPerMission: boolean;
|
|
1796
|
+
/** A mission in a worktree may use its parent repository without asking (default on). */
|
|
1797
|
+
allowWorktreeParent: boolean;
|
|
1651
1798
|
/** Percent of the cap at which the director is told to start verifying (default 80). */
|
|
1652
1799
|
budgetWarnAt: number;
|
|
1800
|
+
/** Ceiling on what this project's SCHEDULED runs may cost in one calendar month. */
|
|
1801
|
+
scheduledMonthlyCapUsd: number;
|
|
1653
1802
|
}> {
|
|
1654
1803
|
const s = await store.readSettings()
|
|
1655
1804
|
.catch(() => ({ global: {}, projects: {} as Record<string, object> }));
|
|
@@ -1671,9 +1820,16 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1671
1820
|
directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
|
|
1672
1821
|
workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
|
|
1673
1822
|
gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
|
|
1823
|
+
allowWorktreeParent: (p.allowWorktreeParent ?? g.allowWorktreeParent) !== false,
|
|
1674
1824
|
budgetWarnAt: (() => {
|
|
1675
1825
|
const raw = Number(p.budgetWarnAt ?? g.budgetWarnAt);
|
|
1676
|
-
return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw :
|
|
1826
|
+
return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 60;
|
|
1827
|
+
})(),
|
|
1828
|
+
scheduledMonthlyCapUsd: (() => {
|
|
1829
|
+
// Zero is a legal answer — "this project may not spend unattended at
|
|
1830
|
+
// all" — so only a negative or unreadable value falls back to the default.
|
|
1831
|
+
const raw = Number(p.scheduledMonthlyCapUsd ?? g.scheduledMonthlyCapUsd);
|
|
1832
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_SCHEDULED_MONTHLY_CAP_USD;
|
|
1677
1833
|
})(),
|
|
1678
1834
|
};
|
|
1679
1835
|
}
|
|
@@ -1694,17 +1850,77 @@ async function gitInfoCached(folder: string): Promise<GitInfo> {
|
|
|
1694
1850
|
return value;
|
|
1695
1851
|
}
|
|
1696
1852
|
|
|
1853
|
+
/**
|
|
1854
|
+
* A mission in a git worktree gets its parent repository without being asked.
|
|
1855
|
+
*
|
|
1856
|
+
* The worktree holds the branch's files; the build config, the shared type
|
|
1857
|
+
* declarations and the parent's node_modules live up in the repository it was
|
|
1858
|
+
* made from. A crew working in a worktree therefore crosses the boundary on
|
|
1859
|
+
* almost every command, and answering the same question all day is not
|
|
1860
|
+
* oversight — it is noise that trains the human to click Allow without
|
|
1861
|
+
* reading. The parent is opened at the start instead, once, in the open: the
|
|
1862
|
+
* transcript records it the same way it records a human's own grant.
|
|
1863
|
+
*
|
|
1864
|
+
* Only the parent. Sibling worktrees stay closed, because one of them may be
|
|
1865
|
+
* another mission's workspace, and so does a parent that a live run is
|
|
1866
|
+
* already working in.
|
|
1867
|
+
*/
|
|
1868
|
+
async function grantWorktreeParent(
|
|
1869
|
+
meta: RunMeta, allowed: boolean, emit: ReturnType<typeof makeEmitter>,
|
|
1870
|
+
): Promise<void> {
|
|
1871
|
+
// Re-derived from scratch every start and every resume, never merely added
|
|
1872
|
+
// to: the setting may have been turned off since, or another mission may
|
|
1873
|
+
// have taken the parent, and a grant that outlives its reason is a hole.
|
|
1874
|
+
// Only Foreman's own grants are withdrawn; a human's stay.
|
|
1875
|
+
const previous = meta.autoRoots ?? [];
|
|
1876
|
+
const shape = allowed ? await worktreeParent(meta.folder).catch(() => null) : null;
|
|
1877
|
+
const busy = activeRuns().filter((r) => r.meta.id !== meta.id).map((r) => r.meta.folder);
|
|
1878
|
+
const decision = worktreeGrant(shape, busy);
|
|
1879
|
+
const now = decision.grant ? [decision.grant] : [];
|
|
1880
|
+
const withdrawn = previous.filter((p) => !now.includes(p));
|
|
1881
|
+
|
|
1882
|
+
if (withdrawn.length || now.some((p) => !previous.includes(p))) {
|
|
1883
|
+
meta.allowedRoots = [...(meta.allowedRoots ?? []).filter((p) => !previous.includes(p)), ...now];
|
|
1884
|
+
meta.autoRoots = now;
|
|
1885
|
+
await store.writeMeta(meta).catch(() => {});
|
|
1886
|
+
}
|
|
1887
|
+
for (const p of withdrawn) {
|
|
1888
|
+
emit('git_note', { text: `Closed ${p} again: ${decision.reason ?? 'Foreman no longer opens it for this mission'}. The crew will ask before it steps outside the mission folder.` });
|
|
1889
|
+
}
|
|
1890
|
+
if (decision.grant && !previous.includes(decision.grant)) {
|
|
1891
|
+
emit('root_allowed', {
|
|
1892
|
+
path: decision.grant, agent: 'foreman',
|
|
1893
|
+
reason: 'this mission runs in a worktree of that repository',
|
|
1894
|
+
});
|
|
1895
|
+
} else if (!decision.grant && decision.reason && !withdrawn.length) {
|
|
1896
|
+
emit('git_note', { text: `Left closed: ${decision.reason}. The crew will ask before it steps outside the mission folder.` });
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1697
1900
|
async function startRun(
|
|
1698
1901
|
projectId: string, folder: string, mission: string, budgetUsd: number,
|
|
1699
1902
|
directorModel: ModelChoice, workerModel: ModelChoice, browserTools: boolean,
|
|
1700
1903
|
provider: ProviderRef,
|
|
1701
1904
|
roleProviders: { director?: string; worker?: string } = {},
|
|
1905
|
+
/**
|
|
1906
|
+
* How this run began. Recorded rather than inferred: an unattended run and
|
|
1907
|
+
* one somebody is watching deserve different treatment later, and the
|
|
1908
|
+
* schedule's monthly ceiling can only count what says it was scheduled.
|
|
1909
|
+
*/
|
|
1910
|
+
origin: {
|
|
1911
|
+
startedBy?: 'human' | 'phone' | 'schedule' | 'mcp';
|
|
1912
|
+
scheduleId?: string;
|
|
1913
|
+
scheduleName?: string;
|
|
1914
|
+
} = {},
|
|
1702
1915
|
): Promise<void> {
|
|
1703
1916
|
const settings = await effectiveSettings(projectId);
|
|
1917
|
+
if (origin.scheduleId && origin.scheduleName) scheduleNames.set(origin.scheduleId, origin.scheduleName);
|
|
1704
1918
|
const meta: RunMeta = {
|
|
1705
1919
|
id: newRunId(),
|
|
1706
1920
|
projectId,
|
|
1707
1921
|
folder, mission, budgetUsd,
|
|
1922
|
+
startedBy: origin.startedBy ?? 'human',
|
|
1923
|
+
...(origin.scheduleId ? { scheduleId: origin.scheduleId } : {}),
|
|
1708
1924
|
// An explicit composer choice wins; "Default" inherits from Settings.
|
|
1709
1925
|
directorModel: directorModel ?? settings.directorModel,
|
|
1710
1926
|
workerModel: workerModel ?? settings.workerModel,
|
|
@@ -1731,6 +1947,23 @@ async function startRun(
|
|
|
1731
1947
|
console.error(`failed to create run for project ${projectId}:`, err);
|
|
1732
1948
|
return;
|
|
1733
1949
|
}
|
|
1950
|
+
// Whoever reads this transcript later did not start this run, and the first
|
|
1951
|
+
// question they will have is who did. It is the opening line, before the
|
|
1952
|
+
// branch note, so the answer is at the top rather than buried in the meta.
|
|
1953
|
+
if (origin.scheduleId) {
|
|
1954
|
+
const emit = makeEmitter(meta.id, projectId);
|
|
1955
|
+
emit('run_note', {
|
|
1956
|
+
text: `Started by schedule ${origin.scheduleName ?? scheduleNames.get(origin.scheduleId) ?? origin.scheduleId}.`,
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
// The schedule's "last run" is this one, recorded the moment it exists
|
|
1960
|
+
// rather than when it ends: a schedule whose mission is still running should
|
|
1961
|
+
// point at it, not at the one before.
|
|
1962
|
+
if (origin.scheduleId) {
|
|
1963
|
+
await store.updateSchedule(origin.scheduleId, {
|
|
1964
|
+
lastRunId: meta.id, lastRunAt: meta.createdAt, lastNote: '',
|
|
1965
|
+
}).catch(() => {});
|
|
1966
|
+
}
|
|
1734
1967
|
await consumeProposal(projectId, meta.id, mission).catch(() => {});
|
|
1735
1968
|
// The folder's MISSION.md belongs to whichever run wrote it. Parked into
|
|
1736
1969
|
// that run's record (when it lacks one) and cleared, so this run's status
|
|
@@ -1744,6 +1977,7 @@ async function startRun(
|
|
|
1744
1977
|
// touches anything — so the deck's baseline, taken at the director's first
|
|
1745
1978
|
// turn, is the branch point, and the diff is exactly the mission.
|
|
1746
1979
|
if (settings.gitBranchPerMission) {
|
|
1980
|
+
const carried = await dirtyPaths(folder);
|
|
1747
1981
|
const g = await startMissionBranch(folder, mission, meta.id);
|
|
1748
1982
|
const emit = makeEmitter(meta.id, projectId);
|
|
1749
1983
|
if ('error' in g) {
|
|
@@ -1753,8 +1987,17 @@ async function startRun(
|
|
|
1753
1987
|
await store.writeMeta(meta).catch(() => {});
|
|
1754
1988
|
gitInfoCache.delete(folder);
|
|
1755
1989
|
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.` });
|
|
1990
|
+
// Whoever started this may not have been at the keyboard to be asked.
|
|
1991
|
+
if (carried.length) {
|
|
1992
|
+
emit('git_note', {
|
|
1993
|
+
text: `The checkout had uncommitted changes when this mission started, and they came along to ${g.branch}: `
|
|
1994
|
+
+ `${carried.slice(0, 5).join(', ')}${carried.length > 5 ? `, and ${carried.length - 5} more` : ''}. `
|
|
1995
|
+
+ "They will be part of the mission's closing commit unless you take them off the branch first.",
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1756
1998
|
}
|
|
1757
1999
|
}
|
|
2000
|
+
await grantWorktreeParent(meta, settings.allowWorktreeParent, makeEmitter(meta.id, projectId));
|
|
1758
2001
|
await driveRun(projectId, meta);
|
|
1759
2002
|
}
|
|
1760
2003
|
|
|
@@ -1815,6 +2058,7 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1815
2058
|
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.` });
|
|
1816
2059
|
gitInfoCache.delete(meta.folder);
|
|
1817
2060
|
}
|
|
2061
|
+
await grantWorktreeParent(meta, (await effectiveSettings(projectId)).allowWorktreeParent, makeEmitter(meta.id, projectId));
|
|
1818
2062
|
// The director resumes from MISSION.md. If another mission ran here since,
|
|
1819
2063
|
// the folder's copy is that mission's; this run's own goes back first.
|
|
1820
2064
|
const restored = await restoreMissionDoc(store.runDirectory(meta.id), meta.folder).catch(() => 'none' as const);
|
|
@@ -1833,6 +2077,153 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1833
2077
|
});
|
|
1834
2078
|
}
|
|
1835
2079
|
|
|
2080
|
+
// ---------------------------------------------------------------------------
|
|
2081
|
+
// Schedules
|
|
2082
|
+
// ---------------------------------------------------------------------------
|
|
2083
|
+
|
|
2084
|
+
/**
|
|
2085
|
+
* Starts a schedule's mission. The project must already be reserved.
|
|
2086
|
+
*
|
|
2087
|
+
* The schedule's own choices win; anything it leaves open falls back to the
|
|
2088
|
+
* project's effective settings, which is what startRun does with an absent
|
|
2089
|
+
* model anyway. The browser judgement is the same heuristic the phone's /run
|
|
2090
|
+
* uses, and for the same reason: there is no box for anyone to tick.
|
|
2091
|
+
*/
|
|
2092
|
+
function startScheduledRun(s: Schedule, project: Project, startedBy: 'human' | 'schedule'): void {
|
|
2093
|
+
void startRun(
|
|
2094
|
+
s.projectId, project.folder, s.brief, s.budgetUsd,
|
|
2095
|
+
modelChoice(s.directorModel), modelChoice(s.workerModel),
|
|
2096
|
+
/screenshot|browser|render|console/i.test(s.brief),
|
|
2097
|
+
providerOf(project),
|
|
2098
|
+
{ director: s.directorProviderId, worker: s.workerProviderId },
|
|
2099
|
+
{ startedBy, scheduleId: s.id, scheduleName: s.name },
|
|
2100
|
+
);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
/** The next firing of this cadence as a timestamp, or null when there is none. */
|
|
2104
|
+
function nextRunAtMs(cadence: Cadence, after: Date): number | null {
|
|
2105
|
+
const next = nextRunAt(cadence, after);
|
|
2106
|
+
return next ? next.getTime() : null;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
/**
|
|
2110
|
+
* A scheduled run ended: the schedule remembers how it went.
|
|
2111
|
+
*
|
|
2112
|
+
* Two failures in a row pause it — see afterRunOutcome. Called from the
|
|
2113
|
+
* emitter, which is where the server learns a run finished; the orchestrator
|
|
2114
|
+
* knows nothing about schedules and should not have to.
|
|
2115
|
+
*/
|
|
2116
|
+
async function recordScheduleOutcome(scheduleId: string, meta: RunMeta): Promise<void> {
|
|
2117
|
+
const s = await store.getSchedule(scheduleId);
|
|
2118
|
+
if (!s) return;
|
|
2119
|
+
const outcome = afterRunOutcome(s, { status: meta.status, stopReason: meta.stopReason });
|
|
2120
|
+
await store.updateSchedule(s.id, {
|
|
2121
|
+
lastRunId: meta.id,
|
|
2122
|
+
lastRunAt: meta.endedAt ?? Date.now(),
|
|
2123
|
+
lastOutcome: meta.status === 'running' ? undefined : meta.status,
|
|
2124
|
+
// Whatever the last tick could not do, it did this time: the note would
|
|
2125
|
+
// otherwise still read "project busy" beside a run that just finished.
|
|
2126
|
+
lastNote: '',
|
|
2127
|
+
consecutiveFailures: outcome.consecutiveFailures,
|
|
2128
|
+
pausedReason: outcome.pausedReason,
|
|
2129
|
+
});
|
|
2130
|
+
if (outcome.pausedNow) {
|
|
2131
|
+
const emit = scheduleNotice(s.projectId);
|
|
2132
|
+
emit('schedule_paused', { scheduleId: s.id, name: s.name, projectId: s.projectId, reason: 'failures' });
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/**
|
|
2137
|
+
* One pass of the ticker: what is due, and what to do about it.
|
|
2138
|
+
*
|
|
2139
|
+
* Passes must not overlap. A pass awaits the store several times, and two of
|
|
2140
|
+
* them interleaving could both see the same schedule as due and start it
|
|
2141
|
+
* twice — the project reservation would catch that, but as a 409 nobody is
|
|
2142
|
+
* there to read rather than as a rule.
|
|
2143
|
+
*/
|
|
2144
|
+
let tickInFlight = false;
|
|
2145
|
+
async function scheduleTick(): Promise<void> {
|
|
2146
|
+
if (tickInFlight) return;
|
|
2147
|
+
tickInFlight = true;
|
|
2148
|
+
try {
|
|
2149
|
+
const schedules = await store.listSchedules();
|
|
2150
|
+
if (!schedules.length) return;
|
|
2151
|
+
for (const s of schedules) scheduleNames.set(s.id, s.name);
|
|
2152
|
+
const now = new Date();
|
|
2153
|
+
// A schedule with no next firing would sit dormant for ever — a record
|
|
2154
|
+
// written by an older build, or one whose cadence was never scheduled.
|
|
2155
|
+
// Giving it one here costs a write once and never again.
|
|
2156
|
+
for (const s of schedules) {
|
|
2157
|
+
if (!s.enabled || s.pausedReason !== null || s.nextRunAt !== null) continue;
|
|
2158
|
+
s.nextRunAt = nextRunAtMs(s.cadence, now);
|
|
2159
|
+
await store.updateSchedule(s.id, { nextRunAt: s.nextRunAt }).catch(() => {});
|
|
2160
|
+
}
|
|
2161
|
+
const runs = await store.listRuns().catch(() => [] as RunMeta[]);
|
|
2162
|
+
const monthSpend = new Map<string, number>();
|
|
2163
|
+
const caps = new Map<string, number>();
|
|
2164
|
+
for (const projectId of new Set(schedules.map((s) => s.projectId))) {
|
|
2165
|
+
monthSpend.set(projectId, monthlyScheduledSpend(runs, projectId, now));
|
|
2166
|
+
caps.set(projectId, (await effectiveSettings(projectId)).scheduledMonthlyCapUsd);
|
|
2167
|
+
}
|
|
2168
|
+
const actions = decideTicks({
|
|
2169
|
+
schedules,
|
|
2170
|
+
now,
|
|
2171
|
+
busyProjectIds: new Set(activeByProject.keys()),
|
|
2172
|
+
monthSpend,
|
|
2173
|
+
capFor: (projectId) => caps.get(projectId) ?? DEFAULT_SCHEDULED_MONTHLY_CAP_USD,
|
|
2174
|
+
});
|
|
2175
|
+
for (const action of actions) {
|
|
2176
|
+
const s = schedules.find((x) => x.id === action.scheduleId);
|
|
2177
|
+
if (!s) continue;
|
|
2178
|
+
if (action.kind === 'pause') {
|
|
2179
|
+
await store.updateSchedule(s.id, { pausedReason: action.reason }).catch(() => {});
|
|
2180
|
+
const emit = scheduleNotice(s.projectId);
|
|
2181
|
+
emit('schedule_paused', {
|
|
2182
|
+
scheduleId: s.id, name: s.name, projectId: s.projectId, reason: action.reason,
|
|
2183
|
+
});
|
|
2184
|
+
continue;
|
|
2185
|
+
}
|
|
2186
|
+
const skipped = async (nextAt: number | null) => {
|
|
2187
|
+
await store.updateSchedule(s.id, {
|
|
2188
|
+
nextRunAt: nextAt, lastOutcome: 'skipped', lastNote: 'project busy',
|
|
2189
|
+
}).catch(() => {});
|
|
2190
|
+
const emit = scheduleNotice(s.projectId);
|
|
2191
|
+
emit('schedule_skipped', {
|
|
2192
|
+
scheduleId: s.id, name: s.name, projectId: s.projectId, reason: 'project busy',
|
|
2193
|
+
});
|
|
2194
|
+
};
|
|
2195
|
+
if (action.kind === 'skip') { await skipped(action.nextRunAt); continue; }
|
|
2196
|
+
const project = await store.getProject(s.projectId).catch(() => null);
|
|
2197
|
+
if (!project) {
|
|
2198
|
+
// The project was unlinked and this schedule outlived it. Nothing to
|
|
2199
|
+
// announce; move it along so the tick does not repeat every 30s.
|
|
2200
|
+
await store.updateSchedule(s.id, { nextRunAt: action.nextRunAt }).catch(() => {});
|
|
2201
|
+
continue;
|
|
2202
|
+
}
|
|
2203
|
+
// Reservation is the last step before dispatch — no awaits in between,
|
|
2204
|
+
// exactly as in POST /run. It can still fail: another dispatch may have
|
|
2205
|
+
// taken the project since this pass read the active map, and that is the
|
|
2206
|
+
// skip case, not a tick to drop on the floor.
|
|
2207
|
+
if (!reserveProject(s.projectId)) { await skipped(action.nextRunAt); continue; }
|
|
2208
|
+
startScheduledRun(s, project, 'schedule');
|
|
2209
|
+
await store.updateSchedule(s.id, { nextRunAt: action.nextRunAt }).catch(() => {});
|
|
2210
|
+
}
|
|
2211
|
+
} catch (err) {
|
|
2212
|
+
// A ticker that throws is a ticker that stops. Nothing here is worth the
|
|
2213
|
+
// schedules of every other project.
|
|
2214
|
+
console.error('schedule tick failed:', err);
|
|
2215
|
+
} finally {
|
|
2216
|
+
tickInFlight = false;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// Every half minute, and once shortly after startup so a firing missed while
|
|
2221
|
+
// the machine was off is caught up rather than waiting for the next slot. The
|
|
2222
|
+
// first pass is delayed: the orphan sweep and the store's own startup work
|
|
2223
|
+
// come first, and a schedule is never so urgent that ten seconds matter.
|
|
2224
|
+
setTimeout(() => void scheduleTick(), 10_000).unref();
|
|
2225
|
+
setInterval(() => void scheduleTick(), 30_000).unref();
|
|
2226
|
+
|
|
1836
2227
|
// ---------------------------------------------------------------------------
|
|
1837
2228
|
// Static files
|
|
1838
2229
|
// ---------------------------------------------------------------------------
|
|
@@ -1916,6 +2307,52 @@ async function browseRoots(): Promise<string[]> {
|
|
|
1916
2307
|
return roots;
|
|
1917
2308
|
}
|
|
1918
2309
|
|
|
2310
|
+
/**
|
|
2311
|
+
* A schedule's writable fields out of a request body, or the first thing wrong
|
|
2312
|
+
* with them. `partial` is the PUT: an absent field means "leave it alone",
|
|
2313
|
+
* where on the POST it means the schedule would be missing something it needs.
|
|
2314
|
+
*
|
|
2315
|
+
* The cadence goes through validateCadence rather than being trusted, because
|
|
2316
|
+
* the failure this guards against is silent — a cron expression nobody can
|
|
2317
|
+
* parse becomes a schedule that simply never fires, and looks healthy doing it.
|
|
2318
|
+
*/
|
|
2319
|
+
function parseScheduleBody(
|
|
2320
|
+
b: Record<string, unknown>, partial: boolean,
|
|
2321
|
+
): { error: string } | { fields: Partial<Schedule> } {
|
|
2322
|
+
const fields: Partial<Schedule> = {};
|
|
2323
|
+
for (const key of ['name', 'brief'] as const) {
|
|
2324
|
+
if (b[key] === undefined) {
|
|
2325
|
+
if (!partial) return { error: `${key} is required` };
|
|
2326
|
+
continue;
|
|
2327
|
+
}
|
|
2328
|
+
const v = typeof b[key] === 'string' ? (b[key] as string).trim() : '';
|
|
2329
|
+
if (!v) return { error: `${key} must not be empty` };
|
|
2330
|
+
fields[key] = v;
|
|
2331
|
+
}
|
|
2332
|
+
if (b.budgetUsd !== undefined) {
|
|
2333
|
+
const n = Number(b.budgetUsd);
|
|
2334
|
+
if (!Number.isFinite(n) || n <= 0) return { error: 'budgetUsd must be a positive number' };
|
|
2335
|
+
fields.budgetUsd = n;
|
|
2336
|
+
} else if (!partial) {
|
|
2337
|
+
return { error: 'budgetUsd is required' };
|
|
2338
|
+
}
|
|
2339
|
+
if (b.cadence !== undefined) {
|
|
2340
|
+
const parsed = validateCadence(b.cadence);
|
|
2341
|
+
if (!parsed.ok) return { error: parsed.error };
|
|
2342
|
+
fields.cadence = parsed.cadence;
|
|
2343
|
+
} else if (!partial) {
|
|
2344
|
+
return { error: 'cadence is required' };
|
|
2345
|
+
}
|
|
2346
|
+
for (const key of ['directorModel', 'workerModel'] as const) {
|
|
2347
|
+
if (typeof b[key] === 'string' && (b[key] as string).trim()) fields[key] = modelChoice(b[key]);
|
|
2348
|
+
}
|
|
2349
|
+
for (const key of ['directorProviderId', 'workerProviderId'] as const) {
|
|
2350
|
+
if (typeof b[key] === 'string' && (b[key] as string).trim()) fields[key] = (b[key] as string).trim();
|
|
2351
|
+
}
|
|
2352
|
+
if (typeof b.enabled === 'boolean') fields.enabled = b.enabled;
|
|
2353
|
+
return { fields };
|
|
2354
|
+
}
|
|
2355
|
+
|
|
1919
2356
|
function json(res: http.ServerResponse, code: number, body: unknown): void {
|
|
1920
2357
|
res.writeHead(code, { 'content-type': 'application/json' });
|
|
1921
2358
|
res.end(JSON.stringify(body));
|
|
@@ -1953,8 +2390,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1953
2390
|
const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
|
|
1954
2391
|
const prMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr$/);
|
|
1955
2392
|
const prStateMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr\/state$/);
|
|
2393
|
+
const runServicesMatch = url.pathname.match(/^\/runs\/([^/]+)\/services$/);
|
|
2394
|
+
const stopServiceMatch = url.pathname.match(/^\/runs\/([^/]+)\/services\/(\d{1,5})\/stop$/);
|
|
1956
2395
|
const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
|
|
1957
2396
|
const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
|
|
2397
|
+
const projectSchedulesMatch = url.pathname.match(/^\/projects\/([^/]+)\/schedules$/);
|
|
2398
|
+
const scheduleMatch = url.pathname.match(/^\/schedules\/([^/]+)$/);
|
|
2399
|
+
const scheduleActionMatch = url.pathname.match(/^\/schedules\/([^/]+)\/(pause|resume|run-now)$/);
|
|
1958
2400
|
|
|
1959
2401
|
try {
|
|
1960
2402
|
if (req.method === 'GET' && (url.pathname === '/'
|
|
@@ -2238,7 +2680,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2238
2680
|
} else if (req.method === 'POST' && url.pathname === '/run') {
|
|
2239
2681
|
const {
|
|
2240
2682
|
projectId, mission, budgetUsd, directorModel, workerModel, browserTools,
|
|
2241
|
-
directorProviderId, workerProviderId,
|
|
2683
|
+
directorProviderId, workerProviderId, allowDirty, startedBy,
|
|
2242
2684
|
} = await readBody(req);
|
|
2243
2685
|
if (typeof projectId !== 'string' || typeof mission !== 'string' || !mission.trim()) {
|
|
2244
2686
|
return json(res, 400, { error: 'projectId and mission are required' });
|
|
@@ -2246,6 +2688,29 @@ const server = http.createServer(async (req, res) => {
|
|
|
2246
2688
|
const project = await store.getProject(projectId);
|
|
2247
2689
|
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
2248
2690
|
await mkdir(project.folder, { recursive: true });
|
|
2691
|
+
// A mission on its own branch takes the working tree with it: `checkout
|
|
2692
|
+
// -b` carries uncommitted work onto the mission branch, and the closing
|
|
2693
|
+
// commit sweeps whatever is left into the mission's own commit, under
|
|
2694
|
+
// the mission's name. So a dirty checkout is refused here rather than
|
|
2695
|
+
// quietly absorbed — with an override, because it is the human's tree
|
|
2696
|
+
// and their call. Only this route refuses: a mission started from the
|
|
2697
|
+
// phone has nobody standing at the keyboard to answer, and gets the
|
|
2698
|
+
// warning in its transcript instead.
|
|
2699
|
+
{
|
|
2700
|
+
const s = await effectiveSettings(projectId);
|
|
2701
|
+
if (s.gitBranchPerMission && allowDirty !== true) {
|
|
2702
|
+
const info = await gitInfo(project.folder);
|
|
2703
|
+
if (info.repo && info.dirty) {
|
|
2704
|
+
const files = await dirtyPaths(project.folder);
|
|
2705
|
+
return json(res, 409, {
|
|
2706
|
+
error: `This checkout has uncommitted changes on ${info.branch ?? 'HEAD'}. `
|
|
2707
|
+
+ 'They would follow the mission onto its branch and be committed with its work. '
|
|
2708
|
+
+ 'Commit or stash them first, or start anyway.',
|
|
2709
|
+
code: 'dirty-checkout', branch: info.branch ?? null, files,
|
|
2710
|
+
});
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2249
2714
|
// Reservation is the last step before dispatch — no awaits in between.
|
|
2250
2715
|
if (!reserveProject(projectId)) {
|
|
2251
2716
|
return json(res, 409, { error: 'this project already has an active mission' });
|
|
@@ -2257,7 +2722,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2257
2722
|
{
|
|
2258
2723
|
director: typeof directorProviderId === 'string' ? directorProviderId : undefined,
|
|
2259
2724
|
worker: typeof workerProviderId === 'string' ? workerProviderId : undefined,
|
|
2260
|
-
}
|
|
2725
|
+
},
|
|
2726
|
+
// 'schedule' is deliberately not accepted here: a caller must not be
|
|
2727
|
+
// able to forge a scheduled start and charge the month's unattended
|
|
2728
|
+
// allowance for a run no schedule asked for.
|
|
2729
|
+
{ startedBy: startedBy === 'phone' || startedBy === 'mcp' ? startedBy : 'human' });
|
|
2261
2730
|
json(res, 200, { ok: true });
|
|
2262
2731
|
|
|
2263
2732
|
} else if (url.pathname === '/notify' && req.method === 'GET') {
|
|
@@ -2673,6 +3142,33 @@ const server = http.createServer(async (req, res) => {
|
|
|
2673
3142
|
broadcastChat(FLEET_CHAT_ID, 'chat_cleared', {});
|
|
2674
3143
|
json(res, 200, { ok: true });
|
|
2675
3144
|
|
|
3145
|
+
} else if (runServicesMatch && req.method === 'GET') {
|
|
3146
|
+
// What this run exposed, and what of it is still up. Asked after a run
|
|
3147
|
+
// ends as much as during it: a finished mission leaves no agent behind
|
|
3148
|
+
// to tidy, so the human needs somewhere to see the leftovers.
|
|
3149
|
+
const meta = await store.readMeta(runServicesMatch[1]).catch(() => null);
|
|
3150
|
+
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
3151
|
+
const list = await Promise.all((meta.services ?? []).map(async (s) => {
|
|
3152
|
+
const holder = await listeningPid(s.port);
|
|
3153
|
+
return {
|
|
3154
|
+
port: s.port, label: s.label, since: s.since, path: s.path,
|
|
3155
|
+
listening: holder !== null,
|
|
3156
|
+
// Only a process Foreman watched start is one it will offer to stop.
|
|
3157
|
+
ours: holder !== null && s.pid !== undefined && holder === s.pid,
|
|
3158
|
+
};
|
|
3159
|
+
}));
|
|
3160
|
+
json(res, 200, { runId: meta.id, status: meta.status, services: list });
|
|
3161
|
+
|
|
3162
|
+
} else if (stopServiceMatch && req.method === 'POST') {
|
|
3163
|
+
const meta = await store.readMeta(stopServiceMatch[1]).catch(() => null);
|
|
3164
|
+
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
3165
|
+
const port = Number(stopServiceMatch[2]);
|
|
3166
|
+
const svc = (meta.services ?? []).find((s) => s.port === port);
|
|
3167
|
+
if (!svc) return json(res, 404, { error: 'this run did not expose that port' });
|
|
3168
|
+
const r = await stopService(port, svc.pid);
|
|
3169
|
+
if (!r.ok) return json(res, 409, { error: r.reason });
|
|
3170
|
+
json(res, 200, { ok: true, how: r.how });
|
|
3171
|
+
|
|
2676
3172
|
} else if (prStateMatch && req.method === 'GET') {
|
|
2677
3173
|
// What became of the run's pull request. A final answer (merged,
|
|
2678
3174
|
// closed) is written to the run so it is not asked again; open is.
|
|
@@ -2695,9 +3191,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
2695
3191
|
if (!info.remote) return json(res, 409, { error: 'the repository has no origin remote to push to' });
|
|
2696
3192
|
const doc = await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null);
|
|
2697
3193
|
const gh = await ghReady();
|
|
3194
|
+
// Where the request can actually go, which is not always where the
|
|
3195
|
+
// mission was branched from — see resolvePrBase.
|
|
3196
|
+
const target = await resolvePrBase(meta.folder, meta.git.base);
|
|
2698
3197
|
json(res, 200, {
|
|
2699
|
-
...prDraft(meta, doc), branch: meta.git.branch, base:
|
|
2700
|
-
|
|
3198
|
+
...prDraft(meta, doc), branch: meta.git.branch, base: target.base, commits: meta.git.commits ?? null,
|
|
3199
|
+
branchedFrom: meta.git.base, baseFellBack: target.fellBack,
|
|
3200
|
+
remote: info.remote, compareUrl: compareUrl(info.remote, target.base, meta.git.branch),
|
|
2701
3201
|
gh, pr: meta.git.pr ?? null, onBranch: info.branch === meta.git.branch, dirty: Boolean(info.dirty),
|
|
2702
3202
|
});
|
|
2703
3203
|
|
|
@@ -2725,18 +3225,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
2725
3225
|
let url: string | undefined;
|
|
2726
3226
|
let method: 'gh' | 'compare' = 'compare';
|
|
2727
3227
|
let note: string | undefined;
|
|
3228
|
+
const target = await resolvePrBase(meta.folder, meta.git.base);
|
|
2728
3229
|
if (gh.present && gh.authed && /github\.com/.test(info.remote)) {
|
|
2729
|
-
const r = await createPullRequest(meta.folder, { base:
|
|
3230
|
+
const r = await createPullRequest(meta.folder, { base: target.base, branch: meta.git.branch, title: t, body: b });
|
|
2730
3231
|
if (r.url) { url = r.url; method = 'gh'; } else note = r.error;
|
|
2731
3232
|
}
|
|
2732
|
-
if (!url) url = compareUrl(info.remote,
|
|
3233
|
+
if (!url) url = compareUrl(info.remote, target.base, meta.git.branch) ?? undefined;
|
|
2733
3234
|
meta.git = { ...meta.git, pr: method === 'gh' ? url : meta.git.pr };
|
|
2734
3235
|
await store.writeMeta(meta).catch(() => {});
|
|
2735
3236
|
gitInfoCache.delete(meta.folder);
|
|
2736
3237
|
const text = method === 'gh'
|
|
2737
3238
|
? `Pushed ${meta.git.branch} and opened a pull request: ${url}`
|
|
2738
3239
|
: `Pushed ${meta.git.branch}.${note ? ` ${note}.` : ''} Finish the pull request in the browser: ${url ?? 'open the repository'}`;
|
|
2739
|
-
emit('pull_request', {
|
|
3240
|
+
emit('pull_request', {
|
|
3241
|
+
branch: meta.git.branch, base: target.base, url, method,
|
|
3242
|
+
text: target.fellBack
|
|
3243
|
+
? `${text} (targeting ${target.base}: this mission was branched from ${meta.git.base}, which is not on the remote)`
|
|
3244
|
+
: text,
|
|
3245
|
+
});
|
|
2740
3246
|
json(res, 200, { ok: true, url, method, pushed: true, note });
|
|
2741
3247
|
|
|
2742
3248
|
} else if (req.method === 'POST' && url.pathname === '/steer') {
|
|
@@ -2827,6 +3333,107 @@ const server = http.createServer(async (req, res) => {
|
|
|
2827
3333
|
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
2828
3334
|
json(res, 200, await readMemory(project.folder));
|
|
2829
3335
|
|
|
3336
|
+
} else if (req.method === 'GET' && url.pathname === '/schedules/preview') {
|
|
3337
|
+
// The picker's "next three runs". Deliberately the same function the
|
|
3338
|
+
// ticker fires from, so what the human is shown and what will actually
|
|
3339
|
+
// happen cannot drift apart.
|
|
3340
|
+
let cadence: unknown;
|
|
3341
|
+
try { cadence = JSON.parse(url.searchParams.get('cadence') ?? ''); }
|
|
3342
|
+
catch { return json(res, 400, { error: 'cadence must be a JSON object in the query string' }); }
|
|
3343
|
+
const parsed = validateCadence(cadence);
|
|
3344
|
+
if (!parsed.ok) return json(res, 400, { error: parsed.error });
|
|
3345
|
+
json(res, 200, { next: nextRuns(parsed.cadence, new Date(), 3).map((d) => d.getTime()) });
|
|
3346
|
+
|
|
3347
|
+
} else if (req.method === 'GET' && projectSchedulesMatch) {
|
|
3348
|
+
const projectId = projectSchedulesMatch[1];
|
|
3349
|
+
if (!await store.getProject(projectId)) return json(res, 404, { error: 'unknown project' });
|
|
3350
|
+
const [schedules, runs, settings] = await Promise.all([
|
|
3351
|
+
store.listSchedules(projectId), store.listRuns().catch(() => [] as RunMeta[]), effectiveSettings(projectId),
|
|
3352
|
+
]);
|
|
3353
|
+
json(res, 200, {
|
|
3354
|
+
schedules,
|
|
3355
|
+
monthSpendUsd: monthlyScheduledSpend(runs, projectId, new Date()),
|
|
3356
|
+
monthlyCapUsd: settings.scheduledMonthlyCapUsd,
|
|
3357
|
+
});
|
|
3358
|
+
|
|
3359
|
+
} else if (req.method === 'POST' && projectSchedulesMatch) {
|
|
3360
|
+
const projectId = projectSchedulesMatch[1];
|
|
3361
|
+
if (!await store.getProject(projectId)) return json(res, 404, { error: 'unknown project' });
|
|
3362
|
+
const parsed = parseScheduleBody(await readBody(req), false);
|
|
3363
|
+
if ('error' in parsed) return json(res, 400, { error: parsed.error });
|
|
3364
|
+
const f = parsed.fields;
|
|
3365
|
+
const schedule = await store.addSchedule({
|
|
3366
|
+
projectId,
|
|
3367
|
+
name: f.name!, brief: f.brief!, cadence: f.cadence!, budgetUsd: f.budgetUsd!,
|
|
3368
|
+
directorModel: f.directorModel, workerModel: f.workerModel,
|
|
3369
|
+
directorProviderId: f.directorProviderId, workerProviderId: f.workerProviderId,
|
|
3370
|
+
// Enabled unless the caller said otherwise: a schedule nobody switched
|
|
3371
|
+
// on is a form somebody filled in and forgot.
|
|
3372
|
+
enabled: f.enabled !== false,
|
|
3373
|
+
nextRunAt: f.enabled === false ? null : nextRunAtMs(f.cadence!, new Date()),
|
|
3374
|
+
consecutiveFailures: 0,
|
|
3375
|
+
pausedReason: null,
|
|
3376
|
+
});
|
|
3377
|
+
scheduleNames.set(schedule.id, schedule.name);
|
|
3378
|
+
json(res, 200, { schedule });
|
|
3379
|
+
|
|
3380
|
+
} else if (req.method === 'PUT' && scheduleMatch) {
|
|
3381
|
+
const existing = await store.getSchedule(scheduleMatch[1]);
|
|
3382
|
+
if (!existing) return json(res, 404, { error: 'unknown schedule' });
|
|
3383
|
+
const parsed = parseScheduleBody(await readBody(req), true);
|
|
3384
|
+
if ('error' in parsed) return json(res, 400, { error: parsed.error });
|
|
3385
|
+
const f = parsed.fields;
|
|
3386
|
+
const enabled = f.enabled ?? existing.enabled;
|
|
3387
|
+
const patch: Partial<Schedule> = { ...f };
|
|
3388
|
+
// A changed cadence is a changed answer to "when next?", counted from
|
|
3389
|
+
// now: keeping the old firing time would mean the schedule the human
|
|
3390
|
+
// just moved fires one more time on the schedule they moved it off.
|
|
3391
|
+
if (f.cadence || f.enabled !== undefined) {
|
|
3392
|
+
patch.nextRunAt = enabled && existing.pausedReason === null
|
|
3393
|
+
? nextRunAtMs(f.cadence ?? existing.cadence, new Date())
|
|
3394
|
+
: null;
|
|
3395
|
+
}
|
|
3396
|
+
const schedule = await store.updateSchedule(existing.id, patch);
|
|
3397
|
+
if (schedule) scheduleNames.set(schedule.id, schedule.name);
|
|
3398
|
+
json(res, 200, { schedule });
|
|
3399
|
+
|
|
3400
|
+
} else if (req.method === 'DELETE' && scheduleMatch) {
|
|
3401
|
+
const removed = await store.removeSchedule(scheduleMatch[1]);
|
|
3402
|
+
json(res, removed ? 200 : 404, removed ? { ok: true } : { error: 'unknown schedule' });
|
|
3403
|
+
|
|
3404
|
+
} else if (req.method === 'POST' && scheduleActionMatch) {
|
|
3405
|
+
const [, scheduleId, action] = scheduleActionMatch;
|
|
3406
|
+
const s = await store.getSchedule(scheduleId);
|
|
3407
|
+
if (!s) return json(res, 404, { error: 'unknown schedule' });
|
|
3408
|
+
if (action === 'pause') {
|
|
3409
|
+
json(res, 200, { schedule: await store.updateSchedule(s.id, { pausedReason: 'human', nextRunAt: null }) });
|
|
3410
|
+
|
|
3411
|
+
} else if (action === 'resume') {
|
|
3412
|
+
// Resuming is a dashboard act and has no remote equivalent on purpose:
|
|
3413
|
+
// whatever paused this — the human, the month's ceiling, two failures
|
|
3414
|
+
// in a row — is a thing to look at before it runs unattended again.
|
|
3415
|
+
json(res, 200, {
|
|
3416
|
+
schedule: await store.updateSchedule(s.id, {
|
|
3417
|
+
pausedReason: null,
|
|
3418
|
+
consecutiveFailures: 0,
|
|
3419
|
+
nextRunAt: s.enabled ? nextRunAtMs(s.cadence, new Date()) : null,
|
|
3420
|
+
}),
|
|
3421
|
+
});
|
|
3422
|
+
|
|
3423
|
+
} else {
|
|
3424
|
+
const project = await store.getProject(s.projectId);
|
|
3425
|
+
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
3426
|
+
// Allowed even at the monthly ceiling, and not counted against it
|
|
3427
|
+
// beforehand: the ceiling governs UNATTENDED spending, and a human
|
|
3428
|
+
// pressing a button is by definition not that. The run still carries
|
|
3429
|
+
// the scheduleId, so what it costs does count towards the month.
|
|
3430
|
+
if (!reserveProject(s.projectId)) {
|
|
3431
|
+
return json(res, 409, { error: 'this project already has an active mission' });
|
|
3432
|
+
}
|
|
3433
|
+
startScheduledRun(s, project, 'human');
|
|
3434
|
+
json(res, 200, { ok: true });
|
|
3435
|
+
}
|
|
3436
|
+
|
|
2830
3437
|
} else if (req.method === 'GET' && url.pathname === '/missiondoc') {
|
|
2831
3438
|
const runId = url.searchParams.get('run');
|
|
2832
3439
|
if (!runId) return json(res, 400, { error: 'run parameter is required' });
|
|
@@ -2958,7 +3565,7 @@ void closeOrphanedChatTurns().then((ids) => {
|
|
|
2958
3565
|
// Services declared by earlier runs are still worth proxying if their
|
|
2959
3566
|
// processes outlived the run; the registry is rebuilt from what was saved.
|
|
2960
3567
|
void store.listRuns().then((runs) => {
|
|
2961
|
-
for (const r of runs) for (const s of (r as { services?: Array<{ port: number; label: string }> }).services ?? []) services.register(r.id, s.port, s.label);
|
|
3568
|
+
for (const r of runs) for (const s of (r as { services?: Array<{ port: number; label: string; pid?: number }> }).services ?? []) services.register(r.id, s.port, s.label, s.pid);
|
|
2962
3569
|
}).catch(() => {});
|
|
2963
3570
|
|
|
2964
3571
|
if (BIND === 'all') {
|