@makerstufferupper/flypi 0.0.0-stage → 0.1.2

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/LICENSE +21 -0
  2. package/README.md +44 -2
  3. package/assets/screenshot.png +0 -0
  4. package/bin/flypi.mjs +28 -0
  5. package/dist/ui/assets/index-CG1CuGxu.js +105 -0
  6. package/dist/ui/assets/index-qaKjH5fH.css +1 -0
  7. package/dist/ui/flypi-logo-dark.jpeg +0 -0
  8. package/dist/ui/flypi-logo-light.jpeg +0 -0
  9. package/dist/ui/index.html +17 -0
  10. package/package.json +71 -5
  11. package/scripts/benchmark-nli.ts +273 -0
  12. package/scripts/flypi.mjs +303 -0
  13. package/scripts/probe-turn-boundary.ts +97 -0
  14. package/scripts/probe.ts +119 -0
  15. package/scripts/record-transcript.ts +63 -0
  16. package/scripts/reembed-memory.ts +41 -0
  17. package/scripts/release.mjs +35 -0
  18. package/scripts/secret.ts +24 -0
  19. package/scripts/serve-daemon.ts +101 -0
  20. package/scripts/serve-memory.ts +78 -0
  21. package/scripts/serve.ts +89 -0
  22. package/scripts/smoke-memory.mjs +239 -0
  23. package/scripts/smoke-secrets.mjs +153 -0
  24. package/scripts/smoke.mjs +59 -0
  25. package/scripts/start.mjs +294 -0
  26. package/scripts/turn-boundary.ts +52 -0
  27. package/scripts/update.ts +92 -0
  28. package/scripts/warm-models.ts +41 -0
  29. package/src/agent/config-files.ts +529 -0
  30. package/src/agent/mcp-signin.ts +137 -0
  31. package/src/agent/mcp-status.ts +168 -0
  32. package/src/agent/onboarding.ts +74 -0
  33. package/src/agent/packages.ts +150 -0
  34. package/src/agent/paths.ts +80 -0
  35. package/src/agent/permission-gate.ts +70 -0
  36. package/src/agent/pi-mcp.ts +97 -0
  37. package/src/agent/runtime.ts +347 -0
  38. package/src/daemon/index.ts +442 -0
  39. package/src/daemon/jobs.ts +202 -0
  40. package/src/daemon/update.ts +248 -0
  41. package/src/memory/capture.ts +109 -0
  42. package/src/memory/config.ts +90 -0
  43. package/src/memory/consolidate.ts +218 -0
  44. package/src/memory/db.ts +393 -0
  45. package/src/memory/embed.ts +154 -0
  46. package/src/memory/judge.ts +167 -0
  47. package/src/memory/mcp.ts +496 -0
  48. package/src/memory/media.ts +135 -0
  49. package/src/memory/progress.ts +39 -0
  50. package/src/memory/recall.ts +528 -0
  51. package/src/memory/review.ts +86 -0
  52. package/src/memory/store.ts +1034 -0
  53. package/src/memory/wiki.ts +140 -0
  54. package/src/secrets/cli.ts +248 -0
  55. package/src/secrets/config.ts +133 -0
  56. package/src/secrets/crypto.ts +180 -0
  57. package/src/secrets/extension.ts +253 -0
  58. package/src/secrets/redact.ts +132 -0
  59. package/src/secrets/vault.ts +396 -0
  60. package/src/server/event-log.ts +49 -0
  61. package/src/server/extension-ui.ts +118 -0
  62. package/src/server/index.ts +398 -0
  63. package/src/server/memory-api.ts +158 -0
  64. package/src/server/protocol.ts +947 -0
  65. package/src/server/run-store.ts +136 -0
  66. package/src/server/secrets-api.ts +88 -0
  67. package/src/server/session-registry.ts +593 -0
  68. package/src/shared/attachments.ts +40 -0
  69. package/src/shared/cache-probe.ts +47 -0
  70. package/src/shared/markdown.ts +17 -0
  71. package/src/shared/metrics.ts +212 -0
  72. package/src/shared/sqlite.ts +74 -0
  73. package/src/shared/transcript.ts +404 -0
  74. package/src/shared/turn-boundary.ts +98 -0
  75. package/src/shared/views.ts +31 -0
