@sema-agent/server 7.51.0 → 7.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +83 -20
  4. package/dist/adoption/plan.d.ts +4 -4
  5. package/dist/adoption/plan.js +11 -0
  6. package/dist/approval-card.d.ts +26 -2
  7. package/dist/approval-card.js +9 -0
  8. package/dist/approval-hmac.d.ts +1 -1
  9. package/dist/approval-reconciler.d.ts +2 -2
  10. package/dist/approval-reconciler.js +1 -1
  11. package/dist/bench/l8/artifact.d.ts +1 -1
  12. package/dist/bench/s1/arms.d.ts +1 -1
  13. package/dist/bench/s1/live-deps.d.ts +1 -1
  14. package/dist/bench/s1/oracle.d.ts +1 -1
  15. package/dist/bench/s1/row.d.ts +1 -1
  16. package/dist/boot/budget-tracing.d.ts +1 -1
  17. package/dist/boot/budget-tracing.js +2 -5
  18. package/dist/boot/coordinators.js +3 -1
  19. package/dist/boot/deferred-sandbox-path-env.d.ts +1 -1
  20. package/dist/boot/engine-lease.d.ts +1 -1
  21. package/dist/boot/execution-env.d.ts +1 -1
  22. package/dist/boot/governance-seams.d.ts +2 -2
  23. package/dist/boot/leader.d.ts +1 -1
  24. package/dist/boot/memory-boundary.d.ts +2 -2
  25. package/dist/boot/memory-consolidation.d.ts +6 -6
  26. package/dist/boot/org-memory.d.ts +1 -1
  27. package/dist/boot/parked-revive-gate.d.ts +3 -3
  28. package/dist/boot/reapers.d.ts +2 -2
  29. package/dist/boot/reapers.js +17 -4
  30. package/dist/boot/resolve-spec.d.ts +12 -0
  31. package/dist/boot/resolve-spec.js +29 -7
  32. package/dist/boot/retention-lane.d.ts +2 -2
  33. package/dist/boot/runner-deps.d.ts +17 -7
  34. package/dist/boot/runner-deps.js +4 -2
  35. package/dist/boot/runtime-caps.d.ts +23 -0
  36. package/dist/boot/runtime-caps.js +24 -2
  37. package/dist/boot/session-faces.d.ts +1 -1
  38. package/dist/boot/session-faces.js +14 -3
  39. package/dist/boot/stores.js +31 -1
  40. package/dist/boot/task-list-lane.d.ts +2 -2
  41. package/dist/brain.js +51 -31
  42. package/dist/budget.d.ts +2 -2
  43. package/dist/capabilities/center-plugins.d.ts +1 -1
  44. package/dist/capabilities/collab-workflows.d.ts +1 -1
  45. package/dist/capabilities/hands-lane.d.ts +3 -3
  46. package/dist/capabilities/scenarios.d.ts +3 -3
  47. package/dist/capabilities/skills.d.ts +1 -1
  48. package/dist/config-center/hot-keys-registry.js +5 -0
  49. package/dist/config-center/http-client.d.ts +2 -2
  50. package/dist/config-center/read-face.d.ts +1 -1
  51. package/dist/config-center/restart-signal.d.ts +1 -1
  52. package/dist/config-types.d.ts +78 -18
  53. package/dist/config.js +45 -10
  54. package/dist/deployment-governance.d.ts +1 -1
  55. package/dist/device-enrollment.d.ts +2 -2
  56. package/dist/device-store.d.ts +3 -3
  57. package/dist/device-ws-protocol.d.ts +2 -2
  58. package/dist/env-facts.d.ts +1 -1
  59. package/dist/execution-lane-caps.d.ts +1 -1
  60. package/dist/fleet/fleet-bus.d.ts +1 -1
  61. package/dist/fleet/fleet-reconciler.d.ts +1 -1
  62. package/dist/fleet/fleet-terminal-window.d.ts +1 -1
  63. package/dist/http/active-run-conflict.d.ts +2 -2
  64. package/dist/http/principal-gate.d.ts +1 -1
  65. package/dist/http/route-ctx.d.ts +24 -4
  66. package/dist/http/routes/admin-memory-optout.d.ts +48 -0
  67. package/dist/http/routes/admin-memory-optout.js +75 -0
  68. package/dist/http/routes/agents-roster.d.ts +1 -1
  69. package/dist/http/routes/approvals-assistant.js +21 -21
  70. package/dist/http/routes/capabilities.js +20 -9
  71. package/dist/http/routes/diagnostics.d.ts +1 -1
  72. package/dist/http/routes/leader.d.ts +1 -1
  73. package/dist/http/routes/memory-bundle.d.ts +1 -1
  74. package/dist/http/routes/memory-bundle.js +3 -1
  75. package/dist/http/routes/memory-compliance.d.ts +4 -4
  76. package/dist/http/routes/memory-origin.d.ts +4 -4
  77. package/dist/http/routes/memory-origin.js +3 -1
  78. package/dist/http/routes/memory-policy.js +6 -2
  79. package/dist/http/routes/retention-ops.d.ts +1 -1
  80. package/dist/http/routes/rules.d.ts +5 -2
  81. package/dist/http/routes/rules.js +84 -5
  82. package/dist/http/routes/run-memory-capture.d.ts +34 -0
  83. package/dist/http/routes/run-memory-capture.js +97 -0
  84. package/dist/http/routes/runs.d.ts +1 -0
  85. package/dist/http/routes/runs.js +212 -6
  86. package/dist/http/routes/session-sync.d.ts +4 -6
  87. package/dist/http/routes/session-sync.js +60 -47
  88. package/dist/http/routes/sessions.js +61 -188
  89. package/dist/http/routes/shared-memory.d.ts +1 -1
  90. package/dist/http/routes/tasks.js +1 -1
  91. package/dist/http/routes/workflows.js +19 -0
  92. package/dist/http/server.d.ts +35 -13
  93. package/dist/http/server.js +105 -12
  94. package/dist/http/verify-rounds.d.ts +1 -1
  95. package/dist/http/wire-types.d.ts +41 -8
  96. package/dist/leader/endpoint.d.ts +1 -1
  97. package/dist/leader/leader.d.ts +1 -1
  98. package/dist/leader/repair-oracle.d.ts +2 -2
  99. package/dist/leader/repair-oracle.js +1 -1
  100. package/dist/main.js +13 -6
  101. package/dist/memory-bundle-engine.d.ts +1 -1
  102. package/dist/memory-operator-faces.d.ts +5 -5
  103. package/dist/memory-origin-fence.d.ts +50 -0
  104. package/dist/memory-origin-fence.js +15 -0
  105. package/dist/memory-posture.d.ts +13 -1
  106. package/dist/memory-posture.js +1 -0
  107. package/dist/memory-scope.d.ts +6 -1
  108. package/dist/memory-scope.js +3 -2
  109. package/dist/model-select.d.ts +2 -2
  110. package/dist/observability/fail-open.d.ts +17 -5
  111. package/dist/observability/fail-open.js +17 -5
  112. package/dist/orchestration/hardened-vm-runner.d.ts +2 -2
  113. package/dist/orchestration/workflow-completion-inbox.d.ts +2 -2
  114. package/dist/plugins/background-shell-support.d.ts +2 -2
  115. package/dist/plugins/blob-backend.d.ts +7 -2
  116. package/dist/plugins/blob-backend.js +22 -18
  117. package/dist/plugins/caching-session-store.d.ts +7 -3
  118. package/dist/plugins/checkpoint-store-sql.d.ts +2 -2
  119. package/dist/plugins/file-history-store-sql.d.ts +170 -0
  120. package/dist/plugins/file-history-store-sql.js +827 -0
  121. package/dist/plugins/file-run-store.d.ts +44 -5
  122. package/dist/plugins/file-run-store.js +85 -13
  123. package/dist/plugins/fork-routing-session-store.d.ts +1 -1
  124. package/dist/plugins/fork-routing-session-store.js +2 -2
  125. package/dist/plugins/leader-run-store-sql.d.ts +1 -1
  126. package/dist/plugins/local-session-store.d.ts +9 -1
  127. package/dist/plugins/memory-embedder-fingerprint.d.ts +1 -1
  128. package/dist/plugins/memory-optout-grant-store-sql.d.ts +139 -0
  129. package/dist/plugins/memory-optout-grant-store-sql.js +157 -0
  130. package/dist/plugins/memory-origin-law.d.ts +2 -2
  131. package/dist/plugins/memory-run-store.d.ts +4 -3
  132. package/dist/plugins/memory-run-store.js +2 -1
  133. package/dist/plugins/permission-rule-store-file.d.ts +16 -6
  134. package/dist/plugins/permission-rule-store-file.js +77 -13
  135. package/dist/plugins/permission-rule-store-sql.d.ts +156 -8
  136. package/dist/plugins/permission-rule-store-sql.js +123 -21
  137. package/dist/plugins/pg-pool.js +59 -0
  138. package/dist/plugins/pg-safe-json.d.ts +1 -1
  139. package/dist/plugins/remote-env-device.d.ts +1 -1
  140. package/dist/plugins/remote-env-k8s.d.ts +1 -1
  141. package/dist/plugins/retention-lane-store-sql.d.ts +3 -3
  142. package/dist/plugins/retention-store-sql.d.ts +7 -7
  143. package/dist/plugins/retention-store-sql.js +6 -0
  144. package/dist/plugins/run-store-sql.d.ts +9 -3
  145. package/dist/plugins/run-store-sql.js +2 -2
  146. package/dist/plugins/send-user-file.d.ts +1 -1
  147. package/dist/plugins/session-placement.d.ts +1 -1
  148. package/dist/plugins/sql-errors.d.ts +1 -1
  149. package/dist/plugins/store-backend.d.ts +59 -29
  150. package/dist/plugins/store-backend.js +75 -15
  151. package/dist/plugins/store-contracts.d.ts +5 -3
  152. package/dist/plugins/task-list-store-sql.d.ts +1 -1
  153. package/dist/plugins/tidb-pool.js +59 -0
  154. package/dist/plugins/web-search.d.ts +1 -1
  155. package/dist/prompts-domain-validate.js +1 -1
  156. package/dist/resource-suspend.d.ts +1 -1
  157. package/dist/resource-window.d.ts +3 -3
  158. package/dist/rules-consent.d.ts +231 -9
  159. package/dist/rules-consent.js +411 -13
  160. package/dist/run-local.js +1 -0
  161. package/dist/runs.d.ts +15 -1
  162. package/dist/runs.js +19 -0
  163. package/dist/runtime-caps-resolver.d.ts +36 -1
  164. package/dist/runtime-caps-resolver.js +25 -0
  165. package/dist/runtime-governance.d.ts +1 -1
  166. package/dist/security.d.ts +5 -5
  167. package/dist/security.js +1 -1
  168. package/dist/session-sync.d.ts +52 -37
  169. package/dist/session-sync.js +19 -16
  170. package/dist/shared-memory-scope-authorizer.d.ts +1 -1
  171. package/dist/sighup-idle.d.ts +1 -1
  172. package/dist/spec-fields.d.ts +22 -1
  173. package/dist/spec-fields.js +11 -0
  174. package/dist/store-live-probe.d.ts +2 -2
  175. package/dist/task-a2a.d.ts +1 -1
  176. package/dist/task-cwd.d.ts +1 -1
  177. package/dist/task-mcp.d.ts +3 -3
  178. package/dist/task-settings.d.ts +3 -3
  179. package/dist/task-workflow.d.ts +1 -1
  180. package/dist/text-bidi.d.ts +2 -2
  181. package/dist/tool-approval.d.ts +191 -9
  182. package/dist/tool-approval.js +248 -29
  183. package/dist/trace/core-keyset-guard.d.ts +1 -1
  184. package/dist/trace/engine-notice-wire.d.ts +6 -4
  185. package/dist/trace/engine-notice-wire.js +2 -0
  186. package/dist/trace/ledger-events.d.ts +1 -1
  187. package/dist/trace/ledger-sink.d.ts +2 -2
  188. package/dist/trace/project.d.ts +3 -2
  189. package/dist/trace/project.js +1 -0
  190. package/dist/turn-activity.d.ts +1 -1
  191. package/dist/wall-clock-jump-guard.d.ts +3 -3
  192. package/package.json +2 -2
  193. package/dist/http/tar.d.ts +0 -17
  194. package/dist/http/tar.js +0 -53
  195. package/dist/http/workspace-content.d.ts +0 -11
  196. package/dist/http/workspace-content.js +0 -20
  197. package/dist/plugins/file-snapshot-store-sql.d.ts +0 -170
  198. package/dist/plugins/file-snapshot-store-sql.js +0 -213
