@amenophis1er/foreman 0.1.16 → 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 +51 -2
- package/src/gitwork.ts +68 -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/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 +540 -7
- package/src/store.test.ts +81 -1
- package/src/store.ts +117 -3
- package/src/types.ts +63 -0
- package/ui/dist/assets/index-Bcc4KMtO.js +68 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-DOVnExqF.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, dirtyPaths, ensureMissionBranch, ghReady, gitInfo, resolvePrBase, 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
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
|
}
|
|
@@ -1613,6 +1754,7 @@ async function driveRun(
|
|
|
1613
1754
|
} finally {
|
|
1614
1755
|
// However the run ended, it no longer needs its gateways.
|
|
1615
1756
|
releaseGateways(meta.id);
|
|
1757
|
+
drivingRuns.delete(meta.id);
|
|
1616
1758
|
if (activeByProject.get(projectId) === run) activeByProject.delete(projectId);
|
|
1617
1759
|
// Freeze the record: the mission doc and the deck as they stand at this
|
|
1618
1760
|
// moment, beside the run's meta and log. Done first, before the branch
|
|
@@ -1651,8 +1793,12 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1651
1793
|
directorProviderId?: string; workerProviderId?: string;
|
|
1652
1794
|
/** In a repository, each mission runs on a branch of its own (default on). */
|
|
1653
1795
|
gitBranchPerMission: boolean;
|
|
1796
|
+
/** A mission in a worktree may use its parent repository without asking (default on). */
|
|
1797
|
+
allowWorktreeParent: boolean;
|
|
1654
1798
|
/** Percent of the cap at which the director is told to start verifying (default 80). */
|
|
1655
1799
|
budgetWarnAt: number;
|
|
1800
|
+
/** Ceiling on what this project's SCHEDULED runs may cost in one calendar month. */
|
|
1801
|
+
scheduledMonthlyCapUsd: number;
|
|
1656
1802
|
}> {
|
|
1657
1803
|
const s = await store.readSettings()
|
|
1658
1804
|
.catch(() => ({ global: {}, projects: {} as Record<string, object> }));
|
|
@@ -1674,10 +1820,17 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1674
1820
|
directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
|
|
1675
1821
|
workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
|
|
1676
1822
|
gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
|
|
1823
|
+
allowWorktreeParent: (p.allowWorktreeParent ?? g.allowWorktreeParent) !== false,
|
|
1677
1824
|
budgetWarnAt: (() => {
|
|
1678
1825
|
const raw = Number(p.budgetWarnAt ?? g.budgetWarnAt);
|
|
1679
1826
|
return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 60;
|
|
1680
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;
|
|
1833
|
+
})(),
|
|
1681
1834
|
};
|
|
1682
1835
|
}
|
|
1683
1836
|
|
|
@@ -1697,17 +1850,77 @@ async function gitInfoCached(folder: string): Promise<GitInfo> {
|
|
|
1697
1850
|
return value;
|
|
1698
1851
|
}
|
|
1699
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
|
+
|
|
1700
1900
|
async function startRun(
|
|
1701
1901
|
projectId: string, folder: string, mission: string, budgetUsd: number,
|
|
1702
1902
|
directorModel: ModelChoice, workerModel: ModelChoice, browserTools: boolean,
|
|
1703
1903
|
provider: ProviderRef,
|
|
1704
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
|
+
} = {},
|
|
1705
1915
|
): Promise<void> {
|
|
1706
1916
|
const settings = await effectiveSettings(projectId);
|
|
1917
|
+
if (origin.scheduleId && origin.scheduleName) scheduleNames.set(origin.scheduleId, origin.scheduleName);
|
|
1707
1918
|
const meta: RunMeta = {
|
|
1708
1919
|
id: newRunId(),
|
|
1709
1920
|
projectId,
|
|
1710
1921
|
folder, mission, budgetUsd,
|
|
1922
|
+
startedBy: origin.startedBy ?? 'human',
|
|
1923
|
+
...(origin.scheduleId ? { scheduleId: origin.scheduleId } : {}),
|
|
1711
1924
|
// An explicit composer choice wins; "Default" inherits from Settings.
|
|
1712
1925
|
directorModel: directorModel ?? settings.directorModel,
|
|
1713
1926
|
workerModel: workerModel ?? settings.workerModel,
|
|
@@ -1734,6 +1947,23 @@ async function startRun(
|
|
|
1734
1947
|
console.error(`failed to create run for project ${projectId}:`, err);
|
|
1735
1948
|
return;
|
|
1736
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
|
+
}
|
|
1737
1967
|
await consumeProposal(projectId, meta.id, mission).catch(() => {});
|
|
1738
1968
|
// The folder's MISSION.md belongs to whichever run wrote it. Parked into
|
|
1739
1969
|
// that run's record (when it lacks one) and cleared, so this run's status
|
|
@@ -1767,6 +1997,7 @@ async function startRun(
|
|
|
1767
1997
|
}
|
|
1768
1998
|
}
|
|
1769
1999
|
}
|
|
2000
|
+
await grantWorktreeParent(meta, settings.allowWorktreeParent, makeEmitter(meta.id, projectId));
|
|
1770
2001
|
await driveRun(projectId, meta);
|
|
1771
2002
|
}
|
|
1772
2003
|
|
|
@@ -1827,6 +2058,7 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1827
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.` });
|
|
1828
2059
|
gitInfoCache.delete(meta.folder);
|
|
1829
2060
|
}
|
|
2061
|
+
await grantWorktreeParent(meta, (await effectiveSettings(projectId)).allowWorktreeParent, makeEmitter(meta.id, projectId));
|
|
1830
2062
|
// The director resumes from MISSION.md. If another mission ran here since,
|
|
1831
2063
|
// the folder's copy is that mission's; this run's own goes back first.
|
|
1832
2064
|
const restored = await restoreMissionDoc(store.runDirectory(meta.id), meta.folder).catch(() => 'none' as const);
|
|
@@ -1845,6 +2077,153 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1845
2077
|
});
|
|
1846
2078
|
}
|
|
1847
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
|
+
|
|
1848
2227
|
// ---------------------------------------------------------------------------
|
|
1849
2228
|
// Static files
|
|
1850
2229
|
// ---------------------------------------------------------------------------
|
|
@@ -1928,6 +2307,52 @@ async function browseRoots(): Promise<string[]> {
|
|
|
1928
2307
|
return roots;
|
|
1929
2308
|
}
|
|
1930
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
|
+
|
|
1931
2356
|
function json(res: http.ServerResponse, code: number, body: unknown): void {
|
|
1932
2357
|
res.writeHead(code, { 'content-type': 'application/json' });
|
|
1933
2358
|
res.end(JSON.stringify(body));
|
|
@@ -1969,6 +2394,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1969
2394
|
const stopServiceMatch = url.pathname.match(/^\/runs\/([^/]+)\/services\/(\d{1,5})\/stop$/);
|
|
1970
2395
|
const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
|
|
1971
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)$/);
|
|
1972
2400
|
|
|
1973
2401
|
try {
|
|
1974
2402
|
if (req.method === 'GET' && (url.pathname === '/'
|
|
@@ -2252,7 +2680,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2252
2680
|
} else if (req.method === 'POST' && url.pathname === '/run') {
|
|
2253
2681
|
const {
|
|
2254
2682
|
projectId, mission, budgetUsd, directorModel, workerModel, browserTools,
|
|
2255
|
-
directorProviderId, workerProviderId, allowDirty,
|
|
2683
|
+
directorProviderId, workerProviderId, allowDirty, startedBy,
|
|
2256
2684
|
} = await readBody(req);
|
|
2257
2685
|
if (typeof projectId !== 'string' || typeof mission !== 'string' || !mission.trim()) {
|
|
2258
2686
|
return json(res, 400, { error: 'projectId and mission are required' });
|
|
@@ -2294,7 +2722,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2294
2722
|
{
|
|
2295
2723
|
director: typeof directorProviderId === 'string' ? directorProviderId : undefined,
|
|
2296
2724
|
worker: typeof workerProviderId === 'string' ? workerProviderId : undefined,
|
|
2297
|
-
}
|
|
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' });
|
|
2298
2730
|
json(res, 200, { ok: true });
|
|
2299
2731
|
|
|
2300
2732
|
} else if (url.pathname === '/notify' && req.method === 'GET') {
|
|
@@ -2901,6 +3333,107 @@ const server = http.createServer(async (req, res) => {
|
|
|
2901
3333
|
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
2902
3334
|
json(res, 200, await readMemory(project.folder));
|
|
2903
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
|
+
|
|
2904
3437
|
} else if (req.method === 'GET' && url.pathname === '/missiondoc') {
|
|
2905
3438
|
const runId = url.searchParams.get('run');
|
|
2906
3439
|
if (!runId) return json(res, 400, { error: 'run parameter is required' });
|