@@ -0,0 +1,442 @@
1
+ /**
2
+ * The always-on daemon (docs/architecture.md Phase 4.5): one process that holds the memory store, serves
3
+ * the agent server and the memory MCP surface, keeps conversations alive with no client attached, and runs
4
+ * the job queue. This is what makes dreaming possible later — and it is the process that knows, after a
5
+ * crash, which turn was in flight.
6
+ *
7
+ * Two databases, one per component (docs/decisions.md): `store` is the memory component's, and `runs` is the
8
+ * backend's. The daemon is the seam where they meet — it writes chat state to one and facts to the other, and
9
+ * nothing joins across the two files. Each keeps its own single writer (risk 3): the memory store is opened by
10
+ * the caller because opening it means loading the embedding and judgment models (~1.4 GB of RSS, docs/memory.md),
11
+ * and so is the run store, which is a file either entrypoint can open in a millisecond. Everything that makes
12
+ * it a *daemon* — the reconciliation, `/healthz`, `daemon.json`, the queue — lives here, and is tested here.
13
+ */
14
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
+ import { spawn } from "node:child_process";
16
+ import { join, resolve } from "node:path";
17
+ import type { DatabaseSync } from "node:sqlite";
18
+ import type { FlypiPaths } from "../agent/paths.ts";
19
+ import { applyConfigEdits, ensureAgentConfig, ensureMemoryEntry, memoryEndpoint } from "../agent/config-files.ts";
20
+ import { applyPackageEdits, checkPackageUpdates, dueForPackageCheck, readPackageCheck, writePackageCheck } from "../agent/packages.ts";
21
+ import { CONSOLIDATE_JOB, consolidate, sweepDue } from "../memory/consolidate.ts";
22
+ import { startMemoryServer, type MemoryServerHandle } from "../memory/mcp.ts";
23
+ import type { MemoryStore } from "../memory/store.ts";
24
+ import { WIKI_JOB, renderWiki } from "../memory/wiki.ts";
25
+ import { openSecretVault } from "../secrets/vault.ts";
26
+ import { REENCRYPT_JOB } from "../secrets/vault.ts";
27
+ import { memoryApi } from "../server/memory-api.ts";
28
+ import { secretsApi } from "../server/secrets-api.ts";
29
+ import { startFlypiServer, type FlypiServerHandle, type FlypiServerOptions } from "../server/index.ts";
30
+ import { SessionRegistry, type CreateSession } from "../server/session-registry.ts";
31
+ import type { RunStore } from "../server/run-store.ts";
32
+ import { JobQueue, startJobLoop, type JobHandler } from "./jobs.ts";
33
+ import { IDLE_BEFORE_INSTALL_MS, checkForUpdate, clearUpdateCheck, downloadUpdate, dueForCheck, installArgs, isGlobalInstall, isNewer, keepDownload, packageDir, packageInfo, readUpdateCheck, stagedUpdate, updateAvailable, writeUpdateCheck } from "./update.ts";
34
+
35
+ /** What `.flypi/daemon.json` holds: enough for `flypi status` to find a running daemon again. */
36
+ export interface DaemonInfo {
37
+ pid: number;
38
+ host: string;
39
+ port: number;
40
+ /** The token the UI needs. The file is 0600 for that reason. */
41
+ token: string;
42
+ /** Where the memory MCP surface answered, and the token its `mcp.json` entry must carry. */
43
+ memoryPort: number;
44
+ startedAt: string;
45
+ version: string;
46
+ }
47
+
48
+ export const DAEMON_FILE = join(".flypi", "daemon.json");
49
+
50
+ export interface DaemonOptions {
51
+ paths: FlypiPaths;
52
+ db: DatabaseSync;
53
+ store: MemoryStore;
54
+ /** The backend's own store: which conversation had a turn in flight (docs/architecture.md Phase 4.5). */
55
+ runs: RunStore;
56
+ /** Only to swap in the stub model in tests; production uses the registry's own factory. */
57
+ createSession?: CreateSession;
58
+ /** How often the queue is asked what is due. Production leaves the loop's own interval alone. */
59
+ jobIntervalMs?: number;
60
+ token?: string;
61
+ port?: number;
62
+ /** When that `port` was carried over from an earlier start rather than named now, one someone else has
63
+ * taken is recoverable: bind another and say so. Absent means `port` is a demand — a container's published
64
+ * port is useless on another number, and moving it quietly is the dead URL this path exists to avoid. */
65
+ portMayMove?: boolean;
66
+ /** Interface to bind. Omitted means the server's loopback default; a container needs `0.0.0.0`, since a
67
+ * published Docker port never reaches the container's own loopback. The token is the auth either way. */
68
+ host?: string;
69
+ uiDir?: string;
70
+ version?: string;
71
+ /** Where a daemon says what it did. Defaults to the console, which is where `flypi start` collects it. */
72
+ log?: (line: string) => void;
73
+ }
74
+
75
+ export interface DaemonHandle {
76
+ port: number;
77
+ memoryPort: number;
78
+ token: string;
79
+ url: string;
80
+ jobs: JobQueue;
81
+ close(): Promise<void>;
82
+ }
83
+
84
+ /** Read a running daemon's pid file, or null when it is missing, unreadable or holds something unusable. */
85
+ export function readDaemonInfo(root: string): DaemonInfo | null {
86
+ try {
87
+ const info = JSON.parse(readFileSync(join(root, DAEMON_FILE), "utf8")) as DaemonInfo;
88
+ if (typeof info.pid !== "number" || typeof info.port !== "number" || typeof info.token !== "string") return null;
89
+ return info;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ export async function startFlypiDaemon(options: DaemonOptions): Promise<DaemonHandle> {
96
+ const log = options.log ?? ((line: string) => console.log(line));
97
+ // Named once: it goes in the health page, the pid file and the server's own version banner, and a
98
+ // daemon that reported three different versions would be a bug nobody could see.
99
+ const version = options.version ?? "dev";
100
+ const paths = options.paths;
101
+ const queue = new JobQueue(options.db);
102
+
103
+ // A claim still held at startup belongs to a process that is gone; leaving it locked would keep that
104
+ // kind of job out of the queue forever (`queueJob` will not re-queue an open one).
105
+ const reclaimed = queue.releaseStaleClaims();
106
+ if (reclaimed > 0) log(`flypi: released ${reclaimed} job claim(s) left by a dead process`);
107
+
108
+ // The other half of crash recovery: a turn left `running` finished nowhere. Say which ones. That is the
109
+ // backend's record, not the memory store's — see src/server/run-store.ts.
110
+ const interrupted = options.runs.reconcileInterrupted();
111
+ if (interrupted.length > 0) log(`flypi: ${interrupted.length} turn(s) were interrupted by the previous shutdown: ${interrupted.join(", ")}`);
112
+
113
+ // A stale index is queued as a job by reading it, which is the point: the daemon fixes it when idle.
114
+ const identity = options.store.indexIdentity();
115
+ if (identity.stale > 0) log(`flypi: ${identity.stale} stored vectors are not from ${identity.expected?.model ?? "the configured model"} — memory.reembed is queued`);
116
+
117
+ // A fresh install has no agent dir at all, and a missing config file reads as a broken install
118
+ // (`could not read …: ENOENT`) rather than as what it is: no model configured yet. Writing the empty pair
119
+ // turns that into the answer the settings panel can act on — an empty list with an add button — and a new
120
+ // conversation then names the half that is still missing.
121
+ const blank = ensureAgentConfig(paths);
122
+ if (blank.length > 0) log(`flypi: wrote ${blank.join(" and ")} — no model is configured yet (the Settings tab adds one)`);
123
+
124
+ const endpoint = memoryEndpoint(paths);
125
+ // The vault is in the memory database, so the daemon — the process that already owns that file — is the
126
+ // process that can write it: `flypi secret put` from another process only reads the same tables, and the
127
+ // sessions started here redact against what is in them (docs/secrets.md Phase 5.5). `write: true` on the
128
+ // handle it already holds is what lets a `saveSecrets` from the web UI land here rather than being refused
129
+ // as a read-only process; without it the Secrets tab can list and redact and never store.
130
+ const vault = openSecretVault(paths, { db: options.db, write: true });
131
+ const drift = vault.keyDrift();
132
+ if (drift.stale > 0 && !drift.reencryptQueued) {
133
+ options.store.queueJob(REENCRYPT_JOB, { payload: { reason: `${drift.stale} secret(s) sealed under an older key` } });
134
+ log(`flypi: ${drift.stale} secret(s) are still sealed under an older key — secrets.reencrypt is queued`);
135
+ }
136
+ // The port and token the agent's own `mcp.json` names, so the daemon cannot be listening on one port
137
+ // while `recall()` connects to another. Absent in the file means the server picks its own.
138
+ const memoryOptions = { store: options.store, token: endpoint.token, principal: "daemon" };
139
+ let memory: MemoryServerHandle;
140
+ // Naming the port in that file makes a later run bind that port again, and an ephemeral port can be
141
+ // someone else's by then. That one failure is recoverable — and an entry pointing at a port nothing
142
+ // answers on is exactly the silent breakage this path exists to prevent, so the file is corrected too.
143
+ let moved = false;
144
+ try {
145
+ memory = await startMemoryServer({ ...memoryOptions, port: endpoint.port });
146
+ } catch (error) {
147
+ if (endpoint.port === undefined || (error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
148
+ log(`flypi: ${paths.mcpPath} names port ${endpoint.port}, which something else already holds — binding another port`);
149
+ memory = await startMemoryServer(memoryOptions);
150
+ moved = true;
151
+ }
152
+ // The port and token above live only in this process until they are in the file the agent reads, so a
153
+ // fresh install — which has no agent dir at all — gets one here, after the bind, saying where the server
154
+ // actually is. An existing entry is left alone: the file is the source of truth, not this process.
155
+ let adopted = false;
156
+ try {
157
+ adopted = ensureMemoryEntry(paths, memory.port, memory.token);
158
+ // Only the url, and only when the port it named was taken: the token, the exposure and everything
159
+ // else in the entry are the operator's, and rewriting one of those is how a `${VAR}` reference
160
+ // becomes a plaintext secret.
161
+ if (moved) applyConfigEdits(paths, { mcpServers: [{ name: "flypi-memory", url: `http://127.0.0.1:${memory.port}/mcp` }] });
162
+ } catch (error) {
163
+ // A file that will not parse belongs to the operator to fix; the daemon still serves everything else.
164
+ log(`flypi: cannot write the flypi-memory entry: ${(error as Error).message}`);
165
+ }
166
+ if (adopted) log(`flypi: wrote the flypi-memory entry in ${paths.mcpPath} — the agent will see the memory tools`);
167
+ // Anything else the file gets wrong is not ours to fix, and the note says which half is missing.
168
+ if (endpoint.note && !adopted) log(`flypi: ${endpoint.note}`);
169
+
170
+ const registry = new SessionRegistry({
171
+ root: paths.root,
172
+ createSession: options.createSession,
173
+ onRunStatus: (conversationId, status, info) => options.runs.setRunStatus(conversationId, status, info),
174
+ vault,
175
+ });
176
+
177
+ const startedAt = new Date().toISOString();
178
+ const serverOptions: FlypiServerOptions = {
179
+ registry,
180
+ ...(options.host ? { host: options.host } : {}),
181
+ token: options.token,
182
+ uiDir: options.uiDir,
183
+ serverVersion: version,
184
+ health: () => {
185
+ // The stamp, not a fresh check: `/healthz` is polled by launchd/systemd and must not reach the
186
+ // network, and it is how `flypi status` and the page report an update without a round trip.
187
+ const stamp = readUpdateCheck(paths.root);
188
+ const newer = updateAvailable(stamp, version);
189
+ const ready = stagedUpdate(stamp, version);
190
+ return {
191
+ pid: process.pid,
192
+ startedAt: startedAt,
193
+ version,
194
+ memoryPort: memory.port,
195
+ jobs: queue.queued(),
196
+ ...(newer ? { updateAvailable: newer } : {}),
197
+ ...(ready ? { updateReady: ready } : {}),
198
+ };
199
+ },
200
+ scheduler: () => queue.snapshot(),
201
+ secrets: secretsApi(vault, paths),
202
+ // The Memory tab (docs/memory.md Phase 6). This process owns the memory database, so it is the one
203
+ // that can write what the browser edits — the same reason the vault is opened here with `write: true`.
204
+ memory: memoryApi(options.store, options.db),
205
+ interruptedRun: (sessionFile) => options.runs.interruptedFor(sessionFile),
206
+ // The page's "install now": the same path the idle rule takes, without the wait.
207
+ installUpdate: () => installStaged(),
208
+ };
209
+ let server: FlypiServerHandle;
210
+ // The same rule as the memory port above, and the reason the pid file is written after the bind rather
211
+ // than from the requested flags: what the file names has to be the port that is actually answering.
212
+ try {
213
+ server = await startFlypiServer({ ...serverOptions, port: options.port });
214
+ } catch (error) {
215
+ if (!options.portMayMove || options.port === undefined || (error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
216
+ log(`flypi: port ${options.port} — the one an earlier start used — is already taken; binding another, and \`flypi status\` says where`);
217
+ server = await startFlypiServer(serverOptions);
218
+ }
219
+
220
+ const infoPath = join(paths.root, DAEMON_FILE);
221
+ const info: DaemonInfo = {
222
+ pid: process.pid,
223
+ host: server.host,
224
+ port: server.port,
225
+ token: server.token,
226
+ memoryPort: memory.port,
227
+ startedAt,
228
+ version,
229
+ };
230
+ // 0600: the file carries the token that guards the agent server.
231
+ writeFileSync(infoPath, `${JSON.stringify(info, null, 2)}\n`, { mode: 0o600 });
232
+
233
+ // Is a newer flypi published, and is its package downloaded? After the bind, so a daemon that is not
234
+ // listening yet is not waiting on a network it may not have, and fire-and-forget, because a registry that
235
+ // cannot be reached is never a startup failure. A checkout is not an installed package: it updates with
236
+ // git, and the registry has never heard of it. Downloading is not installing — see `updatePass`.
237
+ const pkg = packageInfo();
238
+ // One check at a time: the job loop's first pass runs immediately, while the start-of-daemon check is
239
+ // still waiting on the registry.
240
+ let checking = false;
241
+ const checkUpdates = async () => {
242
+ if (checking) return;
243
+ checking = true;
244
+ try {
245
+ const previous = readUpdateCheck(paths.root);
246
+ const stamp = await checkForUpdate({ current: version, name: pkg.name });
247
+ // A package already on disk stays unless the step it was fetched for has changed.
248
+ stamp.download = keepDownload(stamp, previous);
249
+ if (stamp.next && stamp.dist && !stamp.download) {
250
+ const downloaded = await downloadUpdate({ root: paths.root, version: stamp.next, dist: stamp.dist });
251
+ if (downloaded.file) stamp.download = { version: stamp.next, file: downloaded.file, at: new Date().toISOString() };
252
+ else log(`flypi: ${stamp.next} is available but could not be downloaded: ${downloaded.error}`);
253
+ }
254
+ writeUpdateCheck(paths.root, stamp);
255
+ const step = updateAvailable(stamp, version);
256
+ if (step) {
257
+ // One step, and the reader is told when more than one is waiting: the ladder is walked a release
258
+ // at a time so each version's own schema upgrade runs against the schema it expects.
259
+ const further = stamp.latest && isNewer(step, stamp.latest) ? `; one step at a time — ${stamp.latest} is the newest published` : "";
260
+ log(`flypi: ${step} is available — this is ${version}; ${stamp.download ? "downloaded, and it installs the next time the daemon is idle (`flypi update` does it now)" : "not downloaded — `flypi update` installs it"}${further}`);
261
+ } else if (stamp.error) log(`flypi: no update check: ${stamp.error}`);
262
+ } finally {
263
+ checking = false;
264
+ }
265
+ };
266
+
267
+ // Install the downloaded package and restart through the CLI — the same path `flypi restart` takes, so the
268
+ // port and interface carry over and the shutdown is the normal one (pid file removed, sockets closed, a
269
+ // turn that was running left interrupted). The CLI stops this process, so the daemon does not exit here.
270
+ let installRefused = false;
271
+ const installPackage = (to: string, file: string): string | null => {
272
+ if (installRefused) return "this process is not the npm global package — install it yourself";
273
+ if (!isGlobalInstall()) {
274
+ // Say it once: the running code is not what `npm install -g` would replace, so installing would
275
+ // land somewhere else and the restart would come back on this version.
276
+ installRefused = true;
277
+ return "this process is not the npm global package — install it yourself";
278
+ }
279
+ log(`flypi: installing ${to} from ${file} — it restarts itself to run it`);
280
+ const npm = spawn("npm", installArgs(file), { stdio: "ignore" });
281
+ // Both listeners, or a missing `npm` on a launchd/systemd PATH is an unhandled `error` event — which
282
+ // would take the daemon down on the spot, the one thing an update attempt must never do.
283
+ npm.on("error", (error) => log(`flypi: the install could not start: ${error.message} — staying on ${version}`));
284
+ npm.on("exit", (code) => {
285
+ if (code !== 0) {
286
+ log(`flypi: the install failed (npm exited ${code}) — staying on ${version}, and it will try again after the next idle stretch`);
287
+ return;
288
+ }
289
+ const cli = spawn(process.execPath, [resolve(packageDir(), "scripts", "flypi.mjs"), "--root", paths.root, "restart"], { detached: true, stdio: "ignore" });
290
+ cli.on("error", (error) => log(`flypi: ${to} is installed, but the restart could not start: ${error.message}`));
291
+ cli.unref();
292
+ // Forget the answer: the version that comes back builds its own ladder from where it now stands,
293
+ // so the next step is one step, not the rest of an older answer.
294
+ clearUpdateCheck(paths.root);
295
+ });
296
+ return null;
297
+ };
298
+
299
+ // What the page's "install now" asks for: the staged package, or the newest published one if the daily
300
+ // check has not fetched it yet. Same guards, no idle wait — someone is attached, which is exactly the state
301
+ // the idle rule will not restart in, so this restart is asked for rather than stumbled into.
302
+ const installStaged = async (): Promise<string | null> => {
303
+ let stamp = readUpdateCheck(paths.root);
304
+ if (stamp?.next && stamp.dist && !stagedUpdate(stamp, version)) {
305
+ // "Now" can arrive before the daily check has fetched the package: fetch the step, not the top of
306
+ // the ladder.
307
+ const downloaded = await downloadUpdate({ root: paths.root, version: stamp.next, dist: stamp.dist });
308
+ if (downloaded.file) {
309
+ stamp = { ...stamp, download: { version: stamp.next, file: downloaded.file, at: new Date().toISOString() } };
310
+ writeUpdateCheck(paths.root, stamp);
311
+ }
312
+ }
313
+ const staged = stagedUpdate(stamp, version);
314
+ if (!staged) return "there is nothing downloaded to install — and the registry named no package to fetch";
315
+ if (!stamp?.download?.file) return "the downloaded update is not on disk any more";
316
+ return installPackage(staged, stamp.download.file);
317
+ };
318
+
319
+ // One pass of the update rule, from the job loop: re-check when the answer is stale, and install when the
320
+ // package is on disk and the daemon has been idle for a stretch — the sweep's idle (no page attached, no
321
+ // turn in flight) held for IDLE_BEFORE_INSTALL_MS, so a brief lull is never a restart.
322
+ let idleSince = 0;
323
+ // The same idle stretch installs extension packages (src/agent/packages.ts). They need no restart — the code
324
+ // is loaded per conversation — so the one difference is what happens afterwards: the conversations that are
325
+ // open are reloaded instead of the process.
326
+ let updatingPackages = false;
327
+ const updatePackages = async (sources: string[]) => {
328
+ if (updatingPackages) return;
329
+ updatingPackages = true;
330
+ try {
331
+ await applyPackageEdits(paths, { update: sources });
332
+ // The stamp is emptied for what was applied; the next day's check answers again from scratch. A source
333
+ // pi refuses to move (a pinned version, a registry that is down) stays recorded and is tried again.
334
+ writePackageCheck(paths.root, { checkedAt: new Date().toISOString(), updates: [] });
335
+ await registry.reloadExtensions();
336
+ log(`flypi: updated ${sources.length} extension package(s): ${sources.join(", ")}`);
337
+ } catch (error) {
338
+ log(`flypi: the extension update failed: ${(error as Error).message} — it will try again after the next idle stretch`);
339
+ } finally { updatingPackages = false; }
340
+ };
341
+ const checkPackages = async () => {
342
+ const check = await checkPackageUpdates(paths);
343
+ writePackageCheck(paths.root, check);
344
+ if (check.error) log(`flypi: no extension update check: ${check.error}`);
345
+ else if (check.updates.length) log(`flypi: ${check.updates.length} extension package(s) have updates — ${check.updates.join(", ")}; they install the next time the daemon is idle`);
346
+ };
347
+ const updatePass = (idle: boolean) => {
348
+ const stamp = readUpdateCheck(paths.root);
349
+ const pkgStamp = readPackageCheck(paths.root);
350
+ // A checkout is not an installed package, but the extensions it loads are: only flypi's own ladder is
351
+ // skipped there.
352
+ if (!pkg.checkout && dueForCheck(stamp)) void checkUpdates();
353
+ if (dueForPackageCheck(pkgStamp)) void checkPackages();
354
+ const staged = pkg.checkout ? null : stagedUpdate(stamp, version);
355
+ const pending = pkgStamp?.updates ?? [];
356
+ if ((!staged && pending.length === 0) || !idle) {
357
+ idleSince = 0;
358
+ return;
359
+ }
360
+ if (!idleSince) idleSince = Date.now();
361
+ if (Date.now() - idleSince < IDLE_BEFORE_INSTALL_MS) return;
362
+ // Packages first: they need no restart, and the flypi install below may take this process away.
363
+ if (pending.length) void updatePackages(pending);
364
+ if (staged && stamp?.download?.file) installPackage(staged, stamp.download.file);
365
+ // Start the clock again either way: a failed install retries after another idle stretch, not every pass.
366
+ idleSince = 0;
367
+ };
368
+ if (!pkg.checkout) void checkUpdates();
369
+ if (dueForPackageCheck(readPackageCheck(paths.root))) void checkPackages();
370
+
371
+ const handlers: Record<string, JobHandler> = {
372
+ // The kinds this build can run. `secrets.reencrypt` landed in Phase 5.5; `memory.consolidate` and
373
+ // `wiki.render` are Phase 6 — one line each in this map, and the queue needs nothing else.
374
+ "memory.reembed": async () => {
375
+ const result = await options.store.reembed();
376
+ // A new vector space is a different set of near pairs, so the merge pass runs again over it
377
+ // (docs/memory.md Phase 6: "re-run the merge pass after every `memory.reembed`"). Queued, not run
378
+ // inline: a sweep belongs to the same idle moment the re-embed did.
379
+ options.store.queueJob(CONSOLIDATE_JOB, { payload: { reason: "the vectors changed: near pairs are a different set now" } });
380
+ return `re-embedded ${result.embedded.length}, skipped ${result.skipped.length}${result.skipped.length ? `: ${result.skipped.map((row) => `#${row.id} ${row.why}`).join("; ")}` : ""}`;
381
+ },
382
+ "memory.consolidate": () => {
383
+ const sweep = consolidate(options.store);
384
+ // The wiki is a projection of what the sweep left behind, so it is re-rendered after a sweep and
385
+ // not on every write (docs/memory.md Phase 6).
386
+ options.store.queueJob(WIKI_JOB);
387
+ // The operator-facing line (`job_runs.detail` is what a person reads when a sweep ran), so a pass
388
+ // that found nothing to do says that instead of "merged 0 cluster(s) into nothing".
389
+ return `${sweep.merges.length ? `merged ${sweep.merges.length} cluster(s) into ${sweep.merges.map((merge) => `#${merge.survivor}`).join(", ")}` : "nothing to merge"}; promoted ${sweep.promotions.length}; rows ${sweep.rowsBefore} → ${sweep.rowsAfter}`;
390
+ },
391
+ "wiki.render": () => {
392
+ const wiki = renderWiki(options.store, options.db);
393
+ return `rendered ${wiki.pages} page(s), ${wiki.sections} section(s)`;
394
+ },
395
+ "secrets.reencrypt": async () => {
396
+ const result = vault.reencrypt();
397
+ return `re-sealed ${result.rows} secret(s) under v${result.keyVersion}`;
398
+ },
399
+ };
400
+ // A job is bookkeeping: it runs while nothing else is happening, and a pass that throws is a
401
+ // `failed` row in `job_runs`, not a restarted daemon.
402
+ const stopJobs = startJobLoop(queue, handlers, {
403
+ intervalMs: options.jobIntervalMs,
404
+ // The sweep's trigger, on flypi's conditions rather than a 3am cron (docs/memory.md Phase 6): the
405
+ // daemon is idle — no page attached and no turn in flight — and either enough rows have arrived since
406
+ // the last pass or the last one is a day old. A desktop app is off at 3am some nights, so a cron
407
+ // would simply not run.
408
+ beforePass: () => {
409
+ // The update rule and the sweep's trigger ask the same question — is the daemon idle? — so one
410
+ // answer per pass. The update installs only after a stretch of that idle (src/daemon/update.ts).
411
+ const idle = server.clients() === 0 && options.runs.runningTurns() === 0;
412
+ updatePass(idle);
413
+ const state = options.store.sweepState();
414
+ if (!sweepDue({ ...state, at: new Date().toISOString(), idle })) return;
415
+ options.store.queueJob(CONSOLIDATE_JOB, { payload: { reason: `${state.newRows} row(s) since the last sweep${state.lastSweepAt ? `, last ${state.lastSweepAt}` : ", never swept"}` } });
416
+ },
417
+ onPass: (ran) => ran.forEach((attempt) => log(`flypi: job ${attempt.kind} ${attempt.status}${attempt.detail ? `: ${attempt.detail}` : ""}`)),
418
+ });
419
+
420
+ return {
421
+ port: server.port,
422
+ memoryPort: memory.port,
423
+ token: server.token,
424
+ url: server.url,
425
+ jobs: queue,
426
+ close: async () => {
427
+ stopJobs();
428
+ // The vault shares this database handle, so its redaction wiring goes with it.
429
+ vault.close();
430
+ registry.dispose();
431
+ await server.close();
432
+ await memory.close();
433
+ rmSync(infoPath, { force: true });
434
+ },
435
+ };
436
+ }
437
+
438
+ /** Is the built UI there? `npm run build` first; a daemon without it is headless, which is allowed. */
439
+ export function builtUi(root: string): string | undefined {
440
+ const dir = join(root, "dist", "ui");
441
+ return existsSync(dir) ? dir : undefined;
442
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The daemon's job queue (docs/architecture.md Phase 4.5).
3
+ *
4
+ * One `setTimeout` loop, no queue dependency, and no cron parser: `jobs.next_run_at` is enough. What
5
+ * makes it safe is the claim — a single `UPDATE … WHERE id = ? AND locked_at IS NULL` whose `changes`
6
+ * says who won — so two schedulers on one row cannot both run it, and a crash mid-run cannot lose a
7
+ * fact that was already written (the facts live in `memories`; a job only reorganises them).
8
+ *
9
+ * Every attempt is recorded in `job_runs`: what ran, when, whether it worked, and what it changed. The
10
+ * record is written when the attempt ends and carries the kind itself, because a job row closes when its
11
+ * work is done (`reembed()` deletes it once the index is clean) and the history of what it did is not
12
+ * part of that cleanup.
13
+ */
14
+ import type { DatabaseSync } from "node:sqlite";
15
+ import { redactSecrets } from "../secrets/redact.ts";
16
+ import type { JobRow, JobRunRow, SchedulerSnapshot } from "../server/protocol.ts";
17
+
18
+ export interface Job {
19
+ id: number;
20
+ kind: string;
21
+ payload: unknown;
22
+ }
23
+
24
+ /** A job returns what it changed, for `job_runs.detail`. Throwing is a `failed` row, not a crash. */
25
+ export type JobHandler = (job: Job) => string | void | Promise<string | void>;
26
+
27
+ /** What one pass did, so a test (or the debug panel) can see it without reading the database. */
28
+ export interface JobAttempt {
29
+ id: number;
30
+ kind: string;
31
+ status: "ok" | "failed";
32
+ detail?: string;
33
+ }
34
+
35
+ /** How long a job that ran waits before it is due again. Jobs are periodic maintenance, not a queue. */
36
+ const RESCHEDULE_MS = 60 * 60 * 1000;
37
+
38
+ const now = () => new Date().toISOString();
39
+
40
+ /** How many jobs one pass takes. A pass is bounded so a huge backlog cannot starve a live turn. */
41
+ const DUE_BATCH = 50;
42
+
43
+ export class JobQueue {
44
+ constructor(
45
+ private readonly db: DatabaseSync,
46
+ private readonly clock: () => string = now,
47
+ ) {}
48
+
49
+ /**
50
+ * The jobs that are due and unclaimed, oldest first, as of this call. A pass works through this list
51
+ * rather than asking again after each job: a failed job stays due, and asking again would put the pass
52
+ * in a loop that never ends.
53
+ */
54
+ dueNow(): Job[] {
55
+ const rows = this.db
56
+ .prepare("SELECT id, kind, payload FROM jobs WHERE locked_at IS NULL AND next_run_at <= ? ORDER BY id LIMIT ?")
57
+ .all(this.clock(), DUE_BATCH) as { id: number; kind: string; payload: string }[];
58
+ return rows.map((row) => ({ id: row.id, kind: row.kind, payload: JSON.parse(row.payload) as unknown }));
59
+ }
60
+
61
+ /**
62
+ * The claim, exactly as the plan writes it. `changes` is the whole concurrency story: one caller gets
63
+ * 1, everyone else gets 0 and moves on. Nothing is read-then-written, so there is no window to race in.
64
+ */
65
+ claim(id: number, workerId: string): boolean {
66
+ return this.db.prepare("UPDATE jobs SET locked_by = ?, locked_at = ? WHERE id = ? AND locked_at IS NULL").run(workerId, this.clock(), id).changes === 1;
67
+ }
68
+
69
+ /** The queue's clock, so an attempt is stamped with the same clock that decided it was due. */
70
+ at(): string {
71
+ return this.clock();
72
+ }
73
+
74
+ /** Record one attempt — see the file header for why this happens after the run, not before. */
75
+ record(job: Job, startedAt: string, status: "ok" | "failed", detail?: string): void {
76
+ // A job that finished its work closes its own queue row (`reembed()` does), so the id may be gone by
77
+ // the time the attempt is recorded: link it when it is still there, and let the record stand alone
78
+ // when it is not. The kind is copied, so the history never loses what ran.
79
+ this.db
80
+ .prepare("INSERT INTO job_runs (job_id, kind, started_at, finished_at, status, detail) VALUES ((SELECT id FROM jobs WHERE id = ?), ?, ?, ?, ?, ?)")
81
+ .run(job.id, job.kind, startedAt, this.clock(), status, detail ?? null);
82
+ }
83
+
84
+ /**
85
+ * Release the claim. A job that failed keeps its `next_run_at` (the plan's rule: a failed sweep is
86
+ * retried on the next tick, not rescheduled into next week); a job that ran waits out `RESCHEDULE_MS`.
87
+ */
88
+ release(id: number, { failed }: { failed: boolean }): void {
89
+ const at = this.clock();
90
+ this.db
91
+ .prepare("UPDATE jobs SET locked_by = NULL, locked_at = NULL, next_run_at = ? WHERE id = ?")
92
+ .run(failed ? at : new Date(Date.parse(at) + RESCHEDULE_MS).toISOString(), id);
93
+ }
94
+
95
+ /** How many jobs are waiting. `flypi status` prints it, and the health page carries it. */
96
+ queued(): number {
97
+ return Number((this.db.prepare("SELECT count(*) AS n FROM jobs").get() as unknown as { n: number }).n);
98
+ }
99
+
100
+ /**
101
+ * On daemon start, any claim still held belongs to a process that is gone: the daemon is the only
102
+ * writer (risk 3), and a row locked by nobody would never run again — while `queueJob` happily queues a
103
+ * second row of the same kind beside it, because that one is not locked.
104
+ */
105
+ releaseStaleClaims(): number {
106
+ return Number(this.db.prepare("UPDATE jobs SET locked_by = NULL, locked_at = NULL WHERE locked_at IS NOT NULL").run().changes);
107
+ }
108
+
109
+ /** What the scheduler tab shows: the queue, and the attempts that ran. Newest attempts first. */
110
+ snapshot(limit = 20): SchedulerSnapshot {
111
+ const jobs = this.db.prepare("SELECT kind, next_run_at, locked_by FROM jobs ORDER BY next_run_at, id").all() as unknown as { kind: string; next_run_at: string; locked_by: string | null }[];
112
+ // `job_runs.kind` is the record's own copy, so an attempt keeps its name after its queue row closes.
113
+ const runs = this.db
114
+ .prepare("SELECT job_runs.kind AS recorded, jobs.kind AS queued, job_runs.status, job_runs.started_at, job_runs.finished_at, job_runs.detail FROM job_runs LEFT JOIN jobs ON jobs.id = job_runs.job_id ORDER BY job_runs.id DESC LIMIT ?")
115
+ .all(limit) as unknown as { recorded: string; queued: string; status: string; started_at: string; finished_at: string | null; detail: string | null }[];
116
+ return {
117
+ jobs: jobs.map((job) => ({ kind: job.kind, nextRunAt: job.next_run_at, running: job.locked_by !== null } satisfies JobRow)),
118
+ jobRuns: runs.map((run) => ({
119
+ kind: run.recorded || run.queued,
120
+ status: run.status as JobRunRow["status"],
121
+ startedAt: run.started_at,
122
+ ...(run.finished_at ? { finishedAt: run.finished_at } : {}),
123
+ ...(run.detail ? { detail: run.detail } : {}),
124
+ })),
125
+ };
126
+ }
127
+ }
128
+
129
+ /**
130
+ * One pass: claim and run every job that is due, and record each attempt.
131
+ *
132
+ * A kind with no handler here is a `failed` row saying so: the queue is not the place that knows which
133
+ * phases have landed, and a job nobody runs should be visible in the scheduler tab rather than silently
134
+ * open. `memory.consolidate` and `wiki.render` get their handlers in Phase 6, `secrets.reencrypt` in
135
+ * Phase 5.5 — registering one is one line in that phase's `handlers` map.
136
+ */
137
+ export async function runDueJobs(queue: JobQueue, handlers: Record<string, JobHandler>): Promise<JobAttempt[]> {
138
+ // The worker is this process: that is what makes a claim left behind recognisably one that a dead
139
+ // process abandoned.
140
+ const workerId = process.pid.toString();
141
+ const ran: JobAttempt[] = [];
142
+ for (const job of queue.dueNow()) {
143
+ // Someone else claimed it first: whoever won will run it. Not our job — literally.
144
+ if (!queue.claim(job.id, workerId)) continue;
145
+ const startedAt = queue.at();
146
+ let status: "ok" | "failed" = "ok";
147
+ let detail: string | undefined;
148
+ try {
149
+ const handler = handlers[job.kind];
150
+ if (!handler) throw new Error(`no handler for ${job.kind}: that phase has not landed`);
151
+ detail = (await handler(job)) ?? undefined;
152
+ } catch (error) {
153
+ status = "failed";
154
+ detail = (error as Error).message;
155
+ }
156
+ // A failed job's message quotes the command it failed, and that command may have had a credential in
157
+ // it. `job_runs.detail` is what an operator reads, so it is redacted on the way in (docs/secrets.md).
158
+ if (detail !== undefined) detail = redactSecrets(detail);
159
+ queue.record(job, startedAt, status, detail);
160
+ queue.release(job.id, { failed: status === "failed" });
161
+ ran.push({ id: job.id, kind: job.kind, status, ...(detail ? { detail } : {}) });
162
+ }
163
+ return ran;
164
+ }
165
+
166
+ /**
167
+ * The loop: one `setTimeout`, never `setInterval` — a pass that outlives its interval cannot overlap
168
+ * itself, and an unref'd timer never holds the process open on its own.
169
+ */
170
+ export function startJobLoop(
171
+ queue: JobQueue,
172
+ handlers: Record<string, JobHandler>,
173
+ options: { intervalMs?: number; onPass?: (ran: JobAttempt[]) => void; beforePass?: () => void } = {},
174
+ ): () => void {
175
+ const intervalMs = options.intervalMs ?? 60_000;
176
+ let timer: ReturnType<typeof setTimeout> | undefined;
177
+ let stopped = false;
178
+ const tick = () => {
179
+ // The pass can be told what to queue on its way in: the daemon asks "is a sweep due?" here, so the
180
+ // idle check happens at the moment the queue is about to run rather than on another timer that could
181
+ // decide while a turn was in flight.
182
+ try {
183
+ options.beforePass?.();
184
+ } catch (error) {
185
+ console.error(`flypi jobs: ${(error as Error).message}`);
186
+ }
187
+ void runDueJobs(queue, handlers)
188
+ .then((ran) => options.onPass?.(ran))
189
+ .catch((error) => console.error(`flypi jobs: ${(error as Error).message}`))
190
+ .finally(() => {
191
+ if (stopped) return;
192
+ timer = setTimeout(tick, intervalMs);
193
+ timer.unref();
194
+ });
195
+ };
196
+ timer = setTimeout(tick, 0);
197
+ timer.unref();
198
+ return () => {
199
+ stopped = true;
200
+ if (timer) clearTimeout(timer);
201
+ };
202
+ }