@amenophis1er/foreman 0.1.16 → 0.1.18

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/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,16 @@ 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';
85
+ import { crewPresetsFrom, type CrewPreset } from './crew.js';
86
+ import { frozenCrewFor, reviewReportLines, reviewedByNames } from './run-crew.js';
77
87
  import { detectTailscale, tailnetUrl } from './tailscale.js';
78
88
  import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
79
89
  import { ServiceRegistry, listeningPid, portOpen, servicesHandler, stopService } from './services.js';
80
90
  import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
81
91
  import {
82
92
  DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
83
- type FleetHost, type FleetProjectView,
93
+ type FleetHost, type FleetProjectView, type FleetScheduleView,
84
94
  } from './fleet-planner.js';
85
95
  import { escapeHtml as escTg } from './notify.js';
86
96
  import { RunStore, newRunId } from './store.js';
@@ -107,8 +117,12 @@ import {
107
117
  dirHasCredentials, detectAuth, hasKeychainCredentials, readAccount, type AuthMode,
108
118
  } from './preflight.js';
109
119
  import { combineBasis, costBasisOf } from './types.js';
120
+ import { describeCadence, nextRunAt, nextRuns, validateCadence, type Cadence } from './schedule.js';
121
+ import {
122
+ DEFAULT_SCHEDULED_MONTHLY_CAP_USD, afterRunOutcome, decideTicks, monthlyScheduledSpend,
123
+ } from './schedule-guards.js';
110
124
  import type {
111
- ChatMeta, CostBasis, ForemanEvent, MissionProposal, ModelChoice, Project, ProviderRef, RunMeta, ToolPolicy,
125
+ ChatMeta, CostBasis, ForemanEvent, MissionProposal, ModelChoice, Project, ProviderRef, RunMeta, Schedule, ToolPolicy,
112
126
  } from './types.js';
113
127
 
