@hyperdrive.bot/fleet-server 0.3.164 → 0.3.165

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 (55) hide show
  1. package/dist/server/extensions/daemon-backend.js +25 -0
  2. package/dist/server/server/agent/agent-storage.d.ts +67 -12
  3. package/dist/server/server/agent/agent-storage.js +208 -132
  4. package/dist/server/server/agent/providers/claude/transport/pty-query.js +10 -0
  5. package/dist/server/server/bootstrap.d.ts +5 -0
  6. package/dist/server/server/bootstrap.js +26 -3
  7. package/dist/server/server/chat/chat-service.d.ts +22 -0
  8. package/dist/server/server/chat/chat-service.js +46 -3
  9. package/dist/server/server/daemon-worker.js +1 -0
  10. package/dist/server/server/fleet/decision-service.d.ts +5 -0
  11. package/dist/server/server/fleet/decision-service.js +30 -0
  12. package/dist/server/server/loop-service.d.ts +95 -0
  13. package/dist/server/server/loop-service.js +64 -3
  14. package/dist/server/server/migrations/backfill-workspace-id.migration.js +3 -2
  15. package/dist/server/server/push/token-store.d.ts +5 -1
  16. package/dist/server/server/push/token-store.js +26 -5
  17. package/dist/server/server/schedule/service.js +4 -1
  18. package/dist/server/server/schedule/store.d.ts +21 -1
  19. package/dist/server/server/schedule/store.js +283 -2
  20. package/dist/server/server/search/indexer.d.ts +10 -0
  21. package/dist/server/server/search/indexer.js +51 -2
  22. package/dist/server/server/session.js +12 -13
  23. package/dist/server/server/state/agent-state.d.ts +39 -0
  24. package/dist/server/server/state/agent-state.js +94 -0
  25. package/dist/server/server/state/daemon-state.d.ts +30 -0
  26. package/dist/server/server/state/daemon-state.js +99 -0
  27. package/dist/server/server/state/legacy-import.d.ts +124 -0
  28. package/dist/server/server/state/legacy-import.js +682 -0
  29. package/dist/server/server/state/legacy-manifest.d.ts +73 -0
  30. package/dist/server/server/state/legacy-manifest.js +112 -0
  31. package/dist/server/server/state/legacy-mirror.d.ts +57 -0
  32. package/dist/server/server/state/legacy-mirror.js +114 -0
  33. package/dist/server/server/state/legacy-sources.d.ts +9 -0
  34. package/dist/server/server/state/legacy-sources.js +163 -0
  35. package/dist/server/server/state/state-db.d.ts +263 -0
  36. package/dist/server/server/state/state-db.js +772 -0
  37. package/dist/server/server/state/state-schema.d.ts +39 -0
  38. package/dist/server/server/state/state-schema.js +125 -0
  39. package/dist/server/server/state/state-worker.d.ts +48 -0
  40. package/dist/server/server/state/state-worker.js +165 -0
  41. package/dist/server/server/websocket-server.js +2 -1
  42. package/dist/server/server/workspace/session-openable.js +7 -0
  43. package/dist/server/server/workspace-reconciliation-service.js +14 -3
  44. package/dist/server/server/workspace-registry.d.ts +45 -2
  45. package/dist/server/server/workspace-registry.js +75 -4
  46. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js → index-38580ed8926a5ddc569d744b8299d339.js} +4 -4
  47. package/dist/server/web-ui/_expo/static/js/web/index-38580ed8926a5ddc569d744b8299d339.js.br +0 -0
  48. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.gz → index-38580ed8926a5ddc569d744b8299d339.js.gz} +0 -0
  49. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.map.br → index-38580ed8926a5ddc569d744b8299d339.js.map.br} +0 -0
  50. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.map.gz → index-38580ed8926a5ddc569d744b8299d339.js.map.gz} +0 -0
  51. package/dist/server/web-ui/index.html +1 -1
  52. package/dist/server/web-ui/index.html.br +0 -0
  53. package/dist/server/web-ui/index.html.gz +0 -0
  54. package/package.json +6 -6
  55. package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.br +0 -0
