@felan-ai/daemon 0.0.0-stage → 0.1.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 (288) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/README.md +61 -2
  4. package/dist/client.d.ts +5 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +4 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/daemon/admission.d.ts +39 -0
  9. package/dist/daemon/admission.d.ts.map +1 -0
  10. package/dist/daemon/admission.js +175 -0
  11. package/dist/daemon/admission.js.map +1 -0
  12. package/dist/daemon/agent-logs.d.ts +11 -0
  13. package/dist/daemon/agent-logs.d.ts.map +1 -0
  14. package/dist/daemon/agent-logs.js +31 -0
  15. package/dist/daemon/agent-logs.js.map +1 -0
  16. package/dist/daemon/bootstrap.d.ts +7 -0
  17. package/dist/daemon/bootstrap.d.ts.map +1 -0
  18. package/dist/daemon/bootstrap.js +4 -0
  19. package/dist/daemon/bootstrap.js.map +1 -0
  20. package/dist/daemon/broker.d.ts +94 -0
  21. package/dist/daemon/broker.d.ts.map +1 -0
  22. package/dist/daemon/broker.js +374 -0
  23. package/dist/daemon/broker.js.map +1 -0
  24. package/dist/daemon/commands.d.ts +47 -0
  25. package/dist/daemon/commands.d.ts.map +1 -0
  26. package/dist/daemon/commands.js +639 -0
  27. package/dist/daemon/commands.js.map +1 -0
  28. package/dist/daemon/control-recovery.d.ts +9 -0
  29. package/dist/daemon/control-recovery.d.ts.map +1 -0
  30. package/dist/daemon/control-recovery.js +68 -0
  31. package/dist/daemon/control-recovery.js.map +1 -0
  32. package/dist/daemon/control-state.d.ts +1915 -0
  33. package/dist/daemon/control-state.d.ts.map +1 -0
  34. package/dist/daemon/control-state.js +154 -0
  35. package/dist/daemon/control-state.js.map +1 -0
  36. package/dist/daemon/control-store.d.ts +113 -0
  37. package/dist/daemon/control-store.d.ts.map +1 -0
  38. package/dist/daemon/control-store.js +901 -0
  39. package/dist/daemon/control-store.js.map +1 -0
  40. package/dist/daemon/execution-policy.d.ts +15 -0
  41. package/dist/daemon/execution-policy.d.ts.map +1 -0
  42. package/dist/daemon/execution-policy.js +42 -0
  43. package/dist/daemon/execution-policy.js.map +1 -0
  44. package/dist/daemon/execution-services.d.ts +5 -0
  45. package/dist/daemon/execution-services.d.ts.map +1 -0
  46. package/dist/daemon/execution-services.js +73 -0
  47. package/dist/daemon/execution-services.js.map +1 -0
  48. package/dist/daemon/executions.d.ts +134 -0
  49. package/dist/daemon/executions.d.ts.map +1 -0
  50. package/dist/daemon/executions.js +486 -0
  51. package/dist/daemon/executions.js.map +1 -0
  52. package/dist/daemon/extension-ui.d.ts +24 -0
  53. package/dist/daemon/extension-ui.d.ts.map +1 -0
  54. package/dist/daemon/extension-ui.js +161 -0
  55. package/dist/daemon/extension-ui.js.map +1 -0
  56. package/dist/daemon/fanout.d.ts +40 -0
  57. package/dist/daemon/fanout.d.ts.map +1 -0
  58. package/dist/daemon/fanout.js +304 -0
  59. package/dist/daemon/fanout.js.map +1 -0
  60. package/dist/daemon/headless-json.d.ts +3 -0
  61. package/dist/daemon/headless-json.d.ts.map +1 -0
  62. package/dist/daemon/headless-json.js +20 -0
  63. package/dist/daemon/headless-json.js.map +1 -0
  64. package/dist/daemon/host-config.d.ts +61 -0
  65. package/dist/daemon/host-config.d.ts.map +1 -0
  66. package/dist/daemon/host-config.js +167 -0
  67. package/dist/daemon/host-config.js.map +1 -0
  68. package/dist/daemon/host-services.d.ts +61 -0
  69. package/dist/daemon/host-services.d.ts.map +1 -0
  70. package/dist/daemon/host-services.js +2 -0
  71. package/dist/daemon/host-services.js.map +1 -0
  72. package/dist/daemon/ipc.d.ts +14 -0
  73. package/dist/daemon/ipc.d.ts.map +1 -0
  74. package/dist/daemon/ipc.js +233 -0
  75. package/dist/daemon/ipc.js.map +1 -0
  76. package/dist/daemon/legacy-sessions.d.ts +48 -0
  77. package/dist/daemon/legacy-sessions.d.ts.map +1 -0
  78. package/dist/daemon/legacy-sessions.js +328 -0
  79. package/dist/daemon/legacy-sessions.js.map +1 -0
  80. package/dist/daemon/lifecycle.d.ts +44 -0
  81. package/dist/daemon/lifecycle.d.ts.map +1 -0
  82. package/dist/daemon/lifecycle.js +164 -0
  83. package/dist/daemon/lifecycle.js.map +1 -0
  84. package/dist/daemon/main.d.ts +3 -0
  85. package/dist/daemon/main.d.ts.map +1 -0
  86. package/dist/daemon/main.js +83 -0
  87. package/dist/daemon/main.js.map +1 -0
  88. package/dist/daemon/message-display.d.ts +4 -0
  89. package/dist/daemon/message-display.d.ts.map +1 -0
  90. package/dist/daemon/message-display.js +20 -0
  91. package/dist/daemon/message-display.js.map +1 -0
  92. package/dist/daemon/native-ownership.d.ts +13 -0
  93. package/dist/daemon/native-ownership.d.ts.map +1 -0
  94. package/dist/daemon/native-ownership.js +173 -0
  95. package/dist/daemon/native-ownership.js.map +1 -0
  96. package/dist/daemon/operational-log.d.ts +23 -0
  97. package/dist/daemon/operational-log.d.ts.map +1 -0
  98. package/dist/daemon/operational-log.js +79 -0
  99. package/dist/daemon/operational-log.js.map +1 -0
  100. package/dist/daemon/ownership.d.ts +35 -0
  101. package/dist/daemon/ownership.d.ts.map +1 -0
  102. package/dist/daemon/ownership.js +395 -0
  103. package/dist/daemon/ownership.js.map +1 -0
  104. package/dist/daemon/persistence.d.ts +110 -0
  105. package/dist/daemon/persistence.d.ts.map +1 -0
  106. package/dist/daemon/persistence.js +540 -0
  107. package/dist/daemon/persistence.js.map +1 -0
  108. package/dist/daemon/process-adapter.d.ts +23 -0
  109. package/dist/daemon/process-adapter.d.ts.map +1 -0
  110. package/dist/daemon/process-adapter.js +2 -0
  111. package/dist/daemon/process-adapter.js.map +1 -0
  112. package/dist/daemon/processes.d.ts +102 -0
  113. package/dist/daemon/processes.d.ts.map +1 -0
  114. package/dist/daemon/processes.js +691 -0
  115. package/dist/daemon/processes.js.map +1 -0
  116. package/dist/daemon/profile-storage.d.ts +16 -0
  117. package/dist/daemon/profile-storage.d.ts.map +1 -0
  118. package/dist/daemon/profile-storage.js +148 -0
  119. package/dist/daemon/profile-storage.js.map +1 -0
  120. package/dist/daemon/projection.d.ts +110 -0
  121. package/dist/daemon/projection.d.ts.map +1 -0
  122. package/dist/daemon/projection.js +661 -0
  123. package/dist/daemon/projection.js.map +1 -0
  124. package/dist/daemon/provider-loader.d.ts +28 -0
  125. package/dist/daemon/provider-loader.d.ts.map +1 -0
  126. package/dist/daemon/provider-loader.js +376 -0
  127. package/dist/daemon/provider-loader.js.map +1 -0
  128. package/dist/daemon/provider.d.ts +92 -0
  129. package/dist/daemon/provider.d.ts.map +1 -0
  130. package/dist/daemon/provider.js +31 -0
  131. package/dist/daemon/provider.js.map +1 -0
  132. package/dist/daemon/registry.d.ts +209 -0
  133. package/dist/daemon/registry.d.ts.map +1 -0
  134. package/dist/daemon/registry.js +1591 -0
  135. package/dist/daemon/registry.js.map +1 -0
  136. package/dist/daemon/runtime-dependencies.d.ts +5 -0
  137. package/dist/daemon/runtime-dependencies.d.ts.map +1 -0
  138. package/dist/daemon/runtime-dependencies.js +31 -0
  139. package/dist/daemon/runtime-dependencies.js.map +1 -0
  140. package/dist/daemon/server.d.ts +110 -0
  141. package/dist/daemon/server.d.ts.map +1 -0
  142. package/dist/daemon/server.js +556 -0
  143. package/dist/daemon/server.js.map +1 -0
  144. package/dist/daemon/service.d.ts +38 -0
  145. package/dist/daemon/service.d.ts.map +1 -0
  146. package/dist/daemon/service.js +275 -0
  147. package/dist/daemon/service.js.map +1 -0
  148. package/dist/daemon/session-cursor.d.ts +3 -0
  149. package/dist/daemon/session-cursor.d.ts.map +1 -0
  150. package/dist/daemon/session-cursor.js +4 -0
  151. package/dist/daemon/session-cursor.js.map +1 -0
  152. package/dist/daemon/session-execution.d.ts +88 -0
  153. package/dist/daemon/session-execution.d.ts.map +1 -0
  154. package/dist/daemon/session-execution.js +2 -0
  155. package/dist/daemon/session-execution.js.map +1 -0
  156. package/dist/daemon/session-executions.d.ts +5 -0
  157. package/dist/daemon/session-executions.d.ts.map +1 -0
  158. package/dist/daemon/session-executions.js +146 -0
  159. package/dist/daemon/session-executions.js.map +1 -0
  160. package/dist/daemon/session-files.d.ts +28 -0
  161. package/dist/daemon/session-files.d.ts.map +1 -0
  162. package/dist/daemon/session-files.js +189 -0
  163. package/dist/daemon/session-files.js.map +1 -0
  164. package/dist/daemon/session-lifecycle-contracts.d.ts +104 -0
  165. package/dist/daemon/session-lifecycle-contracts.d.ts.map +1 -0
  166. package/dist/daemon/session-lifecycle-contracts.js +2 -0
  167. package/dist/daemon/session-lifecycle-contracts.js.map +1 -0
  168. package/dist/daemon/session-lifecycle.d.ts +26 -0
  169. package/dist/daemon/session-lifecycle.d.ts.map +1 -0
  170. package/dist/daemon/session-lifecycle.js +246 -0
  171. package/dist/daemon/session-lifecycle.js.map +1 -0
  172. package/dist/daemon/session-runtime.d.ts +64 -0
  173. package/dist/daemon/session-runtime.d.ts.map +1 -0
  174. package/dist/daemon/session-runtime.js +3 -0
  175. package/dist/daemon/session-runtime.js.map +1 -0
  176. package/dist/daemon/session-services.d.ts +6 -0
  177. package/dist/daemon/session-services.d.ts.map +1 -0
  178. package/dist/daemon/session-services.js +144 -0
  179. package/dist/daemon/session-services.js.map +1 -0
  180. package/dist/daemon/shutdown-error.d.ts +12 -0
  181. package/dist/daemon/shutdown-error.d.ts.map +1 -0
  182. package/dist/daemon/shutdown-error.js +51 -0
  183. package/dist/daemon/shutdown-error.js.map +1 -0
  184. package/dist/daemon/supervisor.d.ts +28 -0
  185. package/dist/daemon/supervisor.d.ts.map +1 -0
  186. package/dist/daemon/supervisor.js +159 -0
  187. package/dist/daemon/supervisor.js.map +1 -0
  188. package/dist/daemon/target.d.ts +55 -0
  189. package/dist/daemon/target.d.ts.map +1 -0
  190. package/dist/daemon/target.js +191 -0
  191. package/dist/daemon/target.js.map +1 -0
  192. package/dist/daemon/tree.d.ts +7 -0
  193. package/dist/daemon/tree.d.ts.map +1 -0
  194. package/dist/daemon/tree.js +126 -0
  195. package/dist/daemon/tree.js.map +1 -0
  196. package/dist/daemon/ui-vocabulary.d.ts +29 -0
  197. package/dist/daemon/ui-vocabulary.d.ts.map +1 -0
  198. package/dist/daemon/ui-vocabulary.js +323 -0
  199. package/dist/daemon/ui-vocabulary.js.map +1 -0
  200. package/dist/daemon/worker.d.ts +5 -0
  201. package/dist/daemon/worker.d.ts.map +1 -0
  202. package/dist/daemon/worker.js +49 -0
  203. package/dist/daemon/worker.js.map +1 -0
  204. package/dist/index.d.ts +4 -0
  205. package/dist/index.d.ts.map +1 -0
  206. package/dist/index.js +12 -0
  207. package/dist/index.js.map +1 -0
  208. package/dist/lock.d.ts +27 -0
  209. package/dist/lock.d.ts.map +1 -0
  210. package/dist/lock.js +432 -0
  211. package/dist/lock.js.map +1 -0
  212. package/dist/logger.d.ts +9 -0
  213. package/dist/logger.d.ts.map +1 -0
  214. package/dist/logger.js +56 -0
  215. package/dist/logger.js.map +1 -0
  216. package/dist/remote/backoff.d.ts +17 -0
  217. package/dist/remote/backoff.d.ts.map +1 -0
  218. package/dist/remote/backoff.js +18 -0
  219. package/dist/remote/backoff.js.map +1 -0
  220. package/dist/remote/batch.d.ts +3 -0
  221. package/dist/remote/batch.d.ts.map +1 -0
  222. package/dist/remote/batch.js +27 -0
  223. package/dist/remote/batch.js.map +1 -0
  224. package/dist/remote/command-journal.d.ts +30 -0
  225. package/dist/remote/command-journal.d.ts.map +1 -0
  226. package/dist/remote/command-journal.js +72 -0
  227. package/dist/remote/command-journal.js.map +1 -0
  228. package/dist/remote/commands.d.ts +48 -0
  229. package/dist/remote/commands.d.ts.map +1 -0
  230. package/dist/remote/commands.js +145 -0
  231. package/dist/remote/commands.js.map +1 -0
  232. package/dist/remote/connector.d.ts +135 -0
  233. package/dist/remote/connector.d.ts.map +1 -0
  234. package/dist/remote/connector.js +1069 -0
  235. package/dist/remote/connector.js.map +1 -0
  236. package/dist/remote/content.d.ts +17 -0
  237. package/dist/remote/content.d.ts.map +1 -0
  238. package/dist/remote/content.js +116 -0
  239. package/dist/remote/content.js.map +1 -0
  240. package/dist/remote/outbox.d.ts +52 -0
  241. package/dist/remote/outbox.d.ts.map +1 -0
  242. package/dist/remote/outbox.js +321 -0
  243. package/dist/remote/outbox.js.map +1 -0
  244. package/dist/remote/platform.d.ts +53 -0
  245. package/dist/remote/platform.d.ts.map +1 -0
  246. package/dist/remote/platform.js +231 -0
  247. package/dist/remote/platform.js.map +1 -0
  248. package/dist/remote/realtime.d.ts +45 -0
  249. package/dist/remote/realtime.d.ts.map +1 -0
  250. package/dist/remote/realtime.js +265 -0
  251. package/dist/remote/realtime.js.map +1 -0
  252. package/dist/remote/sharing.d.ts +16 -0
  253. package/dist/remote/sharing.d.ts.map +1 -0
  254. package/dist/remote/sharing.js +24 -0
  255. package/dist/remote/sharing.js.map +1 -0
  256. package/dist/remote/state.d.ts +52 -0
  257. package/dist/remote/state.d.ts.map +1 -0
  258. package/dist/remote/state.js +156 -0
  259. package/dist/remote/state.js.map +1 -0
  260. package/dist/remote/text.d.ts +11 -0
  261. package/dist/remote/text.d.ts.map +1 -0
  262. package/dist/remote/text.js +188 -0
  263. package/dist/remote/text.js.map +1 -0
  264. package/dist/remote/token.d.ts +39 -0
  265. package/dist/remote/token.d.ts.map +1 -0
  266. package/dist/remote/token.js +238 -0
  267. package/dist/remote/token.js.map +1 -0
  268. package/dist/runtime-factory.d.ts +12 -0
  269. package/dist/runtime-factory.d.ts.map +1 -0
  270. package/dist/runtime-factory.js +12 -0
  271. package/dist/runtime-factory.js.map +1 -0
  272. package/dist/session-history.d.ts +29 -0
  273. package/dist/session-history.d.ts.map +1 -0
  274. package/dist/session-history.js +374 -0
  275. package/dist/session-history.js.map +1 -0
  276. package/dist/setup/dependencies.d.ts +15 -0
  277. package/dist/setup/dependencies.d.ts.map +1 -0
  278. package/dist/setup/dependencies.js +2 -0
  279. package/dist/setup/dependencies.js.map +1 -0
  280. package/dist/setup/open.d.ts +8 -0
  281. package/dist/setup/open.d.ts.map +1 -0
  282. package/dist/setup/open.js +16 -0
  283. package/dist/setup/open.js.map +1 -0
  284. package/dist/version.d.ts +2 -0
  285. package/dist/version.d.ts.map +1 -0
  286. package/dist/version.js +4 -0
  287. package/dist/version.js.map +1 -0
  288. package/package.json +65 -3
