@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.
Files changed (110) 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/{desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js → desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js} +5 -5
  47. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js.br +0 -0
  48. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js.gz +0 -0
  49. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js.map.br +0 -0
  50. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js.map.gz +0 -0
  51. package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js → desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js} +3 -3
  52. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js.br +0 -0
  53. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js.gz +0 -0
  54. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js.map.br +0 -0
  55. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js.map.gz +0 -0
  56. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js → index-1ff4c43fd657ba6473b02d56dae75a25.js} +1530 -1527
  57. package/dist/server/web-ui/_expo/static/js/web/index-1ff4c43fd657ba6473b02d56dae75a25.js.br +0 -0
  58. package/dist/server/web-ui/_expo/static/js/web/index-1ff4c43fd657ba6473b02d56dae75a25.js.gz +0 -0
  59. package/dist/server/web-ui/_expo/static/js/web/index-1ff4c43fd657ba6473b02d56dae75a25.js.map.br +0 -0
  60. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.map.gz → index-1ff4c43fd657ba6473b02d56dae75a25.js.map.gz} +0 -0
  61. package/dist/server/web-ui/_expo/static/js/web/{indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js → indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js} +3 -3
  62. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js.br +0 -0
  63. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js.gz +0 -0
  64. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js.map.br +0 -0
  65. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js.map.gz +0 -0
  66. package/dist/server/web-ui/_expo/static/js/web/{native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js → native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js} +5 -5
  67. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js.br +0 -0
  68. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js.gz +0 -0
  69. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js.map.br +0 -0
  70. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js.map.gz +0 -0
  71. package/dist/server/web-ui/_expo/static/js/web/{profiler-b509d7fffd6cf457a9c12588b80560cb.js → profiler-a28d140b5874532b9eef737c77fde610.js} +11 -11
  72. package/dist/server/web-ui/_expo/static/js/web/profiler-a28d140b5874532b9eef737c77fde610.js.br +0 -0
  73. package/dist/server/web-ui/_expo/static/js/web/profiler-a28d140b5874532b9eef737c77fde610.js.gz +0 -0
  74. package/dist/server/web-ui/_expo/static/js/web/profiler-a28d140b5874532b9eef737c77fde610.js.map.br +0 -0
  75. package/dist/server/web-ui/_expo/static/js/web/profiler-a28d140b5874532b9eef737c77fde610.js.map.gz +0 -0
  76. package/dist/server/web-ui/_expo/static/js/web/{startRecording-9c90e555a5ecdc643e277f9a98913aaf.js → startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js} +60 -60
  77. package/dist/server/web-ui/_expo/static/js/web/startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js.br +0 -0
  78. package/dist/server/web-ui/_expo/static/js/web/startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js.gz +0 -0
  79. package/dist/server/web-ui/_expo/static/js/web/startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js.map.br +0 -0
  80. package/dist/server/web-ui/_expo/static/js/web/{startRecording-9c90e555a5ecdc643e277f9a98913aaf.js.map.gz → startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js.map.gz} +0 -0
  81. package/dist/server/web-ui/index.html +1 -1
  82. package/dist/server/web-ui/index.html.br +0 -0
  83. package/dist/server/web-ui/index.html.gz +0 -0
  84. package/package.json +6 -6
  85. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js.br +0 -0
  86. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js.gz +0 -0
  87. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js.map.br +0 -0
  88. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js.map.gz +0 -0
  89. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js.br +0 -0
  90. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js.gz +0 -0
  91. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js.map.br +0 -0
  92. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js.map.gz +0 -0
  93. package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.br +0 -0
  94. package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.gz +0 -0
  95. package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.map.br +0 -0
  96. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js.br +0 -0
  97. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js.gz +0 -0
  98. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js.map.br +0 -0
  99. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js.map.gz +0 -0
  100. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js.br +0 -0
  101. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js.gz +0 -0
  102. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js.map.br +0 -0
  103. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js.map.gz +0 -0
  104. package/dist/server/web-ui/_expo/static/js/web/profiler-b509d7fffd6cf457a9c12588b80560cb.js.br +0 -0
  105. package/dist/server/web-ui/_expo/static/js/web/profiler-b509d7fffd6cf457a9c12588b80560cb.js.gz +0 -0
  106. package/dist/server/web-ui/_expo/static/js/web/profiler-b509d7fffd6cf457a9c12588b80560cb.js.map.br +0 -0
  107. package/dist/server/web-ui/_expo/static/js/web/profiler-b509d7fffd6cf457a9c12588b80560cb.js.map.gz +0 -0
  108. package/dist/server/web-ui/_expo/static/js/web/startRecording-9c90e555a5ecdc643e277f9a98913aaf.js.br +0 -0
  109. package/dist/server/web-ui/_expo/static/js/web/startRecording-9c90e555a5ecdc643e277f9a98913aaf.js.gz +0 -0
  110. package/dist/server/web-ui/_expo/static/js/web/startRecording-9c90e555a5ecdc643e277f9a98913aaf.js.map.br +0 -0