@@ -0,0 +1,39 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ /**
3
+ * Schema for `$PASEO_HOME/state.sqlite`, the daemon's non-blob state.
4
+ *
5
+ * Deliberately a separate file from `paseo.sqlite` (the ingestion ledger, with
6
+ * its own versioning contract and one-handle rule) and from
7
+ * `session-index.sqlite` (a derived, rebuildable search index). A bad migration
8
+ * here must never be able to touch either.
9
+ *
10
+ * One table shape, so every store is the same generic code path: each table
11
+ * holds keyed JSON documents. `scope` groups children under a parent (a
12
+ * schedule's runs, a loop's logs, a room's messages, an extension's keys);
13
+ * top-level documents use the empty scope. Every write stamps `rev` from one
14
+ * daemon-wide sequence, and a table with tombstones keeps a deleted row as
15
+ * `deleted_at` + `rev` with a NULL body, so "what changed since rev N" is one
16
+ * indexed range scan that also reports deletions.
17
+ *
18
+ * The append-only jsonl ledgers (blockers, card moves, authorship) are not
19
+ * here on purpose: appends are already O(1), their reads are indexed
20
+ * incrementally (`JsonlCardIndex`), and other processes may append to them.
21
+ */
22
+ export declare const STATE_SCHEMA_VERSION = 2;
23
+ export declare const DOC_TABLES: readonly ["agents", "projects", "workspaces", "schedules", "schedule_runs", "loops", "loop_logs", "chat_rooms", "chat_messages", "fleet_decisions", "push_tokens", "extension_kv"];
24
+ export type DocTableName = (typeof DOC_TABLES)[number];
25
+ /**
26
+ * Tables that keep a tombstone on delete. Fixed per table, not per caller: the
27
+ * import opens these tables before the stores do, and the first opener used
28
+ * to decide for everyone (the workspace registry then hard-deleted rows).
29
+ */
30
+ export declare const TOMBSTONED_TABLES: ReadonlySet<DocTableName>;
31
+ /**
32
+ * Bring a file to the current schema. Idempotent: every statement is
33
+ * `IF NOT EXISTS`, so running it on a current file changes nothing. A file
34
+ * stamped with a NEWER version than this build knows is refused, which makes
35
+ * the caller fall back to the file stores instead of writing rows a newer
36
+ * daemon laid out differently.
37
+ */
38
+ export declare function ensureStateSchema(db: DatabaseSync): void;
39
+ //# sourceMappingURL=state-schema.d.ts.map
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Schema for `$PASEO_HOME/state.sqlite`, the daemon's non-blob state.
3
+ *
4
+ * Deliberately a separate file from `paseo.sqlite` (the ingestion ledger, with
5
+ * its own versioning contract and one-handle rule) and from
6
+ * `session-index.sqlite` (a derived, rebuildable search index). A bad migration
7
+ * here must never be able to touch either.
8
+ *
9
+ * One table shape, so every store is the same generic code path: each table
10
+ * holds keyed JSON documents. `scope` groups children under a parent (a
11
+ * schedule's runs, a loop's logs, a room's messages, an extension's keys);
12
+ * top-level documents use the empty scope. Every write stamps `rev` from one
13
+ * daemon-wide sequence, and a table with tombstones keeps a deleted row as
14
+ * `deleted_at` + `rev` with a NULL body, so "what changed since rev N" is one
15
+ * indexed range scan that also reports deletions.
16
+ *
17
+ * The append-only jsonl ledgers (blockers, card moves, authorship) are not
18
+ * here on purpose: appends are already O(1), their reads are indexed
19
+ * incrementally (`JsonlCardIndex`), and other processes may append to them.
20
+ */
21
+ export const STATE_SCHEMA_VERSION = 2;
22
+ export const DOC_TABLES = [
23
+ "agents",
24
+ "projects",
25
+ "workspaces",
26
+ "schedules",
27
+ "schedule_runs",
28
+ "loops",
29
+ "loop_logs",
30
+ "chat_rooms",
31
+ "chat_messages",
32
+ "fleet_decisions",
33
+ "push_tokens",
34
+ "extension_kv",
35
+ ];
36
+ /**
37
+ * Tables that keep a tombstone on delete. Fixed per table, not per caller: the
38
+ * import opens these tables before the stores do, and the first opener used
39
+ * to decide for everyone (the workspace registry then hard-deleted rows).
40
+ */
41
+ export const TOMBSTONED_TABLES = new Set([
42
+ "agents",
43
+ "projects",
44
+ "workspaces",
45
+ ]);
46
+ function docTableSql(name) {
47
+ return `
48
+ CREATE TABLE IF NOT EXISTS ${name} (
49
+ scope TEXT NOT NULL DEFAULT '',
50
+ id TEXT NOT NULL,
51
+ ord INTEGER NOT NULL DEFAULT 0,
52
+ rev INTEGER NOT NULL,
53
+ updated_at INTEGER NOT NULL,
54
+ deleted_at INTEGER,
55
+ body TEXT,
56
+ PRIMARY KEY (scope, id)
57
+ ) WITHOUT ROWID;
58
+ CREATE INDEX IF NOT EXISTS ${name}_rev ON ${name}(rev);
59
+ CREATE INDEX IF NOT EXISTS ${name}_scope_ord ON ${name}(scope, ord);
60
+ `;
61
+ }
62
+ const V1_SQL = `
63
+ CREATE TABLE IF NOT EXISTS state_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
64
+ CREATE TABLE IF NOT EXISTS state_import (
65
+ store TEXT PRIMARY KEY,
66
+ status TEXT NOT NULL,
67
+ cursor TEXT,
68
+ imported INTEGER NOT NULL DEFAULT 0,
69
+ skipped INTEGER NOT NULL DEFAULT 0,
70
+ started_at INTEGER,
71
+ finished_at INTEGER
72
+ );
73
+ ${DOC_TABLES.map(docTableSql).join("\n")}
74
+ `;
75
+ /**
76
+ * v2: the legacy file manifest. One row per legacy JSON file this daemon wrote
77
+ * (or imported): its content hash, size and mtime as it left them, and the
78
+ * daemon-wide rev the file reflects. A file that no longer matches its row was
79
+ * changed by someone else; a row whose file is gone was deleted by someone
80
+ * else. See `legacy-manifest.ts`. Paths are relative to PASEO_HOME.
81
+ */
82
+ const V2_SQL = `
83
+ CREATE TABLE IF NOT EXISTS legacy_manifest (
84
+ path TEXT PRIMARY KEY,
85
+ store TEXT NOT NULL,
86
+ hash TEXT NOT NULL,
87
+ size INTEGER NOT NULL,
88
+ mtime_ms REAL NOT NULL,
89
+ rev INTEGER NOT NULL
90
+ ) WITHOUT ROWID;
91
+ CREATE INDEX IF NOT EXISTS legacy_manifest_store ON legacy_manifest(store);
92
+ `;
93
+ function readUserVersion(db) {
94
+ const row = db.prepare("PRAGMA user_version").get();
95
+ const value = row?.user_version;
96
+ return typeof value === "number" ? value : 0;
97
+ }
98
+ /**
99
+ * Bring a file to the current schema. Idempotent: every statement is
100
+ * `IF NOT EXISTS`, so running it on a current file changes nothing. A file
101
+ * stamped with a NEWER version than this build knows is refused, which makes
102
+ * the caller fall back to the file stores instead of writing rows a newer
103
+ * daemon laid out differently.
104
+ */
105
+ export function ensureStateSchema(db) {
106
+ const current = readUserVersion(db);
107
+ if (current > STATE_SCHEMA_VERSION) {
108
+ throw new Error(`state.sqlite schema version ${current} is newer than this daemon supports (${STATE_SCHEMA_VERSION})`);
109
+ }
110
+ if (current < STATE_SCHEMA_VERSION) {
111
+ db.exec("BEGIN IMMEDIATE");
112
+ try {
113
+ if (current < 1)
114
+ db.exec(V1_SQL);
115
+ db.exec(V2_SQL);
116
+ db.exec(`PRAGMA user_version = ${STATE_SCHEMA_VERSION}`);
117
+ db.exec("COMMIT");
118
+ }
119
+ catch (error) {
120
+ db.exec("ROLLBACK");
121
+ throw error;
122
+ }
123
+ }
124
+ }
125
+ //# sourceMappingURL=state-schema.js.map
@@ -0,0 +1,48 @@
1
+ import type { Logger } from "pino";
2
+ /** One statement for `StateWorker.write`: SQL plus its positional parameters. */
3
+ export interface WorkerStatement {
4
+ sql: string;
5
+ params: Array<string | number | null>;
6
+ }
7
+ /**
8
+ * A worker thread with its own connection to `state.sqlite`, for the two kinds
9
+ * of work that must not run on the daemon's event loop.
10
+ *
11
+ * WAL checkpoints. SQLite's automatic checkpoint runs inside whichever COMMIT
12
+ * crosses the threshold, on the committing thread, and includes the fsync. On
13
+ * the reference host (WSL2, load average over 40) that put 400ms at p99 and
14
+ * 1.4s at worst into a single 50-row commit. With `wal_autocheckpoint = 0` on
15
+ * the main connection, commits only append to the WAL; this worker folds it
16
+ * into the database file off the loop, every 2s when there were commits.
17
+ *
18
+ * Bulk writes (the first-boot import). A synchronous write stalls whenever the
19
+ * filesystem does, which on that host held the loop for 500ms at a time during
20
+ * an import whose own work was 5ms per batch. The import parses on the main
21
+ * thread and hands each batch here as one transaction.
22
+ */
23
+ export declare class StateWorker {
24
+ private readonly worker;
25
+ private readonly timer;
26
+ private readonly writes;
27
+ private nextWriteId;
28
+ private dirty;
29
+ private inFlight;
30
+ private closed;
31
+ private paused;
32
+ private constructor();
33
+ /** Start the worker, or return null so the caller keeps doing both on its own thread. */
34
+ static start(dbPath: string, logger: Logger): StateWorker | null;
35
+ markDirty(): void;
36
+ /** Commit `statements` as one transaction on the worker's connection. */
37
+ write(statements: WorkerStatement[]): Promise<void>;
38
+ /**
39
+ * Hold checkpoints (the first-boot import): a checkpoint's fsync shares the
40
+ * filesystem journal with the import's WAL appends. The WAL just grows until
41
+ * `resume`, which checkpoints once.
42
+ */
43
+ pause(): void;
44
+ resume(): void;
45
+ private tick;
46
+ close(): Promise<void>;
47
+ }
48
+ //# sourceMappingURL=state-worker.d.ts.map
@@ -0,0 +1,165 @@
1
+ import { Worker } from "node:worker_threads";
2
+ /** How often the worker looks for committed work to fold into the db file. */
3
+ const CHECKPOINT_INTERVAL_MS = 2000;
4
+ // Plain CommonJS so it runs as an eval worker under the bundled daemon, tsx and
5
+ // vitest alike, with no second entry point to ship.
6
+ const WORKER_SOURCE = `
7
+ const { parentPort, workerData } = require("node:worker_threads");
8
+ const { DatabaseSync } = require("node:sqlite");
9
+ const db = new DatabaseSync(workerData.dbPath);
10
+ db.exec("PRAGMA busy_timeout = 5000");
11
+ db.exec("PRAGMA synchronous = NORMAL");
12
+ db.exec("PRAGMA wal_autocheckpoint = 0");
13
+ const statements = new Map();
14
+ function prepare(sql) {
15
+ let statement = statements.get(sql);
16
+ if (!statement) {
17
+ statement = db.prepare(sql);
18
+ statements.set(sql, statement);
19
+ }
20
+ return statement;
21
+ }
22
+ parentPort.on("message", (message) => {
23
+ if (message.type === "close") {
24
+ db.close();
25
+ parentPort.close();
26
+ return;
27
+ }
28
+ if (message.type === "write") {
29
+ try {
30
+ db.exec("BEGIN IMMEDIATE");
31
+ try {
32
+ for (const statement of message.statements) {
33
+ prepare(statement.sql).run(...statement.params);
34
+ }
35
+ db.exec("COMMIT");
36
+ } catch (error) {
37
+ db.exec("ROLLBACK");
38
+ throw error;
39
+ }
40
+ parentPort.postMessage({ type: "write", id: message.id, ok: true });
41
+ } catch (error) {
42
+ parentPort.postMessage({ type: "write", id: message.id, ok: false, error: String(error) });
43
+ }
44
+ return;
45
+ }
46
+ try {
47
+ db.prepare("PRAGMA wal_checkpoint(PASSIVE)").get();
48
+ parentPort.postMessage({ type: "checkpoint", ok: true });
49
+ } catch (error) {
50
+ parentPort.postMessage({ type: "checkpoint", ok: false, error: String(error) });
51
+ }
52
+ });
53
+ `;
54
+ /**
55
+ * A worker thread with its own connection to `state.sqlite`, for the two kinds
56
+ * of work that must not run on the daemon's event loop.
57
+ *
58
+ * WAL checkpoints. SQLite's automatic checkpoint runs inside whichever COMMIT
59
+ * crosses the threshold, on the committing thread, and includes the fsync. On
60
+ * the reference host (WSL2, load average over 40) that put 400ms at p99 and
61
+ * 1.4s at worst into a single 50-row commit. With `wal_autocheckpoint = 0` on
62
+ * the main connection, commits only append to the WAL; this worker folds it
63
+ * into the database file off the loop, every 2s when there were commits.
64
+ *
65
+ * Bulk writes (the first-boot import). A synchronous write stalls whenever the
66
+ * filesystem does, which on that host held the loop for 500ms at a time during
67
+ * an import whose own work was 5ms per batch. The import parses on the main
68
+ * thread and hands each batch here as one transaction.
69
+ */
70
+ export class StateWorker {
71
+ constructor(worker, logger) {
72
+ this.writes = new Map();
73
+ this.nextWriteId = 1;
74
+ this.dirty = false;
75
+ this.inFlight = false;
76
+ this.closed = false;
77
+ this.paused = false;
78
+ this.worker = worker;
79
+ this.worker.unref();
80
+ this.worker.on("message", (message) => {
81
+ if (message.type === "write") {
82
+ const pending = this.writes.get(message.id);
83
+ this.writes.delete(message.id);
84
+ if (message.ok)
85
+ pending?.resolve();
86
+ else
87
+ pending?.reject(new Error(message.error ?? "state.sqlite worker write failed"));
88
+ return;
89
+ }
90
+ this.inFlight = false;
91
+ if (!message.ok) {
92
+ logger.warn({ error: message.error }, "state.sqlite WAL checkpoint failed");
93
+ }
94
+ });
95
+ this.worker.on("error", (error) => {
96
+ this.inFlight = false;
97
+ logger.warn({ err: error }, "state.sqlite worker failed");
98
+ for (const pending of this.writes.values())
99
+ pending.reject(error);
100
+ this.writes.clear();
101
+ });
102
+ this.timer = setInterval(() => this.tick(), CHECKPOINT_INTERVAL_MS);
103
+ this.timer.unref();
104
+ }
105
+ /** Start the worker, or return null so the caller keeps doing both on its own thread. */
106
+ static start(dbPath, logger) {
107
+ try {
108
+ const worker = new Worker(WORKER_SOURCE, { eval: true, workerData: { dbPath } });
109
+ return new StateWorker(worker, logger);
110
+ }
111
+ catch (error) {
112
+ logger.warn({ err: error }, "state.sqlite worker unavailable");
113
+ return null;
114
+ }
115
+ }
116
+ markDirty() {
117
+ this.dirty = true;
118
+ }
119
+ /** Commit `statements` as one transaction on the worker's connection. */
120
+ write(statements) {
121
+ if (this.closed) {
122
+ return Promise.reject(new Error("state.sqlite worker is closed"));
123
+ }
124
+ const id = this.nextWriteId++;
125
+ this.dirty = true;
126
+ return new Promise((resolve, reject) => {
127
+ this.writes.set(id, { resolve, reject });
128
+ // A worker_threads port, not a window: there is no origin to target.
129
+ // oxlint-disable-next-line unicorn/require-post-message-target-origin
130
+ this.worker.postMessage({ type: "write", id, statements });
131
+ });
132
+ }
133
+ /**
134
+ * Hold checkpoints (the first-boot import): a checkpoint's fsync shares the
135
+ * filesystem journal with the import's WAL appends. The WAL just grows until
136
+ * `resume`, which checkpoints once.
137
+ */
138
+ pause() {
139
+ this.paused = true;
140
+ }
141
+ resume() {
142
+ this.paused = false;
143
+ this.tick();
144
+ }
145
+ tick() {
146
+ if (!this.dirty || this.inFlight || this.closed || this.paused) {
147
+ return;
148
+ }
149
+ this.dirty = false;
150
+ this.inFlight = true;
151
+ // oxlint-disable-next-line unicorn/require-post-message-target-origin
152
+ this.worker.postMessage({ type: "checkpoint" });
153
+ }
154
+ async close() {
155
+ if (this.closed) {
156
+ return;
157
+ }
158
+ this.closed = true;
159
+ clearInterval(this.timer);
160
+ // oxlint-disable-next-line unicorn/require-post-message-target-origin
161
+ this.worker.postMessage({ type: "close" });
162
+ await this.worker.terminate();
163
+ }
164
+ }
165
+ //# sourceMappingURL=state-worker.js.map
@@ -11,6 +11,7 @@ import { isHostnameAllowed } from "./hostnames.js";
11
11
  import { Session } from "./session.js";
