@hyperdrive.bot/fleet-server 0.3.164 → 0.3.166
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/extensions/daemon-backend.js +25 -0
- package/dist/server/server/agent/agent-storage.d.ts +67 -12
- package/dist/server/server/agent/agent-storage.js +208 -132
- package/dist/server/server/agent/providers/claude/transport/pty-query.js +10 -0
- package/dist/server/server/bootstrap.d.ts +5 -0
- package/dist/server/server/bootstrap.js +26 -3
- package/dist/server/server/chat/chat-service.d.ts +22 -0
- package/dist/server/server/chat/chat-service.js +46 -3
- package/dist/server/server/daemon-worker.js +1 -0
- package/dist/server/server/fleet/decision-service.d.ts +5 -0
- package/dist/server/server/fleet/decision-service.js +30 -0
- package/dist/server/server/loop-service.d.ts +95 -0
- package/dist/server/server/loop-service.js +64 -3
- package/dist/server/server/migrations/backfill-workspace-id.migration.js +3 -2
- package/dist/server/server/push/token-store.d.ts +5 -1
- package/dist/server/server/push/token-store.js +26 -5
- package/dist/server/server/schedule/service.js +4 -1
- package/dist/server/server/schedule/store.d.ts +21 -1
- package/dist/server/server/schedule/store.js +283 -2
- package/dist/server/server/search/indexer.d.ts +10 -0
- package/dist/server/server/search/indexer.js +51 -2
- package/dist/server/server/session.js +12 -13
- package/dist/server/server/state/agent-state.d.ts +39 -0
- package/dist/server/server/state/agent-state.js +94 -0
- package/dist/server/server/state/daemon-state.d.ts +30 -0
- package/dist/server/server/state/daemon-state.js +99 -0
- package/dist/server/server/state/legacy-import.d.ts +124 -0
- package/dist/server/server/state/legacy-import.js +682 -0
- package/dist/server/server/state/legacy-manifest.d.ts +73 -0
- package/dist/server/server/state/legacy-manifest.js +112 -0
- package/dist/server/server/state/legacy-mirror.d.ts +57 -0
- package/dist/server/server/state/legacy-mirror.js +114 -0
- package/dist/server/server/state/legacy-sources.d.ts +9 -0
- package/dist/server/server/state/legacy-sources.js +163 -0
- package/dist/server/server/state/state-db.d.ts +263 -0
- package/dist/server/server/state/state-db.js +772 -0
- package/dist/server/server/state/state-schema.d.ts +39 -0
- package/dist/server/server/state/state-schema.js +125 -0
- package/dist/server/server/state/state-worker.d.ts +48 -0
- package/dist/server/server/state/state-worker.js +165 -0
- package/dist/server/server/websocket-server.js +2 -1
- package/dist/server/server/workspace/session-openable.js +7 -0
- package/dist/server/server/workspace-reconciliation-service.js +14 -3
- package/dist/server/server/workspace-registry.d.ts +45 -2
- package/dist/server/server/workspace-registry.js +75 -4
- package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js → desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js} +5 -5
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js → desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js} +3 -3
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js → index-1ff4c43fd657ba6473b02d56dae75a25.js} +1530 -1527
- package/dist/server/web-ui/_expo/static/js/web/index-1ff4c43fd657ba6473b02d56dae75a25.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-1ff4c43fd657ba6473b02d56dae75a25.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-1ff4c43fd657ba6473b02d56dae75a25.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.map.gz → index-1ff4c43fd657ba6473b02d56dae75a25.js.map.gz} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js → indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js} +3 -3
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js → native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js} +5 -5
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{profiler-b509d7fffd6cf457a9c12588b80560cb.js → profiler-a28d140b5874532b9eef737c77fde610.js} +11 -11
- package/dist/server/web-ui/_expo/static/js/web/profiler-a28d140b5874532b9eef737c77fde610.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-a28d140b5874532b9eef737c77fde610.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-a28d140b5874532b9eef737c77fde610.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-a28d140b5874532b9eef737c77fde610.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{startRecording-9c90e555a5ecdc643e277f9a98913aaf.js → startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js} +60 -60
- package/dist/server/web-ui/_expo/static/js/web/startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{startRecording-9c90e555a5ecdc643e277f9a98913aaf.js.map.gz → startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +6 -6
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-b509d7fffd6cf457a9c12588b80560cb.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-b509d7fffd6cf457a9c12588b80560cb.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-b509d7fffd6cf457a9c12588b80560cb.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-b509d7fffd6cf457a9c12588b80560cb.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-9c90e555a5ecdc643e277f9a98913aaf.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-9c90e555a5ecdc643e277f9a98913aaf.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-9c90e555a5ecdc643e277f9a98913aaf.js.map.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
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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 =
|
|
121
|
-
|
|
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
|
}
|