114
128
  /**
@@ -535,6 +549,24 @@ function activeRuns(): MissionRun[] {
535
549
  return [...activeByProject.values()].filter((r): r is MissionRun => Boolean(r));
536
550
  }
537
551
 
552
+ /**
553
+ * The metadata of every run this process is currently driving, by run id.
554
+ *
555
+ * The emitter has to answer "was this run started by a schedule?" at the
556
+ * instant it emits, and a run's `run_finished` can be emitted before the
557
+ * MissionRun object exists at all (a provider that will not resolve fails the
558
+ * run in driveRun's first few lines). Reading meta.json back there would be
559
+ * asynchronous, and the notification envelope has already gone out by then.
560
+ */
561
+ const drivingRuns = new Map<string, RunMeta>();
562
+
563
+ /**
564
+ * Schedule names by schedule id, so a notification can say which standing
565
+ * instruction started a run without a disk read on the emitter's path. Filled
566
+ * wherever schedules are loaded anyway — every ticker pass, and each dispatch.
567
+ */
568
+ const scheduleNames = new Map<string, string>();
569
+
538
570
  /**
539
571
  * What happened in the fleet lately, in one line each, for the front desk.
540
572
  *
@@ -610,6 +642,21 @@ function makeEmitter(runId: string, projectId: string) {
610
642
  });
611
643
  }
612
644
  }
645
+ // The end of a scheduled run is also the schedule's news: the phone should
646
+ // say nobody pressed start, and the schedule's own failure bookkeeping has
647
+ // to move on. This is where the server learns a run ended — the
648
+ // orchestrator knows nothing about schedules and should not.
649
+ if (event === 'run_finished') {
650
+ const meta = drivingRuns.get(runId) ?? activeRuns().find((r) => r.meta.id === runId)?.meta;
651
+ if (meta?.scheduleId) {
652
+ d.scheduled = true;
653
+ const name = scheduleNames.get(meta.scheduleId);
654
+ if (name) d.scheduleName = name;
655
+ void recordScheduleOutcome(meta.scheduleId, meta).catch((err) => {
656
+ console.error(`failed to record outcome of schedule ${meta.scheduleId}:`, err);
657
+ });
658
+ }
659
+ }
613
660
  if (!projectsCache.has(projectId)) {
614
661
  void store.getProject(projectId).then((p) => { if (p) projectsCache.set(projectId, { name: p.name }); });
615
662
  }
@@ -617,6 +664,24 @@ function makeEmitter(runId: string, projectId: string) {
617
664
  };
618
665
  }
619
666
 
667
+ /**
668
+ * An announcement about a schedule rather than about a run.
669
+ *
670
+ * A skipped or auto-paused schedule has no run to hang a line off — that is
671
+ * exactly what happened: nothing started. So the frame carries `runId: null`,
672
+ * goes nowhere near a run's event log, and otherwise travels the same three
673
+ * roads every other event does (open tabs, the front desk's news, the phone).
674
+ */
675
+ function scheduleNotice(projectId: string) {
676
+ return (event: string, data: unknown): void => {
677
+ const frame = `event: ${event}\ndata: ${JSON.stringify({ runId: null, projectId, data })}\n\n`;
678
+ for (const res of sseClients) res.write(frame);
679
+ const d = (data ?? {}) as Record<string, unknown>;
680
+ noteFleetEvent(projectId, event, d);
681
+ notifyHub.handle({ event, runId: null, projectId, data: d, ts: Date.now() });
682
+ };
683
+ }
684
+
620
685
  /**
621
686
  * Everything an agent needs to authenticate, including starting the provider's
622
687
  * gateway if it has one.
@@ -639,6 +704,101 @@ async function agentEnvFor(
639
704
  return providerEnv(resolved, ledgerKey ? `${url}/run/${encodeURIComponent(ledgerKey)}` : url);
640
705
  }
641
706
 
707
+ /**
708
+ * An agent env per crew preset that named a provider of its own, keyed by
709
+ * preset id — the third kind of env a run can need, after its two roles. A
710
+ * reviewer pinned to another provider is a real request: the point of a second
711
+ * opinion is partly that it comes from a different model, and a model carries
712
+ * its provider.
713
+ *
714
+ * Only presets that name a `providerId` get an entry, and only when that
715
+ * provider resolves cleanly. Anything else — no id, an id nothing resolves,
716
+ * a resolution `providerProblem` calls unusable — is LEFT OUT rather than
717
+ * reported, because a missing entry means the reviewer runs on the worker env
718
+ * and a thrown error means the run dies. A provider deleted between the moment
719
+ * a human chose the crew and the moment the director asks for the review must
720
+ * degrade to the worker's provider: the alternative is a mission stranded at
721
+ * the one step that would let it be recorded as done, over a credential its
722
+ * reviewer never strictly needed.
723
+ *
724
+ * Each preset's env carries the preset's own model for the same reason the two
725
+ * roles do — the SDK's aliases have to resolve to something this provider's
726
+ * gateway serves — and shares the run's ledger key, since the reviewer's
727
+ * tokens are accounted to the worker role. See the `env` comment in
728
+ * orchestrator.ts's requestReviewTool for why that approximation is chosen.
729
+ */
730
+ /**
731
+ * Each crew preset's own cost basis, by preset id — for the presets that name
732
+ * a provider Foreman can resolve, which are the ones that will run somewhere
733
+ * other than the worker role.
734
+ *
735
+ * The run needs this to count a review's spend honestly. A reviewer pinned to
736
+ * a paid provider beside workers on a free gateway had its dollars discarded,
737
+ * because cost was attributed to the worker role and a gateway role's figures
738
+ * are dropped by design. Whether money is real is the provider's answer, not
739
+ * the role's.
740
+ */
741
+ async function crewBasesFor(meta: RunMeta): Promise<Record<string, { basis: CostBasis; native: boolean }> | undefined> {
742
+ const wanted = (meta.crew ?? []).filter((p) => p.providerId);
743
+ if (!wanted.length) return undefined;
744
+ const out: Record<string, { basis: CostBasis; native: boolean }> = {};
745
+ const seen = new Map<string, { basis: CostBasis; native: boolean } | null>();
746
+ for (const preset of wanted) {
747
+ const id = preset.providerId as string;
748
+ if (!seen.has(id)) {
749
+ const ref = providerForRole(meta, id);
750
+ const resolved = 'id' in ref && ref.id === id
751
+ ? await resolveProvider(ref, store.root).catch(() => null)
752
+ : null;
753
+ seen.set(id, resolved && !providerProblem(resolved)
754
+ ? {
755
+ basis: (await roleCost(withRoleModel(resolved, preset.model ?? meta.workerModel), preset.model ?? meta.workerModel)).basis,
756
+ // Whether the SDK's dollar figure for this preset IS the bill, or
757
+ // whether it is a gateway that reports through the run's ledger.
758
+ native: resolved.wire === 'anthropic-native',
759
+ }
760
+ : null);
761
+ }
762
+ const cost = seen.get(id);
763
+ if (cost) out[preset.id] = cost;
764
+ }
765
+ return Object.keys(out).length ? out : undefined;
766
+ }
767
+
768
+ async function crewEnvsFor(
769
+ meta: RunMeta, ledgerKey: string,
770
+ ): Promise<Record<string, ReturnType<typeof providerEnv>> | undefined> {
771
+ const wanted = (meta.crew ?? []).filter((p) => p.providerId);
772
+ if (!wanted.length) return undefined;
773
+ // One resolution per distinct provider, not per preset: two reviewers on the
774
+ // same endpoint are one credential and one gateway.
775
+ const bases = new Map<string, ResolvedProvider | null>();
776
+ const out: Record<string, ReturnType<typeof providerEnv>> = {};
777
+ for (const preset of wanted) {
778
+ const id = preset.providerId as string;
779
+ if (!bases.has(id)) {
780
+ const ref = providerForRole(meta, id);
781
+ // providerForRole answers with the RUN's provider when the id resolves to
782
+ // nothing it knows. That is the right answer for a role, which must run
783
+ // somewhere; here it would quietly pin the preset to a provider nobody
784
+ // asked for, so it counts as "no entry" instead.
785
+ const resolved = 'id' in ref && ref.id === id
786
+ ? await resolveProvider(ref, store.root).catch(() => null)
787
+ : null;
788
+ bases.set(id, resolved && !providerProblem(resolved) ? resolved : null);
789
+ }
790
+ const base = bases.get(id);
791
+ if (!base) continue;
792
+ const withModel = withRoleModel(base, preset.model ?? meta.workerModel);
793
+ if (providerProblem(withModel)) continue;
794
+ // A gateway that will not start is the same kind of nothing: the reviewer
795
+ // falls back rather than the run failing.
796
+ const env = await agentEnvFor(withModel, meta.id, ledgerKey).catch(() => null);
797
+ if (env) out[preset.id] = env;
798
+ }
799
+ return Object.keys(out).length ? out : undefined;
800
+ }
801
+
642
802
  /**
643
803
  * The ledger bucket for this attempt at a run.
644
804
  *
@@ -805,12 +965,24 @@ notifyHub.onAnswer((a) => {
805
965
  // brief, its budget, its models and its browser judgement.
806
966
  void startRun(a.projectId, project.folder, prop.mission, prop.budgetUsd,
807
967
  modelChoice(prop.directorModel), modelChoice(prop.workerModel), prop.browser === true,
808
- providerOf(project), { director: prop.directorProviderId, worker: prop.workerProviderId });
968
+ providerOf(project), { director: prop.directorProviderId, worker: prop.workerProviderId },
969
+ { startedBy: 'phone' });
809
970
  void notifyHub.say(`Started <b>${escTg(project.name)}</b> as proposed, cap $${prop.budgetUsd}.`);
810
971
  })();
811
972
  }
812
973
  });
813
974
 
975
+ /**
976
+ * A gap as a reader would say it: "in 15 h", "3 days ago". Used where a
977
+ * timestamp would make someone do arithmetic on their phone.
978
+ */
979
+ function relativeTime(ms: number): string {
980
+ const s = Math.round(ms / 1000);
981
+ const a = Math.abs(s);
982
+ 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`;
983
+ return s >= 0 ? `in ${span}` : `${span} ago`;
984
+ }
985
+
814
986
  /** The project the phone last planned with; plain text continues it. */
815
987
  let lastPhonePlanning: string | null = null;
816
988
 
@@ -959,6 +1131,41 @@ const fleetHost: FleetHost = {
959
1131
  return out;
960
1132
  },
961
1133
 
1134
+ /**
1135
+ * The standing schedules, with what they have already cost this month
1136
+ * against their project's ceiling — the number that decides whether the next
1137
+ * unattended firing happens at all, and the one nobody can see from a phone.
1138
+ *
1139
+ * Reading only, like every other verb here. An unknown reference is an
1140
+ * error rather than an empty list: "no schedules" and "you named a project
1141
+ * that does not exist" are different answers, and a typo must not read as
1142
+ * reassurance.
1143
+ */
1144
+ async listSchedules(ref?: string): Promise<FleetScheduleView[]> {
1145
+ const project = ref ? await findProject(ref) : null;
1146
+ if (ref && !project) throw new Error(await noSuchProject(ref));
1147
+ const schedules = await store.listSchedules(project?.id);
1148
+ if (!schedules.length) return [];
1149
+ const runs = await store.listRuns().catch(() => [] as RunMeta[]);
1150
+ const now = new Date();
1151
+ const names = new Map((await store.listProjects()).map((p) => [p.id, p.name]));
1152
+ // One settings read and one spend count per project, not per schedule:
1153
+ // a project with six nightly schedules asks the same two questions six
1154
+ // times, and the answers cannot differ between them.
1155
+ const money = new Map<string, { monthSpendUsd: number; monthlyCapUsd: number }>();
1156
+ for (const projectId of new Set(schedules.map((s) => s.projectId))) {
1157
+ money.set(projectId, {
1158
+ monthSpendUsd: monthlyScheduledSpend(runs, projectId, now),
1159
+ monthlyCapUsd: (await effectiveSettings(projectId)).scheduledMonthlyCapUsd,
1160
+ });
1161
+ }
1162
+ return schedules.map((schedule) => ({
1163
+ projectName: names.get(schedule.projectId) ?? schedule.projectId,
1164
+ schedule,
1165
+ ...money.get(schedule.projectId)!,
1166
+ }));
1167
+ },
1168
+
962
1169
  async projectDetail(ref) {
963
1170
  const project = await findProject(ref);
964
1171
  if (!project) return noSuchProject(ref);
@@ -1008,6 +1215,10 @@ const fleetHost: FleetHost = {
1008
1215
  ];
1009
1216
  if (words.error) lines.push(`it stopped with: ${clipText(words.error, 400)}`);
1010
1217
  if (last.workers.length) lines.push(`crew: ${last.workers.map((w) => `${w.id} ${w.status}`).join(', ')}`);
1218
+ // Verdicts read out here and nowhere else on the phone: which reviewers
1219
+ // ran is a fact about the run, while which presets exist is configuration,
1220
+ // and configuration is edited on the dashboard.
1221
+ lines.push(...reviewReportLines(last.crew, last.reviews));
1011
1222
  if (words.result) lines.push(`director's closing report:\n${clipText(words.result, 2500)}`);
1012
1223
  else if (words.last) lines.push(`director's last words:\n${clipText(words.last, 2500)}`);
1013
1224
  return lines.join('\n');
@@ -1246,9 +1457,35 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
1246
1457
  if (!reserveProject(project.id)) return say(`<b>${escTg(project.name)}</b> already has an active mission.`);
1247
1458
  void startRun(project.id, project.folder, cmd.text, project.defaultBudgetUsd,
1248
1459
  modelChoice(undefined), modelChoice(undefined), /screenshot|browser|render|console/i.test(cmd.text),
1249
- providerOf(project));
1460
+ providerOf(project), {}, { startedBy: 'phone' });
1250
1461
  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