@@ -0,0 +1,73 @@
1
+ import { type Stats } from "node:fs";
2
+ import type { StateDb } from "./state-db.js";
3
+ import type { WorkerStatement } from "./state-worker.js";
4
+ /**
5
+ * What this daemon last left in each legacy JSON file.
6
+ *
7
+ * Every file the mirror writes, and every file the import reads, gets a row:
8
+ * content hash, size, mtime, and the daemon-wide `rev` the file reflects. That
9
+ * row is how the daemon tells its own files from everyone else's, file by file,
10
+ * with no global clock:
11
+ *
12
+ * - the file matches its row (same mtime and size, or same content hash):
13
+ * ours, never re-imported, whatever its mtime says;
14
+ * - the file differs from its row, or has no row: changed by someone else (a
15
+ * rolled-back older daemon, the daemon itself under PASEO_STATE_SQLITE=0, a
16
+ * hand edit), reconciled into the database by content;
17
+ * - the row's file is gone: deleted by someone else.
18
+ *
19
+ * `rev` settles conflicts: a database row whose rev is greater than the
20
+ * manifest rev of its file changed after that file was written, so on a
21
+ * conflict the database wins (and the next mirror write brings the file up).
22
+ *
23
+ * mtime + size is only a fast path to skip hashing a file that was not
24
+ * touched; a differing mtime alone never makes a file foreign.
25
+ */
26
+ export interface ManifestEntry {
27
+ /** Absolute path. */
28
+ path: string;
29
+ store: string;
30
+ hash: string;
31
+ size: number;
32
+ mtimeMs: number;
33
+ rev: number;
34
+ }
35
+ export type LegacyFileState = {
36
+ status: "ours";
37
+ entry: ManifestEntry;
38
+ } | {
39
+ status: "changed" | "created";
40
+ entry: ManifestEntry | null;
41
+ content: string;
42
+ stat: Stats;
43
+ } | {
44
+ status: "deleted";
45
+ entry: ManifestEntry;
46
+ } | {
47
+ status: "absent";
48
+ };
49
+ export declare function hashContent(content: string | Buffer): string;
50
+ export declare class LegacyManifest {
51
+ private readonly state;
52
+ private readonly home;
53
+ constructor(state: StateDb, home: string);
54
+ private key;
55
+ private toEntry;
56
+ get(filePath: string): ManifestEntry | null;
57
+ /** Every entry of a store, keyed by absolute path. */
58
+ entries(store: string): Map<string, ManifestEntry>;
59
+ /** The upsert for a file whose `content` was just read or written, as a batch statement. */
60
+ statement(store: string, filePath: string, content: string | Buffer, stat: Pick<Stats, "size" | "mtimeMs">, rev: number): WorkerStatement;
61
+ forgetStatement(filePath: string): WorkerStatement;
62
+ /** Run a statement inside the caller's open transaction. */
63
+ runNow(statement: WorkerStatement): void;
64
+ /**
65
+ * Record the file as this daemon just left it, reflecting `rev`. Reads the
66
+ * file back, so the hash is exactly what is on disk. Resolves once committed.
67
+ */
68
+ record(store: string, filePath: string, rev: number): Promise<void>;
69
+ forget(filePath: string): Promise<void>;
70
+ /** Compare a file on disk with its entry (looked up when not given). */
71
+ classify(filePath: string, entry?: ManifestEntry | null): Promise<LegacyFileState>;
72
+ }
73
+ //# sourceMappingURL=legacy-manifest.d.ts.map
@@ -0,0 +1,112 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ export function hashContent(content) {
5
+ return createHash("sha1").update(content).digest("hex");
6
+ }
7
+ const UPSERT_SQL = `INSERT INTO legacy_manifest (path, store, hash, size, mtime_ms, rev)
8
+ VALUES (?, ?, ?, ?, ?, ?)
9
+ ON CONFLICT(path) DO UPDATE SET store = excluded.store, hash = excluded.hash,
10
+ size = excluded.size, mtime_ms = excluded.mtime_ms, rev = excluded.rev`;
11
+ export class LegacyManifest {
12
+ constructor(state, home) {
13
+ this.state = state;
14
+ this.home = home;
15
+ }
16
+ key(filePath) {
17
+ return path.relative(this.home, path.resolve(filePath));
18
+ }
19
+ toEntry(row) {
20
+ return {
21
+ path: path.resolve(this.home, String(row.path)),
22
+ store: String(row.store),
23
+ hash: String(row.hash),
24
+ size: Number(row.size),
25
+ mtimeMs: Number(row.mtime_ms),
26
+ rev: Number(row.rev),
27
+ };
28
+ }
29
+ get(filePath) {
30
+ this.state.flushSync();
31
+ const row = this.state
32
+ .prepare("SELECT * FROM legacy_manifest WHERE path = ?")
33
+ .get(this.key(filePath));
34
+ return row ? this.toEntry(row) : null;
35
+ }
36
+ /** Every entry of a store, keyed by absolute path. */
37
+ entries(store) {
38
+ this.state.flushSync();
39
+ const rows = this.state.prepare("SELECT * FROM legacy_manifest WHERE store = ?").all(store);
40
+ return new Map(rows.map((row) => [path.resolve(this.home, String(row.path)), this.toEntry(row)]));
41
+ }
42
+ /** The upsert for a file whose `content` was just read or written, as a batch statement. */
43
+ statement(store, filePath, content, stat, rev) {
44
+ return {
45
+ sql: UPSERT_SQL,
46
+ params: [this.key(filePath), store, hashContent(content), stat.size, stat.mtimeMs, rev],
47
+ };
48
+ }
49
+ forgetStatement(filePath) {
50
+ return { sql: "DELETE FROM legacy_manifest WHERE path = ?", params: [this.key(filePath)] };
51
+ }
52
+ /** Run a statement inside the caller's open transaction. */
53
+ runNow(statement) {
54
+ this.state.prepare(statement.sql).run(...statement.params);
55
+ }
56
+ /**
57
+ * Record the file as this daemon just left it, reflecting `rev`. Reads the
58
+ * file back, so the hash is exactly what is on disk. Resolves once committed.
59
+ */
60
+ async record(store, filePath, rev) {
61
+ let content;
62
+ let stat;
63
+ try {
64
+ content = await fs.readFile(filePath);
65
+ stat = await fs.stat(filePath);
66
+ }
67
+ catch {
68
+ await this.forget(filePath);
69
+ return;
70
+ }
71
+ const statement = this.statement(store, filePath, content, stat, rev);
72
+ await this.state.enqueue(() => this.runNow(statement));
73
+ }
74
+ async forget(filePath) {
75
+ const statement = this.forgetStatement(filePath);
76
+ await this.state.enqueue(() => this.runNow(statement));
77
+ }
78
+ /** Compare a file on disk with its entry (looked up when not given). */
79
+ async classify(filePath, entry) {
80
+ const known = entry === undefined ? this.get(filePath) : entry;
81
+ let stat;
82
+ try {
83
+ stat = await fs.stat(filePath);
84
+ }
85
+ catch {
86
+ return known ? { status: "deleted", entry: known } : { status: "absent" };
87
+ }
88
+ if (known && known.size === stat.size && known.mtimeMs === stat.mtimeMs) {
89
+ return { status: "ours", entry: known };
90
+ }
91
+ let content;
92
+ try {
93
+ content = await fs.readFile(filePath, "utf8");
94
+ }
95
+ catch {
96
+ return known ? { status: "deleted", entry: known } : { status: "absent" };
97
+ }
98
+ if (known && hashContent(content) === known.hash) {
99
+ // Same bytes, new mtime (a copy, a touch): still ours. Remember the mtime
100
+ // so the next look is a stat again.
101
+ const refreshed = this.statement(known.store, filePath, content, stat, known.rev);
102
+ void this.state
103
+ .enqueue(() => this.runNow(refreshed))
104
+ .catch((error) => {
105
+ this.state.logger.warn({ err: error, filePath }, "Failed to refresh a legacy manifest entry");
106
+ });
107
+ return { status: "ours", entry: known };
108
+ }
109
+ return { status: known ? "changed" : "created", entry: known, content, stat };
110
+ }
111
+ }
112
+ //# sourceMappingURL=legacy-manifest.js.map
@@ -0,0 +1,57 @@
1
+ import type { Logger } from "pino";
2
+ /**
3
+ * Keeps the legacy JSON files written after `state.sqlite` took over, for one
4
+ * release, so that:
5
+ * - the updater's automatic rollback (cli daemon/update.ts, 180s readiness
6
+ * budget) lands on an older daemon that reads CURRENT files, not a frozen
7
+ * snapshot from the upgrade moment;
8
+ * - scripts that read the files directly keep working (week-report
9
+ * extract.py reads agents/, loops run-agent.sh reads
10
+ * projects/workspaces.json, paseo-new-host provision.sh greps projects.json).
11
+ *
12
+ * Every write is off the request path: debounced per key (with a max wait, so
13
+ * a hot key still flushes), latest task wins, serialized per key, failures
14
+ * logged and never thrown. On SIGTERM/SIGINT the daemon switches the mirror to
15
+ * immediate mode and flushes before anything else shuts down; only a SIGKILL
16
+ * (or a power cut) inside the last debounce window, at most about a second,
17
+ * can leave a file behind the database. The next boot of this daemon catches
18
+ * those files up. Remove in the release after next (see
19
+ * docs/architecture/data-layer-rework.md section 5).
20
+ */
21
+ export declare class LegacyMirror {
22
+ readonly enabled: boolean;
23
+ private readonly logger;
24
+ private readonly debounceMs;
25
+ private readonly maxWaitMs;
26
+ private readonly queued;
27
+ private readonly running;
28
+ private immediate;
29
+ constructor(options: {
30
+ enabled: boolean;
31
+ logger: Logger;
32
+ debounceMs?: number;
33
+ maxWaitMs?: number;
34
+ });
35
+ /**
36
+ * Debounce `task` under `key`; a later call for the same key replaces it.
37
+ * The first call of a burst fixes a deadline `maxWaitMs` out, which later
38
+ * calls never push back.
39
+ */
40
+ write(key: string, task: () => Promise<void>): void;
41
+ /** True while a write for `key` is debounced or still running. */
42
+ isPending(key: string): boolean;
43
+ /** True while a write for `key` is running (not merely debounced). */
44
+ isRunning(key: string): boolean;
45
+ /**
46
+ * Shutdown: run everything queued now, and every later write without a
47
+ * debounce, so nothing written on the way down waits on a timer.
48
+ */
49
+ beginShutdown(): Promise<void>;
50
+ /** Drop queued writes without running them. Tests use it to simulate a crash. */
51
+ discard(): void;
52
+ get pendingCount(): number;
53
+ /** Run every debounced task now and wait for all of them. */
54
+ flush(): Promise<void>;
55
+ private run;
56
+ }
57
+ //# sourceMappingURL=legacy-mirror.d.ts.map
@@ -0,0 +1,114 @@
1
+ const DEFAULT_DEBOUNCE_MS = 250;
2
+ /**
3
+ * A key written more often than the debounce still reaches its file at least
4
+ * this often (or every four debounces, when that is sooner), so a streaming agent's legacy file is never more than about a
5
+ * second behind.
6
+ */
7
+ const DEFAULT_MAX_WAIT_MS = 1000;
8
+ /**
9
+ * Keeps the legacy JSON files written after `state.sqlite` took over, for one
10
+ * release, so that:
11
+ * - the updater's automatic rollback (cli daemon/update.ts, 180s readiness
12
+ * budget) lands on an older daemon that reads CURRENT files, not a frozen
13
+ * snapshot from the upgrade moment;
14
+ * - scripts that read the files directly keep working (week-report
15
+ * extract.py reads agents/, loops run-agent.sh reads
16
+ * projects/workspaces.json, paseo-new-host provision.sh greps projects.json).
17
+ *
18
+ * Every write is off the request path: debounced per key (with a max wait, so
19
+ * a hot key still flushes), latest task wins, serialized per key, failures
20
+ * logged and never thrown. On SIGTERM/SIGINT the daemon switches the mirror to
21
+ * immediate mode and flushes before anything else shuts down; only a SIGKILL
22
+ * (or a power cut) inside the last debounce window, at most about a second,
23
+ * can leave a file behind the database. The next boot of this daemon catches
24
+ * those files up. Remove in the release after next (see
25
+ * docs/architecture/data-layer-rework.md section 5).
26
+ */
27
+ export class LegacyMirror {
28
+ constructor(options) {
29
+ this.queued = new Map();
30
+ this.running = new Map();
31
+ this.immediate = false;
32
+ this.enabled = options.enabled;
33
+ this.logger = options.logger;
34
+ this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
35
+ this.maxWaitMs = Math.max(this.debounceMs, options.maxWaitMs ?? Math.min(DEFAULT_MAX_WAIT_MS, 4 * this.debounceMs));
36
+ }
37
+ /**
38
+ * Debounce `task` under `key`; a later call for the same key replaces it.
39
+ * The first call of a burst fixes a deadline `maxWaitMs` out, which later
40
+ * calls never push back.
41
+ */
42
+ write(key, task) {
43
+ if (!this.enabled) {
44
+ return;
45
+ }
46
+ const now = Date.now();
47
+ const existing = this.queued.get(key);
48
+ if (existing) {
49
+ clearTimeout(existing.timer);
50
+ }
51
+ const firstQueuedAt = existing?.firstQueuedAt ?? now;
52
+ const delay = this.immediate
53
+ ? 0
54
+ : Math.max(0, Math.min(this.debounceMs, firstQueuedAt + this.maxWaitMs - now));
55
+ const timer = setTimeout(() => this.run(key), delay);
56
+ timer.unref?.();
57
+ this.queued.set(key, { task, timer, firstQueuedAt });
58
+ }
59
+ /** True while a write for `key` is debounced or still running. */
60
+ isPending(key) {
61
+ return this.queued.has(key) || this.running.has(key);
62
+ }
63
+ /** True while a write for `key` is running (not merely debounced). */
64
+ isRunning(key) {
65
+ return this.running.has(key);
66
+ }
67
+ /**
68
+ * Shutdown: run everything queued now, and every later write without a
69
+ * debounce, so nothing written on the way down waits on a timer.
70
+ */
71
+ beginShutdown() {
72
+ this.immediate = true;
73
+ return this.flush();
74
+ }
75
+ /** Drop queued writes without running them. Tests use it to simulate a crash. */
76
+ discard() {
77
+ for (const entry of this.queued.values())
78
+ clearTimeout(entry.timer);
79
+ this.queued.clear();
80
+ }
81
+ get pendingCount() {
82
+ return this.queued.size + this.running.size;
83
+ }
84
+ /** Run every debounced task now and wait for all of them. */
85
+ async flush() {
86
+ for (const [key, entry] of Array.from(this.queued)) {
87
+ clearTimeout(entry.timer);
88
+ this.run(key);
89
+ }
90
+ while (this.running.size > 0) {
91
+ await Promise.allSettled(this.running.values());
92
+ }
93
+ }
94
+ run(key) {
95
+ const entry = this.queued.get(key);
96
+ if (!entry) {
97
+ return;
98
+ }
99
+ this.queued.delete(key);
100
+ const previous = this.running.get(key) ?? Promise.resolve();
101
+ const next = previous
102
+ .then(() => entry.task())
103
+ .catch((error) => {
104
+ this.logger.warn({ err: error, key }, "Legacy file mirror write failed");
105
+ })
106
+ .finally(() => {
107
+ if (this.running.get(key) === next) {
108
+ this.running.delete(key);
109
+ }
110
+ });
111
+ this.running.set(key, next);
112
+ }
113
+ }
114
+ //# sourceMappingURL=legacy-mirror.js.map
@@ -0,0 +1,9 @@
1
+ import type { LegacySource } from "./legacy-import.js";
2
+ /**
3
+ * Every legacy store `state.sqlite` takes over, except agents (imported by
4
+ * `importLegacyAgents` from the agent storage directory). Each parser is the
5
+ * store's own zod schema, so an import accepts exactly what the file store
6
+ * would have loaded.
7
+ */
8
+ export declare function legacySources(paseoHome: string): LegacySource[];
9
+ //# sourceMappingURL=legacy-sources.d.ts.map
@@ -0,0 +1,163 @@
1
+ import path from "node:path";
2
+ import { LoopDecisionSchema } from "@hyperdrive.bot/fleet-protocol/fleet/decisions";
3
+ import { StoredScheduleSchema } from "@hyperdrive.bot/fleet-protocol/schedule/types";
4
+ import { ChatStorePayloadSchema } from "../chat/chat-service.js";
5
+ import { LOOP_LOGS_RETAINED, StoredLoopsSchema } from "../loop-service.js";
6
+ import { capScheduleRuns } from "../schedule/store.js";
7
+ import { PersistedProjectRecordListSchema, PersistedWorkspaceRecordListSchema, } from "../workspace-registry.js";
8
+ /**
9
+ * Every legacy store `state.sqlite` takes over, except agents (imported by
10
+ * `importLegacyAgents` from the agent storage directory). Each parser is the
11
+ * store's own zod schema, so an import accepts exactly what the file store
12
+ * would have loaded.
13
+ */
14
+ export function legacySources(paseoHome) {
15
+ return [
16
+ {
17
+ kind: "directory",
18
+ store: "schedules",
19
+ dir: path.join(paseoHome, "schedules"),
20
+ deleteWins: true,
21
+ owns: (fileName) => {
22
+ const id = fileName.slice(0, -".json".length);
23
+ return [
24
+ { table: "schedules", scope: "", id },
25
+ { table: "schedule_runs", scope: id },
26
+ ];
27
+ },
28
+ toRows: (json) => {
29
+ const { runs, ...schedule } = capScheduleRuns(StoredScheduleSchema.parse(json));
30
+ return [
31
+ { table: "schedules", scope: "", id: schedule.id, value: schedule },
32
+ ...runs.map((run, index) => ({
33
+ table: "schedule_runs",
34
+ scope: schedule.id,
35
+ id: run.id,
36
+ value: run,
37
+ ord: index,
38
+ })),
39
+ ];
40
+ },
41
+ },
42
+ {
43
+ kind: "whole-file",
44
+ store: "loops",
45
+ filePath: path.join(paseoHome, "loops", "loops.json"),
46
+ tables: ["loops", "loop_logs"],
47
+ toRows: (json) => {
48
+ const rows = [];
49
+ for (const { logs, ...loop } of StoredLoopsSchema.parse(json)) {
50
+ rows.push({ table: "loops", scope: "", id: loop.id, value: loop });
51
+ logs.slice(-LOOP_LOGS_RETAINED).forEach((entry, index) => {
52
+ rows.push({
53
+ table: "loop_logs",
54
+ scope: loop.id,
55
+ id: String(entry.seq).padStart(10, "0"),
56
+ value: entry,
57
+ ord: index,
58
+ });
59
+ });
60
+ }
61
+ return rows;
62
+ },
63
+ },
64
+ {
65
+ kind: "whole-file",
66
+ store: "chat",
67
+ filePath: path.join(paseoHome, "chat", "rooms.json"),
68
+ tables: ["chat_rooms", "chat_messages"],
69
+ toRows: (json) => {
70
+ const payload = ChatStorePayloadSchema.parse(json);
71
+ const order = new Map();
72
+ return [
73
+ ...payload.rooms.map((room) => ({
74
+ table: "chat_rooms",
75
+ scope: "",
76
+ id: room.id,
77
+ value: room,
78
+ })),
79
+ ...payload.messages.map((message) => {
80
+ const ord = order.get(message.roomId) ?? 0;
81
+ order.set(message.roomId, ord + 1);
82
+ return {
83
+ table: "chat_messages",
84
+ scope: message.roomId,
85
+ id: message.id,
86
+ value: message,
87
+ ord,
88
+ };
89
+ }),
90
+ ];
91
+ },
92
+ },
93
+ {
94
+ kind: "whole-file",
95
+ store: "projects",
96
+ filePath: path.join(paseoHome, "projects", "projects.json"),
97
+ tables: ["projects"],
98
+ toRows: (json) => PersistedProjectRecordListSchema.parse(json).map((record) => ({
99
+ table: "projects",
100
+ scope: "",
101
+ id: record.projectId,
102
+ value: record,
103
+ })),
104
+ },
105
+ {
106
+ kind: "whole-file",
107
+ store: "workspaces",
108
+ filePath: path.join(paseoHome, "projects", "workspaces.json"),
109
+ tables: ["workspaces"],
110
+ toRows: (json) => PersistedWorkspaceRecordListSchema.parse(json).map((record) => ({
111
+ table: "workspaces",
112
+ scope: "",
113
+ id: record.workspaceId,
114
+ value: record,
115
+ })),
116
+ },
117
+ {
118
+ kind: "whole-file",
119
+ store: "push_tokens",
120
+ filePath: path.join(paseoHome, "push-tokens.json"),
121
+ tables: ["push_tokens"],
122
+ toRows: (json) => {
123
+ const tokens = json?.tokens;
124
+ return (Array.isArray(tokens) ? tokens : [])
125
+ .filter((token) => typeof token === "string" && token.trim() !== "")
126
+ .map((token) => ({
127
+ table: "push_tokens",
128
+ scope: "",
129
+ id: token.trim(),
130
+ value: { token: token.trim() },
131
+ }));
132
+ },
133
+ },
134
+ {
135
+ kind: "directory",
136
+ store: "fleet_decisions",
137
+ dir: path.join(paseoHome, "fleet", "decisions"),
138
+ owns: (fileName) => [
139
+ { table: "fleet_decisions", scope: "", id: fileName.slice(0, -".json".length) },
140
+ ],
141
+ toRows: (json) => {
142
+ const decision = LoopDecisionSchema.parse(json);
143
+ return [{ table: "fleet_decisions", scope: "", id: decision.id, value: decision }];
144
+ },
145
+ },
146
+ {
147
+ kind: "directory",
148
+ store: "extension_kv",
149
+ dir: path.join(paseoHome, "extension-storage"),
150
+ owns: (fileName) => [{ table: "extension_kv", scope: fileName.slice(0, -".json".length) }],
151
+ toRows: (json, fileName) => {
152
+ const extensionId = fileName.slice(0, -".json".length);
153
+ return Object.entries((json ?? {})).map(([key, value]) => ({
154
+ table: "extension_kv",
155
+ scope: extensionId,
156
+ id: key,
157
+ value: value ?? null,
158
+ }));
159
+ },
160
+ },
161
+ ];
162
+ }
163
+ //# sourceMappingURL=legacy-sources.js.map