@@ -0,0 +1,827 @@
1
+ import { createHash } from "node:crypto";
2
+ import { captureFileBackupViaEnv, applyFileRestoreViaEnv, fileHistoryModeProvenDifferent, worldStillMatchesMint, boundaryPublishVerdict, fileHistoryDescribeTrackKey, fileHistoryTrackKeyOf, fileHistoryResolveTrackKey, fileHistoryExportDigest, validateFileHistoryExport, resolveFileHistoryRetention, fileHistoryBoundariesToKeep, } from "@sema-agent/core";
3
+ import { SqlBlobBackend } from "./blob-backend.js";
4
+ import { mysqlDriver, pgDriver } from "./sql-driver.js";
5
+ import { isMissingColumnError } from "./sql-errors.js";
6
+ import { recordFailOpen } from "../observability/fail-open.js";
7
+ export const FILE_HISTORY_BLOB_TABLE = "file_history_blob";
8
+ export const FILE_HISTORY_BLOB_KEY_PREFIX = "file-history/";
9
+ const sha256hex = (s) => createHash("sha256").update(s).digest("hex");
10
+ const truthy = (v) => v === true || v === 1 || v === "1";
11
+ class StagedRefGoneError extends Error {
12
+ }
13
+ function reapedEntryRefusal(entryId) {
14
+ return {
15
+ ok: false,
16
+ error: {
17
+ code: "snapshot_failed",
18
+ message: `entry "${entryId}" already had a boundary and it was reaped by retention — a fresh capture now would record post-turn bytes under a pre-turn key, so this entry has no boundary and cannot get one back`,
19
+ },
20
+ };
21
+ }
22
+ export class SqlFileHistoryStore {
23
+ db;
24
+ pool;
25
+ syncBlobFaces = true;
26
+ blobs;
27
+ spentAttempts = new Set();
28
+ activeAttempts = new Set();
29
+ boundaryKeep;
30
+ constructor(db, pool, blobBackend, opts) {
31
+ this.db = db;
32
+ this.pool = pool;
33
+ this.blobs = blobBackend ?? new SqlBlobBackend(this.db.dialect, this.pool, undefined, FILE_HISTORY_BLOB_TABLE);
34
+ this.boundaryKeep = resolveFileHistoryRetention(this.db.dialect === "tidb" ? "TiDBFileHistoryStore" : "PgFileHistoryStore", opts?.retention);
35
+ }
36
+ q(tidb, pg) {
37
+ return this.db.dialect === "tidb" ? tidb : pg;
38
+ }
39
+ bool(v) {
40
+ return this.db.dialect === "tidb" ? (v ? 1 : 0) : v;
41
+ }
42
+ leaseKey(scope, entryId) {
43
+ return `${scope}\x00${entryId}`;
44
+ }
45
+ async beforeMint(_scope, _keyHash) { }
46
+ async beforeObserve(_scope, _keyHash) { }
47
+ async beforePublish(_scope) { }
48
+ async lockScope(conn, ...scopes) {
49
+ for (const scope of [...new Set(scopes)].sort()) {
50
+ let locked = false;
51
+ for (let i = 0; i < 3 && !locked; i++) {
52
+ await conn.query(this.q("INSERT IGNORE INTO file_history_scope (scope, created_at_ms) VALUES (?,?)", "INSERT INTO file_history_scope (scope, created_at_ms) VALUES ($1,$2) ON CONFLICT (scope) DO NOTHING"), [scope, Date.now()]);
53
+ const r = await conn.query(this.q("SELECT scope FROM file_history_scope WHERE scope = ? FOR UPDATE", "SELECT scope FROM file_history_scope WHERE scope = $1 FOR UPDATE"), [scope]);
54
+ locked = Boolean(r.rows[0]);
55
+ }
56
+ if (!locked)
57
+ throw new Error(`could not acquire the scope write lock for "${scope}" (sentinel row kept vanishing)`);
58
+ }
59
+ }
60
+ async markObserved(scope, keyHash) {
61
+ const r = await this.db.query(this.q("UPDATE file_history_tracked SET observed_later = 1 WHERE scope = ? AND key_hash = ? AND observed_later = 0", "UPDATE file_history_tracked SET observed_later = TRUE WHERE scope = $1 AND key_hash = $2 AND observed_later = FALSE"), [scope, keyHash]);
62
+ return r.affected;
63
+ }
64
+ async trackEdit(scope, absPath, env, root, signal) {
65
+ try {
66
+ const { key, ...keyDesc } = fileHistoryDescribeTrackKey(root, absPath);
67
+ const keyHash = sha256hex(key);
68
+ for (let attempt = 0; attempt < 3; attempt++) {
69
+ const pre = await this.db.query(this.q("SELECT observed_later FROM file_history_tracked WHERE scope = ? AND key_hash = ?", "SELECT observed_later FROM file_history_tracked WHERE scope = $1 AND key_hash = $2"), [scope, keyHash]);
70
+ if (pre.rows[0]) {
71
+ if (truthy(pre.rows[0].observed_later))
72
+ return { ok: true, minted: false };
73
+ await this.beforeObserve(scope, keyHash);
74
+ if ((await this.markObserved(scope, keyHash)) === 1)
75
+ return { ok: true, minted: false };
76
+ continue;
77
+ }
78
+ const cap = await captureFileBackupViaEnv(env, absPath, root, signal);
79
+ if (cap.state === "aborted") {
80
+ return { ok: false, error: { code: "aborted", message: `first-touch backup of ${absPath} aborted` } };
81
+ }
82
+ const baseAvailable = cap.state !== "unreadable";
83
+ let v1;
84
+ if (cap.state === "file") {
85
+ await this.blobs.putBlob(cap.hash, cap.bytes);
86
+ v1 = { blobHash: cap.hash, ...(cap.mode !== undefined ? { mode: cap.mode } : {}), ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) };
87
+ }
88
+ else if (cap.state === "absent") {
89
+ v1 = { blobHash: null, ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) };
90
+ }
91
+ await this.beforeMint(scope, keyHash);
92
+ const conn = await this.db.connect();
93
+ let minted = false;
94
+ try {
95
+ await conn.beginPessimistic();
96
+ await this.lockScope(conn, scope);
97
+ const ins = await conn.query(this.q("INSERT IGNORE INTO file_history_tracked (scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms) VALUES (?,?,?,?,?,?,0,?)", "INSERT INTO file_history_tracked (scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms) VALUES ($1,$2,$3,$4,$5,$6,FALSE,$7) ON CONFLICT (scope, key_hash) DO NOTHING"), [scope, keyHash, key, keyDesc.kind, keyDesc.family, this.bool(baseAvailable), Date.now()]);
98
+ minted = ins.affected === 1;
99
+ if (minted && v1 !== undefined) {
100
+ await conn.query(this.q("INSERT INTO file_history_version (scope, key_hash, version, blob_hash, mode, parent_ident, backup_time) VALUES (?,?,?,?,?,?,?)", "INSERT INTO file_history_version (scope, key_hash, version, blob_hash, mode, parent_ident, backup_time) VALUES ($1,$2,$3,$4,$5,$6,$7)"), [scope, keyHash, 1, v1.blobHash, v1.mode ?? null, v1.parentIdent ?? null, new Date().toISOString()]);
101
+ }
102
+ await conn.commit();
103
+ }
104
+ catch (err) {
105
+ await conn.rollback().catch(() => undefined);
106
+ throw err;
107
+ }
108
+ finally {
109
+ conn.release();
110
+ }
111
+ if (minted)
112
+ return { ok: true, minted: true };
113
+ if ((await this.markObserved(scope, keyHash)) === 1)
114
+ return { ok: true, minted: false };
115
+ }
116
+ return { ok: false, error: { code: "track_failed", message: `first-touch record for ${absPath} kept racing a concurrent retraction — retry` } };
117
+ }
118
+ catch (err) {
119
+ return { ok: false, error: { code: "track_failed", message: `first-touch record for ${absPath} could not be persisted: ${err.message}` } };
120
+ }
121
+ }
122
+ async annulIneligibleReason(scope, keyHash, absPath) {
123
+ const ref = await this.db.query(this.q("SELECT 1 FROM file_history_boundary_file WHERE scope = ? AND key_hash = ? LIMIT 1", "SELECT 1 FROM file_history_boundary_file WHERE scope = $1 AND key_hash = $2 LIMIT 1"), [scope, keyHash]);
124
+ if (ref.rows[0])
125
+ return `first-touch record for ${absPath} is referenced by a committed boundary — it is history now, not a retractable mint`;
126
+ const grown = await this.db.query(this.q("SELECT 1 FROM file_history_version WHERE scope = ? AND key_hash = ? AND version > 1 LIMIT 1", "SELECT 1 FROM file_history_version WHERE scope = $1 AND key_hash = $2 AND version > 1 LIMIT 1"), [scope, keyHash]);
127
+ if (grown.rows[0])
128
+ return `first-touch record for ${absPath} has grown past its first version — only the mint itself is retractable`;
129
+ const row = await this.db.query(this.q("SELECT observed_later FROM file_history_tracked WHERE scope = ? AND key_hash = ?", "SELECT observed_later FROM file_history_tracked WHERE scope = $1 AND key_hash = $2"), [scope, keyHash]);
130
+ if (row.rows[0] && truthy(row.rows[0].observed_later)) {
131
+ return `first-touch record for ${absPath} has since been served to another edit of the same path — it is that edit's baseline now, not a retractable mint`;
132
+ }
133
+ return undefined;
134
+ }
135
+ async annulTrack(scope, absPath, root, verify) {
136
+ try {
137
+ const key = fileHistoryTrackKeyOf(root, absPath);
138
+ const keyHash = sha256hex(key);
139
+ const t = await this.db.query(this.q("SELECT base_available FROM file_history_tracked WHERE scope = ? AND key_hash = ?", "SELECT base_available FROM file_history_tracked WHERE scope = $1 AND key_hash = $2"), [scope, keyHash]);
140
+ if (!t.rows[0])
141
+ return { ok: true };
142
+ const before = await this.annulIneligibleReason(scope, keyHash, absPath);
143
+ if (before !== undefined)
144
+ return { ok: false, error: { code: "conflict", message: before } };
145
+ if (verify !== undefined) {
146
+ const v1 = await this.db.query(this.q("SELECT blob_hash FROM file_history_version WHERE scope = ? AND key_hash = ? AND version = 1", "SELECT blob_hash FROM file_history_version WHERE scope = $1 AND key_hash = $2 AND version = 1"), [scope, keyHash]);
147
+ const baseAvailable = truthy(t.rows[0].base_available);
148
+ const mintedBlobHash = v1.rows[0]?.blob_hash != null ? String(v1.rows[0].blob_hash) : null;
149
+ const proof = await worldStillMatchesMint(verify.env, absPath, root, baseAvailable, mintedBlobHash, verify.signal);
150
+ if (!proof.unchanged) {
151
+ return {
152
+ ok: false,
153
+ error: {
154
+ code: "conflict",
155
+ message: `first-touch record for ${absPath} is NOT retractable: ${proof.reason} — the write this retraction compensates for may have changed the file, and deleting the only baseline of a modified file is worse than keeping a stale record`,
156
+ },
157
+ };
158
+ }
159
+ }
160
+ const conn = await this.db.connect();
161
+ try {
162
+ await conn.beginPessimistic();
163
+ await this.lockScope(conn, scope);
164
+ const del = await conn.query(this.q(`DELETE FROM file_history_tracked WHERE scope = ? AND key_hash = ? AND observed_later = 0
165
+ AND NOT EXISTS (SELECT 1 FROM file_history_boundary_file bf WHERE bf.scope = ? AND bf.key_hash = ?)
166
+ AND NOT EXISTS (SELECT 1 FROM file_history_version v WHERE v.scope = ? AND v.key_hash = ? AND v.version > 1)`, `DELETE FROM file_history_tracked WHERE scope = $1 AND key_hash = $2 AND observed_later = FALSE
167
+ AND NOT EXISTS (SELECT 1 FROM file_history_boundary_file bf WHERE bf.scope = $3 AND bf.key_hash = $4)
168
+ AND NOT EXISTS (SELECT 1 FROM file_history_version v WHERE v.scope = $5 AND v.key_hash = $6 AND v.version > 1)`), [scope, keyHash, scope, keyHash, scope, keyHash]);
169
+ if (del.affected === 1) {
170
+ await conn.query(this.q("DELETE FROM file_history_version WHERE scope = ? AND key_hash = ?", "DELETE FROM file_history_version WHERE scope = $1 AND key_hash = $2"), [scope, keyHash]);
171
+ await conn.commit();
172
+ return { ok: true };
173
+ }
174
+ await conn.commit();
175
+ }
176
+ catch (err) {
177
+ await conn.rollback().catch(() => undefined);
178
+ throw err;
179
+ }
180
+ finally {
181
+ conn.release();
182
+ }
183
+ const after = await this.annulIneligibleReason(scope, keyHash, absPath);
184
+ if (after !== undefined)
185
+ return { ok: false, error: { code: "conflict", message: after } };
186
+ const still = await this.db.query(this.q("SELECT 1 FROM file_history_tracked WHERE scope = ? AND key_hash = ?", "SELECT 1 FROM file_history_tracked WHERE scope = $1 AND key_hash = $2"), [scope, keyHash]);
187
+ if (!still.rows[0])
188
+ return { ok: true };
189
+ return { ok: false, error: { code: "conflict", message: `first-touch record for ${absPath} was replaced while this retraction was being proven` } };
190
+ }
191
+ catch (err) {
192
+ return { ok: false, error: { code: "track_failed", message: `annulling the first-touch record for ${absPath} failed: ${err.message}` } };
193
+ }
194
+ }
195
+ async loadTracked(exec, scope, forUpdate = false) {
196
+ const suffix = forUpdate ? " FOR UPDATE" : "";
197
+ const r = await exec.query(this.q(`SELECT key_hash, track_key, key_kind, key_family, base_available FROM file_history_tracked WHERE scope = ?${suffix}`, `SELECT key_hash, track_key, key_kind, key_family, base_available FROM file_history_tracked WHERE scope = $1${suffix}`), [scope]);
198
+ return r.rows.map((row) => ({
199
+ keyHash: String(row.key_hash),
200
+ trackKey: String(row.track_key),
201
+ keyDesc: { kind: String(row.key_kind), family: String(row.key_family) },
202
+ baseAvailable: truthy(row.base_available),
203
+ }));
204
+ }
205
+ async loadVersions(exec, scope) {
206
+ const r = await exec.query(this.q("SELECT key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE scope = ? ORDER BY version", "SELECT key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE scope = $1 ORDER BY version"), [scope]);
207
+ const out = new Map();
208
+ for (const row of r.rows) {
209
+ const kh = String(row.key_hash);
210
+ let list = out.get(kh);
211
+ if (list === undefined)
212
+ out.set(kh, (list = []));
213
+ list.push({
214
+ version: Number(row.version),
215
+ blobHash: row.blob_hash != null ? String(row.blob_hash) : null,
216
+ ...(row.mode != null ? { mode: Number(row.mode) } : {}),
217
+ ...(row.parent_ident != null ? { parentIdent: String(row.parent_ident) } : {}),
218
+ backupTime: String(row.backup_time),
219
+ });
220
+ }
221
+ return out;
222
+ }
223
+ async entryReaped(exec, scope, entryId, forUpdate = false) {
224
+ const suffix = forUpdate ? " FOR UPDATE" : "";
225
+ const r = await exec.query(this.q(`SELECT 1 FROM file_history_reaped_entry WHERE scope = ? AND entry_id = ? LIMIT 1${suffix}`, `SELECT 1 FROM file_history_reaped_entry WHERE scope = $1 AND entry_id = $2 LIMIT 1${suffix}`), [scope, entryId]);
226
+ return r.rows[0] !== undefined;
227
+ }
228
+ async copyReapedEntries(conn, fromScope, toScope) {
229
+ await conn.query(this.q("INSERT IGNORE INTO file_history_reaped_entry (scope, entry_id, reaped_at_ms) SELECT ?, entry_id, reaped_at_ms FROM file_history_reaped_entry WHERE scope = ?", "INSERT INTO file_history_reaped_entry (scope, entry_id, reaped_at_ms) SELECT $1, entry_id, reaped_at_ms FROM file_history_reaped_entry WHERE scope = $2 ON CONFLICT (scope, entry_id) DO NOTHING"), [toScope, fromScope]);
230
+ }
231
+ async beforeRetentionPass(_scope) { }
232
+ async duringRetentionPass(_scope) { }
233
+ async duringRestorePlan(_scope, _entryId) { }
234
+ async beforePublishLock(_scope) { }
235
+ async duringExportGraph(_scope) { }
236
+ async applyBoundaryRetention(scope, justCommitted) {
237
+ if (this.boundaryKeep === null)
238
+ return;
239
+ try {
240
+ await this.beforeRetentionPass(scope);
241
+ const conn = await this.db.connect();
242
+ try {
243
+ await conn.beginPessimistic();
244
+ await this.lockScope(conn, scope);
245
+ const { rows } = await conn.query(this.q("SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq ASC", "SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq ASC"), [scope]);
246
+ const ordered = rows.map((r) => ({ id: String(r.entry_id), seq: Number(r.publish_seq) }));
247
+ if (ordered.length === 0) {
248
+ await conn.rollback();
249
+ return;
250
+ }
251
+ const mine = ordered.find((b) => b.id === justCommitted);
252
+ const pin = mine ?? ordered[ordered.length - 1];
253
+ const keepIds = fileHistoryBoundariesToKeep(ordered.map((b) => b.id), pin.id, this.boundaryKeep);
254
+ if (keepIds === null) {
255
+ await conn.rollback();
256
+ return;
257
+ }
258
+ const keep = new Set(keepIds);
259
+ for (const b of ordered)
260
+ if (b.seq > pin.seq)
261
+ keep.add(b.id);
262
+ await this.duringRetentionPass(scope);
263
+ await this.reapWithin(conn, scope, keep, ordered.map((b) => b.id));
264
+ await conn.commit();
265
+ }
266
+ catch (err) {
267
+ await conn.rollback().catch(() => undefined);
268
+ throw err;
269
+ }
270
+ finally {
271
+ conn.release();
272
+ }
273
+ }
274
+ catch (err) {
275
+ recordFailOpen("server.file-history.retention-trim-failed", `scope=${scope} entry=${justCommitted}: ${err instanceof Error ? err.message : String(err)}`);
276
+ }
277
+ }
278
+ async boundaryExists(exec, scope, entryId) {
279
+ const r = await exec.query(this.q("SELECT 1 FROM file_history_boundary WHERE scope = ? AND entry_id = ? LIMIT 1", "SELECT 1 FROM file_history_boundary WHERE scope = $1 AND entry_id = $2 LIMIT 1"), [scope, entryId]);
280
+ return Boolean(r.rows[0]);
281
+ }
282
+ async snapshot(scope, entryId, env, root, signal) {
283
+ const lease = this.leaseKey(scope, entryId);
284
+ try {
285
+ if (await this.boundaryExists(this.db, scope, entryId))
286
+ return { ok: true };
287
+ if (await this.entryReaped(this.db, scope, entryId))
288
+ return reapedEntryRefusal(entryId);
289
+ if (this.spentAttempts.has(lease)) {
290
+ return { ok: false, error: { code: "snapshot_failed", message: `capture lease for entry "${entryId}" is closed (a prior attempt failed); no complete pre-close staging exists, so this turn has no boundary` } };
291
+ }
292
+ if (this.activeAttempts.has(lease)) {
293
+ return { ok: false, error: { code: "snapshot_failed", message: `a capture attempt for entry "${entryId}" is already in flight — concurrent same-key capture is refused (the in-flight attempt owns the lease)` } };
294
+ }
295
+ }
296
+ catch (err) {
297
+ return { ok: false, error: { code: "snapshot_failed", message: err.message } };
298
+ }
299
+ this.activeAttempts.add(lease);
300
+ try {
301
+ const tracked = await this.loadTracked(this.db, scope);
302
+ const versions = await this.loadVersions(this.db, scope);
303
+ const staged = new Map();
304
+ for (const t of tracked) {
305
+ if (signal?.aborted) {
306
+ this.spentAttempts.add(lease);
307
+ return { ok: false, error: { code: "aborted", message: `boundary capture for entry "${entryId}" aborted` } };
308
+ }
309
+ const resolved = fileHistoryResolveTrackKey(root, t.trackKey, t.keyDesc);
310
+ if (!resolved.ok) {
311
+ this.spentAttempts.add(lease);
312
+ return { ok: false, error: { code: "snapshot_failed", message: resolved.reason } };
313
+ }
314
+ const cap = await captureFileBackupViaEnv(env, resolved.abs, root, signal);
315
+ if (cap.state === "aborted") {
316
+ this.spentAttempts.add(lease);
317
+ return { ok: false, error: { code: "aborted", message: `boundary capture for entry "${entryId}" aborted` } };
318
+ }
319
+ if (cap.state === "unreadable") {
320
+ this.spentAttempts.add(lease);
321
+ return { ok: false, error: { code: "snapshot_failed", message: cap.cause } };
322
+ }
323
+ const chain = versions.get(t.keyHash) ?? [];
324
+ const last = chain[chain.length - 1];
325
+ if (cap.state === "absent") {
326
+ if (last !== undefined && last.blobHash === null)
327
+ staged.set(t.keyHash, { kind: "unchanged", version: last.version });
328
+ else
329
+ staged.set(t.keyHash, { kind: "changed", blobHash: null, ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) });
330
+ continue;
331
+ }
332
+ if (last !== undefined && last.blobHash === cap.hash && !fileHistoryModeProvenDifferent(last.mode, cap.mode)) {
333
+ staged.set(t.keyHash, { kind: "unchanged", version: last.version });
334
+ continue;
335
+ }
336
+ await this.blobs.putBlob(cap.hash, cap.bytes);
337
+ staged.set(t.keyHash, { kind: "changed", blobHash: cap.hash, ...(cap.mode !== undefined ? { mode: cap.mode } : {}), ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) });
338
+ }
339
+ const verdict = boundaryPublishVerdict({ aborted: signal?.aborted === true });
340
+ if (!verdict.publish) {
341
+ this.spentAttempts.add(lease);
342
+ return { ok: false, error: { code: "aborted", message: `boundary capture for entry "${entryId}" aborted (fenced — never committed)` } };
343
+ }
344
+ await this.beforePublish(scope);
345
+ const conn = await this.db.connect();
346
+ try {
347
+ await conn.beginPessimistic();
348
+ await this.beforePublishLock(scope);
349
+ await this.lockScope(conn, scope);
350
+ if (signal?.aborted) {
351
+ await conn.rollback();
352
+ this.spentAttempts.add(lease);
353
+ return { ok: false, error: { code: "aborted", message: `boundary capture for entry "${entryId}" aborted (fenced — never committed)` } };
354
+ }
355
+ if (await this.entryReaped(conn, scope, entryId, true)) {
356
+ await conn.rollback();
357
+ return reapedEntryRefusal(entryId);
358
+ }
359
+ const seqRow = await conn.query(this.q("SELECT publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq DESC LIMIT 1 FOR UPDATE", "SELECT publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq DESC LIMIT 1"), [scope]);
360
+ const publishSeq = (seqRow.rows[0] !== undefined ? Number(seqRow.rows[0].publish_seq) : 0) + 1;
361
+ const ins = await conn.query(this.q("INSERT IGNORE INTO file_history_boundary (scope, entry_id, published_at_ms, publish_seq) VALUES (?,?,CAST(UNIX_TIMESTAMP(NOW(3))*1000 AS SIGNED),?)", "INSERT INTO file_history_boundary (scope, entry_id, published_at_ms, publish_seq) VALUES ($1,$2,(EXTRACT(EPOCH FROM clock_timestamp())*1000)::bigint,$3) ON CONFLICT (scope, entry_id) DO NOTHING"), [scope, entryId, publishSeq]);
362
+ if (ins.affected !== 1) {
363
+ const won = await conn.query(this.q("SELECT 1 FROM file_history_boundary WHERE scope = ? AND entry_id = ? FOR UPDATE", "SELECT 1 FROM file_history_boundary WHERE scope = $1 AND entry_id = $2 FOR UPDATE"), [scope, entryId]);
364
+ if (!won.rows[0]) {
365
+ throw new Error(`boundary publish for entry "${entryId}" was suppressed without a same-entry winner (publish_seq allocation invariant breach) — refusing to report a boundary that was not stored`);
366
+ }
367
+ await conn.rollback();
368
+ return { ok: true };
369
+ }
370
+ for (const [keyHash, cap] of staged) {
371
+ let version;
372
+ if (cap.kind === "unchanged") {
373
+ const still = await conn.query(this.q("SELECT 1 FROM file_history_version WHERE scope = ? AND key_hash = ? AND version = ? FOR UPDATE", "SELECT 1 FROM file_history_version WHERE scope = $1 AND key_hash = $2 AND version = $3 FOR UPDATE"), [scope, keyHash, cap.version]);
374
+ if (!still.rows[0])
375
+ throw new StagedRefGoneError(`staged reference ${keyHash}@v${cap.version} no longer exists (reaped mid-capture)`);
376
+ version = cap.version;
377
+ }
378
+ else {
379
+ const stillTracked = await conn.query(this.q("SELECT base_available FROM file_history_tracked WHERE scope = ? AND key_hash = ? FOR UPDATE", "SELECT base_available FROM file_history_tracked WHERE scope = $1 AND key_hash = $2 FOR UPDATE"), [scope, keyHash]);
380
+ if (!stillTracked.rows[0])
381
+ continue;
382
+ const tail = await conn.query(this.q("SELECT version FROM file_history_version WHERE scope = ? AND key_hash = ? ORDER BY version DESC LIMIT 1 FOR UPDATE", "SELECT version FROM file_history_version WHERE scope = $1 AND key_hash = $2 ORDER BY version DESC LIMIT 1 FOR UPDATE"), [scope, keyHash]);
383
+ const last = tail.rows[0] !== undefined ? Number(tail.rows[0].version) : undefined;
384
+ version = last !== undefined ? last + 1 : truthy(stillTracked.rows[0].base_available) ? 1 : 2;
385
+ await conn.query(this.q("INSERT INTO file_history_version (scope, key_hash, version, blob_hash, mode, parent_ident, backup_time) VALUES (?,?,?,?,?,?,?)", "INSERT INTO file_history_version (scope, key_hash, version, blob_hash, mode, parent_ident, backup_time) VALUES ($1,$2,$3,$4,$5,$6,$7)"), [scope, keyHash, version, cap.blobHash, cap.mode ?? null, cap.parentIdent ?? null, new Date().toISOString()]);
386
+ }
387
+ await conn.query(this.q("INSERT INTO file_history_boundary_file (scope, entry_id, key_hash, version) VALUES (?,?,?,?)", "INSERT INTO file_history_boundary_file (scope, entry_id, key_hash, version) VALUES ($1,$2,$3,$4)"), [scope, entryId, keyHash, version]);
388
+ }
389
+ if (signal?.aborted) {
390
+ await conn.rollback();
391
+ this.spentAttempts.add(lease);
392
+ return { ok: false, error: { code: "aborted", message: `boundary capture for entry "${entryId}" aborted (fenced — never committed)` } };
393
+ }
394
+ await conn.commit();
395
+ }
396
+ catch (err) {
397
+ await conn.rollback().catch(() => undefined);
398
+ this.spentAttempts.add(lease);
399
+ return { ok: false, error: { code: "snapshot_failed", message: `boundary for entry "${entryId}" could not be committed: ${err.message}` } };
400
+ }
401
+ finally {
402
+ conn.release();
403
+ }
404
+ await this.applyBoundaryRetention(scope, entryId);
405
+ return { ok: true };
406
+ }
407
+ catch (err) {
408
+ this.spentAttempts.add(lease);
409
+ return { ok: false, error: { code: "snapshot_failed", message: err.message } };
410
+ }
411
+ finally {
412
+ this.activeAttempts.delete(lease);
413
+ }
414
+ }
415
+ async restore(scope, entryId, env, root, signal) {
416
+ const empty = { filesChanged: [], identicalSkipped: [], refused: [], failed: [] };
417
+ try {
418
+ if (!(await this.boundaryExists(this.db, scope, entryId))) {
419
+ return { outcome: "failed", ...empty, error: { code: "not_found", message: `no history boundary for entry "${entryId}"` } };
420
+ }
421
+ const plan = await this.restorePlan(scope, entryId);
422
+ if (plan === null) {
423
+ return { outcome: "failed", ...empty, error: { code: "not_found", message: `no history boundary for entry "${entryId}"` } };
424
+ }
425
+ const { tracked, versions, boundary } = plan;
426
+ const ledger = empty;
427
+ let modeNotPreserved = 0;
428
+ for (const t of tracked) {
429
+ if (signal?.aborted) {
430
+ ledger.failed.push({ path: t.trackKey, reason: "aborted before attempt" });
431
+ continue;
432
+ }
433
+ let targetVersion = boundary.get(t.keyHash);
434
+ if (targetVersion === undefined) {
435
+ if (!t.baseAvailable) {
436
+ ledger.refused.push({ path: t.trackKey, reason: "base_unavailable" });
437
+ continue;
438
+ }
439
+ targetVersion = 1;
440
+ }
441
+ const rec = (versions.get(t.keyHash) ?? []).find((v) => v.version === targetVersion);
442
+ if (rec === undefined) {
443
+ ledger.failed.push({ path: t.trackKey, reason: `version ${targetVersion} record missing (store integrity)` });
444
+ continue;
445
+ }
446
+ if (rec.mode !== undefined && !(Number.isInteger(rec.mode) && rec.mode >= 0 && rec.mode <= 0o7777)) {
447
+ ledger.refused.push({ path: t.trackKey, reason: `recorded mode ${String(rec.mode)} is not a valid POSIX permission value — refusing to apply a corrupted permission record` });
448
+ continue;
449
+ }
450
+ const resolved = fileHistoryResolveTrackKey(root, t.trackKey, t.keyDesc);
451
+ if (!resolved.ok) {
452
+ ledger.refused.push({ path: t.trackKey, reason: resolved.reason });
453
+ continue;
454
+ }
455
+ let applied;
456
+ try {
457
+ let target;
458
+ if (rec.blobHash === null)
459
+ target = { absent: true };
460
+ else {
461
+ const bytes = await this.blobs.getBlob(rec.blobHash);
462
+ if (bytes === undefined) {
463
+ ledger.failed.push({ path: t.trackKey, reason: `blob ${rec.blobHash} missing (store integrity)` });
464
+ continue;
465
+ }
466
+ target = { bytes, hash: rec.blobHash };
467
+ }
468
+ applied = await applyFileRestoreViaEnv(env, resolved.abs, target, {
469
+ root,
470
+ ...(rec.parentIdent !== undefined ? { parentIdent: rec.parentIdent } : {}),
471
+ ...(rec.mode !== undefined ? { mode: rec.mode } : {}),
472
+ ...(signal !== undefined ? { signal } : {}),
473
+ });
474
+ }
475
+ catch (err) {
476
+ applied = { outcome: "failed", reason: err.message };
477
+ }
478
+ if (applied.outcome === "applied")
479
+ ledger.filesChanged.push(t.trackKey);
480
+ else if (applied.outcome === "identical")
481
+ ledger.identicalSkipped.push(t.trackKey);
482
+ else if (applied.outcome === "refused")
483
+ ledger.refused.push({ path: t.trackKey, reason: applied.reason });
484
+ else
485
+ ledger.failed.push({ path: t.trackKey, reason: applied.reason });
486
+ if ((applied.outcome === "applied" || applied.outcome === "identical") && applied.modeNotPreserved === true)
487
+ modeNotPreserved++;
488
+ }
489
+ return { outcome: ledger.refused.length > 0 || ledger.failed.length > 0 ? "partial" : "complete", ...ledger, ...(modeNotPreserved > 0 ? { modeNotPreserved } : {}) };
490
+ }
491
+ catch (err) {
492
+ const touched = empty.filesChanged.length > 0 || empty.identicalSkipped.length > 0 || empty.refused.length > 0 || empty.failed.length > 0;
493
+ return { outcome: touched ? "partial" : "failed", ...empty, error: { code: "restore_failed", message: err.message } };
494
+ }
495
+ }
496
+ async restorePlan(scope, entryId) {
497
+ const conn = await this.db.connect();
498
+ try {
499
+ await conn.beginPessimistic();
500
+ await this.lockScope(conn, scope);
501
+ if (!(await this.boundaryExists(conn, scope, entryId))) {
502
+ await conn.rollback();
503
+ return null;
504
+ }
505
+ await this.duringRestorePlan(scope, entryId);
506
+ const tracked = (await this.loadTracked(conn, scope)).sort((a, b) => (a.trackKey < b.trackKey ? -1 : a.trackKey > b.trackKey ? 1 : 0));
507
+ const versions = await this.loadVersions(conn, scope);
508
+ const mapRows = await conn.query(this.q("SELECT key_hash, version FROM file_history_boundary_file WHERE scope = ? AND entry_id = ?", "SELECT key_hash, version FROM file_history_boundary_file WHERE scope = $1 AND entry_id = $2"), [scope, entryId]);
509
+ const boundary = new Map(mapRows.rows.map((r) => [String(r.key_hash), Number(r.version)]));
510
+ await conn.commit();
511
+ return { tracked, versions, boundary };
512
+ }
513
+ catch (err) {
514
+ await conn.rollback().catch(() => undefined);
515
+ throw err;
516
+ }
517
+ finally {
518
+ conn.release();
519
+ }
520
+ }
521
+ async canRestore(scope, entryId) {
522
+ return this.boundaryExists(this.db, scope, entryId);
523
+ }
524
+ async reap(scope, keepEntryIds) {
525
+ const conn = await this.db.connect();
526
+ try {
527
+ await conn.beginPessimistic();
528
+ await this.lockScope(conn, scope);
529
+ const ids = await conn.query(this.q("SELECT entry_id FROM file_history_boundary WHERE scope = ?", "SELECT entry_id FROM file_history_boundary WHERE scope = $1"), [scope]);
530
+ const dropped = await this.reapWithin(conn, scope, new Set(keepEntryIds), ids.rows.map((r) => String(r.entry_id)));
531
+ await conn.commit();
532
+ return dropped;
533
+ }
534
+ catch (err) {
535
+ await conn.rollback().catch(() => undefined);
536
+ throw err;
537
+ }
538
+ finally {
539
+ conn.release();
540
+ }
541
+ }
542
+ async reapWithin(conn, scope, keep, present) {
543
+ {
544
+ const drop = present.filter((id) => !keep.has(id));
545
+ const reapedAtMs = Date.now();
546
+ for (const id of drop) {
547
+ await conn.query(this.q("INSERT IGNORE INTO file_history_reaped_entry (scope, entry_id, reaped_at_ms) VALUES (?,?,?)", "INSERT INTO file_history_reaped_entry (scope, entry_id, reaped_at_ms) VALUES ($1,$2,$3) ON CONFLICT (scope, entry_id) DO NOTHING"), [scope, id, reapedAtMs]);
548
+ await conn.query(this.q("DELETE FROM file_history_boundary WHERE scope = ? AND entry_id = ?", "DELETE FROM file_history_boundary WHERE scope = $1 AND entry_id = $2"), [scope, id]);
549
+ await conn.query(this.q("DELETE FROM file_history_boundary_file WHERE scope = ? AND entry_id = ?", "DELETE FROM file_history_boundary_file WHERE scope = $1 AND entry_id = $2"), [scope, id]);
550
+ }
551
+ await conn.query(this.q(`DELETE FROM file_history_version WHERE scope = ? AND version <> 1
552
+ AND NOT EXISTS (SELECT 1 FROM file_history_boundary_file bf WHERE bf.scope = ? AND bf.key_hash = file_history_version.key_hash AND bf.version = file_history_version.version)`, `DELETE FROM file_history_version WHERE scope = $1 AND version <> 1
553
+ AND NOT EXISTS (SELECT 1 FROM file_history_boundary_file bf WHERE bf.scope = $2 AND bf.key_hash = file_history_version.key_hash AND bf.version = file_history_version.version)`), [scope, scope]);
554
+ return drop.length;
555
+ }
556
+ }
557
+ async deleteBySession(scope) {
558
+ const conn = await this.db.connect();
559
+ try {
560
+ await conn.beginPessimistic();
561
+ await this.lockScope(conn, scope);
562
+ let total = 0;
563
+ for (const table of ["file_history_boundary_file", "file_history_boundary", "file_history_reaped_entry", "file_history_version", "file_history_tracked", "file_history_scope"]) {
564
+ const r = await conn.query(this.q(`DELETE FROM ${table} WHERE scope = ?`, `DELETE FROM ${table} WHERE scope = $1`), [scope]);
565
+ total += r.affected;
566
+ }
567
+ await conn.commit();
568
+ return total;
569
+ }
570
+ catch (err) {
571
+ await conn.rollback().catch(() => undefined);
572
+ throw err;
573
+ }
574
+ finally {
575
+ conn.release();
576
+ }
577
+ }
578
+ async sweepOrphanBlobs() {
579
+ const { rows } = await this.db.query("SELECT blob_hash FROM file_history_blob WHERE blob_hash NOT IN (SELECT blob_hash FROM file_history_version WHERE blob_hash IS NOT NULL)");
580
+ const orphans = rows.map((r) => String(r.blob_hash));
581
+ if (orphans.length === 0)
582
+ return 0;
583
+ return this.blobs.deleteBlobs(orphans);
584
+ }
585
+ async putBlob(hash, bytes) {
586
+ if (sha256hex(bytes) !== hash) {
587
+ return { ok: false, error: { code: "unsupported_format", message: `putBlob ${hash} fails content-address integrity — nothing stored` } };
588
+ }
589
+ try {
590
+ await this.blobs.putBlob(hash, bytes);
591
+ return { ok: true };
592
+ }
593
+ catch (err) {
594
+ return { ok: false, error: { code: "snapshot_failed", message: `putBlob ${hash} write/upload failed: ${err.message}` } };
595
+ }
596
+ }
597
+ async getBlob(hash) {
598
+ return this.blobs.getBlob(hash);
599
+ }
600
+ async hasBlobs(hashes) {
601
+ return this.blobs.hasBlobs(hashes);
602
+ }
603
+ async adoptScope(fromScope, toScope) {
604
+ try {
605
+ if (fromScope === toScope)
606
+ return { ok: true };
607
+ const conn = await this.db.connect();
608
+ try {
609
+ await conn.beginPessimistic();
610
+ await this.lockScope(conn, fromScope, toScope);
611
+ if (!(await this.scopeEmpty(conn, toScope, true))) {
612
+ await conn.rollback();
613
+ return { ok: false, error: { code: "conflict", message: `adoptScope target "${toScope}" already has history of its own — adoption is legal only onto an EMPTY scope (the fork/resume boundary before the new session's first turn)` } };
614
+ }
615
+ if (await this.scopeEmpty(conn, fromScope)) {
616
+ await this.copyReapedEntries(conn, fromScope, toScope);
617
+ await conn.commit();
618
+ return { ok: true };
619
+ }
620
+ await conn.query(this.q("INSERT INTO file_history_tracked (scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms) SELECT ?, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms FROM file_history_tracked WHERE scope = ?", "INSERT INTO file_history_tracked (scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms) SELECT $1, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms FROM file_history_tracked WHERE scope = $2"), [toScope, fromScope]);
621
+ await conn.query(this.q("INSERT INTO file_history_version (scope, key_hash, version, blob_hash, mode, parent_ident, backup_time) SELECT ?, key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE scope = ?", "INSERT INTO file_history_version (scope, key_hash, version, blob_hash, mode, parent_ident, backup_time) SELECT $1, key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE scope = $2"), [toScope, fromScope]);
622
+ await conn.query(this.q("INSERT INTO file_history_boundary (scope, entry_id, published_at_ms, publish_seq) SELECT ?, entry_id, published_at_ms, publish_seq FROM file_history_boundary WHERE scope = ?", "INSERT INTO file_history_boundary (scope, entry_id, published_at_ms, publish_seq) SELECT $1, entry_id, published_at_ms, publish_seq FROM file_history_boundary WHERE scope = $2"), [toScope, fromScope]);
623
+ await conn.query(this.q("INSERT INTO file_history_boundary_file (scope, entry_id, key_hash, version) SELECT ?, entry_id, key_hash, version FROM file_history_boundary_file WHERE scope = ?", "INSERT INTO file_history_boundary_file (scope, entry_id, key_hash, version) SELECT $1, entry_id, key_hash, version FROM file_history_boundary_file WHERE scope = $2"), [toScope, fromScope]);
624
+ await this.copyReapedEntries(conn, fromScope, toScope);
625
+ await conn.commit();
626
+ return { ok: true };
627
+ }
628
+ catch (err) {
629
+ await conn.rollback().catch(() => undefined);
630
+ throw err;
631
+ }
632
+ finally {
633
+ conn.release();
634
+ }
635
+ }
636
+ catch (err) {
637
+ return { ok: false, error: { code: "conflict", message: `adoptScope failed: ${err.message}` } };
638
+ }
639
+ }
640
+ async scopeEmpty(exec, scope, forUpdate = false) {
641
+ const suffix = forUpdate ? " FOR UPDATE" : "";
642
+ const t = await exec.query(this.q(`SELECT 1 FROM file_history_tracked WHERE scope = ? LIMIT 1${suffix}`, `SELECT 1 FROM file_history_tracked WHERE scope = $1 LIMIT 1${suffix}`), [scope]);
643
+ if (t.rows[0])
644
+ return false;
645
+ const b = await exec.query(this.q(`SELECT 1 FROM file_history_boundary WHERE scope = ? LIMIT 1${suffix}`, `SELECT 1 FROM file_history_boundary WHERE scope = $1 LIMIT 1${suffix}`), [scope]);
646
+ return !b.rows[0];
647
+ }
648
+ async exportOf(exec, scope) {
649
+ const tracked = await this.loadTracked(exec, scope);
650
+ const versions = await this.loadVersions(exec, scope);
651
+ const headers = await exec.query(this.q("SELECT entry_id FROM file_history_boundary WHERE scope = ?", "SELECT entry_id FROM file_history_boundary WHERE scope = $1"), [scope]);
652
+ if (tracked.length === 0 && headers.rows.length === 0)
653
+ return null;
654
+ await this.duringExportGraph(scope);
655
+ const files = await exec.query(this.q("SELECT entry_id, key_hash, version FROM file_history_boundary_file WHERE scope = ?", "SELECT entry_id, key_hash, version FROM file_history_boundary_file WHERE scope = $1"), [scope]);
656
+ const keyOfHash = new Map(tracked.map((t) => [t.keyHash, t.trackKey]));
657
+ const byEntry = new Map();
658
+ for (const row of files.rows) {
659
+ const entry = String(row.entry_id);
660
+ let list = byEntry.get(entry);
661
+ if (list === undefined)
662
+ byEntry.set(entry, (list = []));
663
+ list.push({ relOrAbsPath: keyOfHash.get(String(row.key_hash)) ?? String(row.key_hash), version: Number(row.version) });
664
+ }
665
+ const sortStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
666
+ return {
667
+ formatVersion: "file-history/1",
668
+ tracked: [...tracked]
669
+ .sort((a, b) => sortStr(a.trackKey, b.trackKey))
670
+ .map((t) => ({
671
+ relOrAbsPath: t.trackKey,
672
+ keyKind: t.keyDesc.kind,
673
+ keyFamily: t.keyDesc.family,
674
+ baseAvailable: t.baseAvailable,
675
+ versions: (versions.get(t.keyHash) ?? []).map((v) => ({
676
+ version: v.version,
677
+ blobHash: v.blobHash,
678
+ ...(v.mode !== undefined ? { mode: v.mode } : {}),
679
+ ...(v.parentIdent !== undefined ? { parentIdent: v.parentIdent } : {}),
680
+ backupTime: v.backupTime,
681
+ })),
682
+ })),
683
+ boundaries: [...headers.rows.map((r) => String(r.entry_id))]
684
+ .sort(sortStr)
685
+ .map((entryId) => ({ entryId, files: (byEntry.get(entryId) ?? []).sort((a, b) => sortStr(a.relOrAbsPath, b.relOrAbsPath)) })),
686
+ };
687
+ }
688
+ async exportHistory(scope) {
689
+ const conn = await this.db.connect();
690
+ try {
691
+ await conn.beginPessimistic();
692
+ await this.lockScope(conn, scope);
693
+ const out = await this.exportOf(conn, scope);
694
+ await conn.commit();
695
+ return out;
696
+ }
697
+ catch (err) {
698
+ await conn.rollback().catch(() => undefined);
699
+ throw err;
700
+ }
701
+ finally {
702
+ conn.release();
703
+ }
704
+ }
705
+ async importHistory(scope, data, srcGetBlob) {
706
+ try {
707
+ const shaped = validateFileHistoryExport(data);
708
+ if (!shaped.ok)
709
+ return shaped;
710
+ for (const t of data.tracked) {
711
+ for (const v of t.versions) {
712
+ const m = v.mode;
713
+ if (m !== undefined && !(typeof m === "number" && Number.isInteger(m) && m >= 0 && m <= 0o7777)) {
714
+ return { ok: false, error: { code: "unsupported_format", message: `tracked row ${t.relOrAbsPath} v${v.version} carries an invalid POSIX mode ${JSON.stringify(m)} — a permission value outside integer [0, 0o7777] is refused, never masked (an imported mode becomes filesystem permission bits on restore)` } };
715
+ }
716
+ }
717
+ }
718
+ const existing = await this.exportHistory(scope);
719
+ if (existing !== null) {
720
+ if (fileHistoryExportDigest(existing) === fileHistoryExportDigest(data))
721
+ return { ok: true };
722
+ return { ok: false, error: { code: "conflict", message: `importHistory target "${scope}" already holds a DIFFERENT history — a first import requires an empty target and a retry is legal only for the identical envelope (atomic visibility prevents half-graphs; this law prevents publishing the wrong complete graph over locally-grown history)` } };
723
+ }
724
+ const needed = new Set();
725
+ for (const t of data.tracked)
726
+ for (const v of t.versions)
727
+ if (v.blobHash !== null)
728
+ needed.add(v.blobHash);
729
+ for (const hash of needed) {
730
+ let bytes;
731
+ try {
732
+ bytes = await srcGetBlob(hash);
733
+ }
734
+ catch (err) {
735
+ return { ok: false, error: { code: "unsupported_format", message: `source blob ${hash} fetch failed: ${err.message}` } };
736
+ }
737
+ if (bytes === undefined)
738
+ return { ok: false, error: { code: "not_found", message: `source blob ${hash} is missing — the envelope's graph cannot be completed, so nothing is published` } };
739
+ if (sha256hex(bytes) !== hash) {
740
+ return { ok: false, error: { code: "unsupported_format", message: `source blob ${hash} fails content-address integrity — refused, nothing published` } };
741
+ }
742
+ await this.blobs.putBlob(hash, bytes);
743
+ }
744
+ const conn = await this.db.connect();
745
+ try {
746
+ await conn.beginPessimistic();
747
+ await this.lockScope(conn, scope);
748
+ if (!(await this.scopeEmpty(conn, scope, true))) {
749
+ const now = await this.exportOf(conn, scope);
750
+ if (now !== null && fileHistoryExportDigest(now) === fileHistoryExportDigest(data)) {
751
+ await conn.commit();
752
+ return { ok: true };
753
+ }
754
+ await conn.rollback();
755
+ return { ok: false, error: { code: "conflict", message: `importHistory target "${scope}" gained a DIFFERENT history while this import was fetching blobs — nothing was published` } };
756
+ }
757
+ const now = Date.now();
758
+ for (const t of data.tracked) {
759
+ const keyHash = sha256hex(t.relOrAbsPath);
760
+ await conn.query(this.q("INSERT INTO file_history_tracked (scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms) VALUES (?,?,?,?,?,?,0,?)", "INSERT INTO file_history_tracked (scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms) VALUES ($1,$2,$3,$4,$5,$6,FALSE,$7)"), [scope, keyHash, t.relOrAbsPath, t.keyKind, t.keyFamily, this.bool(t.baseAvailable), now]);
761
+ for (const v of t.versions) {
762
+ await conn.query(this.q("INSERT INTO file_history_version (scope, key_hash, version, blob_hash, mode, parent_ident, backup_time) VALUES (?,?,?,?,?,?,?)", "INSERT INTO file_history_version (scope, key_hash, version, blob_hash, mode, parent_ident, backup_time) VALUES ($1,$2,$3,$4,$5,$6,$7)"), [scope, keyHash, v.version, v.blobHash, v.mode ?? null, v.parentIdent ?? null, v.backupTime]);
763
+ }
764
+ }
765
+ const seqBase = await conn.query(this.q("SELECT COALESCE(MAX(publish_seq), 0) AS s FROM file_history_boundary WHERE scope = ?", "SELECT COALESCE(MAX(publish_seq), 0) AS s FROM file_history_boundary WHERE scope = $1"), [scope]);
766
+ let publishSeq = Number(seqBase.rows[0].s);
767
+ for (const b of data.boundaries) {
768
+ publishSeq += 1;
769
+ await conn.query(this.q("INSERT INTO file_history_boundary (scope, entry_id, published_at_ms, publish_seq) VALUES (?,?,?,?)", "INSERT INTO file_history_boundary (scope, entry_id, published_at_ms, publish_seq) VALUES ($1,$2,$3,$4)"), [scope, b.entryId, now, publishSeq]);
770
+ for (const f of b.files) {
771
+ await conn.query(this.q("INSERT INTO file_history_boundary_file (scope, entry_id, key_hash, version) VALUES (?,?,?,?)", "INSERT INTO file_history_boundary_file (scope, entry_id, key_hash, version) VALUES ($1,$2,$3,$4)"), [scope, b.entryId, sha256hex(f.relOrAbsPath), f.version]);
772
+ }
773
+ }
774
+ await conn.commit();
775
+ return { ok: true };
776
+ }
777
+ catch (err) {
778
+ await conn.rollback().catch(() => undefined);
779
+ throw err;
780
+ }
781
+ finally {
782
+ conn.release();
783
+ }
784
+ }
785
+ catch (err) {
786
+ return { ok: false, error: { code: "unsupported_format", message: `importHistory failed: ${err.message}` } };
787
+ }
788
+ }
789
+ }
790
+ export class TiDBFileHistoryStore extends SqlFileHistoryStore {
791
+ constructor(pool, blobBackend, opts) {
792
+ super(mysqlDriver(pool), pool, blobBackend, opts);
793
+ }
794
+ }
795
+ export class PgFileHistoryStore extends SqlFileHistoryStore {
796
+ constructor(pool, blobBackend, opts) {
797
+ super(pgDriver(pool), pool, blobBackend, opts);
798
+ }
799
+ }
800
+ export async function assertFileHistorySchema(query, dialect) {
801
+ const probes = [
802
+ ["file_history_tracked", "SELECT scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms FROM file_history_tracked WHERE 1=0"],
803
+ ["file_history_version", "SELECT scope, key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE 1=0"],
804
+ ["file_history_boundary", "SELECT scope, entry_id, published_at_ms, publish_seq FROM file_history_boundary WHERE 1=0"],
805
+ ["file_history_boundary_file", "SELECT scope, entry_id, key_hash, version FROM file_history_boundary_file WHERE 1=0"],
806
+ ["file_history_scope", "SELECT scope, created_at_ms FROM file_history_scope WHERE 1=0"],
807
+ ["file_history_reaped_entry", "SELECT scope, entry_id, reaped_at_ms FROM file_history_reaped_entry WHERE 1=0"],
808
+ [FILE_HISTORY_BLOB_TABLE, `SELECT blob_hash, byte_len, bytes, created_at FROM ${FILE_HISTORY_BLOB_TABLE} WHERE 1=0`],
809
+ ];
810
+ for (const [table, sql] of probes) {
811
+ try {
812
+ await query(sql);
813
+ }
814
+ catch (err) {
815
+ if (!isMissingColumnError(err, dialect))
816
+ throw err;
817
+ throw new Error(`${table} is missing required columns — refusing to start. This build's per-file rewind history (S-15) ` +
818
+ `references every probed column unconditionally; a partial table would fail mid-write and could publish a ` +
819
+ `half-shaped history graph. This repository ships no ALTER TABLE migrations, so the table must be recreated: run ` +
820
+ `\`DROP TABLE ${table};\` on the ${dialect === "pg" ? "PostgreSQL" : "MySQL-protocol"} backend and restart — the ` +
821
+ `schema is recreated at boot. Cost, honestly stated: dropping file_history_* tables loses per-file REWIND history ` +
822
+ `(working-tree convenience state); session transcripts and all other durable subsystems are untouched. ` +
823
+ `Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
824
+ }
825
+ }
826
+ }
827
+ //# sourceMappingURL=file-history-store-sql.js.map