1462
  }
1463
+ case 'schedules': {
1464
+ // Reading only. A schedule is standing configuration — it decides
1465
+ // what a machine does while nobody is watching — and remote surfaces
1466
+ // never grant standing changes, so there is no create, edit, pause,
1467
+ // resume or run-now here. The reply says so rather than leaving
1468
+ // someone to discover it by trying.
1469
+ const project = cmd.project ? await findProject(cmd.project) : null;
1470
+ if (cmd.project && !project) return say(`No project called <b>${escTg(cmd.project)}</b>. /projects lists them.`);
1471
+ const schedules = await store.listSchedules(project?.id);
1472
+ const footer = 'Schedules are read-only from here — create, edit, pause and resume live in the dashboard.';
1473
+ if (!schedules.length) {
1474
+ return say(`No schedules${project ? ` on <b>${escTg(project.name)}</b>` : ''} yet.\n${footer}`);
1475
+ }
1476
+ const names = new Map((await store.listProjects()).map((p) => [p.id, p.name]));
1477
+ const lines = schedules.map((s) => {
1478
+ const state = s.pausedReason
1479
+ ? `paused (${s.pausedReason === 'monthly-cap' ? 'monthly cap' : s.pausedReason})`
1480
+ : s.enabled ? 'enabled' : 'disabled';
1481
+ const next = s.pausedReason || !s.enabled || s.nextRunAt === null
1482
+ ? 'no next run'
1483
+ : `next ${relativeTime(s.nextRunAt - Date.now())}`;
1484
+ const where = project ? '' : ` · ${escTg(names.get(s.projectId) ?? s.projectId)}`;
1485
+ return `• <b>${escTg(s.name)}</b>${where}\n ${escTg(describeCadence(s.cadence))} · ${next} · ${state}`;
1486
+ });
1487
+ return say(`<b>Schedules${project ? ` · ${escTg(project.name)}` : ''}</b>\n${lines.join('\n')}\n\n${footer}`);
1488
+ }
1252
1489
  case 'stop': {
1253
1490
  const project = cmd.project ? await findProject(cmd.project) : (lastPhonePlanning ? await store.getProject(lastPhonePlanning) : null);
1254
1491
  const abort = project && chatAborts.get(project.id);
@@ -1451,6 +1688,9 @@ async function driveRun(
1451
1688
  changes?: { directorChanged: boolean; workerChanged: boolean },
1452
1689
  ): Promise<void> {
1453
1690
  const emit = makeEmitter(meta.id, projectId);
1691
+ // Registered before anything can fail, so even a run that dies on its
1692
+ // provider still tells the emitter which schedule it belonged to.
1693
+ drivingRuns.set(meta.id, meta);
1454
1694
  // One resolution per run, from the provider frozen into the run's metadata.
1455
1695
  // A run that cannot resolve a credential must not start: dispatching anyway
1456
1696
  // would fall back to whatever the environment happens to hold.
@@ -1462,6 +1702,7 @@ async function driveRun(
1462
1702
  await store.writeMeta(meta).catch(() => {});
1463
1703
  emit('run_error', { error: `provider unavailable — ${problem}` });
1464
1704
  emit('run_finished', { status: 'error', costUsd: meta.costUsd });
1705
+ drivingRuns.delete(meta.id);
1465
1706
  activeByProject.delete(projectId);
1466
1707
  return;
1467
1708
  }
@@ -1491,8 +1732,8 @@ async function driveRun(
1491
1732
  let agentEnv;
1492
1733
  let roleBasis = resolved.costBasis;
1493
1734
  let prices: { director?: ModelPrice; worker?: ModelPrice } = {};
1494
- let roleBases: { director: CostBasis; worker: CostBasis } | undefined;
1495
- let gatewayRoles = { director: false, worker: false };
1735
+ let roleBases: { director: CostBasis; worker: CostBasis; crew?: Record<string, { basis: CostBasis; native: boolean }> } | undefined;
1736
+ let gatewayRoles: { director: boolean; worker: boolean; crew?: boolean } = { director: false, worker: false };
1496
1737
  try {
1497
1738
  // Resolved per role. Where both roles share a provider this resolves once
1498
1739
  // and starts one gateway; where they differ, the supervisor already runs a
@@ -1522,11 +1763,15 @@ async function driveRun(
1522
1763
  worker: directorProvider === workerProvider
1523
1764
  ? await agentEnvFor(directorProvider, meta.id, key)
1524
1765
  : await agentEnvFor(workerProvider, meta.id, key),
1766
+ crew: await crewEnvsFor(meta, key),
1525
1767
  };
1526
1768
  // Only roles that actually go through a gateway are counted there; a
1527
1769
  // native role's tokens arrive on the SDK's own result message, and adding
1528
1770
  // both would double every one of them.
1529
1771
  gatewayRoles = {
1772
+ // A crew preset on a gateway sends its tokens to the same ledger, so
1773
+ // polling has to run even when both ordinary roles are native.
1774
+ crew: Object.values(await crewBasesFor(meta) ?? {}).some((c) => !c.native),
1530
1775
  director: directorProvider.wire !== 'anthropic-native',
1531
1776
  worker: workerProvider.wire !== 'anthropic-native',
1532
1777
  };
@@ -1536,13 +1781,14 @@ async function driveRun(
1536
1781
  : await roleCost(workerProvider, meta.workerModel);
1537
1782
  roleBasis = combineBasis(directorCost.basis, workerCost.basis);
1538
1783
  prices = { director: directorCost.price, worker: workerCost.price };
1539
- roleBases = { director: directorCost.basis, worker: workerCost.basis };
1784
+ roleBases = { director: directorCost.basis, worker: workerCost.basis, crew: await crewBasesFor(meta) };
1540
1785
  } catch (err) {
1541
1786
  meta.status = 'error';
1542
1787
  meta.endedAt = Date.now();
1543
1788
  await store.writeMeta(meta).catch(() => {});
1544
1789
  emit('run_error', { error: String(err instanceof Error ? err.message : err) });
1545
1790
  emit('run_finished', { status: 'error', costUsd: meta.costUsd });
1791
+ drivingRuns.delete(meta.id);
1546
1792
  activeByProject.delete(projectId);
1547
1793
  return;
1548
1794
  }
@@ -1613,6 +1859,7 @@ async function driveRun(
1613
1859
  } finally {
1614
1860
  // However the run ended, it no longer needs its gateways.
1615
1861
  releaseGateways(meta.id);
1862
+ drivingRuns.delete(meta.id);
1616
1863
  if (activeByProject.get(projectId) === run) activeByProject.delete(projectId);
1617
1864
  // Freeze the record: the mission doc and the deck as they stand at this
1618
1865
  // moment, beside the run's meta and log. Done first, before the branch
@@ -1651,8 +1898,14 @@ async function effectiveSettings(projectId: string): Promise<{
1651
1898
  directorProviderId?: string; workerProviderId?: string;
1652
1899
  /** In a repository, each mission runs on a branch of its own (default on). */
1653
1900
  gitBranchPerMission: boolean;
1901
+ /** A mission in a worktree may use its parent repository without asking (default on). */
1902
+ allowWorktreeParent: boolean;
1654
1903
  /** Percent of the cap at which the director is told to start verifying (default 80). */
1655
1904
  budgetWarnAt: number;
1905
+ /** Ceiling on what this project's SCHEDULED runs may cost in one calendar month. */
1906
+ scheduledMonthlyCapUsd: number;
1907
+ /** The crew presets a mission here may be started with (built-ins until edited). */
1908
+ crewPresets: CrewPreset[];
1656
1909
  }> {
1657
1910
  const s = await store.readSettings()
1658
1911
  .catch(() => ({ global: {}, projects: {} as Record<string, object> }));
@@ -1674,10 +1927,20 @@ async function effectiveSettings(projectId: string): Promise<{
1674
1927
  directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
1675
1928
  workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
1676
1929
  gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
1930
+ allowWorktreeParent: (p.allowWorktreeParent ?? g.allowWorktreeParent) !== false,
1677
1931
  budgetWarnAt: (() => {
1678
1932
  const raw = Number(p.budgetWarnAt ?? g.budgetWarnAt);
1679
1933
  return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 60;
1680
1934
  })(),
1935
+ scheduledMonthlyCapUsd: (() => {
1936
+ // Zero is a legal answer — "this project may not spend unattended at
1937
+ // all" — so only a negative or unreadable value falls back to the default.
1938
+ const raw = Number(p.scheduledMonthlyCapUsd ?? g.scheduledMonthlyCapUsd);
1939
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_SCHEDULED_MONTHLY_CAP_USD;
1940
+ })(),
1941
+ // The project's list replaces the global one whole, like the rest of the
1942
+ // overlay — merging would make "no reviewer on this project" unsayable.
1943
+ crewPresets: crewPresetsFrom(g, p),
1681
1944
  };
1682
1945
  }
1683
1946
 
@@ -1697,17 +1960,83 @@ async function gitInfoCached(folder: string): Promise<GitInfo> {
1697
1960
  return value;
1698
1961
  }
1699
1962
 
1963
+ /**
1964
+ * A mission in a git worktree gets its parent repository without being asked.
1965
+ *
1966
+ * The worktree holds the branch's files; the build config, the shared type
1967
+ * declarations and the parent's node_modules live up in the repository it was
1968
+ * made from. A crew working in a worktree therefore crosses the boundary on
1969
+ * almost every command, and answering the same question all day is not
1970
+ * oversight — it is noise that trains the human to click Allow without
1971
+ * reading. The parent is opened at the start instead, once, in the open: the
1972
+ * transcript records it the same way it records a human's own grant.
1973
+ *
1974
+ * Only the parent. Sibling worktrees stay closed, because one of them may be
1975
+ * another mission's workspace, and so does a parent that a live run is
1976
+ * already working in.
1977
+ */
1978
+ async function grantWorktreeParent(
1979
+ meta: RunMeta, allowed: boolean, emit: ReturnType<typeof makeEmitter>,
1980
+ ): Promise<void> {
1981
+ // Re-derived from scratch every start and every resume, never merely added
1982
+ // to: the setting may have been turned off since, or another mission may
1983
+ // have taken the parent, and a grant that outlives its reason is a hole.
1984
+ // Only Foreman's own grants are withdrawn; a human's stay.
1985
+ const previous = meta.autoRoots ?? [];
1986
+ const shape = allowed ? await worktreeParent(meta.folder).catch(() => null) : null;
1987
+ const busy = activeRuns().filter((r) => r.meta.id !== meta.id).map((r) => r.meta.folder);
1988
+ const decision = worktreeGrant(shape, busy);
1989
+ const now = decision.grant ? [decision.grant] : [];
1990
+ const withdrawn = previous.filter((p) => !now.includes(p));
1991
+
1992
+ if (withdrawn.length || now.some((p) => !previous.includes(p))) {
1993
+ meta.allowedRoots = [...(meta.allowedRoots ?? []).filter((p) => !previous.includes(p)), ...now];
1994
+ meta.autoRoots = now;
1995
+ await store.writeMeta(meta).catch(() => {});
1996
+ }
1997
+ for (const p of withdrawn) {
1998
+ 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.` });
1999
+ }
2000
+ if (decision.grant && !previous.includes(decision.grant)) {
2001
+ emit('root_allowed', {
2002
+ path: decision.grant, agent: 'foreman',
2003
+ reason: 'this mission runs in a worktree of that repository',
2004
+ });
2005
+ } else if (!decision.grant && decision.reason && !withdrawn.length) {
2006
+ emit('git_note', { text: `Left closed: ${decision.reason}. The crew will ask before it steps outside the mission folder.` });
2007
+ }
2008
+ }
2009
+
1700
2010
  async function startRun(
1701
2011
  projectId: string, folder: string, mission: string, budgetUsd: number,
1702
2012
  directorModel: ModelChoice, workerModel: ModelChoice, browserTools: boolean,
1703
2013
  provider: ProviderRef,
1704
2014
  roleProviders: { director?: string; worker?: string } = {},
2015
+ /**
2016
+ * How this run began. Recorded rather than inferred: an unattended run and
2017
+ * one somebody is watching deserve different treatment later, and the
2018
+ * schedule's monthly ceiling can only count what says it was scheduled.
2019
+ */
2020
+ origin: {
2021
+ startedBy?: 'human' | 'phone' | 'schedule' | 'mcp';
2022
+ scheduleId?: string;
2023
+ scheduleName?: string;
2024
+ } = {},
2025
+ /**
2026
+ * Crew presets the human opted this mission into, by id. Resolved and copied
2027
+ * onto the record here and nowhere else: the run is reviewed against the
2028
+ * presets as they stood when it started, whatever Settings says later.
2029
+ */
2030
+ crewIds?: readonly string[],
1705
2031
  ): Promise<void> {
1706
2032
  const settings = await effectiveSettings(projectId);
2033
+ if (origin.scheduleId && origin.scheduleName) scheduleNames.set(origin.scheduleId, origin.scheduleName);
1707
2034
  const meta: RunMeta = {
1708
2035
  id: newRunId(),
1709
2036
  projectId,
1710
2037
  folder, mission, budgetUsd,
2038
+ startedBy: origin.startedBy ?? 'human',
2039
+ ...(origin.scheduleId ? { scheduleId: origin.scheduleId } : {}),
1711
2040
  // An explicit composer choice wins; "Default" inherits from Settings.
1712
2041
  directorModel: directorModel ?? settings.directorModel,
1713
2042
  workerModel: workerModel ?? settings.workerModel,
@@ -1724,6 +2053,13 @@ async function startRun(
1724
2053
  // must not silently move an in-flight or resumed run to another provider,
1725
2054
  // or another bill.
1726
2055
  provider,
2056
+ // Frozen for the same reason as the provider, and against a stronger
2057
+ // temptation: a preset edited next week must not change what a run that is
2058
+ // still going — or one that finished in March — was reviewed against.
2059
+ ...(() => {
2060
+ const crew = frozenCrewFor(settings.crewPresets, crewIds);
2061
+ return crew ? { crew } : {};
2062
+ })(),
1727
2063
  status: 'running', costUsd: 0,
1728
2064
  createdAt: Date.now(), workers: [],
1729
2065
  };
@@ -1734,6 +2070,23 @@ async function startRun(
1734
2070
  console.error(`failed to create run for project ${projectId}:`, err);
1735
2071
  return;
1736
2072
  }
2073
+ // Whoever reads this transcript later did not start this run, and the first
2074
+ // question they will have is who did. It is the opening line, before the
2075
+ // branch note, so the answer is at the top rather than buried in the meta.
2076
+ if (origin.scheduleId) {
2077
+ const emit = makeEmitter(meta.id, projectId);
2078
+ emit('run_note', {
2079
+ text: `Started by schedule ${origin.scheduleName ?? scheduleNames.get(origin.scheduleId) ?? origin.scheduleId}.`,
2080
+ });
2081
+ }
2082
+ // The schedule's "last run" is this one, recorded the moment it exists
2083
+ // rather than when it ends: a schedule whose mission is still running should
2084
+ // point at it, not at the one before.
2085
+ if (origin.scheduleId) {
2086
+ await store.updateSchedule(origin.scheduleId, {
2087
+ lastRunId: meta.id, lastRunAt: meta.createdAt, lastNote: '',
2088
+ }).catch(() => {});
2089
+ }
1737
2090
  await consumeProposal(projectId, meta.id, mission).catch(() => {});
1738
2091
  // The folder's MISSION.md belongs to whichever run wrote it. Parked into
1739
2092
  // that run's record (when it lacks one) and cleared, so this run's status
@@ -1767,6 +2120,7 @@ async function startRun(
1767
2120
  }
1768
2121
  }
1769
2122
  }
2123
+ await grantWorktreeParent(meta, settings.allowWorktreeParent, makeEmitter(meta.id, projectId));
1770
2124
  await driveRun(projectId, meta);
1771
2125
  }
1772
2126
 
@@ -1827,6 +2181,7 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1827
2181
  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
2182
  gitInfoCache.delete(meta.folder);
1829
2183
  }
2184
+ await grantWorktreeParent(meta, (await effectiveSettings(projectId)).allowWorktreeParent, makeEmitter(meta.id, projectId));
1830
2185
  // The director resumes from MISSION.md. If another mission ran here since,
1831
2186
  // the folder's copy is that mission's; this run's own goes back first.
1832
2187
  const restored = await restoreMissionDoc(store.runDirectory(meta.id), meta.folder).catch(() => 'none' as const);
@@ -1845,6 +2200,153 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1845
2200
  });
1846
2201
  }
1847
2202
 
2203
+ // ---------------------------------------------------------------------------
2204
+ // Schedules
2205
+ // ---------------------------------------------------------------------------
2206
+
2207
+ /**
2208
+ * Starts a schedule's mission. The project must already be reserved.
2209
+ *
2210
+ * The schedule's own choices win; anything it leaves open falls back to the
2211
+ * project's effective settings, which is what startRun does with an absent
2212
+ * model anyway. The browser judgement is the same heuristic the phone's /run
2213
+ * uses, and for the same reason: there is no box for anyone to tick.
2214
+ */
2215
+ function startScheduledRun(s: Schedule, project: Project, startedBy: 'human' | 'schedule'): void {
2216
+ void startRun(
2217
+ s.projectId, project.folder, s.brief, s.budgetUsd,
2218
+ modelChoice(s.directorModel), modelChoice(s.workerModel),
2219
+ /screenshot|browser|render|console/i.test(s.brief),
2220
+ providerOf(project),
2221
+ { director: s.directorProviderId, worker: s.workerProviderId },
2222
+ { startedBy, scheduleId: s.id, scheduleName: s.name },
2223
+ );
2224
+ }
2225
+
2226
+ /** The next firing of this cadence as a timestamp, or null when there is none. */
2227
+ function nextRunAtMs(cadence: Cadence, after: Date): number | null {
2228
+ const next = nextRunAt(cadence, after);
2229
+ return next ? next.getTime() : null;
2230
+ }
2231
+
2232
+ /**
2233
+ * A scheduled run ended: the schedule remembers how it went.
2234
+ *
2235
+ * Two failures in a row pause it — see afterRunOutcome. Called from the
2236
+ * emitter, which is where the server learns a run finished; the orchestrator
2237
+ * knows nothing about schedules and should not have to.
2238
+ */
2239
+ async function recordScheduleOutcome(scheduleId: string, meta: RunMeta): Promise<void> {
2240
+ const s = await store.getSchedule(scheduleId);
2241
+ if (!s) return;
2242
+ const outcome = afterRunOutcome(s, { status: meta.status, stopReason: meta.stopReason });
2243
+ await store.updateSchedule(s.id, {
2244
+ lastRunId: meta.id,
2245
+ lastRunAt: meta.endedAt ?? Date.now(),
2246
+ lastOutcome: meta.status === 'running' ? undefined : meta.status,
2247
+ // Whatever the last tick could not do, it did this time: the note would
2248
+ // otherwise still read "project busy" beside a run that just finished.
2249
+ lastNote: '',
2250
+ consecutiveFailures: outcome.consecutiveFailures,
2251
+ pausedReason: outcome.pausedReason,
2252
+ });
2253
+ if (outcome.pausedNow) {
2254
+ const emit = scheduleNotice(s.projectId);
2255
+ emit('schedule_paused', { scheduleId: s.id, name: s.name, projectId: s.projectId, reason: 'failures' });
2256
+ }
2257
+ }
2258
+
2259
+ /**
2260
+ * One pass of the ticker: what is due, and what to do about it.
2261
+ *
2262
+ * Passes must not overlap. A pass awaits the store several times, and two of
2263
+ * them interleaving could both see the same schedule as due and start it
2264
+ * twice — the project reservation would catch that, but as a 409 nobody is
2265
+ * there to read rather than as a rule.
2266
+ */
2267
+ let tickInFlight = false;
2268
+ async function scheduleTick(): Promise<void> {
2269
+ if (tickInFlight) return;
2270
+ tickInFlight = true;
2271
+ try {
2272
+ const schedules = await store.listSchedules();
2273
+ if (!schedules.length) return;
2274
+ for (const s of schedules) scheduleNames.set(s.id, s.name);
2275
+ const now = new Date();
2276
+ // A schedule with no next firing would sit dormant for ever — a record
2277
+ // written by an older build, or one whose cadence was never scheduled.
2278
+ // Giving it one here costs a write once and never again.
2279
+ for (const s of schedules) {
2280
+ if (!s.enabled || s.pausedReason !== null || s.nextRunAt !== null) continue;
2281
+ s.nextRunAt = nextRunAtMs(s.cadence, now);
2282
+ await store.updateSchedule(s.id, { nextRunAt: s.nextRunAt }).catch(() => {});
2283
+ }
2284
+ const runs = await store.listRuns().catch(() => [] as RunMeta[]);
2285
+ const monthSpend = new Map<string, number>();
2286
+ const caps = new Map<string, number>();
2287
+ for (const projectId of new Set(schedules.map((s) => s.projectId))) {
2288
+ monthSpend.set(projectId, monthlyScheduledSpend(runs, projectId, now));
2289
+ caps.set(projectId, (await effectiveSettings(projectId)).scheduledMonthlyCapUsd);
2290
+ }
2291
+ const actions = decideTicks({
2292
+ schedules,
2293
+ now,
2294
+ busyProjectIds: new Set(activeByProject.keys()),
2295
+ monthSpend,
2296
+ capFor: (projectId) => caps.get(projectId) ?? DEFAULT_SCHEDULED_MONTHLY_CAP_USD,
2297
+ });
2298
+ for (const action of actions) {
2299
+ const s = schedules.find((x) => x.id === action.scheduleId);
2300
+ if (!s) continue;
2301
+ if (action.kind === 'pause') {
2302
+ await store.updateSchedule(s.id, { pausedReason: action.reason }).catch(() => {});
2303
+ const emit = scheduleNotice(s.projectId);
2304
+ emit('schedule_paused', {
2305
+ scheduleId: s.id, name: s.name, projectId: s.projectId, reason: action.reason,
2306
+ });
2307
+ continue;
2308
+ }
2309
+ const skipped = async (nextAt: number | null) => {
2310
+ await store.updateSchedule(s.id, {
2311
+ nextRunAt: nextAt, lastOutcome: 'skipped', lastNote: 'project busy',
2312
+ }).catch(() => {});
2313
+ const emit = scheduleNotice(s.projectId);
2314
+ emit('schedule_skipped', {
2315
+ scheduleId: s.id, name: s.name, projectId: s.projectId, reason: 'project busy',
2316
+ });
2317
+ };
2318
+ if (action.kind === 'skip') { await skipped(action.nextRunAt); continue; }
2319
+ const project = await store.getProject(s.projectId).catch(() => null);
2320
+ if (!project) {
2321
+ // The project was unlinked and this schedule outlived it. Nothing to
2322
+ // announce; move it along so the tick does not repeat every 30s.
2323
+ await store.updateSchedule(s.id, { nextRunAt: action.nextRunAt }).catch(() => {});
2324
+ continue;
2325
+ }
2326
+ // Reservation is the last step before dispatch — no awaits in between,
2327
+ // exactly as in POST /run. It can still fail: another dispatch may have
2328
+ // taken the project since this pass read the active map, and that is the
2329
+ // skip case, not a tick to drop on the floor.
2330
+ if (!reserveProject(s.projectId)) { await skipped(action.nextRunAt); continue; }
2331
+ startScheduledRun(s, project, 'schedule');
2332
+ await store.updateSchedule(s.id, { nextRunAt: action.nextRunAt }).catch(() => {});
2333
+ }
2334
+ } catch (err) {
2335
+ // A ticker that throws is a ticker that stops. Nothing here is worth the
2336
+ // schedules of every other project.
2337
+ console.error('schedule tick failed:', err);
2338
+ } finally {
2339
+ tickInFlight = false;
2340
+ }
2341
+ }
2342
+
2343
+ // Every half minute, and once shortly after startup so a firing missed while
2344
+ // the machine was off is caught up rather than waiting for the next slot. The
2345
+ // first pass is delayed: the orphan sweep and the store's own startup work
2346
+ // come first, and a schedule is never so urgent that ten seconds matter.
2347
+ setTimeout(() => void scheduleTick(), 10_000).unref();
2348
+ setInterval(() => void scheduleTick(), 30_000).unref();
2349
+
1848
2350
  // ---------------------------------------------------------------------------
1849
2351
  // Static files
1850
2352
  // ---------------------------------------------------------------------------
@@ -1928,6 +2430,52 @@ async function browseRoots(): Promise<string[]> {
1928
2430
  return roots;
1929
2431
  }
1930
2432
 
2433
+ /**
2434
+ * A schedule's writable fields out of a request body, or the first thing wrong
2435
+ * with them. `partial` is the PUT: an absent field means "leave it alone",
2436
+ * where on the POST it means the schedule would be missing something it needs.
2437
+ *
2438
+ * The cadence goes through validateCadence rather than being trusted, because
2439
+ * the failure this guards against is silent — a cron expression nobody can
2440
+ * parse becomes a schedule that simply never fires, and looks healthy doing it.
2441
+ */
2442
+ function parseScheduleBody(
2443
+ b: Record<string, unknown>, partial: boolean,
2444
+ ): { error: string } | { fields: Partial<Schedule> } {
2445
+ const fields: Partial<Schedule> = {};
2446
+ for (const key of ['name', 'brief'] as const) {
2447
+ if (b[key] === undefined) {
2448
+ if (!partial) return { error: `${key} is required` };
2449
+ continue;
2450
+ }
2451
+ const v = typeof b[key] === 'string' ? (b[key] as string).trim() : '';
2452
+ if (!v) return { error: `${key} must not be empty` };
2453
+ fields[key] = v;
2454
+ }
2455
+ if (b.budgetUsd !== undefined) {
2456
+ const n = Number(b.budgetUsd);
2457
+ if (!Number.isFinite(n) || n <= 0) return { error: 'budgetUsd must be a positive number' };
2458
+ fields.budgetUsd = n;
2459
+ } else if (!partial) {
2460
+ return { error: 'budgetUsd is required' };
2461
+ }
2462
+ if (b.cadence !== undefined) {
2463
+ const parsed = validateCadence(b.cadence);
2464
+ if (!parsed.ok) return { error: parsed.error };
2465
+ fields.cadence = parsed.cadence;
2466
+ } else if (!partial) {
2467
+ return { error: 'cadence is required' };
2468
+ }
2469
+ for (const key of ['directorModel', 'workerModel'] as const) {
2470
+ if (typeof b[key] === 'string' && (b[key] as string).trim()) fields[key] = modelChoice(b[key]);
2471
+ }
2472
+ for (const key of ['directorProviderId', 'workerProviderId'] as const) {
2473
+ if (typeof b[key] === 'string' && (b[key] as string).trim()) fields[key] = (b[key] as string).trim();
2474
+ }
2475
+ if (typeof b.enabled === 'boolean') fields.enabled = b.enabled;
2476
+ return { fields };
2477
+ }
2478
+
1931
2479
  function json(res: http.ServerResponse, code: number, body: unknown): void {
1932
2480
  res.writeHead(code, { 'content-type': 'application/json' });
1933
2481
  res.end(JSON.stringify(body));
@@ -1969,6 +2517,9 @@ const server = http.createServer(async (req, res) => {
1969
2517
  const stopServiceMatch = url.pathname.match(/^\/runs\/([^/]+)\/services\/(\d{1,5})\/stop$/);
1970
2518
  const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
1971
2519
  const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
2520
+ const projectSchedulesMatch = url.pathname.match(/^\/projects\/([^/]+)\/schedules$/);
2521
+ const scheduleMatch = url.pathname.match(/^\/schedules\/([^/]+)$/);
2522
+ const scheduleActionMatch = url.pathname.match(/^\/schedules\/([^/]+)\/(pause|resume|run-now)$/);
1972
2523
 
1973
2524
  try {
1974
2525
  if (req.method === 'GET' && (url.pathname === '/'
@@ -2103,6 +2654,10 @@ const server = http.createServer(async (req, res) => {
2103
2654
  createdAt: lastRun.createdAt, costUsd: lastRun.costUsd,
2104
2655
  // The card may print a dollar only where the dollar was real.
2105
2656
  costBasis: costBasisOf(lastRun), usage: lastRun.usage,
2657
+ // Not the verdicts themselves — see reviewedByNames. The tile
2658
+ // renders a glyph and a name, and this projection is polled for
2659
+ // every project every few seconds.
2660
+ reviewedBy: reviewedByNames(lastRun),
2106
2661
  },
2107
2662
  // When this project last did anything, so the fleet can lead with it.
2108
2663
  // A planner parked on a question is doing something — waiting on
@@ -2252,7 +2807,7 @@ const server = http.createServer(async (req, res) => {
2252
2807
  } else if (req.method === 'POST' && url.pathname === '/run') {
2253
2808
  const {
2254
2809
  projectId, mission, budgetUsd, directorModel, workerModel, browserTools,
2255
- directorProviderId, workerProviderId, allowDirty,
2810
+ directorProviderId, workerProviderId, allowDirty, startedBy, crew,
2256
2811
  } = await readBody(req);
2257
2812
  if (typeof projectId !== 'string' || typeof mission !== 'string' || !mission.trim()) {
2258
2813
  return json(res, 400, { error: 'projectId and mission are required' });
@@ -2294,7 +2849,14 @@ const server = http.createServer(async (req, res) => {
2294
2849
  {
2295
2850
  director: typeof directorProviderId === 'string' ? directorProviderId : undefined,
2296
2851
  worker: typeof workerProviderId === 'string' ? workerProviderId : undefined,
2297
- });
2852
+ },
2853
+ // 'schedule' is deliberately not accepted here: a caller must not be
2854
+ // able to forge a scheduled start and charge the month's unattended
2855
+ // allowance for a run no schedule asked for.
2856
+ { startedBy: startedBy === 'phone' || startedBy === 'mcp' ? startedBy : 'human' },
2857
+ // The composer is the only place a crew is chosen, so it is the only
2858
+ // dispatch path that carries one; unknown ids are dropped downstream.
2859
+ Array.isArray(crew) ? crew as string[] : undefined);
2298
2860
  json(res, 200, { ok: true });
2299
2861
 
2300
2862
  } else if (url.pathname === '/notify' && req.method === 'GET') {
@@ -2763,7 +3325,7 @@ const server = http.createServer(async (req, res) => {
2763
3325
  // mission was branched from — see resolvePrBase.
2764
3326
  const target = await resolvePrBase(meta.folder, meta.git.base);
2765
3327
  json(res, 200, {
2766
- ...prDraft(meta, doc), branch: meta.git.branch, base: target.base, commits: meta.git.commits ?? null,
3328
+ ...prDraft(meta, doc, meta.reviews), branch: meta.git.branch, base: target.base, commits: meta.git.commits ?? null,
2767
3329
  branchedFrom: meta.git.base, baseFellBack: target.fellBack,
2768
3330
  remote: info.remote, compareUrl: compareUrl(info.remote, target.base, meta.git.branch),
2769
3331
  gh, pr: meta.git.pr ?? null, onBranch: info.branch === meta.git.branch, dirty: Boolean(info.dirty),
@@ -2901,6 +3463,107 @@ const server = http.createServer(async (req, res) => {
2901
3463
  if (!project) return json(res, 404, { error: 'unknown project' });
2902
3464
  json(res, 200, await readMemory(project.folder));
2903
3465
 
3466
+ } else if (req.method === 'GET' && url.pathname === '/schedules/preview') {
3467
+ // The picker's "next three runs". Deliberately the same function the
3468
+ // ticker fires from, so what the human is shown and what will actually
3469
+ // happen cannot drift apart.
3470
+ let cadence: unknown;
3471
+ try { cadence = JSON.parse(url.searchParams.get('cadence') ?? ''); }
3472
+ catch { return json(res, 400, { error: 'cadence must be a JSON object in the query string' }); }
3473
+ const parsed = validateCadence(cadence);
3474
+ if (!parsed.ok) return json(res, 400, { error: parsed.error });
3475
+ json(res, 200, { next: nextRuns(parsed.cadence, new Date(), 3).map((d) => d.getTime()) });
3476
+
3477
+ } else if (req.method === 'GET' && projectSchedulesMatch) {
3478
+ const projectId = projectSchedulesMatch[1];
3479
+ if (!await store.getProject(projectId)) return json(res, 404, { error: 'unknown project' });
3480
+ const [schedules, runs, settings] = await Promise.all([
3481
+ store.listSchedules(projectId), store.listRuns().catch(() => [] as RunMeta[]), effectiveSettings(projectId),
3482
+ ]);
3483
+ json(res, 200, {
3484
+ schedules,
3485
+ monthSpendUsd: monthlyScheduledSpend(runs, projectId, new Date()),
3486
+ monthlyCapUsd: settings.scheduledMonthlyCapUsd,
3487
+ });
3488
+
3489
+ } else if (req.method === 'POST' && projectSchedulesMatch) {
3490
+ const projectId = projectSchedulesMatch[1];
3491
+ if (!await store.getProject(projectId)) return json(res, 404, { error: 'unknown project' });
3492
+ const parsed = parseScheduleBody(await readBody(req), false);
3493
+ if ('error' in parsed) return json(res, 400, { error: parsed.error });
3494
+ const f = parsed.fields;
3495
+ const schedule = await store.addSchedule({
3496
+ projectId,
3497
+ name: f.name!, brief: f.brief!, cadence: f.cadence!, budgetUsd: f.budgetUsd!,
3498
+ directorModel: f.directorModel, workerModel: f.workerModel,
3499
+ directorProviderId: f.directorProviderId, workerProviderId: f.workerProviderId,
3500
+ // Enabled unless the caller said otherwise: a schedule nobody switched
3501
+ // on is a form somebody filled in and forgot.
3502
+ enabled: f.enabled !== false,
3503
+ nextRunAt: f.enabled === false ? null : nextRunAtMs(f.cadence!, new Date()),
3504
+ consecutiveFailures: 0,
3505
+ pausedReason: null,
3506
+ });
3507
+ scheduleNames.set(schedule.id, schedule.name);
3508
+ json(res, 200, { schedule });
3509
+
3510
+ } else if (req.method === 'PUT' && scheduleMatch) {
3511
+ const existing = await store.getSchedule(scheduleMatch[1]);
3512
+ if (!existing) return json(res, 404, { error: 'unknown schedule' });
3513
+ const parsed = parseScheduleBody(await readBody(req), true);
3514
+ if ('error' in parsed) return json(res, 400, { error: parsed.error });
3515
+ const f = parsed.fields;
3516
+ const enabled = f.enabled ?? existing.enabled;
3517
+ const patch: Partial<Schedule> = { ...f };
3518
+ // A changed cadence is a changed answer to "when next?", counted from
3519
+ // now: keeping the old firing time would mean the schedule the human
3520
+ // just moved fires one more time on the schedule they moved it off.
3521
+ if (f.cadence || f.enabled !== undefined) {
3522
+ patch.nextRunAt = enabled && existing.pausedReason === null
3523
+ ? nextRunAtMs(f.cadence ?? existing.cadence, new Date())
3524
+ : null;
3525
+ }
3526
+ const schedule = await store.updateSchedule(existing.id, patch);
3527
+ if (schedule) scheduleNames.set(schedule.id, schedule.name);
3528
+ json(res, 200, { schedule });
3529
+
3530
+ } else if (req.method === 'DELETE' && scheduleMatch) {
3531
+ const removed = await store.removeSchedule(scheduleMatch[1]);
3532
+ json(res, removed ? 200 : 404, removed ? { ok: true } : { error: 'unknown schedule' });
3533
+
3534
+ } else if (req.method === 'POST' && scheduleActionMatch) {
3535
+ const [, scheduleId, action] = scheduleActionMatch;
3536
+ const s = await store.getSchedule(scheduleId);
3537
+ if (!s) return json(res, 404, { error: 'unknown schedule' });
3538
+ if (action === 'pause') {
3539
+ json(res, 200, { schedule: await store.updateSchedule(s.id, { pausedReason: 'human', nextRunAt: null }) });
3540
+
3541
+ } else if (action === 'resume') {
3542
+ // Resuming is a dashboard act and has no remote equivalent on purpose:
3543
+ // whatever paused this — the human, the month's ceiling, two failures
3544
+ // in a row — is a thing to look at before it runs unattended again.
3545
+ json(res, 200, {
3546
+ schedule: await store.updateSchedule(s.id, {
3547
+ pausedReason: null,
3548
+ consecutiveFailures: 0,
3549
+ nextRunAt: s.enabled ? nextRunAtMs(s.cadence, new Date()) : null,
3550
+ }),
3551
+ });
3552
+
3553
+ } else {
3554
+ const project = await store.getProject(s.projectId);
3555
+ if (!project) return json(res, 404, { error: 'unknown project' });
3556
+ // Allowed even at the monthly ceiling, and not counted against it
3557
+ // beforehand: the ceiling governs UNATTENDED spending, and a human
3558
+ // pressing a button is by definition not that. The run still carries
3559
+ // the scheduleId, so what it costs does count towards the month.
3560
+ if (!reserveProject(s.projectId)) {
3561
+ return json(res, 409, { error: 'this project already has an active mission' });
3562
+ }
3563
+ startScheduledRun(s, project, 'human');
3564
+ json(res, 200, { ok: true });
3565
+ }
3566
+
2904
3567
  } else if (req.method === 'GET' && url.pathname === '/missiondoc') {
2905
3568
  const runId = url.searchParams.get('run');
2906
3569
  if (!runId) return json(res, 400, { error: 'run parameter is required' });