12
12
  import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
13
13
  import { PushTokenStore } from "./push/token-store.js";
14
+ import { stateDbFor } from "./state/state-db.js";
14
15
  import { createPushNotificationSender } from "./push/notifications.js";
15
16
  import { computeNotificationPlan, isPushEligibleAttentionReason, } from "./agent-attention-policy.js";
16
17
  import { buildAgentAttentionNotificationPayload, findLatestPermissionRequest, } from "@hyperdrive.bot/fleet-protocol/agent-attention-notification";
@@ -348,7 +349,7 @@ export class VoiceAssistantWebSocketServer {
348
349
  this.broadcastDaemonConfigChanged(config);
349
350
  });
350
351
  const pushLogger = this.logger.child({ module: "push" });
351
- this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json"));
352
+ this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json"), stateDbFor(paseoHome));
352
353
  // pushNotificationSender accepts an override from bootstrap (used in tests
353
354
  // to inject a mock); otherwise create the default one from the token store.
354
355
  this.pushNotificationSender =
@@ -2,10 +2,17 @@ import { promises as fsp } from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { resolvePaseoHome } from "../paseo-home.js";
4
4
  import { findClaudeSessionJsonl } from "../agent/providers/claude/resume-source.js";
5
+ import { stateDbFor } from "../state/state-db.js";
5
6
  async function defaultClaudeJsonlExists(sid) {
6
7
  return (await findClaudeSessionJsonl(sid)) !== null;
7
8
  }