@@ -0,0 +1,901 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { Type } from 'typebox';
4
+ import { canonicalProspectivePath, pathInside } from './target.js';
5
+ import { CONTROL_LIMITS, ControlResourceSchema, ControlSessionMetadataSchema, ControlSessionSchema, ControlStoreError, SessionProfileSchema, assertControlRow, parseControlState } from './control-state.js';
6
+ import { CONTROL_TABLES, ControlDatabase, sha256 } from './persistence.js';
7
+ import { recoverControlState } from './control-recovery.js';
8
+ import { IdSchema, LIMITS, OPERATION_CATALOG, parseClientEnvelope, canonicalRequestPayload, parseInteraction, parseSchema, parseServerEnvelope, requestRetainUntil, validateInteractionAnswer } from '@felan-ai/session-protocol';
9
+ const ROW_TABLES = ['sessions', 'requests', 'interactions', 'continuations', 'resources'];
10
+ export class DaemonControlStore {
11
+ owner;
12
+ directory;
13
+ file;
14
+ get database() { return this.file; }
15
+ /** Committed rows are deep-frozen and shared with commit drafts, which replace a row before changing it. */
16
+ state;
17
+ /** What the database holds for each row, so a commit writes only rows whose data or order changed. */
18
+ persisted = emptyRows();
19
+ maxOrd = 0;
20
+ /** Whether the database holds this store's namespace; until then even an unchanged state is written. */
21
+ initialized = false;
22
+ now;
23
+ recoveryRequired = false;
24
+ unownedDirectories = new Set();
25
+ transientRevisionCeilings = new Map();
26
+ constructor(owner, options = {}) {
27
+ this.owner = owner;
28
+ this.now = options.now ?? Date.now;
29
+ this.file = new ControlDatabase(owner, options.directory ?? join(owner.target.agentDir, 'daemon-control'), options.fault);
30
+ this.directory = this.file.directory;
31
+ try {
32
+ const stored = this.file.read();
33
+ let state;
34
+ if (stored) {
35
+ state = this.restore(assemble(stored));
36
+ // Invalid or foreign state is moved aside; legacy discovery re-adopts the transcripts it described.
37
+ if (state) {
38
+ this.persisted = storedRows(stored);
39
+ this.maxOrd = maxOrd(stored);
40
+ this.initialized = true;
41
+ }
42
+ else
43
+ this.file.quarantine();
44
+ }
45
+ if (state) {
46
+ for (const table of ROW_TABLES)
47
+ for (const row of state[table])
48
+ freeze(row);
49
+ this.state = state;
50
+ this.recoveryRequired = true;
51
+ }
52
+ else {
53
+ this.state = { version: 1, namespace: owner.target.namespaceId, agentDir: owner.target.agentDir, clock: this.time(0), sessions: [], requests: [], interactions: [], continuations: [], resources: [] };
54
+ this.persist(this.state);
55
+ }
56
+ }
57
+ catch (error) {
58
+ this.file.close();
59
+ throw error;
60
+ }
61
+ }
62
+ /** Only a foreign or structurally invalid file is discarded; individually inconsistent rows are dropped. */
63
+ restore(raw) {
64
+ let state;
65
+ try {
66
+ state = parseControlState(raw);
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ if (state.namespace !== this.owner.target.namespaceId || state.agentDir !== this.owner.target.agentDir)
72
+ return undefined;
73
+ const repaired = this.repair(state);
74
+ try {
75
+ return this.validate(repaired);
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /**
82
+ * Session directories recorded outside the current store roots (for example after `daemon-store-roots.json` was lost)
83
+ * become owned roots again; rows whose directory cannot be owned are dropped individually.
84
+ */
85
+ async ownPersistedRoots() {
86
+ const dropped = new Set();
87
+ for (const directory of this.unownedDirectories) {
88
+ try {
89
+ if (!existsSync(directory))
90
+ throw new Error('Session directory is missing.');
91
+ await this.owner.ownRoot(directory);
92
+ }
93
+ catch {
94
+ this.owner.assertCurrent();
95
+ for (const session of this.state.sessions)
96
+ if (dirname(session.piPath) === directory)
97
+ dropped.add(session.id);
98
+ }
99
+ }
100
+ this.unownedDirectories.clear();
101
+ if (!dropped.size)
102
+ return;
103
+ const state = this.dropSessions(structuredClone(this.state), dropped);
104
+ this.persist(state);
105
+ this.state = state;
106
+ }
107
+ close() { this.file.close(); }
108
+ get needsRecovery() { return this.recoveryRequired; }
109
+ snapshot() { this.file.assertCurrent(); return structuredClone(this.state); }
110
+ session(id) { this.file.assertCurrent(); return structuredClone(this.findSession(this.state, id)); }
111
+ /** Copies a session without its projections, which dominate the stored size. */
112
+ sessionMetadata(id) { this.file.assertCurrent(); return metadataCopy(this.findSession(this.state, id)); }
113
+ sessionMetadatas() { this.file.assertCurrent(); return this.state.sessions.map(metadataCopy); }
114
+ findSessionMetadata(id) { this.file.assertCurrent(); const item = this.state.sessions.find(item => item.id === id); return item && metadataCopy(item); }
115
+ interactions() { this.file.assertCurrent(); return structuredClone(this.state.interactions); }
116
+ request(id) { this.file.assertCurrent(); const item = this.state.requests.find(item => item.id === id); return item && structuredClone(item); }
117
+ resource(id) { this.file.assertCurrent(); const item = this.state.resources.find(item => item.id === id); return item && structuredClone(item); }
118
+ resources() { this.file.assertCurrent(); return structuredClone(this.state.resources); }
119
+ assertCurrent() { this.file.assertCurrent(); }
120
+ createSession(input) {
121
+ const session = parseSchema(ControlSessionSchema, { ...input, revision: 0, updatedAt: this.time(this.state.clock), projections: [] });
122
+ if (session.deletedAt !== undefined)
123
+ throw new ControlStoreError('invalid_request', 'Only explicit deletion can create a tombstone.');
124
+ session.piPath = this.owner.assertOwnsPath(session.piPath);
125
+ if (this.state.sessions.some(item => item.id === session.id || item.piPath === session.piPath))
126
+ throw new ControlStoreError('request_conflict', 'Session identity or transcript path is already registered.');
127
+ return this.commit((state, touched) => {
128
+ state.sessions.push(session);
129
+ touched.add(session.id);
130
+ return session;
131
+ });
132
+ }
133
+ updateSession(id, patch) {
134
+ this.file.assertCurrent();
135
+ const old = this.findSession(this.state, id);
136
+ if (old.deletedAt !== undefined)
137
+ throw new ControlStoreError('not_found', 'Session was deleted.');
138
+ const { projections, ...metadata } = old;
139
+ const next = { ...parseSchema(ControlSessionMetadataSchema, { ...metadata, ...structuredClone(patch) }), projections };
140
+ if (JSON.stringify(old.policy) !== JSON.stringify(next.policy) && next.policy.generation <= old.policy.generation)
141
+ throw new ControlStoreError('invalid_request', 'A policy change must advance its generation.');
142
+ return this.commit((state, touched) => {
143
+ const at = state.sessions.findIndex(item => item.id === id);
144
+ state.sessions[at] = next;
145
+ touched.add(id);
146
+ return next;
147
+ });
148
+ }
149
+ /** Tombstones a draft that was never used. Unlike deletion, no client request backs it. */
150
+ discardDraft(id) {
151
+ this.file.assertCurrent();
152
+ return this.commit((state, touched, now) => {
153
+ const session = this.findSession(state, id);
154
+ if (!session.draft || session.deletedAt !== undefined)
155
+ throw new ControlStoreError('invalid_request', 'Only an unused draft can be discarded.');
156
+ const item = editSession(state, session);
157
+ item.deletedAt = now;
158
+ item.state = 'closed';
159
+ item.residency = 'evicted';
160
+ item.run = { ...item.run, state: 'idle' };
161
+ item.projections = [];
162
+ touched.add(id);
163
+ return item;
164
+ });
165
+ }
166
+ configureSession(requestId, values, overrides) {
167
+ return this.commit((state, touched) => {
168
+ const request = edit(state.requests, this.findRequest(state, requestId));
169
+ if (request.op !== 'config.set' || request.phase !== 'intent' || !request.session)
170
+ throw new ControlStoreError('invalid_request', 'Session configuration requires an undispatched session request.');
171
+ const session = editSession(state, this.findSession(state, request.session));
172
+ const payload = canonicalRequestPayload({ v: 1, id: requestId, op: 'config.set', session: session.id, params: { scope: 'session', values } });
173
+ if (sha256(payload) !== request.identity.sha256 || Buffer.byteLength(payload) !== request.identity.bytes)
174
+ throw new ControlStoreError('request_conflict', 'Configuration differs from its admitted payload.');
175
+ session.profile = parseSchema(SessionProfileSchema, { ...session.profile, extensionConfigOverrides: overrides });
176
+ request.phase = 'dispatch_intent';
177
+ touched.add(session.id);
178
+ return session;
179
+ });
180
+ }
181
+ interruptSessionRun(id, runId) {
182
+ return this.commit((state, touched) => {
183
+ const session = editSession(state, this.findSession(state, id));
184
+ if (session.state !== 'open' || session.deletedAt !== undefined || !runId || session.run.id !== runId)
185
+ throw new ControlStoreError('request_conflict', 'Run identity changed before interruption.');
186
+ if (session.run.state !== 'interrupted') {
187
+ session.run = { ...session.run, state: 'interrupted' };
188
+ touched.add(id);
189
+ }
190
+ return session;
191
+ });
192
+ }
193
+ transitionSessionForSafety(id, transition) {
194
+ if (!['cancelling', 'idle', 'closed', 'evicted'].includes(transition))
195
+ throw new ControlStoreError('invalid_request', 'Unknown safety transition.');
196
+ return this.commit((state, touched) => {
197
+ const session = editSession(state, this.findSession(state, id));
198
+ if (session.deletedAt !== undefined)
199
+ throw new ControlStoreError('not_found', 'Session was deleted.');
200
+ const active = ['running', 'waiting', 'cancelling'].includes(session.run.state);
201
+ if (transition === 'cancelling' && (session.state !== 'open' || !active || !session.run.id))
202
+ throw new ControlStoreError('invalid_request', 'Cancellation requires an existing active open run.');
203
+ if ((transition === 'closed' || transition === 'evicted') && active)
204
+ throw new ControlStoreError('invalid_request', 'Settle the active run before closing or evicting.');
205
+ if (transition === 'idle' && session.state === 'closed' && session.run.state !== 'idle')
206
+ throw new ControlStoreError('invalid_request', 'A closed session cannot settle a new run.');
207
+ let changed = false;
208
+ const runState = transition === 'cancelling' ? 'cancelling' : transition === 'idle' || transition === 'closed' ? 'idle' : session.run.state;
209
+ if (session.run.state !== runState) {
210
+ session.run = { ...session.run, state: runState };
211
+ changed = true;
212
+ }
213
+ if (transition === 'closed' && session.state !== 'closed') {
214
+ session.state = 'closed';
215
+ changed = true;
216
+ }
217
+ if ((transition === 'closed' || transition === 'evicted') && session.residency !== 'evicted') {
218
+ session.residency = 'evicted';
219
+ changed = true;
220
+ }
221
+ if (changed)
222
+ touched.add(id);
223
+ return session;
224
+ });
225
+ }
226
+ advanceTransientRevision(id) {
227
+ this.file.assertCurrent();
228
+ if (this.recoveryRequired)
229
+ throw new ControlStoreError('storage_failure', 'Recover the persisted control state before publishing.');
230
+ const session = this.findSession(this.state, id);
231
+ if (session.deletedAt !== undefined)
232
+ throw new ControlStoreError('not_found', 'Session was deleted.');
233
+ const revision = session.revision + 1;
234
+ const ceiling = this.transientRevisionCeilings.get(id);
235
+ const next = { ...session, revision };
236
+ if (revision <= (ceiling ?? session.revision)) {
237
+ this.state.sessions[this.state.sessions.indexOf(session)] = freeze(next);
238
+ return revision;
239
+ }
240
+ if (!Number.isSafeInteger(revision + 4095))
241
+ throw new ControlStoreError('storage_failure', 'Session revision exhausted.');
242
+ // Reserve revisions across crashes without persisting every transient frame.
243
+ const state = draft(this.state);
244
+ state.sessions[state.sessions.indexOf(session)] = next;
245
+ this.transientRevisionCeilings.set(id, revision + 4095);
246
+ try {
247
+ this.persist(state);
248
+ }
249
+ catch (error) {
250
+ if (ceiling === undefined)
251
+ this.transientRevisionCeilings.delete(id);
252
+ else
253
+ this.transientRevisionCeilings.set(id, ceiling);
254
+ throw error;
255
+ }
256
+ this.state = state;
257
+ return revision;
258
+ }
259
+ project(id, values) {
260
+ return this.commit((state, touched, now) => {
261
+ const session = editSession(state, this.findSession(state, id));
262
+ if (session.deletedAt !== undefined)
263
+ throw new ControlStoreError('not_found', 'Session was deleted.');
264
+ for (const value of values) {
265
+ const key = value.type === 'message' ? `message:${value.id}` : 'queue';
266
+ const record = parseSchema(Type.Index(ControlSessionSchema, ['projections']), [{ key, updatedAt: now, value }])[0];
267
+ const previous = session.projections.find(item => item.key === key);
268
+ if (previous?.value.type === 'message' && record.value.type === 'message' && previous.value.entryId !== record.value.entryId)
269
+ throw new ControlStoreError('request_conflict', 'A message cannot change its Pi entry identity.');
270
+ session.projections = session.projections.filter(item => item.key !== key);
271
+ session.projections.push(record);
272
+ }
273
+ this.trimProjections(session);
274
+ touched.add(id);
275
+ return session;
276
+ });
277
+ }
278
+ reconcilePi(id, observation) {
279
+ if (observation.present.length + observation.absentEntryIds.length > CONTROL_LIMITS.projections)
280
+ throw new ControlStoreError('invalid_request', 'Reconcile Pi references in bounded pages.');
281
+ const session = this.session(id);
282
+ const values = observation.present.map(item => ({ type: 'message', ...item, state: 'observed' }));
283
+ for (const projection of session.projections)
284
+ if (projection.value.type === 'message' && observation.absentEntryIds.includes(projection.value.entryId))
285
+ values.push({ ...projection.value, state: 'missing' });
286
+ return this.project(id, values);
287
+ }
288
+ admit(value, principal) {
289
+ this.file.assertCurrent();
290
+ if (this.recoveryRequired)
291
+ throw new ControlStoreError('storage_failure', 'Recover the persisted control state before admitting mutations.');
292
+ const request = parseClientEnvelope(value);
293
+ if (OPERATION_CATALOG[request.op].storage !== 'durable')
294
+ throw new ControlStoreError('invalid_request', 'Only durable mutations have control request records.');
295
+ const payload = canonicalRequestPayload(request);
296
+ const identity = { sha256: sha256(payload), bytes: Buffer.byteLength(payload) };
297
+ const previous = this.state.requests.find(item => item.id === request.id);
298
+ if (previous) {
299
+ if (previous.principal !== principal || previous.identity.sha256 !== identity.sha256 || previous.identity.bytes !== identity.bytes)
300
+ throw new ControlStoreError('request_conflict', 'Request ID was already used by another principal or payload.');
301
+ return { value: structuredClone(previous), revisions: {} };
302
+ }
303
+ const now = this.time(this.state.clock);
304
+ const session = request.session;
305
+ if (session) {
306
+ const target = this.findSession(this.state, session);
307
+ if (target.deletedAt !== undefined && request.op !== 'session.delete')
308
+ throw new ControlStoreError('not_found', 'Session was deleted.');
309
+ }
310
+ const record = { id: request.id, principal, ...(session ? { session } : {}), op: request.op, admittedAt: now, retainUntil: requestRetainUntil(request.id, now), identity, phase: 'intent' };
311
+ return this.commit((state, touched) => {
312
+ this.prune(state, now, touched);
313
+ state.requests.push(record);
314
+ return record;
315
+ });
316
+ }
317
+ markDispatched(id) {
318
+ return this.commit((state, touched) => {
319
+ const record = edit(state.requests, this.findRequest(state, id));
320
+ if (record.phase !== 'intent')
321
+ throw new ControlStoreError('already_resolved', 'Request is already dispatched or terminal; never dispatch it twice.');
322
+ if (record.session) {
323
+ const session = this.findSession(state, record.session);
324
+ if (session.state !== 'open')
325
+ throw new ControlStoreError('invalid_request', 'Closed sessions cannot dispatch.');
326
+ touched.add(record.session);
327
+ }
328
+ record.phase = 'dispatch_intent';
329
+ return record;
330
+ });
331
+ }
332
+ markSessionDeleted(requestId) {
333
+ return this.commit((state, touched, now) => {
334
+ const request = edit(state.requests, this.findRequest(state, requestId));
335
+ if (request.op !== 'session.delete' || request.phase !== 'intent' || !request.session)
336
+ throw new ControlStoreError('invalid_request', 'Deletion requires its undispatched session request.');
337
+ const session = this.findSession(state, request.session);
338
+ const payload = canonicalRequestPayload({ v: 1, id: request.id, op: 'session.delete', session: session.id, params: {} });
339
+ if (sha256(payload) !== request.identity.sha256 || Buffer.byteLength(payload) !== request.identity.bytes)
340
+ throw new ControlStoreError('request_conflict', 'Deletion differs from its admitted payload.');
341
+ request.phase = 'dispatch_intent';
342
+ const tree = [session.id];
343
+ for (let index = 0; index < tree.length; index++)
344
+ for (const item of state.sessions)
345
+ if (item.parent === tree[index] && !tree.includes(item.id))
346
+ tree.push(item.id);
347
+ for (const id of tree)
348
+ if (this.findSession(state, id).deletedAt === undefined) {
349
+ const item = editSession(state, this.findSession(state, id));
350
+ item.deletedAt = now;
351
+ item.state = 'closed';
352
+ item.residency = 'evicted';
353
+ item.run = { ...item.run, state: 'idle' };
354
+ item.projections = [];
355
+ touched.add(item.id);
356
+ }
357
+ return this.findSession(state, session.id);
358
+ });
359
+ }
360
+ finishRequest(id, response) {
361
+ response = structuredClone(response);
362
+ return this.commit(state => {
363
+ const record = edit(state.requests, this.findRequest(state, id));
364
+ if (record.phase === 'terminal')
365
+ throw new ControlStoreError('already_resolved', 'Request already has an outcome.');
366
+ const parsed = this.parseResponse(record, response);
367
+ if (parsed.outcome === 'accepted' && record.phase !== 'dispatch_intent')
368
+ throw new ControlStoreError('invalid_request', 'An effectful request cannot succeed without a committed dispatch intent.');
369
+ record.phase = 'terminal';
370
+ record.response = parsed;
371
+ return parsed;
372
+ });
373
+ }
374
+ putInteraction(value) {
375
+ const interaction = parseInteraction(structuredClone(value));
376
+ if (interaction.state !== 'waiting')
377
+ throw new ControlStoreError('invalid_request', 'New interactions must be waiting.');
378
+ return this.commit((state, touched) => {
379
+ if (state.interactions.some(item => item.id === interaction.id))
380
+ throw new ControlStoreError('request_conflict', 'Interaction ID already exists.');
381
+ if (interaction.scope.kind === 'session') {
382
+ const session = this.findSession(state, interaction.scope.session);
383
+ if (session.root !== interaction.scope.root)
384
+ throw new ControlStoreError('invalid_request', 'Interaction root mismatch.');
385
+ touched.add(session.id);
386
+ }
387
+ state.interactions.push(interaction);
388
+ return interaction;
389
+ });
390
+ }
391
+ deferInteraction(id) {
392
+ return this.commit((state, touched, now) => {
393
+ const item = this.findInteraction(state, id);
394
+ if (!['waiting', 'deferred'].includes(item.state) || !item.deferrable)
395
+ throw new ControlStoreError('invalid_request', 'Only a pending deferrable interaction is serializable across drain.');
396
+ const next = { ...item, state: 'deferred', expiresAt: item.expiresAt ?? now + 30 * 60 * 1000 };
397
+ state.interactions[state.interactions.indexOf(item)] = next;
398
+ if (item.scope.kind === 'session')
399
+ touched.add(item.scope.session);
400
+ return next;
401
+ });
402
+ }
403
+ cancelInteraction(id, reason) {
404
+ return this.commit((state, touched, now) => {
405
+ const item = this.findInteraction(state, id);
406
+ if (item.state !== 'waiting' && item.state !== 'deferred')
407
+ throw new ControlStoreError('already_resolved', 'Interaction already resolved.');
408
+ if (reason === 'timeout' && (item.expiresAt === undefined || item.expiresAt > now))
409
+ throw new ControlStoreError('invalid_request', 'Interaction has not expired.');
410
+ const next = { ...item, state: reason === 'timeout' ? 'expired' : 'cancelled', resolvedAt: now, result: { cancelled: true, reason } };
411
+ state.interactions[state.interactions.indexOf(item)] = next;
412
+ if (item.scope.kind === 'session')
413
+ touched.add(item.scope.session);
414
+ return next;
415
+ });
416
+ }
417
+ answerInteraction(requestId, answer, continuationId, responder) {
418
+ answer = structuredClone(answer);
419
+ return this.commit((state, touched, now) => {
420
+ const record = this.findRequest(state, requestId);
421
+ if (record.op !== 'interaction.answer' || record.phase !== 'intent')
422
+ throw new ControlStoreError('already_resolved', 'An answer requires its newly admitted request.');
423
+ const candidates = state.interactions.filter(item => {
424
+ const envelope = { v: 1, id: record.id, op: 'interaction.answer', ...(record.session ? { session: record.session } : {}), params: { interactionId: item.id, answer } };
425
+ const payload = canonicalRequestPayload(envelope);
426
+ return sha256(payload) === record.identity.sha256 && Buffer.byteLength(payload) === record.identity.bytes;
427
+ });
428
+ const item = candidates[0];
429
+ if (!item || candidates.length !== 1)
430
+ throw new ControlStoreError('request_conflict', 'Answer differs from admitted payload.');
431
+ if (item.state !== 'waiting' && item.state !== 'deferred')
432
+ throw new ControlStoreError('already_resolved', 'Interaction already resolved.');
433
+ if (item.expiresAt !== undefined && item.expiresAt <= now)
434
+ throw new ControlStoreError('already_resolved', 'Interaction expired.');
435
+ if ((item.scope.kind === 'session' ? item.scope.session : undefined) !== record.session)
436
+ throw new ControlStoreError('request_conflict', 'Answer scope differs from interaction scope.');
437
+ const next = 'cancelled' in answer ? { ...item, state: 'cancelled', resolvedAt: now, result: answer } : { ...item, state: 'answered', responder: parseSchema(IdSchema, responder ?? record.principal), resolvedAt: now, result: validateInteractionAnswer(item, answer) };
438
+ if (continuationId !== undefined) {
439
+ if (next.state !== 'answered' || !item.deferrable || item.scope.kind !== 'session')
440
+ throw new ControlStoreError('invalid_request', 'Only answered deferrable session interactions have replayable continuations.');
441
+ if (state.continuations.some(item => item.id === continuationId || item.interaction === next.id))
442
+ throw new ControlStoreError('request_conflict', 'Continuation identity already exists.');
443
+ state.continuations.push({ id: continuationId, interaction: item.id, session: item.scope.session, phase: 'ready', createdAt: now, updatedAt: now });
444
+ }
445
+ state.interactions[state.interactions.indexOf(item)] = next;
446
+ const answered = edit(state.requests, record);
447
+ answered.phase = 'terminal';
448
+ answered.response = { v: 1, re: record.id, outcome: 'accepted', result: {} };
449
+ if (item.scope.kind === 'session')
450
+ touched.add(item.scope.session);
451
+ return next;
452
+ });
453
+ }
454
+ transitionContinuation(id, phase) {
455
+ return this.commit((state, touched, now) => {
456
+ const item = state.continuations.find(item => item.id === id);
457
+ if (!item)
458
+ throw new ControlStoreError('not_found', 'Continuation not found.');
459
+ if ((phase === 'dispatch_intent' && item.phase !== 'ready') || (phase !== 'dispatch_intent' && item.phase !== 'dispatch_intent'))
460
+ throw new ControlStoreError('already_resolved', 'Continuation cannot transition or dispatch twice.');
461
+ const session = this.findSession(state, item.session);
462
+ if (phase === 'dispatch_intent' && session.state !== 'open')
463
+ throw new ControlStoreError('invalid_request', 'Closed sessions cannot dispatch continuations.');
464
+ const next = edit(state.continuations, item);
465
+ next.phase = phase;
466
+ next.updatedAt = now;
467
+ touched.add(item.session);
468
+ return next;
469
+ });
470
+ }
471
+ putResource(value) {
472
+ const resource = parseSchema(ControlResourceSchema, value);
473
+ if (resource.reference)
474
+ resource.reference = this.owner.assertOwnsPath(resource.reference);
475
+ return this.commit((state, touched, now) => {
476
+ if (this.findSession(state, resource.session).root !== resource.root)
477
+ throw new ControlStoreError('invalid_request', 'Resource root mismatch.');
478
+ const previous = state.resources.find(item => item.id === resource.id);
479
+ if (previous && (previous.kind !== resource.kind || previous.session !== resource.session || previous.reference !== resource.reference || previous.pid !== resource.pid || previous.startIdentity !== resource.startIdentity))
480
+ throw new ControlStoreError('request_conflict', 'Resource identity cannot be replaced.');
481
+ const next = { ...resource, updatedAt: now };
482
+ state.resources = state.resources.filter(item => item.id !== resource.id);
483
+ state.resources.push(next);
484
+ touched.add(resource.session);
485
+ return next;
486
+ });
487
+ }
488
+ recover() {
489
+ if (!this.recoveryRequired)
490
+ throw new ControlStoreError('invalid_request', 'Recovery is only valid immediately after reopening persisted state.');
491
+ this.recoveryRequired = false;
492
+ try {
493
+ return this.commit((state, touched, now) => {
494
+ const { changedSessions, ...plan } = recoverControlState(state, now);
495
+ for (const id of changedSessions)
496
+ touched.add(id);
497
+ return plan;
498
+ }, true);
499
+ }
500
+ catch (error) {
501
+ this.recoveryRequired = true;
502
+ throw error;
503
+ }
504
+ }
505
+ compact() { return this.commit((state, touched, now) => this.prune(state, now, touched)); }
506
+ /** `deep` gives the change a fully editable copy, for changes that update rows in place. */
507
+ commit(change, deep = false) {
508
+ this.file.assertCurrent();
509
+ if (this.recoveryRequired)
510
+ throw new ControlStoreError('storage_failure', 'Recover the persisted control state before admitting mutations.');
511
+ const state = deep ? structuredClone(this.state) : draft(this.state);
512
+ const touched = new Set();
513
+ const now = this.time(state.clock);
514
+ const value = change(state, touched, now);
515
+ const revisions = Object.create(null);
516
+ for (const id of touched) {
517
+ const session = editSession(state, this.findSession(state, id));
518
+ session.revision++;
519
+ session.updatedAt = now;
520
+ revisions[id] = session.revision;
521
+ }
522
+ state.clock = now;
523
+ this.persist(state);
524
+ this.state = state;
525
+ return { value: structuredClone(value), revisions };
526
+ }
527
+ /**
528
+ * Writes the rows whose committed object, data or position changed. Changed rows are validated individually;
529
+ * cross-row references are rechecked for every row because a change can invalidate rows that refer to it.
530
+ * A session whose committed object is unchanged also has unchanged projections, which are not visited.
531
+ */
532
+ persist(state) {
533
+ const upserts = [];
534
+ const deletes = [];
535
+ const applied = [];
536
+ const replaced = new Set();
537
+ const installed = [];
538
+ let ord = this.maxOrd;
539
+ /** `key` indexes `previous`; `storedId` maps it to the database row ID. */
540
+ const visit = (table, key, storedId, row, previous, last, value) => {
541
+ const old = previous.get(key);
542
+ // A row keeps its position unless the array moved it ahead of a row stored after it.
543
+ const position = old && old.ord > last ? old.ord : ++ord;
544
+ if (old?.row === row && old.ord === position)
545
+ return position;
546
+ let data = old?.data;
547
+ if (old?.row !== row) {
548
+ const stored = value();
549
+ data = JSON.stringify(stored);
550
+ if (data !== old?.data)
551
+ assertControlRow(table, stored);
552
+ replaced.add(`${table}:${key}`);
553
+ installed.push(row);
554
+ }
555
+ if (old?.ord !== position || data !== old.data)
556
+ upserts.push([table, { id: storedId(key), ord: position, data: data }]);
557
+ const entry = { ord: position, data: data, row };
558
+ applied.push(() => previous.set(key, entry));
559
+ return position;
560
+ };
561
+ const same = (key) => key;
562
+ const sweep = (table, previous, present, storedId = same) => {
563
+ for (const key of previous.keys())
564
+ if (!present.has(key)) {
565
+ deletes.push([table, storedId(key)]);
566
+ applied.push(() => { previous.delete(key); if (table === 'sessions')
567
+ this.transientRevisionCeilings.delete(key); });
568
+ }
569
+ };
570
+ for (const table of ROW_TABLES) {
571
+ const previous = this.persisted.rows[table];
572
+ const present = new Set();
573
+ let last = 0;
574
+ for (const row of state[table]) {
575
+ if (present.has(row.id))
576
+ throw new ControlStoreError('storage_failure', 'Duplicate control identities.');
577
+ present.add(row.id);
578
+ last = visit(table, row.id, same, row, previous, last, () => table === 'sessions' ? this.durableSession(row) : row);
579
+ }
580
+ sweep(table, previous, present);
581
+ }
582
+ const sessions = new Map(state.sessions.map(session => [session.id, session]));
583
+ for (const session of state.sessions) {
584
+ if (this.persisted.rows.sessions.get(session.id)?.row === session)
585
+ continue;
586
+ let previous = this.persisted.projections.get(session.id);
587
+ if (!previous) {
588
+ const created = previous = new Map();
589
+ applied.push(() => this.persisted.projections.set(session.id, created));
590
+ }
591
+ const present = new Set();
592
+ let last = 0;
593
+ const storedId = (key) => JSON.stringify([session.id, key]);
594
+ for (const record of session.projections) {
595
+ present.add(record.key);
596
+ last = visit('projections', record.key, storedId, record, previous, last, () => record);
597
+ }
598
+ sweep('projections', previous, present, storedId);
599
+ }
600
+ for (const [id, previous] of this.persisted.projections)
601
+ if (!sessions.has(id)) {
602
+ for (const key of previous.keys())
603
+ deletes.push(['projections', JSON.stringify([id, key])]);
604
+ applied.push(() => this.persisted.projections.delete(id));
605
+ }
606
+ const lookup = (id) => sessions.get(id);
607
+ if (state.requests.some(request => !this.validRequest(state, request, replaced.has(`requests:${request.id}`), lookup)))
608
+ throw new ControlStoreError('storage_failure', 'Invalid durable request state.');
609
+ if (state.interactions.some(interaction => !this.validInteraction(state, interaction, replaced.has(`interactions:${interaction.id}`), lookup)))
610
+ throw new ControlStoreError('storage_failure', 'Interaction scope mismatch.');
611
+ if (state.continuations.some(item => !this.validContinuation(state, item)))
612
+ throw new ControlStoreError('storage_failure', 'Continuation lacks a committed winning answer.');
613
+ if (state.sessions.some(session => !this.validSession(state, session, replaced.has(`sessions:${session.id}`), lookup)))
614
+ throw new ControlStoreError('storage_failure', 'Session metadata or root lineage is inconsistent.');
615
+ if (state.resources.some(resource => sessions.get(resource.session)?.root !== resource.root))
616
+ throw new ControlStoreError('storage_failure', 'Resource scope mismatch.');
617
+ // An unchanged state skips the synchronous transaction; its clock advance is never needed below a row's timestamp.
618
+ if (upserts.length || deletes.length || !this.initialized) {
619
+ this.file.write({ meta: { namespace: state.namespace, agentDir: state.agentDir, clock: state.clock }, upserts, deletes });
620
+ this.initialized = true;
621
+ }
622
+ for (const apply of applied)
623
+ apply();
624
+ this.maxOrd = ord;
625
+ for (const row of installed)
626
+ freeze(row);
627
+ }
628
+ /** The stored session row: metadata only, with any reserved transient revision ceiling. */
629
+ durableSession(session) {
630
+ const { projections: _projections, ...metadata } = session;
631
+ const ceiling = this.transientRevisionCeilings.get(session.id);
632
+ return ceiling !== undefined && ceiling > session.revision ? { ...metadata, revision: ceiling } : metadata;
633
+ }
634
+ parseResponse(record, value) {
635
+ const response = parseServerEnvelope(value, record.op);
636
+ if (response.re !== record.id)
637
+ throw new ControlStoreError('request_conflict', 'Response ID differs from request.');
638
+ return response;
639
+ }
640
+ validate(value) {
641
+ const state = parseControlState(value);
642
+ for (const key of ['sessions', 'requests', 'interactions', 'continuations', 'resources']) {
643
+ const ids = state[key].map(item => item.id);
644
+ if (new Set(ids).size !== ids.length)
645
+ throw new ControlStoreError('storage_failure', 'Duplicate control identities.');
646
+ }
647
+ if (state.requests.some(request => !this.validRequest(state, request)))
648
+ throw new ControlStoreError('storage_failure', 'Invalid durable request state.');
649
+ if (state.interactions.some(interaction => !this.validInteraction(state, interaction)))
650
+ throw new ControlStoreError('storage_failure', 'Interaction scope mismatch.');
651
+ if (state.continuations.some(item => !this.validContinuation(state, item)))
652
+ throw new ControlStoreError('storage_failure', 'Continuation lacks a committed winning answer.');
653
+ if (state.sessions.some(session => !this.validSession(state, session)))
654
+ throw new ControlStoreError('storage_failure', 'Session metadata or root lineage is inconsistent.');
655
+ if (state.resources.some(resource => !this.validResource(state, resource)))
656
+ throw new ControlStoreError('storage_failure', 'Resource scope mismatch.');
657
+ return state;
658
+ }
659
+ /** Drops individually inconsistent rows (and the rows that depend on them) from a structurally valid persisted state. */
660
+ repair(state) {
661
+ const unique = (items) => items.filter((item, index) => items.findIndex(other => other.id === item.id) === index);
662
+ state.sessions = unique(state.sessions);
663
+ state.requests = unique(state.requests);
664
+ state.interactions = unique(state.interactions);
665
+ state.continuations = unique(state.continuations);
666
+ state.resources = unique(state.resources);
667
+ const invalid = new Set(state.sessions.filter(session => !this.validSession(state, session) || !this.usablePersistedPath(session.piPath)).map(session => session.id));
668
+ const repaired = this.dropSessions(state, invalid);
669
+ repaired.requests = repaired.requests.filter(request => this.validRequest(repaired, request));
670
+ repaired.interactions = repaired.interactions.filter(interaction => this.validInteraction(repaired, interaction));
671
+ repaired.continuations = repaired.continuations.filter(item => this.validContinuation(repaired, item));
672
+ repaired.resources = repaired.resources.filter(resource => this.validResource(repaired, resource) && (!resource.reference || this.usablePersistedPath(resource.reference)));
673
+ return repaired;
674
+ }
675
+ /** Records directories outside the current roots for re-ownership; a path whose canonical identity changed is unusable. */
676
+ usablePersistedPath(path) {
677
+ let canonical;
678
+ try {
679
+ canonical = canonicalProspectivePath(path);
680
+ }
681
+ catch {
682
+ return false;
683
+ }
684
+ if (canonical !== path)
685
+ return false;
686
+ if (!this.owner.target.rootSet.some(root => pathInside(root, canonical)))
687
+ this.unownedDirectories.add(dirname(canonical));
688
+ return true;
689
+ }
690
+ dropSessions(state, ids) {
691
+ const dropped = new Set(ids);
692
+ for (let changed = dropped.size > 0; changed;) {
693
+ changed = false;
694
+ for (const session of state.sessions) {
695
+ if (!dropped.has(session.id) && (dropped.has(session.root) || session.parent !== undefined && dropped.has(session.parent))) {
696
+ dropped.add(session.id);
697
+ changed = true;
698
+ }
699
+ }
700
+ }
701
+ if (!dropped.size)
702
+ return state;
703
+ state.sessions = state.sessions.filter(session => !dropped.has(session.id));
704
+ state.requests = state.requests.filter(request => !request.session || !dropped.has(request.session));
705
+ state.interactions = state.interactions.filter(item => item.scope.kind !== 'session' || !dropped.has(item.scope.session));
706
+ state.continuations = state.continuations.filter(item => !dropped.has(item.session));
707
+ state.resources = state.resources.filter(item => !dropped.has(item.session));
708
+ return state;
709
+ }
710
+ /** `structural` rechecks the record itself; unchanged committed records only need their references checked. */
711
+ validRequest(state, request, structural = true, lookup = (id) => state.sessions.find(item => item.id === id)) {
712
+ try {
713
+ if (structural) {
714
+ if (!Object.hasOwn(OPERATION_CATALOG, request.op) || OPERATION_CATALOG[request.op].storage !== 'durable' || request.retainUntil < requestRetainUntil(request.id, request.admittedAt) || (request.phase === 'terminal') !== (request.response !== undefined))
715
+ return false;
716
+ if (request.response)
717
+ this.parseResponse(request, request.response);
718
+ }
719
+ return !request.session || lookup(request.session) !== undefined;
720
+ }
721
+ catch {
722
+ return false;
723
+ }
724
+ }
725
+ validInteraction(state, interaction, structural = true, lookup = (id) => state.sessions.find(item => item.id === id)) {
726
+ try {
727
+ if (structural)
728
+ parseInteraction(interaction);
729
+ return interaction.scope.kind !== 'session' || lookup(interaction.scope.session)?.root === interaction.scope.root;
730
+ }
731
+ catch {
732
+ return false;
733
+ }
734
+ }
735
+ validContinuation(state, item) {
736
+ const interaction = state.interactions.find(candidate => candidate.id === item.interaction);
737
+ return !!interaction && interaction.state === 'answered' && !!interaction.deferrable && interaction.scope.kind === 'session' && interaction.scope.session === item.session
738
+ && state.sessions.some(session => session.id === item.session);
739
+ }
740
+ validSession(state, session, structural = true, lookup = (id) => state.sessions.find(item => item.id === id)) {
741
+ const execution = session.execution;
742
+ if (execution && (!session.parent || !!execution.completedAt !== !!execution.outcome || execution.completionPending && !execution.completedAt))
743
+ return false;
744
+ if (session.deletedAt !== undefined && (session.state !== 'closed' || session.residency !== 'evicted' || session.run.state !== 'idle'))
745
+ return false;
746
+ if (structural && (session.projections.length > CONTROL_LIMITS.projections || new Set(session.projections.map(item => item.key)).size !== session.projections.length))
747
+ return false;
748
+ const path = new Set();
749
+ let ancestor = session;
750
+ for (;;) {
751
+ if (path.has(ancestor.id) || ancestor.root !== session.root)
752
+ return false;
753
+ path.add(ancestor.id);
754
+ if (ancestor.parent === undefined)
755
+ return ancestor.id === session.root;
756
+ ancestor = lookup(ancestor.parent);
757
+ if (!ancestor)
758
+ return false;
759
+ }
760
+ }
761
+ validResource(state, resource) {
762
+ return state.sessions.find(session => session.id === resource.session)?.root === resource.root;
763
+ }
764
+ prune(state, now, touched) {
765
+ state.requests = state.requests.filter(item => item.phase !== 'terminal' || item.retainUntil >= now);
766
+ state.continuations = state.continuations.filter(item => ['ready', 'dispatch_intent', 'indeterminate'].includes(item.phase) || item.updatedAt + LIMITS.requestRetentionMs >= now);
767
+ state.interactions = state.interactions.filter(item => {
768
+ const keep = !('resolvedAt' in item) || item.resolvedAt + LIMITS.requestRetentionMs >= now || state.continuations.some(c => c.interaction === item.id);
769
+ if (!keep && item.scope.kind === 'session')
770
+ touched.add(item.scope.session);
771
+ return keep;
772
+ });
773
+ state.resources = state.resources.filter(item => {
774
+ const keep = item.state !== 'exited' || item.updatedAt + LIMITS.requestRetentionMs >= now;
775
+ if (!keep)
776
+ touched.add(item.session);
777
+ return keep;
778
+ });
779
+ state.sessions = state.sessions.filter(session => {
780
+ const keep = session.deletedAt === undefined || session.deletedAt + LIMITS.requestRetentionMs >= now
781
+ || state.sessions.some(other => other.id !== session.id && (other.parent === session.id || other.root === session.id))
782
+ || state.requests.some(item => item.session === session.id)
783
+ || state.interactions.some(item => item.scope.kind === 'session' && item.scope.session === session.id)
784
+ || state.continuations.some(item => item.session === session.id)
785
+ || state.resources.some(item => item.session === session.id || item.root === session.id);
786
+ if (!keep)
787
+ touched.delete(session.id);
788
+ return keep;
789
+ });
790
+ for (const session of state.sessions) {
791
+ const retained = session.projections.filter(item => item.updatedAt + CONTROL_LIMITS.projectionAgeMs >= now || item.value.type === 'queue');
792
+ if (retained.length === session.projections.length)
793
+ continue;
794
+ editSession(state, session).projections = retained;
795
+ touched.add(session.id);
796
+ }
797
+ }
798
+ trimProjections(session) {
799
+ while (session.projections.length > CONTROL_LIMITS.projections) {
800
+ const at = session.projections.findIndex(item => item.value.type === 'message');
801
+ if (at < 0)
802
+ throw new ControlStoreError('request_capacity', 'Active projection capacity reached.');
803
+ session.projections.splice(at, 1);
804
+ }
805
+ }
806
+ findSession(state, id) {
807
+ const item = state.sessions.find(item => item.id === id);
808
+ if (!item)
809
+ throw new ControlStoreError('not_found', 'Session not found.');
810
+ return item;
811
+ }
812
+ findRequest(state, id) {
813
+ const item = state.requests.find(item => item.id === id.toLowerCase());
814
+ if (!item)
815
+ throw new ControlStoreError('not_found', 'Request not found.');
816
+ return item;
817
+ }
818
+ findInteraction(state, id) {
819
+ const item = state.interactions.find(item => item.id === id);
820
+ if (!item)
821
+ throw new ControlStoreError('not_found', 'Interaction not found.');
822
+ return item;
823
+ }
824
+ time(previous) {
825
+ const now = this.now();
826
+ if (!Number.isSafeInteger(now) || now < 0)
827
+ throw new ControlStoreError('invalid_request', 'Invalid clock.');
828
+ return Math.max(previous, now);
829
+ }
830
+ }
831
+ function emptyRows() {
832
+ return { rows: Object.fromEntries(ROW_TABLES.map(table => [table, new Map()])), projections: new Map() };
833
+ }
834
+ function storedRows(stored) {
835
+ const persisted = emptyRows();
836
+ for (const table of ROW_TABLES)
837
+ for (const row of stored.rows[table])
838
+ persisted.rows[table].set(row.id, { ord: row.ord, data: row.data });
839
+ for (const row of stored.rows.projections) {
840
+ const [session, key] = JSON.parse(row.id);
841
+ const projections = persisted.projections.get(session) ?? new Map();
842
+ persisted.projections.set(session, projections.set(key, { ord: row.ord, data: row.data }));
843
+ }
844
+ return persisted;
845
+ }
846
+ function maxOrd(stored) {
847
+ return CONTROL_TABLES.reduce((max, table) => stored.rows[table].reduce((value, row) => Math.max(value, row.ord), max), 0);
848
+ }
849
+ /** The whole-state shape `parseControlState` validates; undefined when a row is not JSON. */
850
+ function assemble(stored) {
851
+ try {
852
+ const projections = new Map();
853
+ for (const row of stored.rows.projections) {
854
+ const [session] = JSON.parse(row.id);
855
+ projections.set(session, [...projections.get(session) ?? [], JSON.parse(row.data)]);
856
+ }
857
+ const rows = (table) => stored.rows[table].map(row => JSON.parse(row.data));
858
+ return { version: 1, namespace: stored.meta.namespace, agentDir: stored.meta.agentDir, clock: stored.meta.clock,
859
+ sessions: rows('sessions').map(session => ({ ...session, projections: projections.get(session.id) ?? [] })),
860
+ requests: rows('requests'), interactions: rows('interactions'), continuations: rows('continuations'), resources: rows('resources') };
861
+ }
862
+ catch {
863
+ return undefined;
864
+ }
865
+ }
866
+ /** Shares every committed row with a commit; `edit` and `editSession` replace a row before it is changed. */
867
+ function draft(state) {
868
+ return { ...state, sessions: [...state.sessions], requests: [...state.requests], interactions: [...state.interactions], continuations: [...state.continuations], resources: [...state.resources] };
869
+ }
870
+ /** Replaces a committed (frozen) row with an editable shallow copy; nested values stay frozen and must be replaced, not mutated. */
871
+ function edit(rows, row) {
872
+ if (!Object.isFrozen(row))
873
+ return row;
874
+ const copy = { ...row };
875
+ rows[rows.indexOf(row)] = copy;
876
+ return copy;
877
+ }
878
+ function editSession(state, session) {
879
+ if (!Object.isFrozen(session)) {
880
+ if (Object.isFrozen(session.projections))
881
+ session.projections = [...session.projections];
882
+ return session;
883
+ }
884
+ const copy = { ...session, projections: [...session.projections] };
885
+ state.sessions[state.sessions.indexOf(session)] = copy;
886
+ return copy;
887
+ }
888
+ /** Committed rows are deep-frozen; the table arrays are not, so the store can replace rows outside a commit. */
889
+ function freeze(value) {
890
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
891
+ Object.freeze(value);
892
+ for (const child of Object.values(value))
893
+ freeze(child);
894
+ }
895
+ return value;
896
+ }
897
+ function metadataCopy(session) {
898
+ const { projections: _projections, ...metadata } = session;
899
+ return structuredClone(metadata);
900
+ }
901
+ //# sourceMappingURL=control-store.js.map