phoebe-agent 0.0.0 → 0.1.0

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.
Files changed (75) hide show
  1. package/README.md +77 -8
  2. package/bootstrap/bin.mjs +42 -0
  3. package/bootstrap/boot.ts +383 -0
  4. package/bootstrap/cli.ts +29 -0
  5. package/bootstrap/crash-loop.ts +391 -0
  6. package/bootstrap/define-config.ts +20 -0
  7. package/bootstrap/engine-source.ts +83 -0
  8. package/bootstrap/github-engine.ts +169 -0
  9. package/bootstrap/index.mjs +9 -0
  10. package/bootstrap/index.ts +24 -0
  11. package/bootstrap/materialize.mjs +53 -0
  12. package/bootstrap/reconcile.ts +314 -0
  13. package/bootstrap/spawn-engine.mjs +78 -0
  14. package/package.json +26 -24
  15. package/prompts/checks-prompt.md +21 -6
  16. package/prompts/conflict-prompt.md +12 -1
  17. package/prompts/research-prompt.md +61 -0
  18. package/src/agent-env.ts +32 -0
  19. package/src/branded.ts +19 -0
  20. package/src/cli.ts +187 -0
  21. package/src/config-schema.ts +278 -0
  22. package/src/drain.ts +74 -0
  23. package/src/execution-gate.ts +27 -0
  24. package/src/git-model.ts +113 -0
  25. package/src/init.ts +277 -0
  26. package/src/load-config.ts +168 -0
  27. package/src/main.ts +1636 -0
  28. package/src/orchestrator.ts +953 -0
  29. package/src/prompt.ts +98 -0
  30. package/src/providers/providers.ts +222 -0
  31. package/src/providers/run-agent.ts +90 -0
  32. package/src/providers/types.ts +26 -0
  33. package/src/resolved-config.ts +55 -0
  34. package/templates/.env.example +11 -5
  35. package/templates/container/Dockerfile +41 -19
  36. package/templates/container/compose.local.yml +37 -0
  37. package/templates/container/compose.yml +34 -13
  38. package/templates/phoebe.config.ts +30 -6
  39. package/dist/phoebe.config.d.ts +0 -2
  40. package/dist/phoebe.config.js +0 -43
  41. package/dist/src/agent-env.d.ts +0 -6
  42. package/dist/src/agent-env.js +0 -24
  43. package/dist/src/cli.d.ts +0 -25
  44. package/dist/src/cli.js +0 -161
  45. package/dist/src/config-schema.d.ts +0 -163
  46. package/dist/src/config-schema.js +0 -140
  47. package/dist/src/execution-gate.d.ts +0 -9
  48. package/dist/src/execution-gate.js +0 -17
  49. package/dist/src/git-model.d.ts +0 -30
  50. package/dist/src/git-model.js +0 -71
  51. package/dist/src/index.d.ts +0 -2
  52. package/dist/src/index.js +0 -12
  53. package/dist/src/init.d.ts +0 -82
  54. package/dist/src/init.js +0 -207
  55. package/dist/src/load-config.d.ts +0 -88
  56. package/dist/src/load-config.js +0 -153
  57. package/dist/src/main.d.ts +0 -7
  58. package/dist/src/main.js +0 -1180
  59. package/dist/src/orchestrator.d.ts +0 -275
  60. package/dist/src/orchestrator.js +0 -579
  61. package/dist/src/prompt.d.ts +0 -13
  62. package/dist/src/prompt.js +0 -55
  63. package/dist/src/providers/providers.d.ts +0 -3
  64. package/dist/src/providers/providers.js +0 -210
  65. package/dist/src/providers/run-agent.d.ts +0 -34
  66. package/dist/src/providers/run-agent.js +0 -57
  67. package/dist/src/providers/types.d.ts +0 -27
  68. package/dist/src/providers/types.js +0 -6
  69. package/dist/src/resolved-config.d.ts +0 -8
  70. package/dist/src/resolved-config.js +0 -49
  71. package/dist/src/supervisor-decision.d.ts +0 -70
  72. package/dist/src/supervisor-decision.js +0 -94
  73. package/templates/container/compose.daemon.yml +0 -16
  74. package/templates/container/supervisor.sh +0 -50
  75. /package/prompts/{prompt.md → issues-prompt.md} +0 -0