8
9
  async function defaultPaseoAgentExists(sid) {
10
+ // Agent records live in state.sqlite when the daemon has it; the files are
11
+ // only a lagging mirror (and absent under PASEO_STATE_FILE_MIRROR=0).
12
+ const stateDb = stateDbFor(resolvePaseoHome());
13
+ if (stateDb) {
14
+ return stateDb.doc("agents", { tombstones: true }).get("", sid) !== null;
15
+ }
9
16
  const agentsRoot = path.join(resolvePaseoHome(), "agents");
10
17
  let slugs;
11
18
  try {
@@ -215,10 +215,14 @@ export class WorkspaceReconciliationService {
215
215
  project.displayName !== currentGit.projectDisplayName) {
216
216
  projectUpdates.displayName = currentGit.projectDisplayName;
217
217
  }
218
- if (Object.keys(projectUpdates).length > 0) {
218
+ const latestProject = Object.keys(projectUpdates).length > 0
219
+ ? await this.projectRegistry.get(project.projectId)
220
+ : null;
221
+ if (latestProject) {
222
+ // Same stale-snapshot guard as for the workspaces below.
219
223
  const timestamp = new Date().toISOString();
220
224
  await this.projectRegistry.upsert({
221
- ...project,
225
+ ...latestProject,
222
226
  ...projectUpdates,
223
227
  updatedAt: timestamp,
224
228
  });
@@ -244,9 +248,16 @@ export class WorkspaceReconciliationService {
244
248
  if (Object.keys(workspaceUpdates).length === 0) {
245
249
  return;
246
250
  }
251
+ // Re-read after the git await: a record changed meanwhile (openProject
252
+ // reclassifying this workspace under its parent repo) must not be
253
+ // overwritten by the snapshot this pass started from.
254
+ const latest = await this.workspaceRegistry.get(workspace.workspaceId);
255
+ if (!latest) {
256
+ return;
257
+ }
247
258
  const timestamp = new Date().toISOString();
248
259
  await this.workspaceRegistry.upsert({
249
- ...workspace,
260
+ ...latest,
250
261
  ...workspaceUpdates,
251
262
  updatedAt: timestamp,
252
263
  });
@@ -1,5 +1,6 @@
1
1
  import type { Logger } from "pino";
2
2
  import { z } from "zod";
3
+ import { type ChangedRow, type StateDb } from "./state/state-db.js";
3
4
  import type { PersistedProjectKind, PersistedWorkspaceKind } from "./workspace-registry-model.js";
4
5
  declare const PersistedProjectRecordSchema: z.ZodObject<{
5
6
  projectId: z.ZodString;
@@ -31,6 +32,36 @@ declare const PersistedWorkspaceRecordSchema: z.ZodObject<{
31
32
  updatedAt: z.ZodString;
32
33
  archivedAt: z.ZodNullable<z.ZodString>;
33
34
  }, z.core.$strip>;
35
+ export declare const PersistedProjectRecordListSchema: z.ZodArray<z.ZodObject<{
36
+ projectId: z.ZodString;
37
+ rootPath: z.ZodString;
38
+ kind: z.ZodEnum<{
39
+ git: "git";
40
+ non_git: "non_git";
41
+ }>;
42
+ displayName: z.ZodString;
43
+ customName: z.ZodPipe<z.ZodOptional<z.ZodNullable<z.ZodString>>, z.ZodTransform<string | null, string | null | undefined>>;
44
+ createdAt: z.ZodString;
45
+ updatedAt: z.ZodString;
46
+ archivedAt: z.ZodNullable<z.ZodString>;
47
+ }, z.core.$strip>>;
48
+ export declare const PersistedWorkspaceRecordListSchema: z.ZodArray<z.ZodObject<{
49
+ workspaceId: z.ZodString;
50
+ projectId: z.ZodString;
51
+ cwd: z.ZodString;
52
+ kind: z.ZodEnum<{
53
+ local_checkout: "local_checkout";
54
+ worktree: "worktree";
55
+ directory: "directory";
56
+ }>;
57
+ displayName: z.ZodString;
58
+ title: z.ZodPipe<z.ZodOptional<z.ZodNullable<z.ZodString>>, z.ZodTransform<string | null, string | null | undefined>>;
59
+ branch: z.ZodPipe<z.ZodOptional<z.ZodNullable<z.ZodString>>, z.ZodTransform<string | null, string | null | undefined>>;
60
+ baseBranch: z.ZodPipe<z.ZodOptional<z.ZodNullable<z.ZodString>>, z.ZodTransform<string | null, string | null | undefined>>;
61
+ createdAt: z.ZodString;
62
+ updatedAt: z.ZodString;
63
+ archivedAt: z.ZodNullable<z.ZodString>;
64
+ }, z.core.$strip>>;
34
65
  export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>;
35
66
  export type PersistedWorkspaceRecord = z.infer<typeof PersistedWorkspaceRecordSchema>;
36
67
  export interface ProjectRegistry {
@@ -60,12 +91,15 @@ declare class FileBackedRegistry<TRecord extends RegistryRecord> {
60
91
  private loaded;
61
92
  private readonly cache;
62
93
  private persistQueue;
94
+ private readonly sqlite;
63
95
  constructor(options: {
64
96
  filePath: string;
65
97
  logger: Logger;
66
98
  schema: z.ZodType<TRecord, unknown>;
67
99
  getId: (record: TRecord) => string;
68
100
  component: string;
101
+ stateDb?: StateDb | null;
102
+ tableName: "projects" | "workspaces";
69
103
  });
70
104
  initialize(): Promise<void>;
71
105
  existsOnDisk(): Promise<boolean>;
@@ -76,13 +110,22 @@ declare class FileBackedRegistry<TRecord extends RegistryRecord> {
76
110
  remove(id: string): Promise<void>;
77
111
  private load;
78
112
  private persist;
113
+ /**
114
+ * Rows changed after a daemon-wide revision, tombstones included. Null on
115
+ * the file backend. Internal until the delta protocol ships.
116
+ */
117
+ listChangedSince(rev: number, limit?: number): ChangedRow[] | null;
79
118
  private enqueuePersist;
80
119
  }
81
120
  export declare class FileBackedProjectRegistry extends FileBackedRegistry<PersistedProjectRecord> implements ProjectRegistry {
82
- constructor(filePath: string, logger: Logger);
121
+ constructor(filePath: string, logger: Logger, options?: {
122
+ stateDb?: StateDb | null;
123
+ });
83
124
  }
84
125
  export declare class FileBackedWorkspaceRegistry extends FileBackedRegistry<PersistedWorkspaceRecord> implements WorkspaceRegistry {
85
- constructor(filePath: string, logger: Logger);
126
+ constructor(filePath: string, logger: Logger, options?: {
127
+ stateDb?: StateDb | null;
128
+ });
86
129
  }
87
130
  export declare function createPersistedProjectRecord(input: {
88
131
  projectId: string;
@@ -1,6 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { writeJsonFileAtomic } from "./atomic-file.js";
4
+ import { ScopeSync } from "./state/state-db.js";
4
5
  const PersistedProjectRecordSchema = z.object({
5
6
  projectId: z.string(),
6
7
  rootPath: z.string(),
@@ -51,12 +52,62 @@ const PersistedWorkspaceRecordSchema = z.object({
51
52
  updatedAt: z.string(),
52
53
  archivedAt: z.string().nullable(),
53
54
  });
55
+ export const PersistedProjectRecordListSchema = z.array(PersistedProjectRecordSchema);
56
+ export const PersistedWorkspaceRecordListSchema = z.array(PersistedWorkspaceRecordSchema);
57
+ /**
58
+ * Registry rows in `state.sqlite` (`projects` / `workspaces`), each with a
59
+ * daemon-wide `rev` and a tombstone on removal. The registry still persists
60
+ * "the whole set" on every change; `ScopeSync` turns that into writes of only
61
+ * the rows that changed. The legacy JSON array is mirrored for the scripts that
62
+ * read it (loops run-agent.sh, paseo-new-host provision.sh).
63
+ */
64
+ class SqliteRegistryStore {
65
+ constructor(state, tableName, filePath, getId) {
66
+ this.state = state;
67
+ this.filePath = filePath;
68
+ this.getId = getId;
69
+ this.table = state.doc(tableName, { tombstones: true });
70
+ this.store = tableName;
71
+ this.sync = new ScopeSync(this.table);
72
+ }
73
+ load() {
74
+ const rows = this.table.loadAll("");
75
+ this.sync.seed(rows);
76
+ const records = rows.map((row) => JSON.parse(row.body));
77
+ // The process died before the mirror caught the file up: write it now, so
78
+ // a rollback or a script does not read the older list.
79
+ const mirrored = this.state.manifest.get(this.filePath)?.rev ?? -1;
80
+ if (this.table.maxRev() > mirrored)
81
+ this.mirror(records);
82
+ return records;
83
+ }
84
+ mirror(records) {
85
+ const rev = this.state.mirrorRev();
86
+ this.state.mirror.write(`registry:${this.filePath}`, async () => {
87
+ await writeJsonFileAtomic(this.filePath, records);
88
+ await this.state.manifest.record(this.store, this.filePath, rev);
89
+ });
90
+ }
91
+ async save(records) {
92
+ await this.sync.sync("", records.map((record) => ({ id: this.getId(record), value: record })), { prune: true });
93
+ this.mirror(records);
94
+ }
95
+ listChangedSince(rev, limit) {
96
+ return this.table.listChangedSince(rev, limit);
97
+ }
98
+ hasRows() {
99
+ return this.table.count() > 0;
100
+ }
101
+ }
54
102
  class FileBackedRegistry {
55
103
  constructor(options) {
56
104
  this.loaded = false;
57
105
  this.cache = new Map();
58
106
  this.persistQueue = Promise.resolve();
59
107
  this.filePath = options.filePath;
108
+ this.sqlite = options.stateDb
109
+ ? new SqliteRegistryStore(options.stateDb, options.tableName, options.filePath, options.getId)
110
+ : null;
60
111
  this.schema = options.schema;
61
112
  this.getId = options.getId;
62
113
  this.logger = options.logger.child({
@@ -68,6 +119,9 @@ class FileBackedRegistry {
68
119
  await this.load();
69
120
  }
70
121
  async existsOnDisk() {
122
+ if (this.sqlite?.hasRows()) {
123
+ return true;
124
+ }
71
125
  try {
72
126
  await fs.access(this.filePath);
73
127
  return true;
@@ -117,8 +171,10 @@ class FileBackedRegistry {
117
171
  }
118
172
  this.cache.clear();
119
173
  try {
120
- const raw = await fs.readFile(this.filePath, "utf8");
121
- const parsed = z.array(this.schema).parse(JSON.parse(raw));
174
+ const raw = this.sqlite
175
+ ? this.sqlite.load()
176
+ : JSON.parse(await fs.readFile(this.filePath, "utf8"));
177
+ const parsed = z.array(this.schema).parse(raw);
122
178
  for (const record of parsed) {
123
179
  this.cache.set(this.getId(record), record);
124
180
  }
@@ -133,8 +189,19 @@ class FileBackedRegistry {
133
189
  }
134
190
  async persist() {
135
191
  const records = Array.from(this.cache.values());
192
+ if (this.sqlite) {
193
+ await this.sqlite.save(records);
194
+ return;
195
+ }
136
196
  await writeJsonFileAtomic(this.filePath, records);
137
197
  }
198
+ /**
199
+ * Rows changed after a daemon-wide revision, tombstones included. Null on
200
+ * the file backend. Internal until the delta protocol ships.
201
+ */
202
+ listChangedSince(rev, limit) {
203
+ return this.sqlite?.listChangedSince(rev, limit) ?? null;
204
+ }
138
205
  async enqueuePersist() {
139
206
  const nextPersist = this.persistQueue.then(() => this.persist());
140
207
  this.persistQueue = nextPersist.catch(() => { });
@@ -142,24 +209,28 @@ class FileBackedRegistry {
142
209
  }
143
210
  }
144
211
  export class FileBackedProjectRegistry extends FileBackedRegistry {
145
- constructor(filePath, logger) {
212
+ constructor(filePath, logger, options = {}) {
146
213
  super({
147
214
  filePath,
148
215
  logger,
149
216
  schema: PersistedProjectRecordSchema,
150
217
  getId: (record) => record.projectId,
151
218
  component: "projects",
219
+ stateDb: options.stateDb,
220
+ tableName: "projects",
152
221
  });
153
222
  }
154
223
  }
155
224
  export class FileBackedWorkspaceRegistry extends FileBackedRegistry {
156
- constructor(filePath, logger) {
225
+ constructor(filePath, logger, options = {}) {
157
226
  super({
158
227
  filePath,
159
228
  logger,
160
229
  schema: PersistedWorkspaceRecordSchema,
161
230
  getId: (record) => record.workspaceId,
162
231
  component: "workspaces",
232
+ stateDb: options.stateDb,
233
+ tableName: "workspaces",
163
234
  });
164
235
  }
165
236
  }