@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/store.test.ts CHANGED
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { RunStore, newRunId } from './store.js';
7
- import type { RunMeta } from './types.js';
7
+ import type { RunMeta, Schedule } from './types.js';
8
8
 
9
9
  function meta(id: string, over: Partial<RunMeta> = {}): RunMeta {
10
10
  return {
@@ -151,3 +151,83 @@ test('sweepOrphans leaves a running run alone while another live process owns it
151
151
  assert.equal((await store.readMeta(theirs))!.status, 'running');
152
152
  await rm(root, { recursive: true, force: true });
153
153
  });
154
+
155
+ function schedule(over: Partial<Schedule> = {}): Omit<Schedule, 'id' | 'createdAt'> & { createdAt?: number } {
156
+ return {
157
+ projectId: 'p-1', name: 'nightly', brief: 'tidy the tests', budgetUsd: 3,
158
+ cadence: { kind: 'daily', at: '03:00' } as unknown as Schedule['cadence'],
159
+ enabled: true, nextRunAt: 1000, consecutiveFailures: 0, pausedReason: null,
160
+ ...over,
161
+ };
162
+ }
163
+
164
+ test('schedules: add generates an id, list is per project and oldest first', async () => {
165
+ const { store, root } = await tmpStore();
166
+ const a = await store.addSchedule(schedule({ createdAt: 2000 }));
167
+ const b = await store.addSchedule(schedule({ createdAt: 1000, name: 'weekly' }));
168
+ const other = await store.addSchedule(schedule({ projectId: 'p-2' }));
169
+
170
+ assert.match(a.id, /^s-[0-9a-f]{12}$/);
171
+ assert.notEqual(a.id, b.id);
172
+ assert.deepEqual((await store.listSchedules()).map((s) => s.id), [b.id, a.id, other.id]);
173
+ assert.deepEqual((await store.listSchedules('p-1')).map((s) => s.name), ['weekly', 'nightly']);
174
+ assert.deepEqual(await store.getSchedule(a.id), a);
175
+ assert.equal(await store.getSchedule('s-missing'), null);
176
+ await rm(root, { recursive: true, force: true });
177
+ });
178
+
179
+ test('schedules: update merges, and null clears where undefined leaves alone', async () => {
180
+ const { store, root } = await tmpStore();
181
+ const s = await store.addSchedule(schedule({ pausedReason: 'failures', consecutiveFailures: 3 }));
182
+
183
+ const paused = await store.updateSchedule(s.id, { lastOutcome: 'error' });
184
+ assert.equal(paused?.pausedReason, 'failures', 'an absent key is left alone');
185
+ assert.equal(paused?.lastOutcome, 'error');
186
+
187
+ const resumed = await store.updateSchedule(s.id, {
188
+ pausedReason: null, nextRunAt: null, consecutiveFailures: 0,
189
+ });
190
+ assert.equal(resumed?.pausedReason, null);
191
+ assert.equal(resumed?.nextRunAt, null);
192
+ assert.equal(resumed?.consecutiveFailures, 0);
193
+ // And it survived the write, not just the returned object.
194
+ assert.equal((await store.getSchedule(s.id))?.nextRunAt, null);
195
+
196
+ assert.equal(await store.updateSchedule('s-missing', { enabled: false }), null);
197
+ await rm(root, { recursive: true, force: true });
198
+ });
199
+
200
+ test('schedules: remove reports whether it existed', async () => {
201
+ const { store, root } = await tmpStore();
202
+ const a = await store.addSchedule(schedule());
203
+ const b = await store.addSchedule(schedule({ name: 'other' }));
204
+ assert.equal(await store.removeSchedule(a.id), true);
205
+ assert.equal(await store.removeSchedule(a.id), false);
206
+ assert.deepEqual((await store.listSchedules()).map((s) => s.id), [b.id]);
207
+ await rm(root, { recursive: true, force: true });
208
+ });
209
+
210
+ test('schedules: unlinking a project takes its schedules with it', async () => {
211
+ const { store, root } = await tmpStore();
212
+ const p = await store.addProject('/tmp/sched-proj');
213
+ const q = await store.addProject('/tmp/other-proj');
214
+ await store.addSchedule(schedule({ projectId: p.id }));
215
+ await store.addSchedule(schedule({ projectId: p.id, name: 'second' }));
216
+ const keep = await store.addSchedule(schedule({ projectId: q.id }));
217
+
218
+ assert.equal(await store.removeProject(p.id), true);
219
+ assert.deepEqual((await store.listSchedules()).map((s) => s.id), [keep.id]);
220
+ assert.deepEqual(await store.listSchedules(p.id), []);
221
+ await rm(root, { recursive: true, force: true });
222
+ });
223
+
224
+ test('schedules: a corrupt schedules.json reads as empty rather than throwing', async () => {
225
+ const { store, root } = await tmpStore();
226
+ const { writeFile } = await import('node:fs/promises');
227
+ await writeFile(path.join(root, 'schedules.json'), '{not json');
228
+ assert.deepEqual(await store.listSchedules(), []);
229
+ // And a write over it recovers the file.
230
+ const s = await store.addSchedule(schedule());
231
+ assert.deepEqual((await store.listSchedules()).map((x) => x.id), [s.id]);
232
+ await rm(root, { recursive: true, force: true });
233
+ });
package/src/store.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * <root>/runs/<runId>/events.jsonl — one ForemanEvent per line, append-only
8
8
  * <root>/chats/<projectId>/… — same two files for a project's
9
9
  * planning conversation
10
+ * <root>/schedules.json — every Schedule, one JSON array
10
11
  *
11
12
  * Design notes for reviewers:
12
13
  * - The event log is the source of truth for the UI; meta.json is a derived
@@ -24,7 +25,7 @@ import os from 'node:os';
24
25
  import path from 'node:path';
25
26
  import crypto from 'node:crypto';
26
27
  import type {
27
- ChatMeta, ForemanEvent, Project, ProviderRef, RunMeta, RunSummary, SettingsFile,
28
+ ChatMeta, ForemanEvent, Project, ProviderRef, RunMeta, RunSummary, Schedule, SettingsFile,
28
29
  } from './types.js';
29
30
 
30
31
  const RUN_ID_RE = /^[0-9]{13}-[0-9a-f]{8}$/;
@@ -142,11 +143,16 @@ export class RunStore {
142
143
  }
143
144
 
144
145
  /** Unlinks a project (run history is kept). Returns whether it existed. */
145
- removeProject(projectId: string): Promise<boolean> {
146
- return this.mutateProjects((projects) => {
146
+ async removeProject(projectId: string): Promise<boolean> {
147
+ const existed = await this.mutateProjects((projects) => {
147
148
  const rest = projects.filter((p) => p.id !== projectId);
148
149
  return { projects: rest, result: rest.length !== projects.length };
149
150
  });
151
+ // Its schedules go with it: a standing instruction to run missions in a
152
+ // folder Foreman no longer knows about has nowhere to fire. Sequential and
153
+ // not nested inside the mutate, because both writes share one chain.
154
+ await this.removeProjectSchedules(projectId);
155
+ return existed;
150
156
  }
151
157
 
152
158
  async getProject(projectId: string): Promise<Project | null> {
@@ -183,6 +189,114 @@ export class RunStore {
183
189
  return task;
184
190
  }
185
191
 
192
+ // -- schedules ------------------------------------------------------------
193
+
194
+ /**
195
+ * Every schedule lives in one small file, not a directory per schedule:
196
+ * there are a handful of them, the ticker reads all of them on every tick to
197
+ * decide what is due, and a single array is one read and one atomic write.
198
+ * It shares `projectsChain` with projects.json and settings.json so a write
199
+ * here can never interleave with one of those.
200
+ */
201
+ private get schedulesFile(): string {
202
+ return path.join(this.root, 'schedules.json');
203
+ }
204
+
205
+ /** All schedules, or one project's; oldest first so the list never reorders
206
+ * itself under the human between visits. A missing or unparseable file is
207
+ * an empty list — the ticker must keep running, not crash on a bad byte. */
208
+ async listSchedules(projectId?: string): Promise<Schedule[]> {
209
+ const raw = await readFile(this.schedulesFile, 'utf8').catch(() => null);
210
+ let all: Schedule[] = [];
211
+ if (raw) {
212
+ try {
213
+ const parsed: unknown = JSON.parse(raw);
214
+ if (Array.isArray(parsed)) all = parsed as Schedule[];
215
+ } catch {
216
+ all = [];
217
+ }
218
+ }
219
+ const wanted = projectId ? all.filter((s) => s.projectId === projectId) : all;
220
+ return [...wanted].sort((a, b) => a.createdAt - b.createdAt);
221
+ }
222
+
223
+ /** Atomically rewrites schedules.json through `mutate`; returns its result. */
224
+ private mutateSchedules<T>(
225
+ mutate: (schedules: Schedule[]) => { schedules: Schedule[]; result: T },
226
+ ): Promise<T> {
227
+ const task = this.projectsChain.then(async () => {
228
+ const { schedules, result } = mutate(await this.listSchedules());
229
+ await mkdir(this.root, { recursive: true });
230
+ const tmp = path.join(this.root, `.schedules.${crypto.randomBytes(4).toString('hex')}.tmp`);
231
+ await writeFile(tmp, JSON.stringify(schedules, null, 2));
232
+ await rename(tmp, this.schedulesFile);
233
+ return result;
234
+ });
235
+ this.projectsChain = task.catch(() => {});
236
+ return task;
237
+ }
238
+
239
+ async getSchedule(id: string): Promise<Schedule | null> {
240
+ return (await this.listSchedules()).find((s) => s.id === id) ?? null;
241
+ }
242
+
243
+ /** Records a new schedule. The id and creation time are the store's to give,
244
+ * like a project's; callers may pass them when restoring a known record. */
245
+ addSchedule(
246
+ s: Omit<Schedule, 'id' | 'createdAt'> & { id?: string; createdAt?: number },
247
+ ): Promise<Schedule> {
248
+ return this.mutateSchedules((schedules) => {
249
+ const schedule: Schedule = {
250
+ ...s,
251
+ id: s.id ?? `s-${crypto.randomBytes(6).toString('hex')}`,
252
+ createdAt: s.createdAt ?? Date.now(),
253
+ };
254
+ return { schedules: [...schedules, schedule], result: schedule };
255
+ });
256
+ }
257
+
258
+ /**
259
+ * Merges a partial change. `undefined` means "leave it alone" and `null` is
260
+ * a value in its own right — `pausedReason` and `nextRunAt` are both cleared
261
+ * by writing null, and a spread alone would let an absent key erase them.
262
+ * Identity (id, project, creation) is not patchable; a schedule that moved
263
+ * project would silently start running missions in another folder.
264
+ */
265
+ updateSchedule(
266
+ id: string,
267
+ patch: Partial<Omit<Schedule, 'id' | 'projectId' | 'createdAt'>>,
268
+ ): Promise<Schedule | null> {
269
+ return this.mutateSchedules((schedules) => {
270
+ const i = schedules.findIndex((s) => s.id === id);
271
+ if (i === -1) return { schedules, result: null };
272
+ const next: Schedule = { ...schedules[i] };
273
+ for (const [key, value] of Object.entries(patch)) {
274
+ if (value === undefined) continue;
275
+ (next as unknown as Record<string, unknown>)[key] = value;
276
+ }
277
+ const updated = [...schedules];
278
+ updated[i] = next;
279
+ return { schedules: updated, result: next };
280
+ });
281
+ }
282
+
283
+ /** Forgets one schedule. Returns whether it existed. */
284
+ removeSchedule(id: string): Promise<boolean> {
285
+ return this.mutateSchedules((schedules) => {
286
+ const rest = schedules.filter((s) => s.id !== id);
287
+ return { schedules: rest, result: rest.length !== schedules.length };
288
+ });
289
+ }
290
+
291
+ /** Forgets a project's schedules; returns how many went. Called when a
292
+ * project is unlinked, so no schedule outlives the project it fires in. */
293
+ removeProjectSchedules(projectId: string): Promise<number> {
294
+ return this.mutateSchedules((schedules) => {
295
+ const rest = schedules.filter((s) => s.projectId !== projectId);
296
+ return { schedules: rest, result: schedules.length - rest.length };
297
+ });
298
+ }
299
+
186
300
  // -- runs -----------------------------------------------------------------
187
301
 
188
302
  private runDir(runId: string): string {
package/src/types.ts CHANGED
@@ -5,6 +5,17 @@
5
5
  * broadcast to connected SSE clients *and* appended to the run's event log,
6
6
  * so replaying a log reproduces exactly what a live client observed.
7
7
  */
8
+ import type { Cadence } from './schedule.js';
9
+ import type { CrewPreset, ReviewVerdict } from './crew.js';
10
+
11
+ /** Re-exported so callers can name a schedule's cadence without reaching past
12
+ * this module for it; the rules that interpret one live in schedule.ts. */
13
+ export type { Cadence };
14
+
15
+ /** Re-exported on the same principle: a record can be described without
16
+ * reaching past this module, while crew.ts stays the definition site and
17
+ * keeps the rules that read them. */
18
+ export type { CrewPreset, ReviewVerdict };
8
19
 
9
20
  export type RunStatus = 'running' | 'done' | 'error' | 'interrupted';
10
21
 
@@ -27,6 +38,44 @@ export interface Project {
27
38
  claudeInstance?: ClaudeInstanceRef;
28
39
  }
29
40
 
41
+ /**
42
+ * A standing instruction to start a mission on a cadence. Foreman's own
43
+ * record, never the project's repo: a schedule is an operator's decision about
44
+ * a machine, not something a checkout should carry to whoever clones it.
45
+ *
46
+ * The fields after `enabled` are the ticker's memory. It is stateless between
47
+ * ticks — every question it asks ("is this one due?", "has it been failing?")
48
+ * is answered from here, so nothing is lost across a restart.
49
+ */
50
+ export interface Schedule {
51
+ /** `s-<hex>`, generated by the store like a project's id. */
52
+ id: string;
53
+ projectId: string;
54
+ name: string;
55
+ /** The mission text, sent verbatim to the director on every firing. */
56
+ brief: string;
57
+ cadence: Cadence;
58
+ /** Budget for one run, not for the schedule's life. */
59
+ budgetUsd: number;
60
+ directorModel?: ModelChoice;
61
+ workerModel?: ModelChoice;
62
+ directorProviderId?: string;
63
+ workerProviderId?: string;
64
+ enabled: boolean;
65
+ createdAt: number;
66
+ lastRunId?: string;
67
+ lastRunAt?: number;
68
+ lastOutcome?: 'done' | 'error' | 'interrupted' | 'skipped';
69
+ /** Why the last tick did nothing, when it did nothing ("project busy"). */
70
+ lastNote?: string;
71
+ /** Next firing, ms epoch; null when disabled or nothing is scheduled. */
72
+ nextRunAt: number | null;
73
+ /** Failures in a row, reset by a success. What auto-pausing counts. */
74
+ consecutiveFailures: number;
75
+ /** Not running, and why — so the UI can say what would un-pause it. */
76
+ pausedReason: null | 'failures' | 'monthly-cap' | 'human';
77
+ }
78
+
30
79
  /**
31
80
  * @deprecated The pre-provider shape, still read from records written before
32
81
  * {@link ProviderRef} existed. Never written. See providerFromLegacy().
@@ -206,6 +255,14 @@ export interface WorkerMeta {
206
255
  status: WorkerStatus;
207
256
  costUsd: number;
208
257
  sessionId?: string;
258
+ /**
259
+ * The crew preset this worker IS, when it is a reviewer rather than an
260
+ * ordinary worker. Persisted — unlike the launch overrides, which hold a
261
+ * live credential — so a resumed run can rebuild the read-only policy, the
262
+ * model and the provider from the frozen crew instead of resuming a
263
+ * reviewer as a worker that may write.
264
+ */
265
+ crewPresetId?: string;
209
266
  /** First 500 chars of the task brief, for run-history display. */
210
267
  task: string;
211
268
  /**
@@ -274,6 +331,20 @@ export interface RunMeta {
274
331
  title?: string;
275
332
  /** Owning project; absent on runs recorded before projects existed. */
276
333
  projectId?: string;
334
+ /**
335
+ * The schedule that started this run, when one did. Kept on the run rather
336
+ * than only on the schedule because a schedule has many runs and one
337
+ * `lastRunId`: this is what lets the history page group them.
338
+ */
339
+ scheduleId?: string;
340
+ /**
341
+ * How the run began. Absent means 'human' — every record made before
342
+ * schedules existed, and the reason this is not required: an unattended run
343
+ * and one someone is watching deserve different treatment (timeouts,
344
+ * notifications), and guessing from the presence of other fields would get
345
+ * it wrong for the old records.
346
+ */
347
+ startedBy?: 'human' | 'phone' | 'schedule' | 'mcp';
277
348
  /** Model override for the director session. */
278
349
  directorModel?: ModelChoice;
279
350
  /** Model override for worker sessions (cost lever). */
@@ -296,6 +367,19 @@ export interface RunMeta {
296
367
  provider?: ProviderRef;
297
368
  /** @deprecated Pre-provider pin, still read for runs recorded before providers. */
298
369
  claudeInstance?: ClaudeInstanceRef;
370
+ /**
371
+ * Crew presets frozen onto the run at dispatch: a later edit of a preset
372
+ * must not change a running or past run. Absent means the run was dispatched
373
+ * with no crew chosen — which is every run recorded before presets existed,
374
+ * and the reason the gate reads an absent field as "nothing required".
375
+ */
376
+ crew?: CrewPreset[];
377
+ /**
378
+ * Review verdicts this run collected, appended in order. Kept whole rather
379
+ * than reduced to a pass/fail: the gate needs the diff each verdict was
380
+ * about, and the human reading the record afterwards needs the findings.
381
+ */
382
+ reviews?: ReviewVerdict[];
299
383
  /** Number of times this run was resumed after an interruption. */
300
384
  resumes?: number;
301
385
  /**
@@ -314,6 +398,12 @@ export interface RunMeta {
314
398
  * human already answered.
315
399
  */
316
400
  allowedRoots?: string[];
401
+ /**
402
+ * The subset of `allowedRoots` Foreman opened by itself rather than the
403
+ * human (today: a worktree's parent repository). Kept apart so it can be
404
+ * withdrawn when the reason for it stops holding — a human's grant never is.
405
+ */
406
+ autoRoots?: string[];
317
407
  /** Dev servers the crew exposed through Foreman's proxy (see services.ts). */
318
408
  services?: Array<{ port: number; label: string; path: string; since: number; pid?: number }>;
319
409
  /**
@@ -463,6 +553,8 @@ export interface MissionProposal {
463
553
  directorProviderId?: string;
464
554
  workerProviderId?: string;
465
555
  modelRationale?: string;
556
+ /** Preset ids the planner may suggest; the human's toggles decide. */
557
+ crew?: string[];
466
558
  createdAt: number;
467
559
  }
468
560