@@ -0,0 +1,391 @@
1
+ // Crash-loop fallback for `phoebe boot` — the guard on a bad engine ref.
2
+ //
3
+ // The reconcile watch (reconcile.ts) will happily follow the tracked branch onto
4
+ // any commit someone pushes, including one that dies on startup. Without a guard
5
+ // that is an unattended container crash-looping on an unattended repo. So boot
6
+ // keeps a small record of which engine commits actually *ran*: a SHA that dies
7
+ // fast, repeatedly, is quarantined, and boot pins back to the last SHA that ran
8
+ // healthily until the tracked branch moves past the bad one.
9
+ //
10
+ // This module is that record and the decisions over it — pure folds plus a JSON
11
+ // file — so the whole ladder (first crash → threshold → fallback → recovery) is
12
+ // tested without spawning an engine. The wiring (when a run ends, what to
13
+ // materialize, what to log) lives in boot.ts; the loop that calls it is
14
+ // reconcile.ts.
15
+ //
16
+ // The record lives in the engine's state dir (`paths.stateDir`, default
17
+ // `/data/state` — a named volume) rather than beside the engine clone, so a
18
+ // quarantine survives a container restart *and* a wiped engine volume: the whole
19
+ // point is to remember across the restart the crash itself causes.
20
+ //
21
+ // Only a github source with a moving branch is guarded. A local mount has no
22
+ // commit to pin, and a pinned ref means pinning — boot must not silently serve
23
+ // different code than the operator asked for (see boot.ts's eligibility check).
24
+ //
25
+ // This is the only crash-loop policy there is: the engine's own self-update and
26
+ // the shell supervisor that mirrored a copy of these decisions are gone (#44).
27
+
28
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
29
+ import { dirname, join } from "node:path";
30
+
31
+ /**
32
+ * Consecutive fast crashes of one engine SHA before boot pins back to the
33
+ * last-good one. Three is enough to rule out a one-off (a flaky network on the
34
+ * engine's first fetch, a busy host) while still recovering in minutes.
35
+ */
36
+ export const CRASH_LOOP_THRESHOLD = 3;
37
+
38
+ /**
39
+ * How long an engine run must survive to count as healthy. A commit that cannot
40
+ * boot dies well inside this; a long-lived engine that later hits a runtime
41
+ * error does not, and pinning to older code would not help it.
42
+ */
43
+ export const HEALTHY_RUN_MS = 60_000;
44
+
45
+ /** State dir used when the mounted config names none (matches the engine's default). */
46
+ export const DEFAULT_STATE_DIR = "/data/state";
47
+
48
+ /** Filename of the crash-loop record inside the state dir. */
49
+ export const CRASH_LOOP_STATE_FILE = "engine-crash-loop.json";
50
+
51
+ /** Crash-loop bookkeeping, persisted across container restarts on the state volume. */
52
+ export type CrashLoopState = {
53
+ /** SHA that last ran healthily — the fallback target. */
54
+ lastGoodSha: string | null;
55
+ /** SHA currently accumulating fast-crash counts (or quarantined as bad). */
56
+ failingSha: string | null;
57
+ /** Consecutive fast crashes recorded for `failingSha`. */
58
+ failureCount: number;
59
+ };
60
+
61
+ /** Nothing known yet: no proven commit, no quarantine. */
62
+ export const INITIAL_CRASH_LOOP_STATE: CrashLoopState = {
63
+ lastGoodSha: null,
64
+ failingSha: null,
65
+ failureCount: 0,
66
+ };
67
+
68
+ /** A finished engine run, as the fallback policy sees it. */
69
+ export type RunOutcome = {
70
+ /** The engine commit that was running. */
71
+ sha: string;
72
+ /** Its exit code; null when a signal killed it. */
73
+ exitCode: number | null;
74
+ /** How long it lived. */
75
+ elapsedMs: number;
76
+ /**
77
+ * Whether boot ended this run — a reconcile drain or a container stop — rather
78
+ * than the engine exiting of its own accord. Load-bearing: a run boot cut
79
+ * short proves nothing either way.
80
+ */
81
+ requestedStop: boolean;
82
+ };
83
+
84
+ /** What a finished run proved about the commit it was running. */
85
+ export type RunVerdict =
86
+ /** The commit works — it lived past the healthy window, or finished its own work. */
87
+ | "healthy"
88
+ /** The commit dies on startup: the thing the fallback exists to catch. */
89
+ | "crash"
90
+ /** Nothing was proved, so the record must not move. */
91
+ | "inconclusive";
92
+
93
+ /**
94
+ * Judge a finished run. The three-way answer matters: a run that neither proved
95
+ * nor disproved its commit has to leave the record alone, and reading it as
96
+ * "healthy" would be the worse mistake — a container stop landing mid-crash-loop
97
+ * would promote the crashing commit to last-good and disarm the fallback for
98
+ * good.
99
+ */
100
+ export function judgeRun(run: RunOutcome, healthyMs: number = HEALTHY_RUN_MS): RunVerdict {
101
+ // Living past the window proves the commit boots, however the run then ended.
102
+ if (run.elapsedMs >= healthyMs) return "healthy";
103
+ // Boot pulled the plug before the window was up: no verdict either way.
104
+ if (run.requestedStop) return "inconclusive";
105
+ // Exiting 0 unprompted means the engine finished what it was asked to do
106
+ // (`--run-once`), which it could only do by booting.
107
+ if (run.exitCode === 0) return "healthy";
108
+ // A signal from outside — an OOM kill, a `docker kill` — says nothing about
109
+ // the code either.
110
+ if (run.exitCode === null) return "inconclusive";
111
+ return "crash";
112
+ }
113
+
114
+ /**
115
+ * Fold a finished run into the record. A healthy run becomes the new last-good,
116
+ * but a quarantine on a *different* SHA is preserved — the healthy run is
117
+ * usually the fallback itself, and clearing the record there would send the next
118
+ * launch straight back into the commit it is avoiding. A fast crash counts
119
+ * against its own SHA, starting over whenever the failing SHA moves — except
120
+ * against an *active* quarantine, which outlives it.
121
+ */
122
+ export function recordRun(
123
+ state: CrashLoopState,
124
+ run: RunOutcome,
125
+ opts: { healthyMs?: number; threshold?: number } = {},
126
+ ): CrashLoopState {
127
+ const threshold = opts.threshold ?? CRASH_LOOP_THRESHOLD;
128
+ switch (judgeRun(run, opts.healthyMs ?? HEALTHY_RUN_MS)) {
129
+ case "inconclusive":
130
+ return state;
131
+ case "healthy": {
132
+ const stillQuarantining = state.failingSha !== null && state.failingSha !== run.sha;
133
+ return {
134
+ lastGoodSha: run.sha,
135
+ failingSha: stillQuarantining ? state.failingSha : null,
136
+ failureCount: stillQuarantining ? state.failureCount : 0,
137
+ };
138
+ }
139
+ case "crash": {
140
+ if (state.failingSha === run.sha) return { ...state, failureCount: state.failureCount + 1 };
141
+ // The fallback crashing too does not exonerate the commit it is standing
142
+ // in for: the quarantine has to hold until the tracked ref moves past the
143
+ // bad SHA, so a crash of some other commit must not overwrite it.
144
+ if (state.failingSha !== null && state.failureCount >= threshold) return state;
145
+ return { lastGoodSha: state.lastGoodSha, failingSha: run.sha, failureCount: 1 };
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Is there a better commit to relaunch onto than the one that just crashed?
152
+ * Answers boot's question after a fast crash: retry (the fallback is reachable,
153
+ * even if the threshold is not met yet) or give up and let the container exit.
154
+ * Without this, a first-ever bad ref — or a fallback that crashes too — would
155
+ * relaunch forever instead of failing where an operator can see it.
156
+ */
157
+ export function hasFallbackTarget(state: CrashLoopState, crashedSha: string): boolean {
158
+ return state.lastGoodSha !== null && state.lastGoodSha !== crashedSha;
159
+ }
160
+
161
+ /**
162
+ * The commit to run *instead of* `targetSha` (the tracked ref's current tip), or
163
+ * null to run the target as normal. Non-null only once the target has
164
+ * fast-crashed `threshold` times and a different known-good commit exists — so
165
+ * the fallback lapses by itself the moment the branch advances past the bad SHA,
166
+ * which is exactly "fallback persists until the ref moves on".
167
+ */
168
+ export function fallbackSha(
169
+ targetSha: string,
170
+ state: CrashLoopState,
171
+ threshold: number = CRASH_LOOP_THRESHOLD,
172
+ ): string | null {
173
+ if (state.failingSha !== targetSha || state.failureCount < threshold) return null;
174
+ return hasFallbackTarget(state, targetSha) ? state.lastGoodSha : null;
175
+ }
176
+
177
+ /**
178
+ * Where the engine keeps its persistent state, read straight from the mounted
179
+ * config. The bootstrapper resolves this itself — it writes the record before
180
+ * the engine (which owns the config schema) has even been materialized — so a
181
+ * missing or malformed `paths.stateDir` falls back to the engine's own default
182
+ * rather than being trusted or rejected.
183
+ */
184
+ export function readStateDir(config: Record<string, unknown>): string {
185
+ const paths = config["paths"];
186
+ if (paths === null || typeof paths !== "object") return DEFAULT_STATE_DIR;
187
+ const stateDir = (paths as Record<string, unknown>)["stateDir"];
188
+ return typeof stateDir === "string" && stateDir.length > 0 ? stateDir : DEFAULT_STATE_DIR;
189
+ }
190
+
191
+ /** The crash-loop record's path inside a state dir. */
192
+ export function crashLoopStatePath(stateDir: string): string {
193
+ return join(stateDir, CRASH_LOOP_STATE_FILE);
194
+ }
195
+
196
+ function isCrashLoopState(value: unknown): value is CrashLoopState {
197
+ if (value === null || typeof value !== "object") return false;
198
+ const state = value as Record<string, unknown>;
199
+ return (
200
+ (state["lastGoodSha"] === null || typeof state["lastGoodSha"] === "string") &&
201
+ (state["failingSha"] === null || typeof state["failingSha"] === "string") &&
202
+ typeof state["failureCount"] === "number"
203
+ );
204
+ }
205
+
206
+ /**
207
+ * Read the record, or start fresh. Anything unreadable — no file yet, a
208
+ * half-written one from a container killed mid-write, a hand-edited mess —
209
+ * degrades to "nothing known" rather than failing boot: the cost is losing one
210
+ * fallback target, and the alternative is a bootstrapper that a bad file bricks.
211
+ */
212
+ export function readCrashLoopState(path: string): CrashLoopState {
213
+ try {
214
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
215
+ if (!isCrashLoopState(parsed)) return INITIAL_CRASH_LOOP_STATE;
216
+ return {
217
+ lastGoodSha: parsed.lastGoodSha,
218
+ failingSha: parsed.failingSha,
219
+ failureCount: parsed.failureCount,
220
+ };
221
+ } catch {
222
+ return INITIAL_CRASH_LOOP_STATE;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Persist the record, creating the state dir if the volume is empty. Throws on a
228
+ * genuinely unwritable state dir; the guard turns that into an event, since
229
+ * losing the fallback is better than refusing to run the engine.
230
+ */
231
+ export function writeCrashLoopState(path: string, state: CrashLoopState): void {
232
+ mkdirSync(dirname(path), { recursive: true });
233
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
234
+ }
235
+
236
+ /**
237
+ * Everything the guard decides that is worth telling an operator about. Emitted
238
+ * rather than logged so the policy stays testable and boot.ts owns the wording —
239
+ * a container silently serving older code than its config asks for is exactly
240
+ * the confusing situation these lines exist to prevent.
241
+ */
242
+ export type CrashGuardEvent =
243
+ | {
244
+ kind: "crash";
245
+ sha: string;
246
+ exitCode: number | null;
247
+ elapsedMs: number;
248
+ failureCount: number;
249
+ threshold: number;
250
+ }
251
+ /** A run proved its commit; it is now the fallback target. */
252
+ | { kind: "last-good"; sha: string }
253
+ /** This launch is pinned to `lastGoodSha` instead of the tracked ref's tip. */
254
+ | { kind: "fallback"; quarantinedSha: string; lastGoodSha: string; failureCount: number }
255
+ /** The fallback crashed too. The quarantine holds; boot is out of options. */
256
+ | {
257
+ kind: "fallback-crashed";
258
+ sha: string;
259
+ quarantinedSha: string;
260
+ exitCode: number | null;
261
+ elapsedMs: number;
262
+ }
263
+ /** The tracked ref moved past the quarantined commit; the pin lapses. */
264
+ | { kind: "recovered"; quarantinedSha: string; sha: string }
265
+ | { kind: "persist-failed"; path: string; error: unknown };
266
+
267
+ /**
268
+ * The crash-loop guard boot supervises with: the persisted record, held in
269
+ * memory for the life of the container and written through on every change.
270
+ */
271
+ export type CrashGuard = {
272
+ /**
273
+ * The commit to run instead of `targetSha` (the tracked ref's tip), or null to
274
+ * run the target. Called once per launch, before the engine is spawned.
275
+ */
276
+ fallbackFor: (targetSha: string) => string | null;
277
+ /** Fold a finished run into the record and persist it. */
278
+ record: (run: RunOutcome) => void;
279
+ /**
280
+ * A commit is still running, and has been for `elapsedMs`. Once that passes
281
+ * the healthy window the commit has proved itself, so it is banked as the
282
+ * fallback target *while it runs* — an engine that has been up for a month and
283
+ * is then killed by a host reboot would otherwise have left no record at all,
284
+ * and the bad commit waiting on the branch would have nothing to fall back to.
285
+ */
286
+ noteAlive: (sha: string, elapsedMs: number) => void;
287
+ /**
288
+ * Is relaunching after this run worth it? Only for a crash, and only when a
289
+ * *different* known-good commit exists to end up on — otherwise boot lets the
290
+ * container exit rather than loop on the same broken commit forever. The
291
+ * answer does not depend on whether `record` ran first: folding a crash in
292
+ * never changes which commit is last-good.
293
+ */
294
+ shouldRetry: (run: RunOutcome) => boolean;
295
+ };
296
+
297
+ export function createCrashGuard(deps: {
298
+ /** The record's path, resolved from the mounted config at boot. */
299
+ statePath: string;
300
+ onEvent?: (event: CrashGuardEvent) => void;
301
+ threshold?: number;
302
+ healthyMs?: number;
303
+ }): CrashGuard {
304
+ const threshold = deps.threshold ?? CRASH_LOOP_THRESHOLD;
305
+ const healthyMs = deps.healthyMs ?? HEALTHY_RUN_MS;
306
+ const options = { threshold, healthyMs };
307
+ const emit = (event: CrashGuardEvent) => deps.onEvent?.(event);
308
+ let state = readCrashLoopState(deps.statePath);
309
+
310
+ const persist = (next: CrashLoopState) => {
311
+ state = next;
312
+ try {
313
+ writeCrashLoopState(deps.statePath, next);
314
+ } catch (error) {
315
+ // In-memory bookkeeping still guards this container's life; only the
316
+ // across-restart half is lost.
317
+ emit({ kind: "persist-failed", path: deps.statePath, error });
318
+ }
319
+ };
320
+
321
+ return {
322
+ fallbackFor(targetSha) {
323
+ const pin = fallbackSha(targetSha, state, threshold);
324
+ if (pin !== null) {
325
+ emit({
326
+ kind: "fallback",
327
+ quarantinedSha: targetSha,
328
+ lastGoodSha: pin,
329
+ failureCount: state.failureCount,
330
+ });
331
+ return pin;
332
+ }
333
+ if (state.failingSha !== null && state.failingSha !== targetSha) {
334
+ // The tracked ref has moved on, so whatever we knew about the old commit
335
+ // is history. Dropping it here (rather than leaving it for the next
336
+ // crash to overwrite) is what makes the fallback lapse exactly once.
337
+ const quarantinedSha = state.failingSha;
338
+ const wasQuarantined = state.failureCount >= threshold;
339
+ persist({ lastGoodSha: state.lastGoodSha, failingSha: null, failureCount: 0 });
340
+ if (wasQuarantined) emit({ kind: "recovered", quarantinedSha, sha: targetSha });
341
+ }
342
+ return null;
343
+ },
344
+
345
+ record(run) {
346
+ const before = state;
347
+ const verdict = judgeRun(run, healthyMs);
348
+ persist(recordRun(before, run, options));
349
+
350
+ if (verdict === "inconclusive") return;
351
+ if (verdict === "healthy") {
352
+ if (state.lastGoodSha !== before.lastGoodSha) emit({ kind: "last-good", sha: run.sha });
353
+ return;
354
+ }
355
+ if (
356
+ before.failingSha !== null &&
357
+ before.failingSha !== run.sha &&
358
+ before.failureCount >= threshold
359
+ ) {
360
+ // A crash the quarantine swallowed — this is the fallback itself dying,
361
+ // which reads very differently in a log to the tracked ref dying.
362
+ emit({
363
+ kind: "fallback-crashed",
364
+ sha: run.sha,
365
+ quarantinedSha: before.failingSha,
366
+ exitCode: run.exitCode,
367
+ elapsedMs: run.elapsedMs,
368
+ });
369
+ return;
370
+ }
371
+ emit({
372
+ kind: "crash",
373
+ sha: run.sha,
374
+ exitCode: run.exitCode,
375
+ elapsedMs: run.elapsedMs,
376
+ failureCount: state.failureCount,
377
+ threshold,
378
+ });
379
+ },
380
+
381
+ noteAlive(sha, elapsedMs) {
382
+ if (elapsedMs < healthyMs || state.lastGoodSha === sha) return;
383
+ persist(recordRun(state, { sha, exitCode: null, elapsedMs, requestedStop: false }, options));
384
+ emit({ kind: "last-good", sha });
385
+ },
386
+
387
+ shouldRetry(run) {
388
+ return judgeRun(run, healthyMs) === "crash" && hasFallbackTarget(state, run.sha);
389
+ },
390
+ };
391
+ }
@@ -0,0 +1,20 @@
1
+ // `defineConfig` — the identity typing helper consumers import from
2
+ // `phoebe-agent` to author their `phoebe.config.ts`:
3
+ //
4
+ // ```ts
5
+ // import { defineConfig } from "phoebe-agent";
6
+ // export default defineConfig({ repoSlug: "...", ... });
7
+ // ```
8
+ //
9
+ // It lives in the bootstrapper (the published package surface) rather than the
10
+ // engine because the bootstrapper is what a consumer installs. The config shape
11
+ // itself (`PhoebeUserConfig`, including the bootstrapper-only `engine` field) is
12
+ // owned by the engine's config schema; this is only the typing helper. The
13
+ // value is never read at runtime beyond being forwarded — the whole benefit is
14
+ // editor autocomplete and a compile-time check that only known fields appear.
15
+
16
+ import type { PhoebeUserConfig } from "../src/config-schema.ts";
17
+
18
+ export function defineConfig(config: PhoebeUserConfig): PhoebeUserConfig {
19
+ return config;
20
+ }
@@ -0,0 +1,83 @@
1
+ // The bootstrapper's minimal engine-source reader.
2
+ //
3
+ // The published `phoebe-agent` package is a thin bootstrapper: it materializes
4
+ // the engine (`src/`) from a git ref or a local mount, then runs it. The only
5
+ // part of the consumer's mounted `phoebe.config.ts` the bootstrapper needs is
6
+ // the `engine` field — the full config schema stays the engine's concern
7
+ // (config-schema.ts + resolveConfig). This module is that minimal reader: it
8
+ // takes the `engine` field (or a loaded config carrying it) and returns the
9
+ // resolved source with defaults applied, and nothing else.
10
+ //
11
+ // The user-facing field type (`EngineSourceField`) lives with the rest of the
12
+ // config schema in the engine so there is one source of truth for the config
13
+ // shape; this module owns only the resolution + the defaults.
14
+
15
+ import type { EngineSourceField } from "../src/config-schema.ts";
16
+
17
+ /** Repo the engine is cloned from when `source: "github"` omits `repo`. */
18
+ export const DEFAULT_ENGINE_REPO = "JesusFilm/phoebe";
19
+ /** Ref the engine is checked out at when `source: "github"` omits `ref`. */
20
+ export const DEFAULT_ENGINE_REF = "main";
21
+
22
+ /**
23
+ * The engine source with every default applied — what the bootstrapper acts on.
24
+ * `github` always carries a concrete `ref` and `repo`; `local` carries nothing
25
+ * (the engine is read from its mount).
26
+ */
27
+ export type ResolvedEngineSource =
28
+ | { source: "github"; ref: string; repo: string }
29
+ | { source: "local" };
30
+
31
+ /**
32
+ * Resolve the optional `engine` field into a concrete source. An omitted field
33
+ * (or an omitted `ref`/`repo` on a github source) falls back to the shipped
34
+ * defaults: github, ref `main`, repo `JesusFilm/phoebe`.
35
+ */
36
+ export function resolveEngineSource(field: EngineSourceField | undefined): ResolvedEngineSource {
37
+ if (field === undefined || field.source === "github") {
38
+ return {
39
+ source: "github",
40
+ ref: field?.ref ?? DEFAULT_ENGINE_REF,
41
+ repo: field?.repo ?? DEFAULT_ENGINE_REPO,
42
+ };
43
+ }
44
+ return { source: "local" };
45
+ }
46
+
47
+ /**
48
+ * Whether an arbitrary value is a well-formed `engine` field. The bootstrapper
49
+ * is the only reader of `engine` — the engine drops it (`resolveConfig`) rather
50
+ * than validating it — so a malformed value has to be rejected here or it never
51
+ * is. An unknown `source` must not fall through to `local`, and a non-string
52
+ * `ref`/`repo` must not escape into a `ResolvedEngineSource` typed as strings.
53
+ */
54
+ function isEngineSourceField(value: unknown): value is EngineSourceField {
55
+ if (value === null || typeof value !== "object") return false;
56
+ const field = value as Record<string, unknown>;
57
+ if (field["source"] === "local") return true;
58
+ return (
59
+ field["source"] === "github" &&
60
+ (field["ref"] === undefined || typeof field["ref"] === "string") &&
61
+ (field["repo"] === undefined || typeof field["repo"] === "string")
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Extract the resolved engine source from a loaded consumer config, ignoring
67
+ * every other field. This is the whole of the bootstrapper's interest in the
68
+ * config; the engine reads (and validates) the rest once it is materialized and
69
+ * run. The config arrives as a dynamically-imported module value, so the param
70
+ * is an arbitrary record — `engine`, if present, is validated before use so a
71
+ * typo (bad `source`, numeric `ref`/`repo`) fails loudly instead of silently
72
+ * resolving to the wrong source.
73
+ */
74
+ export function readEngineSource(config: Record<string, unknown>): ResolvedEngineSource {
75
+ const field = config["engine"];
76
+ if (field !== undefined && !isEngineSourceField(field)) {
77
+ throw new Error(
78
+ `phoebe.config.ts \`engine\` must be { source: "github", ref?, repo? } or ` +
79
+ `{ source: "local" } with string ref/repo (got ${JSON.stringify(field)}).`,
80
+ );
81
+ }
82
+ return resolveEngineSource(field);
83
+ }
@@ -0,0 +1,169 @@
1
+ // Materialize the Phoebe engine from GitHub for `engine: { source: "github" }`.
2
+ //
3
+ // The published package is a thin bootstrapper; when the config selects a github
4
+ // source, `phoebe boot` runs the engine straight from a git checkout rather than
5
+ // the bundled copy. First boot clones the engine repo into a persistent dir;
6
+ // every boot fetches the configured ref and checks it out, so a branch tracks
7
+ // its tip while a pinned SHA or tag pins an exact commit. The clone lives on a
8
+ // persistent volume (keep `PHOEBE_ENGINE_DIR` on one) so later boots fetch into
9
+ // the existing clone instead of re-cloning.
10
+ //
11
+ // Auth reuses `GH_TOKEN` (the same token every `gh` call uses), embedded as an
12
+ // `x-access-token` HTTPS credential. The token is passed explicitly to each
13
+ // clone/fetch and scrubbed from the persisted `origin` URL, so a rotated token
14
+ // keeps working and no secret is written to the volume's git config.
15
+ //
16
+ // The git mechanics reuse the engine's injectable GitRunner seam
17
+ // (src/git-model.ts) so this materializer is unit-tested without running git.
18
+ //
19
+ // This module also owns the *ref watch* the reconcile loop polls (#42): asking
20
+ // the remote where the configured ref points now, so `phoebe boot` can notice
21
+ // the tracked branch has advanced past the commit the running engine was checked
22
+ // out at. Only a moving branch is watched — a pinned SHA or tag is inert.
23
+
24
+ import { existsSync, mkdirSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { defaultGit, type GitRunner } from "../src/git-model.ts";
27
+
28
+ /** The resolved github source the materializer acts on (defaults already applied). */
29
+ export type GithubSource = { source: "github"; ref: string; repo: string };
30
+
31
+ /** The persistent clone directory for a given engine repo, keyed by repo slug. */
32
+ export function githubEngineDir(baseDir: string, repo: string): string {
33
+ return join(baseDir, "github", repo.replace(/[^a-zA-Z0-9]+/g, "-"));
34
+ }
35
+
36
+ /**
37
+ * The HTTPS URL git clones/fetches from. With a token it carries an
38
+ * `x-access-token` credential (GitHub's convention for PAT/app tokens); without
39
+ * one it is the plain URL, so public repos and anonymous local dev still work.
40
+ */
41
+ export function buildAuthenticatedRepoUrl(repo: string, token: string | undefined): string {
42
+ if (!token) return `https://github.com/${repo}.git`;
43
+ return `https://x-access-token:${token}@github.com/${repo}.git`;
44
+ }
45
+
46
+ /**
47
+ * Ensure a git checkout of `repo` at `ref` exists under `baseDir` and return the
48
+ * engine-CLI path (`<clone>/src/cli.ts`) `phoebe boot` execs, plus the commit it
49
+ * landed on. Clones only when the clone is absent; otherwise fetches into the
50
+ * existing one. `ref` (branch, 40-char SHA, or tag) is fetched by name and
51
+ * checked out via `FETCH_HEAD`, so a branch lands on its current tip and a
52
+ * SHA/tag on that exact commit.
53
+ *
54
+ * The returned `sha` is what the reconcile ref-watch compares the remote tip
55
+ * against — it is the commit actually running, so a relaunch is triggered by the
56
+ * branch advancing rather than by anything the poll itself remembers.
57
+ *
58
+ * `git`/`exists` are injectable so the command sequence is unit-tested without a
59
+ * real repository or network.
60
+ */
61
+ export function materializeGithubEngine(
62
+ source: GithubSource,
63
+ deps: {
64
+ baseDir: string;
65
+ token?: string | undefined;
66
+ git?: GitRunner;
67
+ exists?: (path: string) => boolean;
68
+ },
69
+ ): { entry: string; sha: string | null } {
70
+ const git = deps.git ?? defaultGit;
71
+ const exists = deps.exists ?? existsSync;
72
+ const dir = githubEngineDir(deps.baseDir, source.repo);
73
+ const authUrl = buildAuthenticatedRepoUrl(source.repo, deps.token);
74
+ const cleanUrl = buildAuthenticatedRepoUrl(source.repo, undefined);
75
+
76
+ let sha: string | null;
77
+ try {
78
+ if (!exists(join(dir, ".git"))) {
79
+ // Clone into a pre-created empty dir (git needs the leading dirs to exist).
80
+ // Inlined rather than reusing git-model's `ensureClone`: this has to inject
81
+ // `exists` (for testing) and interleave the token-scrub below, neither of
82
+ // which that helper exposes.
83
+ mkdirSync(dir, { recursive: true });
84
+ git(["clone", authUrl, dir], { stdio: "inherit" });
85
+ // Drop the token from the persisted `origin` URL — the fetch below
86
+ // re-supplies it explicitly, so a rotated token keeps working and the
87
+ // volume's git config never holds the secret. The token is still passed on
88
+ // git's argv (clone/fetch); that is acceptable here because `GH_TOKEN`
89
+ // already lives in the container env and boot runs before any agent child.
90
+ git(["-C", dir, "remote", "set-url", "origin", cleanUrl]);
91
+ }
92
+ // Fetch the ref by name (works for branch/tag/reachable-SHA on GitHub) and
93
+ // detach onto exactly what was fetched — latest for a branch, pinned for a
94
+ // SHA/tag. `--force` lets a moved tag/branch and a dirty tree update cleanly.
95
+ git(["-C", dir, "fetch", "--force", "--tags", authUrl, source.ref], { stdio: "inherit" });
96
+ git(["-C", dir, "checkout", "--force", "--detach", "FETCH_HEAD"], { stdio: "inherit" });
97
+ sha = git(["-C", dir, "rev-parse", "HEAD"]).trim() || null;
98
+ } catch (error) {
99
+ throw new Error(
100
+ `Failed to materialize the engine from ${source.repo}@${source.ref}: ` +
101
+ `${error instanceof Error ? error.message : String(error)}`,
102
+ );
103
+ }
104
+
105
+ return { entry: join(dir, "src", "cli.ts"), sha };
106
+ }
107
+
108
+ /** A 40-char hex ref is an exact commit — nothing for the ref-watch to track. */
109
+ export function isPinnedSha(ref: string): boolean {
110
+ return /^[0-9a-f]{40}$/i.test(ref);
111
+ }
112
+
113
+ /**
114
+ * Bound on the reconcile poll's `ls-remote`. It runs on the bootstrapper's event
115
+ * loop (`execFileSync`), so a hung remote must not stall boot's SIGTERM latch or
116
+ * its child-exit handling — a timed-out poll surfaces as a sample error and is
117
+ * treated as "no change", not a crash.
118
+ */
119
+ export const LS_REMOTE_TIMEOUT_MS = 30_000;
120
+
121
+ /**
122
+ * Parse `git ls-remote` output into `{ sha, refName }` rows. Each line is
123
+ * `<sha>\t<refname>`; peeled tag lines (`refs/tags/v1^{}`) come through as
124
+ * ordinary rows and are simply never matched as branches.
125
+ */
126
+ export function parseLsRemote(output: string): Array<{ sha: string; refName: string }> {
127
+ const rows: Array<{ sha: string; refName: string }> = [];
128
+ for (const line of output.split("\n")) {
129
+ const [sha, refName] = line.trim().split(/\s+/);
130
+ if (sha && refName) rows.push({ sha, refName });
131
+ }
132
+ return rows;
133
+ }
134
+
135
+ /**
136
+ * The tip `ref` currently points at, but only when `ref` names a **branch** —
137
+ * the one kind of ref that moves under a running engine. A tag (or a ref the
138
+ * remote does not have) yields null, which the reconcile loop reads as "nothing
139
+ * to watch": per #42 a pinned SHA/tag never triggers a relaunch, even if someone
140
+ * force-moves the tag.
141
+ *
142
+ * Accepts both the short form (`main`) and a fully-qualified `refs/heads/main`.
143
+ */
144
+ export function branchShaFromLsRemote(output: string, ref: string): string | null {
145
+ const wanted = ref.startsWith("refs/") ? ref : `refs/heads/${ref}`;
146
+ if (!wanted.startsWith("refs/heads/")) return null;
147
+ return parseLsRemote(output).find((row) => row.refName === wanted)?.sha ?? null;
148
+ }
149
+
150
+ /**
151
+ * Ask the remote where the configured branch points now — the ref half of a
152
+ * reconcile poll, and deliberately the *only* network call it makes: `ls-remote`
153
+ * reads a ref without fetching any objects. Returns null when there is nothing
154
+ * to watch (pinned SHA — not even asked; tag; unknown ref).
155
+ *
156
+ * The token rides on the URL exactly as it does for clone/fetch (same tradeoff,
157
+ * documented above): `GH_TOKEN` is already in the container env and boot runs
158
+ * before any agent child.
159
+ */
160
+ export function lsRemoteBranchSha(
161
+ source: GithubSource,
162
+ deps: { token?: string | undefined; git?: GitRunner } = {},
163
+ ): string | null {
164
+ if (isPinnedSha(source.ref)) return null;
165
+ const git = deps.git ?? defaultGit;
166
+ const url = buildAuthenticatedRepoUrl(source.repo, deps.token);
167
+ const output = git(["ls-remote", url, source.ref], { timeout: LS_REMOTE_TIMEOUT_MS });
168
+ return branchShaFromLsRemote(output, source.ref);
169
+ }
@@ -0,0 +1,9 @@
1
+ // Runtime surface of the `phoebe-agent` package — what a consumer's
2
+ // `phoebe.config.ts` resolves when it imports from `phoebe-agent`. Node resolves
3
+ // a bare package specifier into `node_modules`, where Node 24 won't type-strip
4
+ // `.ts`, so this entry must be JS. It is trivially small: the only thing a
5
+ // consumer calls at runtime is `defineConfig`, an identity helper (its typed
6
+ // signature lives in index.ts, the package `types` entry). Everything else the
7
+ // package exposes is types-only. The bootstrapper's real logic is TypeScript —
8
+ // see bootstrap/cli.ts, reached via the bin launcher (bootstrap/bin.mjs).
9
+ export const defineConfig = (config) => config;