@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71

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 (170) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-delete-network-http.test.ts +177 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access.ts +461 -0
  9. package/src/agent-acl-groups-http.test.ts +247 -0
  10. package/src/agent-acl-http.test.ts +510 -0
  11. package/src/agent-acl-slice3-http.test.ts +267 -0
  12. package/src/agent-groups-perf.test.ts +167 -0
  13. package/src/alias-filter.test.ts +72 -0
  14. package/src/alias-filter.ts +32 -0
  15. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  16. package/src/api-host-supervisors-fallback.test.ts +271 -0
  17. package/src/api-nodes-shape.test.ts +26 -10
  18. package/src/auth-sessions-http.test.ts +250 -0
  19. package/src/auth-validate.test.ts +5 -1
  20. package/src/auth.ts +423 -29
  21. package/src/avatar-validate.test.ts +163 -0
  22. package/src/avatar-validate.ts +103 -0
  23. package/src/blocked-exit-on-reply.test.ts +122 -0
  24. package/src/boot-side-effect.test.ts +124 -0
  25. package/src/config-apply-sec1.test.ts +107 -0
  26. package/src/config-apply-validate.test.ts +92 -0
  27. package/src/config-apply-validate.ts +81 -3
  28. package/src/create-node-blocked-gate.test.ts +356 -0
  29. package/src/create-node-tool-schema.test.ts +39 -0
  30. package/src/create-node-validate.test.ts +89 -1
  31. package/src/create-node-validate.ts +89 -7
  32. package/src/create-node-workdir.test.ts +255 -0
  33. package/src/create-node.ts +51 -1
  34. package/src/dashboard-slash-routing-http.test.ts +107 -0
  35. package/src/db-adapter-guard.test.ts +85 -0
  36. package/src/db-adapter-translate.test.ts +112 -0
  37. package/src/db-adapter.ts +246 -83
  38. package/src/db-timestamp.test.ts +38 -0
  39. package/src/db-timestamp.ts +15 -0
  40. package/src/db.ts +799 -33
  41. package/src/delivered-without-push.test.ts +140 -0
  42. package/src/desktop-message-seam.test.ts +150 -0
  43. package/src/desktop-message.test.ts +368 -0
  44. package/src/ext-token-shared.test.ts +60 -0
  45. package/src/ext-validation-regression.test.ts +131 -0
  46. package/src/external-schedule-edits-http.test.ts +321 -0
  47. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  48. package/src/external-schedule-edits.ts +327 -0
  49. package/src/file-download-authz-dev-open.test.ts +135 -0
  50. package/src/file-download-authz.test.ts +404 -0
  51. package/src/file-download-date-bucket.test.ts +436 -0
  52. package/src/file-network-scope-dev-open.test.ts +97 -0
  53. package/src/file-network-scope.test.ts +732 -0
  54. package/src/health-redaction.test.ts +642 -0
  55. package/src/http-gzip.test.ts +73 -0
  56. package/src/http-gzip.ts +60 -0
  57. package/src/hub-timestamp.test.ts +38 -0
  58. package/src/hub-timestamp.ts +27 -0
  59. package/src/human-dm.ts +149 -0
  60. package/src/inbox-count-sse.test.ts +310 -0
  61. package/src/inbox-count.ts +29 -0
  62. package/src/index.ts +15 -2331
  63. package/src/lifecycle-guard.ts +50 -0
  64. package/src/mcp-write-network-resolution.test.ts +429 -0
  65. package/src/messages-ack-by-agent-http.test.ts +142 -0
  66. package/src/network-name-http.test.ts +75 -0
  67. package/src/network-scope.ts +224 -0
  68. package/src/node-attrs-validate.ts +88 -0
  69. package/src/node-attrs.test.ts +217 -0
  70. package/src/node-create-request-status-http.test.ts +92 -0
  71. package/src/node-files-transport.test.ts +344 -0
  72. package/src/node-id-alias.test.ts +188 -0
  73. package/src/node-lifecycle-controllable.test.ts +64 -0
  74. package/src/node-lifecycle-controllable.ts +51 -0
  75. package/src/node-logs-transport.test.ts +263 -0
  76. package/src/node-request-retention.test.ts +281 -0
  77. package/src/node-request-retention.ts +120 -0
  78. package/src/node-skills-transport.test.ts +209 -0
  79. package/src/node-token-privilege-http.test.ts +149 -0
  80. package/src/observer-avatar-http.test.ts +429 -0
  81. package/src/observer-push.test.ts +252 -0
  82. package/src/package-version-consistency.test.ts +25 -0
  83. package/src/peer-reply-atomic.test.ts +455 -0
  84. package/src/peer-reply-capability.test.ts +70 -0
  85. package/src/pg-worker.ts +87 -0
  86. package/src/push.ts +204 -7
  87. package/src/redact-tokens.test.ts +65 -0
  88. package/src/redact-tokens.ts +40 -0
  89. package/src/rename.ts +7 -2
  90. package/src/reply-target-derivation.test.ts +96 -0
  91. package/src/report-status-host-schema.test.ts +211 -0
  92. package/src/report-status-resume-id-handover.test.ts +150 -0
  93. package/src/require-explicit-test-db.ts +9 -0
  94. package/src/requirement-tags.ts +16 -0
  95. package/src/requirements-etag-http.test.ts +91 -0
  96. package/src/requirements-http.test.ts +708 -0
  97. package/src/requirements-mcp.test.ts +142 -0
  98. package/src/requirements-migrate.ts +83 -0
  99. package/src/requirements-priority-lowest.test.ts +114 -0
  100. package/src/requirements-start-date.test.ts +125 -0
  101. package/src/requirements-upgrade-e2e.test.ts +126 -0
  102. package/src/requirements-upgrade-migration.test.ts +85 -0
  103. package/src/requirements.ts +899 -0
  104. package/src/resolve-port.test.ts +57 -0
  105. package/src/resolve-port.ts +44 -0
  106. package/src/response-charset.test.ts +130 -0
  107. package/src/rest-explicit-columns-http.test.ts +216 -0
  108. package/src/rest-identity.test.ts +129 -0
  109. package/src/rest-identity.ts +100 -0
  110. package/src/rest-projections.ts +68 -0
  111. package/src/rest-write-network-resolution.test.ts +133 -0
  112. package/src/restricted-files.ts +73 -0
  113. package/src/retention.ts +10 -1
  114. package/src/rules-file-duplicate-alias.test.ts +234 -0
  115. package/src/rules-file-session-target.test.ts +207 -0
  116. package/src/rules-file-transport.test.ts +237 -0
  117. package/src/runtime-label.test.ts +22 -0
  118. package/src/runtime-label.ts +21 -0
  119. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  120. package/src/scheduled-run-terminal.test.ts +268 -0
  121. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  122. package/src/scheduled-tasks-http.test.ts +625 -0
  123. package/src/scheduled-tasks.ts +597 -0
  124. package/src/send-dedup-request-id.test.ts +229 -0
  125. package/src/send-reply-agent-warning.test.ts +202 -0
  126. package/src/send-reply-alias-optional.test.ts +131 -0
  127. package/src/send-reply-attachments.test.ts +458 -0
  128. package/src/send_dedup.test.ts +54 -0
  129. package/src/send_dedup.ts +52 -6
  130. package/src/serve-error.test.ts +38 -0
  131. package/src/serve-error.ts +27 -0
  132. package/src/server.ts +4385 -0
  133. package/src/shared/external-schedule-contract.ts +60 -0
  134. package/src/side-thread-command-transport.test.ts +302 -0
  135. package/src/side-thread-command-transport.ts +318 -0
  136. package/src/side-thread-http-integration.test.ts +233 -0
  137. package/src/side-thread-http.ts +504 -0
  138. package/src/side-thread-production.test.ts +72 -0
  139. package/src/side-thread-production.ts +118 -0
  140. package/src/side-thread.test.ts +1067 -0
  141. package/src/side-thread.ts +2495 -0
  142. package/src/skillhub-http.test.ts +92 -0
  143. package/src/start-node.test.ts +109 -0
  144. package/src/status-alias-filter-http.test.ts +106 -0
  145. package/src/stop-delete-node.test.ts +1164 -0
  146. package/src/task-auth-origin-http.test.ts +91 -0
  147. package/src/task-auth-origin.test.ts +24 -0
  148. package/src/task-auth-origin.ts +7 -0
  149. package/src/task-consumption.test.ts +482 -0
  150. package/src/task-diagnostic-http.test.ts +149 -0
  151. package/src/task-diagnostic.test.ts +115 -0
  152. package/src/task-diagnostic.ts +118 -0
  153. package/src/task-idempotency-mcp.test.ts +142 -0
  154. package/src/task-idempotency.test.ts +60 -0
  155. package/src/task-idempotency.ts +75 -0
  156. package/src/task-lifecycle-watcher.test.ts +204 -0
  157. package/src/task-lifecycle-watcher.ts +49 -0
  158. package/src/task-network-resolution-http.test.ts +141 -0
  159. package/src/tasks-pagination-http.test.ts +141 -0
  160. package/src/tools-list-json-schema.test.ts +66 -0
  161. package/src/tools.ts +3446 -257
  162. package/src/update-provider.test.ts +65 -0
  163. package/src/upload-early-reject-guard.test.ts +155 -0
  164. package/src/uploads-http.test.ts +97 -15
  165. package/src/uploads.test.ts +66 -0
  166. package/src/uploads.ts +130 -5
  167. package/src/user-inbox-by-agent-http.test.ts +161 -0
  168. package/src/user-inbox-netid-notnull.test.ts +122 -0
  169. package/src/user-inbox-read-http.test.ts +223 -0
  170. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,2495 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ import type { DbAdapter } from "./db-adapter.js";
4
+
5
+ export const SIDE_THREAD_FEATURE_FLAG = "COMMHUB_ENABLE_SIDE_THREADS";
6
+ export const SIDE_THREAD_API_ROOT = "/api/side-threads";
7
+ export const SIDE_THREAD_EVENTS_SUFFIX = "/events";
8
+ export const SIDE_THREAD_API_ACTIONS = [
9
+ "cancel",
10
+ "retry",
11
+ "archive",
12
+ "purge",
13
+ "bring-back",
14
+ SIDE_THREAD_EVENTS_SUFFIX.slice(1),
15
+ ] as const;
16
+
17
+ export type SideThreadState =
18
+ | "creating"
19
+ | "running"
20
+ | "completed"
21
+ | "failed"
22
+ | "cancelled"
23
+ | "ambiguous"
24
+ | "reconciling"
25
+ | "archived"
26
+ | "purged";
27
+ export type SideThreadAttemptState =
28
+ | "starting"
29
+ | "running"
30
+ | "completed"
31
+ | "failed"
32
+ | "cancelled"
33
+ | "ambiguous"
34
+ | "reconciling";
35
+ export type SideThreadBoundary =
36
+ { kind: "through"; turnId: string } | { kind: "before"; turnId: string };
37
+
38
+ export interface SideThreadActor {
39
+ userId: string;
40
+ username: string;
41
+ tokenId: string;
42
+ kind: "user" | "node";
43
+ boundNetworkId?: string;
44
+ boundNodeId?: string;
45
+ isAdmin?: boolean;
46
+ }
47
+
48
+ export interface SideThreadAttachmentRef {
49
+ fileId: string;
50
+ }
51
+
52
+ export interface SideThreadCapability {
53
+ supported: boolean;
54
+ mode?: "native-exact-fork";
55
+ runtime?: string;
56
+ runtimeVersion?: string;
57
+ topology?: string;
58
+ evidenceRevision?: string;
59
+ exactBoundary?: { through: boolean; before: boolean };
60
+ reason?: string;
61
+ }
62
+
63
+ export interface SideThreadRuntimeEvent {
64
+ sideChatId: string;
65
+ attemptId: string;
66
+ threadId: string;
67
+ turnId: string;
68
+ status: "completed" | "failed" | "interrupted";
69
+ text?: string;
70
+ error?: string;
71
+ }
72
+
73
+ /**
74
+ * The only bridge from Hub state into a runtime. Implementations must use a
75
+ * native exact-boundary side-thread mechanism. Ordinary task/inbox/FIFO
76
+ * delivery is deliberately absent from this interface.
77
+ */
78
+ export interface SideThreadExecutionPort {
79
+ capability(
80
+ nodeId: string,
81
+ ): Promise<SideThreadCapability> | SideThreadCapability;
82
+ fork(input: {
83
+ operationId: string;
84
+ requestKey: string;
85
+ sideChatId: string;
86
+ nodeId: string;
87
+ sourceThreadId: string;
88
+ boundary: SideThreadBoundary;
89
+ }): Promise<{ threadId: string }>;
90
+ start(input: {
91
+ operationId: string;
92
+ requestKey: string;
93
+ sideChatId: string;
94
+ attemptId: string;
95
+ nodeId: string;
96
+ threadId: string;
97
+ prompt: string;
98
+ attachments: SideThreadAttachmentRef[];
99
+ }): Promise<{ turnId: string }>;
100
+ cancel(input: {
101
+ operationId: string;
102
+ sideChatId: string;
103
+ attemptId: string;
104
+ nodeId: string;
105
+ threadId: string;
106
+ turnId: string;
107
+ }): Promise<void>;
108
+ archive(input: {
109
+ operationId: string;
110
+ sideChatId: string;
111
+ nodeId: string;
112
+ threadId: string;
113
+ }): Promise<void>;
114
+ purge(input: {
115
+ operationId: string;
116
+ sideChatId: string;
117
+ nodeId: string;
118
+ threadId: string;
119
+ }): Promise<void>;
120
+ bringBack(input: {
121
+ operationId: string;
122
+ sideChatId: string;
123
+ attemptId: string;
124
+ nodeId: string;
125
+ sourceThreadId: string;
126
+ sourceTurnId: string;
127
+ destinationThreadId: string;
128
+ requestKey: string;
129
+ text: string;
130
+ }): Promise<{ destinationTurnId: string }>;
131
+ subscribe(listener: (event: SideThreadRuntimeEvent) => void | Promise<void>): () => void;
132
+ }
133
+
134
+ export class SideThreadError extends Error {
135
+ constructor(
136
+ readonly code: string,
137
+ message: string,
138
+ readonly status: number,
139
+ readonly operationId?: string,
140
+ readonly sideChatId?: string,
141
+ readonly attemptId?: string,
142
+ ) {
143
+ super(message);
144
+ this.name = "SideThreadError";
145
+ }
146
+ }
147
+
148
+ export class UnsupportedSideThreadPort implements SideThreadExecutionPort {
149
+ capability(): SideThreadCapability {
150
+ return { supported: false, reason: "runtime-adapter-unavailable" };
151
+ }
152
+ private unsupported(): never {
153
+ throw new SideThreadError(
154
+ "SIDE_THREAD_UNSUPPORTED",
155
+ "verified native SideThread adapter is unavailable",
156
+ 501,
157
+ );
158
+ }
159
+ async fork(): Promise<never> {
160
+ return this.unsupported();
161
+ }
162
+ async start(): Promise<never> {
163
+ return this.unsupported();
164
+ }
165
+ async cancel(): Promise<never> {
166
+ return this.unsupported();
167
+ }
168
+ async archive(): Promise<never> {
169
+ return this.unsupported();
170
+ }
171
+ async purge(): Promise<never> {
172
+ return this.unsupported();
173
+ }
174
+ async bringBack(): Promise<never> {
175
+ return this.unsupported();
176
+ }
177
+ subscribe(): () => void {
178
+ return () => {};
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Process-local adapter seam. Production starts unsupported; a node/runtime
184
+ * integration may install one verified adapter explicitly. Replacing it also
185
+ * rewires terminal-event subscribers, so the coordinator never subscribes to
186
+ * an untracked transport.
187
+ */
188
+ export class SideThreadPortRegistry implements SideThreadExecutionPort {
189
+ private delegate: SideThreadExecutionPort = new UnsupportedSideThreadPort();
190
+ private readonly listeners = new Set<
191
+ (event: SideThreadRuntimeEvent) => void | Promise<void>
192
+ >();
193
+ private detach: () => void = () => {};
194
+ install(port: SideThreadExecutionPort): () => void {
195
+ this.detach();
196
+ this.delegate = port;
197
+ this.detach = port.subscribe(async (event) => {
198
+ for (const listener of this.listeners) await listener(event);
199
+ });
200
+ return () => {
201
+ if (this.delegate !== port) return;
202
+ this.detach();
203
+ this.detach = () => {};
204
+ this.delegate = new UnsupportedSideThreadPort();
205
+ };
206
+ }
207
+ capability(nodeId: string) {
208
+ return this.delegate.capability(nodeId);
209
+ }
210
+ fork(input: Parameters<SideThreadExecutionPort["fork"]>[0]) {
211
+ return this.delegate.fork(input);
212
+ }
213
+ start(input: Parameters<SideThreadExecutionPort["start"]>[0]) {
214
+ return this.delegate.start(input);
215
+ }
216
+ cancel(input: Parameters<SideThreadExecutionPort["cancel"]>[0]) {
217
+ return this.delegate.cancel(input);
218
+ }
219
+ archive(input: Parameters<SideThreadExecutionPort["archive"]>[0]) {
220
+ return this.delegate.archive(input);
221
+ }
222
+ purge(input: Parameters<SideThreadExecutionPort["purge"]>[0]) {
223
+ return this.delegate.purge(input);
224
+ }
225
+ bringBack(input: Parameters<SideThreadExecutionPort["bringBack"]>[0]) {
226
+ return this.delegate.bringBack(input);
227
+ }
228
+ subscribe(listener: (event: SideThreadRuntimeEvent) => void | Promise<void>): () => void {
229
+ if (this.listeners.size > 0)
230
+ throw new Error("SideThread runtime port supports exactly one durable terminal applier");
231
+ this.listeners.add(listener);
232
+ return () => this.listeners.delete(listener);
233
+ }
234
+ }
235
+
236
+ export interface SideThreadRecord {
237
+ sideChatId: string;
238
+ requestKey: string;
239
+ networkId: string;
240
+ nodeId: string;
241
+ ownerUserId: string;
242
+ sourceThreadId: string;
243
+ /** Owner-readable original BTW question, durably restored across windows. */
244
+ question: string;
245
+ boundary: SideThreadBoundary;
246
+ threadId?: string;
247
+ state: SideThreadState;
248
+ activeAttemptId?: string;
249
+ runtime?: string;
250
+ runtimeVersion?: string;
251
+ topology?: string;
252
+ evidenceRevision?: string;
253
+ attachments: SideThreadAttachmentRef[];
254
+ attempts: SideThreadAttemptRecord[];
255
+ bringBacks: SideThreadBringBackRecord[];
256
+ operations: SideThreadOperationRecord[];
257
+ createdAt: number;
258
+ updatedAt: number;
259
+ }
260
+
261
+ export interface SideThreadAttemptRecord {
262
+ attemptId: string;
263
+ requestKey: string;
264
+ parentAttemptId?: string;
265
+ threadId?: string;
266
+ turnId?: string;
267
+ state: SideThreadAttemptState;
268
+ result?: string;
269
+ error?: string;
270
+ attachments: SideThreadAttachmentRef[];
271
+ createdAt: number;
272
+ updatedAt: number;
273
+ }
274
+
275
+ export interface SideThreadBringBackRecord {
276
+ bringBackId: string;
277
+ attemptId: string;
278
+ requestKey: string;
279
+ destinationThreadId: string;
280
+ destinationTurnId?: string;
281
+ state: "starting" | "completed" | "failed";
282
+ createdAt: number;
283
+ updatedAt: number;
284
+ completedAt?: number;
285
+ }
286
+
287
+ export interface SideThreadOperationRecord {
288
+ operationId: string;
289
+ attemptId?: string;
290
+ kind: "fork" | "start" | "cancel" | "archive" | "purge" | "bring-back";
291
+ requestKey: string;
292
+ state: "pending" | "ambiguous" | "reconciling" | "completed" | "failed";
293
+ threadId?: string;
294
+ turnId?: string;
295
+ errorCode?: string;
296
+ createdAt: number;
297
+ updatedAt: number;
298
+ }
299
+
300
+ export interface SideThreadEventRecord {
301
+ eventId: number;
302
+ sideChatId: string;
303
+ attemptId?: string;
304
+ threadId?: string;
305
+ turnId?: string;
306
+ type: string;
307
+ state?: string;
308
+ reason?: string;
309
+ createdAt: number;
310
+ }
311
+
312
+ export function installSideThreadSchema(database: DbAdapter): void {
313
+ database.exec(`
314
+ CREATE TABLE IF NOT EXISTS side_chats (
315
+ side_chat_id TEXT PRIMARY KEY,
316
+ network_id TEXT NOT NULL,
317
+ node_id TEXT NOT NULL,
318
+ owner_user_id TEXT NOT NULL,
319
+ created_by_token_id TEXT NOT NULL,
320
+ create_request_key TEXT NOT NULL,
321
+ create_fingerprint TEXT NOT NULL,
322
+ source_thread_id TEXT NOT NULL,
323
+ question_text TEXT NOT NULL,
324
+ boundary_kind TEXT NOT NULL CHECK(boundary_kind IN ('through','before')),
325
+ boundary_turn_id TEXT NOT NULL,
326
+ derived_thread_id TEXT,
327
+ state TEXT NOT NULL,
328
+ active_attempt_id TEXT,
329
+ lifecycle_operation TEXT,
330
+ runtime TEXT,
331
+ runtime_version TEXT,
332
+ topology TEXT,
333
+ evidence_revision TEXT,
334
+ attachments_json TEXT NOT NULL DEFAULT '[]',
335
+ created_at INTEGER NOT NULL,
336
+ updated_at INTEGER NOT NULL,
337
+ archived_at INTEGER,
338
+ purged_at INTEGER,
339
+ UNIQUE(owner_user_id, create_request_key)
340
+ );
341
+ CREATE INDEX IF NOT EXISTS idx_side_chats_owner_network
342
+ ON side_chats(owner_user_id, network_id, updated_at DESC);
343
+ CREATE INDEX IF NOT EXISTS idx_side_chats_node
344
+ ON side_chats(node_id, state, updated_at DESC);
345
+
346
+ CREATE TABLE IF NOT EXISTS side_chat_attempts (
347
+ attempt_id TEXT PRIMARY KEY,
348
+ side_chat_id TEXT NOT NULL,
349
+ parent_attempt_id TEXT,
350
+ request_key TEXT NOT NULL,
351
+ request_fingerprint TEXT NOT NULL,
352
+ prompt_hash TEXT NOT NULL,
353
+ attachments_json TEXT NOT NULL DEFAULT '[]',
354
+ thread_id TEXT,
355
+ turn_id TEXT,
356
+ cancel_requested_at INTEGER,
357
+ state TEXT NOT NULL,
358
+ result_text TEXT,
359
+ error_text TEXT,
360
+ created_at INTEGER NOT NULL,
361
+ updated_at INTEGER NOT NULL,
362
+ UNIQUE(side_chat_id, request_key)
363
+ );
364
+ CREATE INDEX IF NOT EXISTS idx_side_attempts_chat_time
365
+ ON side_chat_attempts(side_chat_id, created_at, attempt_id);
366
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_side_attempt_active
367
+ ON side_chat_attempts(side_chat_id)
368
+ WHERE state IN ('starting','running');
369
+
370
+ CREATE TABLE IF NOT EXISTS side_chat_events (
371
+ event_id INTEGER PRIMARY KEY AUTOINCREMENT,
372
+ side_chat_id TEXT NOT NULL,
373
+ attempt_id TEXT,
374
+ thread_id TEXT,
375
+ turn_id TEXT,
376
+ event_type TEXT NOT NULL,
377
+ state TEXT,
378
+ reason TEXT,
379
+ created_at INTEGER NOT NULL
380
+ );
381
+ CREATE INDEX IF NOT EXISTS idx_side_events_chat_id
382
+ ON side_chat_events(side_chat_id, event_id);
383
+
384
+ CREATE TABLE IF NOT EXISTS side_chat_operations (
385
+ operation_id TEXT PRIMARY KEY,
386
+ side_chat_id TEXT NOT NULL,
387
+ attempt_id TEXT,
388
+ operation_kind TEXT NOT NULL,
389
+ request_key TEXT NOT NULL,
390
+ request_fingerprint TEXT NOT NULL,
391
+ state TEXT NOT NULL,
392
+ thread_id TEXT,
393
+ turn_id TEXT,
394
+ error_code TEXT,
395
+ created_at INTEGER NOT NULL,
396
+ updated_at INTEGER NOT NULL,
397
+ UNIQUE(side_chat_id, operation_kind, request_key)
398
+ );
399
+ CREATE INDEX IF NOT EXISTS idx_side_operations_chat_time
400
+ ON side_chat_operations(side_chat_id, created_at, operation_id);
401
+
402
+ CREATE TABLE IF NOT EXISTS side_chat_bring_backs (
403
+ bring_back_id TEXT PRIMARY KEY,
404
+ side_chat_id TEXT NOT NULL,
405
+ attempt_id TEXT NOT NULL,
406
+ owner_user_id TEXT NOT NULL,
407
+ request_key TEXT NOT NULL,
408
+ request_fingerprint TEXT NOT NULL,
409
+ destination_thread_id TEXT NOT NULL,
410
+ destination_turn_id TEXT,
411
+ state TEXT NOT NULL,
412
+ error_text TEXT,
413
+ created_at INTEGER NOT NULL,
414
+ updated_at INTEGER NOT NULL,
415
+ completed_at INTEGER,
416
+ UNIQUE(side_chat_id, request_key)
417
+ );
418
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_side_bring_back_destination
419
+ ON side_chat_bring_backs(side_chat_id, attempt_id, destination_thread_id);
420
+ `);
421
+ // Additive development migration for databases created by an earlier PR2
422
+ // draft before cross-coordinator cancel claiming was introduced.
423
+ try {
424
+ database.exec(
425
+ "ALTER TABLE side_chat_attempts ADD COLUMN cancel_requested_at INTEGER",
426
+ );
427
+ } catch (error: any) {
428
+ if (!/duplicate column|already exists/i.test(error?.message ?? ""))
429
+ throw error;
430
+ }
431
+ try {
432
+ database.exec(
433
+ "ALTER TABLE side_chat_attempts ADD COLUMN attachments_json TEXT NOT NULL DEFAULT '[]'",
434
+ );
435
+ } catch (error: any) {
436
+ if (!/duplicate column|already exists/i.test(error?.message ?? ""))
437
+ throw error;
438
+ }
439
+ try {
440
+ database.exec(
441
+ "ALTER TABLE side_chat_bring_backs ADD COLUMN completed_at INTEGER",
442
+ );
443
+ } catch (error: any) {
444
+ if (!/duplicate column|already exists/i.test(error?.message ?? ""))
445
+ throw error;
446
+ }
447
+ try {
448
+ database.exec("ALTER TABLE side_chats ADD COLUMN lifecycle_operation TEXT");
449
+ } catch (error: any) {
450
+ if (!/duplicate column|already exists/i.test(error?.message ?? ""))
451
+ throw error;
452
+ }
453
+ }
454
+
455
+ type SideChatRow = {
456
+ side_chat_id: string;
457
+ create_request_key: string;
458
+ network_id: string;
459
+ node_id: string;
460
+ owner_user_id: string;
461
+ source_thread_id: string;
462
+ question_text: string;
463
+ boundary_kind: "through" | "before";
464
+ boundary_turn_id: string;
465
+ derived_thread_id: string | null;
466
+ state: SideThreadState;
467
+ active_attempt_id: string | null;
468
+ runtime: string | null;
469
+ runtime_version: string | null;
470
+ topology: string | null;
471
+ evidence_revision: string | null;
472
+ attachments_json: string;
473
+ created_at: number;
474
+ updated_at: number;
475
+ };
476
+ type AttemptRow = {
477
+ attempt_id: string;
478
+ request_key: string;
479
+ parent_attempt_id: string | null;
480
+ thread_id: string | null;
481
+ turn_id: string | null;
482
+ state: SideThreadAttemptState;
483
+ result_text: string | null;
484
+ error_text: string | null;
485
+ attachments_json: string;
486
+ created_at: number;
487
+ updated_at: number;
488
+ };
489
+
490
+ export class SideThreadStore {
491
+ constructor(readonly db: DbAdapter) {
492
+ installSideThreadSchema(db);
493
+ }
494
+
495
+ getOwned(
496
+ sideChatId: string,
497
+ actor: SideThreadActor,
498
+ ): SideThreadRecord | undefined {
499
+ const row = this.db.get<SideChatRow>(
500
+ "SELECT * FROM side_chats WHERE side_chat_id = ?1 AND owner_user_id = ?2",
501
+ sideChatId,
502
+ actor.userId,
503
+ );
504
+ if (!row) return undefined;
505
+ if (actor.kind === "node" && actor.boundNodeId !== row.node_id)
506
+ return undefined;
507
+ if (actor.boundNetworkId && actor.boundNetworkId !== row.network_id)
508
+ return undefined;
509
+ return this.hydrate(row);
510
+ }
511
+
512
+ getByCreateKey(
513
+ actor: SideThreadActor,
514
+ requestKey: string,
515
+ ): { record: SideThreadRecord; fingerprint: string } | undefined {
516
+ const row = this.db.get<SideChatRow & { create_fingerprint: string }>(
517
+ "SELECT * FROM side_chats WHERE owner_user_id = ?1 AND create_request_key = ?2",
518
+ actor.userId,
519
+ requestKey,
520
+ );
521
+ if (!row) return undefined;
522
+ if (actor.kind === "node" && actor.boundNodeId !== row.node_id)
523
+ return undefined;
524
+ return { record: this.hydrate(row), fingerprint: row.create_fingerprint };
525
+ }
526
+
527
+ listOwned(
528
+ actor: SideThreadActor,
529
+ filters: { networkId?: string; nodeId?: string; limit?: number } = {},
530
+ ): SideThreadRecord[] {
531
+ let sql = "SELECT * FROM side_chats WHERE owner_user_id = ?1";
532
+ const params: unknown[] = [actor.userId];
533
+ const networkId = actor.boundNetworkId ?? filters.networkId;
534
+ if (networkId) {
535
+ params.push(networkId);
536
+ sql += ` AND network_id = ?${params.length}`;
537
+ }
538
+ const nodeId = actor.kind === "node" ? actor.boundNodeId : filters.nodeId;
539
+ if (nodeId) {
540
+ params.push(nodeId);
541
+ sql += ` AND node_id = ?${params.length}`;
542
+ }
543
+ params.push(Math.min(Math.max(filters.limit ?? 50, 1), 100));
544
+ sql += ` ORDER BY updated_at DESC, side_chat_id DESC LIMIT ?${params.length}`;
545
+ return this.db
546
+ .all<SideChatRow>(sql, ...params)
547
+ .map((row) => this.hydrate(row));
548
+ }
549
+
550
+ hydrate(row: SideChatRow): SideThreadRecord {
551
+ const attempts = this.db
552
+ .all<AttemptRow>(
553
+ "SELECT attempt_id,request_key,parent_attempt_id,thread_id,turn_id,state,result_text,error_text,attachments_json,created_at,updated_at FROM side_chat_attempts WHERE side_chat_id = ?1 ORDER BY created_at,attempt_id",
554
+ row.side_chat_id,
555
+ )
556
+ .map((a) => ({
557
+ attemptId: a.attempt_id,
558
+ requestKey: a.request_key,
559
+ parentAttemptId: a.parent_attempt_id ?? undefined,
560
+ threadId: a.thread_id ?? undefined,
561
+ turnId: a.turn_id ?? undefined,
562
+ state: a.state,
563
+ result: a.result_text ?? undefined,
564
+ error: a.error_text ?? undefined,
565
+ attachments: parseAttachmentJson(a.attachments_json),
566
+ createdAt: a.created_at,
567
+ updatedAt: a.updated_at,
568
+ }));
569
+ let attachments: SideThreadAttachmentRef[] = [];
570
+ try {
571
+ attachments = JSON.parse(row.attachments_json);
572
+ } catch {}
573
+ const bringBacks = this.db
574
+ .all<any>(
575
+ "SELECT bring_back_id,attempt_id,request_key,destination_thread_id,destination_turn_id,state,created_at,updated_at,completed_at FROM side_chat_bring_backs WHERE side_chat_id=?1 ORDER BY created_at,bring_back_id",
576
+ row.side_chat_id,
577
+ )
578
+ .map((b) => ({
579
+ bringBackId: b.bring_back_id,
580
+ attemptId: b.attempt_id,
581
+ requestKey: b.request_key,
582
+ destinationThreadId: b.destination_thread_id,
583
+ destinationTurnId: b.destination_turn_id ?? undefined,
584
+ state: b.state,
585
+ createdAt: b.created_at,
586
+ updatedAt: b.updated_at,
587
+ completedAt: b.completed_at ?? undefined,
588
+ }));
589
+ const operations = this.db
590
+ .all<any>(
591
+ "SELECT operation_id,attempt_id,operation_kind,request_key,state,thread_id,turn_id,error_code,created_at,updated_at FROM side_chat_operations WHERE side_chat_id=?1 ORDER BY created_at,rowid",
592
+ row.side_chat_id,
593
+ )
594
+ .map((op) => ({
595
+ operationId: op.operation_id,
596
+ attemptId: op.attempt_id ?? undefined,
597
+ kind: op.operation_kind,
598
+ requestKey: op.request_key,
599
+ state: op.state,
600
+ threadId: op.thread_id ?? undefined,
601
+ turnId: op.turn_id ?? undefined,
602
+ errorCode: op.error_code ?? undefined,
603
+ createdAt: op.created_at,
604
+ updatedAt: op.updated_at,
605
+ }));
606
+ return {
607
+ sideChatId: row.side_chat_id,
608
+ requestKey: row.create_request_key,
609
+ networkId: row.network_id,
610
+ nodeId: row.node_id,
611
+ ownerUserId: row.owner_user_id,
612
+ sourceThreadId: row.source_thread_id,
613
+ question: row.question_text,
614
+ boundary: {
615
+ kind: row.boundary_kind,
616
+ turnId: row.boundary_turn_id,
617
+ } as SideThreadBoundary,
618
+ threadId: row.derived_thread_id ?? undefined,
619
+ state: row.state,
620
+ activeAttemptId: row.active_attempt_id ?? undefined,
621
+ runtime: row.runtime ?? undefined,
622
+ runtimeVersion: row.runtime_version ?? undefined,
623
+ topology: row.topology ?? undefined,
624
+ evidenceRevision: row.evidence_revision ?? undefined,
625
+ attachments,
626
+ attempts,
627
+ bringBacks,
628
+ operations,
629
+ createdAt: row.created_at,
630
+ updatedAt: row.updated_at,
631
+ };
632
+ }
633
+
634
+ events(sideChatId: string, after = 0): SideThreadEventRecord[] {
635
+ return this.db
636
+ .all<any>(
637
+ "SELECT event_id,side_chat_id,attempt_id,thread_id,turn_id,event_type,state,reason,created_at FROM side_chat_events WHERE side_chat_id = ?1 AND event_id > ?2 ORDER BY event_id LIMIT 500",
638
+ sideChatId,
639
+ after,
640
+ )
641
+ .map((e) => ({
642
+ eventId: e.event_id,
643
+ sideChatId: e.side_chat_id,
644
+ attemptId: e.attempt_id ?? undefined,
645
+ threadId: e.thread_id ?? undefined,
646
+ turnId: e.turn_id ?? undefined,
647
+ type: e.event_type,
648
+ state: e.state ?? undefined,
649
+ reason: e.reason ?? undefined,
650
+ createdAt: e.created_at,
651
+ }));
652
+ }
653
+ }
654
+
655
+ type CreateInput = {
656
+ requestKey: string;
657
+ networkId: string;
658
+ nodeId: string;
659
+ sourceThreadId: string;
660
+ boundary: SideThreadBoundary;
661
+ prompt: string;
662
+ attachments?: SideThreadAttachmentRef[];
663
+ };
664
+
665
+ export class SideThreadCoordinator {
666
+ private readonly emitter = new EventEmitter();
667
+ private readonly unsubscribe: () => void;
668
+ private readonly inFlight = new Map<string, Promise<unknown>>();
669
+ constructor(
670
+ readonly store: SideThreadStore,
671
+ readonly port: SideThreadExecutionPort,
672
+ private readonly opts: {
673
+ enabled: boolean;
674
+ authorizeNode: (
675
+ actor: SideThreadActor,
676
+ networkId: string,
677
+ nodeId: string,
678
+ ) => boolean;
679
+ authorizeAttachment?: (
680
+ actor: SideThreadActor,
681
+ networkId: string,
682
+ ref: SideThreadAttachmentRef,
683
+ ) => boolean;
684
+ now?: () => number;
685
+ id?: () => string;
686
+ },
687
+ ) {
688
+ this.unsubscribe = port.subscribe((event) => this.acceptRuntimeEvent(event));
689
+ }
690
+
691
+ close(): void {
692
+ this.unsubscribe();
693
+ this.emitter.removeAllListeners();
694
+ }
695
+ private now(): number {
696
+ return (this.opts.now ?? Date.now)();
697
+ }
698
+ private id(prefix: string): string {
699
+ return `${prefix}_${(this.opts.id ?? randomUUID)().replace(/-/g, "")}`;
700
+ }
701
+
702
+ private assertEnabled(): void {
703
+ if (!this.opts.enabled)
704
+ throw new SideThreadError(
705
+ "SIDE_THREAD_DISABLED",
706
+ "SideThread API is disabled",
707
+ 404,
708
+ );
709
+ }
710
+ private assertActor(actor: SideThreadActor): void {
711
+ requireIdentity(actor.userId, "actor.userId");
712
+ requireIdentity(actor.tokenId, "actor.tokenId");
713
+ }
714
+ private async assertCapability(
715
+ nodeId: string,
716
+ boundary?: SideThreadBoundary,
717
+ ): Promise<SideThreadCapability> {
718
+ const cap = await this.port.capability(nodeId);
719
+ if (!cap.supported)
720
+ throw new SideThreadError(
721
+ "SIDE_THREAD_UNSUPPORTED",
722
+ capabilityReason(cap.reason),
723
+ 501,
724
+ );
725
+ if (cap.mode !== "native-exact-fork")
726
+ throw new SideThreadError(
727
+ "SIDE_THREAD_UNSUPPORTED",
728
+ "verified native exact-fork mode unavailable",
729
+ 501,
730
+ );
731
+ if (boundary && !cap.exactBoundary?.[boundary.kind]) {
732
+ throw new SideThreadError(
733
+ "SIDE_THREAD_UNSUPPORTED",
734
+ `exact '${boundary.kind}' boundary unavailable`,
735
+ 501,
736
+ );
737
+ }
738
+ return cap;
739
+ }
740
+
741
+ async create(
742
+ actor: SideThreadActor,
743
+ input: CreateInput,
744
+ ): Promise<SideThreadRecord> {
745
+ this.assertEnabled();
746
+ this.assertActor(actor);
747
+ validateCreateInput(input);
748
+ if (!this.opts.authorizeNode(actor, input.networkId, input.nodeId)) {
749
+ throw new SideThreadError("SIDE_THREAD_NOT_FOUND", "node not found", 404);
750
+ }
751
+ const attachments = normalizeAttachments(input.attachments);
752
+ for (const ref of attachments) {
753
+ if (!this.opts.authorizeAttachment?.(actor, input.networkId, ref)) {
754
+ throw new SideThreadError(
755
+ "SIDE_THREAD_ATTACHMENT_NOT_FOUND",
756
+ "attachment not found",
757
+ 404,
758
+ );
759
+ }
760
+ }
761
+ const fingerprint = hashJson([
762
+ input.networkId,
763
+ input.nodeId,
764
+ input.sourceThreadId,
765
+ input.boundary,
766
+ input.prompt,
767
+ attachments,
768
+ ]);
769
+ const existing = this.store.getByCreateKey(actor, input.requestKey);
770
+ if (existing) {
771
+ if (existing.fingerprint !== fingerprint)
772
+ throw conflict("idempotency key reused with different create payload");
773
+ if (this.createNeedsReconciliation(existing.record)) {
774
+ return this.singleFlight(
775
+ `create:${actor.userId}:${input.requestKey}`,
776
+ () => this.reconcileCreate(actor, input, attachments, fingerprint),
777
+ );
778
+ }
779
+ return existing.record;
780
+ }
781
+ return this.singleFlight(
782
+ `create:${actor.userId}:${input.requestKey}`,
783
+ async () => {
784
+ const replay = this.store.getByCreateKey(actor, input.requestKey);
785
+ if (replay) {
786
+ if (replay.fingerprint !== fingerprint)
787
+ throw conflict(
788
+ "idempotency key reused with different create payload",
789
+ );
790
+ if (this.createNeedsReconciliation(replay.record))
791
+ return this.reconcileCreate(actor, input, attachments, fingerprint);
792
+ return replay.record;
793
+ }
794
+ const capability = await this.assertCapability(
795
+ input.nodeId,
796
+ input.boundary,
797
+ );
798
+ const sideChatId = this.id("sch");
799
+ const attemptId = this.id("sat");
800
+ const forkOperationId = stableOperationId(
801
+ sideChatId,
802
+ "fork",
803
+ input.requestKey,
804
+ );
805
+ const now = this.now();
806
+ try {
807
+ this.store.db.transaction(() => {
808
+ this.store.db.run(
809
+ `INSERT INTO side_chats (side_chat_id,network_id,node_id,owner_user_id,created_by_token_id,create_request_key,create_fingerprint,source_thread_id,question_text,boundary_kind,boundary_turn_id,state,active_attempt_id,runtime,runtime_version,topology,evidence_revision,attachments_json,created_at,updated_at)
810
+ VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,'creating',?12,?13,?14,?15,?16,?17,?18,?18)`,
811
+ [
812
+ sideChatId,
813
+ input.networkId,
814
+ input.nodeId,
815
+ actor.userId,
816
+ actor.tokenId,
817
+ input.requestKey,
818
+ fingerprint,
819
+ input.sourceThreadId,
820
+ input.prompt,
821
+ input.boundary.kind,
822
+ input.boundary.turnId,
823
+ attemptId,
824
+ capability.runtime ?? null,
825
+ capability.runtimeVersion ?? null,
826
+ capability.topology ?? null,
827
+ capability.evidenceRevision ?? null,
828
+ JSON.stringify(attachments),
829
+ now,
830
+ ],
831
+ );
832
+ this.store.db.run(
833
+ `INSERT INTO side_chat_attempts (attempt_id,side_chat_id,request_key,request_fingerprint,prompt_hash,attachments_json,state,created_at,updated_at)
834
+ VALUES (?1,?2,?3,?4,?5,?6,'starting',?7,?7)`,
835
+ [
836
+ attemptId,
837
+ sideChatId,
838
+ input.requestKey,
839
+ fingerprint,
840
+ hashText(input.prompt),
841
+ JSON.stringify(attachments),
842
+ now,
843
+ ],
844
+ );
845
+ this.insertOperation({
846
+ operationId: forkOperationId,
847
+ sideChatId,
848
+ attemptId,
849
+ kind: "fork",
850
+ requestKey: input.requestKey,
851
+ fingerprint,
852
+ at: now,
853
+ });
854
+ this.emitStored(
855
+ sideChatId,
856
+ attemptId,
857
+ undefined,
858
+ undefined,
859
+ "side_chat.created",
860
+ "creating",
861
+ undefined,
862
+ now,
863
+ );
864
+ });
865
+ } catch (error) {
866
+ const winner = this.store.getByCreateKey(actor, input.requestKey);
867
+ if (winner) {
868
+ if (winner.fingerprint !== fingerprint)
869
+ throw conflict(
870
+ "idempotency key reused with different create payload",
871
+ );
872
+ return winner.record;
873
+ }
874
+ throw error;
875
+ }
876
+ let activeOperationId = forkOperationId;
877
+ try {
878
+ const forked = await this.port.fork({
879
+ operationId: forkOperationId,
880
+ requestKey: input.requestKey,
881
+ sideChatId,
882
+ nodeId: input.nodeId,
883
+ sourceThreadId: input.sourceThreadId,
884
+ boundary: input.boundary,
885
+ });
886
+ requireRuntimeIdentity(forked.threadId, "threadId");
887
+ if (forked.threadId === input.sourceThreadId)
888
+ throw runtimeProtocol(
889
+ "runtime returned source thread as side thread",
890
+ );
891
+ this.applyRuntimeReceipt(() => {
892
+ const t = this.now();
893
+ this.store.db.run(
894
+ "UPDATE side_chats SET derived_thread_id=?1,updated_at=?2 WHERE side_chat_id=?3 AND state='creating'",
895
+ [forked.threadId, t, sideChatId],
896
+ );
897
+ this.store.db.run(
898
+ "UPDATE side_chat_attempts SET thread_id=?1,updated_at=?2 WHERE attempt_id=?3 AND state='starting'",
899
+ [forked.threadId, t, attemptId],
900
+ );
901
+ this.settleOperation(forkOperationId, "completed", {
902
+ threadId: forked.threadId,
903
+ });
904
+ });
905
+ const startOperationId = stableOperationId(
906
+ sideChatId,
907
+ "start",
908
+ input.requestKey,
909
+ );
910
+ activeOperationId = startOperationId;
911
+ this.insertOperation({
912
+ operationId: startOperationId,
913
+ sideChatId,
914
+ attemptId,
915
+ kind: "start",
916
+ requestKey: input.requestKey,
917
+ fingerprint,
918
+ threadId: forked.threadId,
919
+ at: this.now(),
920
+ });
921
+ const started = await this.port.start({
922
+ operationId: startOperationId,
923
+ requestKey: input.requestKey,
924
+ sideChatId,
925
+ attemptId,
926
+ nodeId: input.nodeId,
927
+ threadId: forked.threadId,
928
+ prompt: input.prompt,
929
+ attachments,
930
+ });
931
+ requireRuntimeIdentity(started.turnId, "turnId");
932
+ const attemptBeforeApply = this.store.db.get<any>(
933
+ "SELECT state,turn_id FROM side_chat_attempts WHERE attempt_id=?1",
934
+ attemptId,
935
+ );
936
+ if (!attemptBeforeApply) throw conflict("attempt disappeared during start");
937
+ if (attemptBeforeApply.turn_id && attemptBeforeApply.turn_id !== started.turnId)
938
+ throw runtimeProtocol("runtime turn identity changed during start");
939
+ this.applyRuntimeReceipt(() => {
940
+ const attempt = this.store.db.get<any>(
941
+ "SELECT state,turn_id FROM side_chat_attempts WHERE attempt_id=?1",
942
+ attemptId,
943
+ );
944
+ this.settleOperation(startOperationId, "completed", {
945
+ threadId: forked.threadId,
946
+ turnId: started.turnId,
947
+ });
948
+ if (attempt.state === "starting") {
949
+ const t = this.now();
950
+ this.store.db.run(
951
+ "UPDATE side_chat_attempts SET state='running',turn_id=?1,updated_at=?2 WHERE attempt_id=?3 AND state='starting'",
952
+ [started.turnId, t, attemptId],
953
+ );
954
+ this.store.db.run(
955
+ "UPDATE side_chats SET state='running',derived_thread_id=?1,updated_at=?2 WHERE side_chat_id=?3 AND active_attempt_id=?4",
956
+ [forked.threadId, t, sideChatId, attemptId],
957
+ );
958
+ this.emitStored(
959
+ sideChatId,
960
+ attemptId,
961
+ forked.threadId,
962
+ started.turnId,
963
+ "attempt.running",
964
+ "running",
965
+ undefined,
966
+ t,
967
+ );
968
+ }
969
+ });
970
+ } catch (error) {
971
+ if (isAmbiguousRuntimeError(error)) {
972
+ if (this.operationState(activeOperationId) === "completed")
973
+ return this.mustOwned(sideChatId, actor);
974
+ this.markAmbiguous(sideChatId, attemptId, activeOperationId, error);
975
+ throw ambiguousError(activeOperationId, sideChatId, attemptId);
976
+ }
977
+ this.failStarting(sideChatId, attemptId, error, activeOperationId);
978
+ throw normalizePortError(error);
979
+ }
980
+ return this.mustOwned(sideChatId, actor);
981
+ },
982
+ );
983
+ }
984
+
985
+ /**
986
+ * Resume the stable fork/start operations after an ACK arrived outside the
987
+ * request window. All identity needed to resume lives in SQLite; calling
988
+ * create again from another process therefore consumes the durable receipt
989
+ * instead of issuing a second native operation.
990
+ */
991
+ private async reconcileCreate(
992
+ actor: SideThreadActor,
993
+ input: CreateInput,
994
+ attachments: SideThreadAttachmentRef[],
995
+ fingerprint: string,
996
+ ): Promise<SideThreadRecord> {
997
+ const found = this.store.getByCreateKey(actor, input.requestKey);
998
+ if (!found) throw conflict("create record disappeared during reconciliation");
999
+ if (found.fingerprint !== fingerprint)
1000
+ throw conflict("idempotency key reused with different create payload");
1001
+ const sideChatId = found.record.sideChatId;
1002
+ const attemptId = found.record.activeAttemptId;
1003
+ if (!attemptId) throw conflict("create attempt disappeared during reconciliation");
1004
+ const forkOperationId = stableOperationId(sideChatId, "fork", input.requestKey);
1005
+ const startOperationId = stableOperationId(sideChatId, "start", input.requestKey);
1006
+ let activeOperationId = forkOperationId;
1007
+ try {
1008
+ this.applyRuntimeReceipt(() => {
1009
+ const at = this.now();
1010
+ this.store.db.run(
1011
+ "UPDATE side_chats SET state='reconciling',updated_at=?1 WHERE side_chat_id=?2 AND state IN ('creating','ambiguous')",
1012
+ [at, sideChatId],
1013
+ );
1014
+ this.store.db.run(
1015
+ "UPDATE side_chat_attempts SET state='reconciling',updated_at=?1 WHERE attempt_id=?2 AND state IN ('starting','ambiguous')",
1016
+ [at, attemptId],
1017
+ );
1018
+ this.store.db.run(
1019
+ "UPDATE side_chat_operations SET state='reconciling',updated_at=?1 WHERE operation_id=?2 AND state='ambiguous'",
1020
+ [at, forkOperationId],
1021
+ );
1022
+ });
1023
+ const forkOp = this.store.db.get<any>(
1024
+ "SELECT state,thread_id FROM side_chat_operations WHERE operation_id=?1",
1025
+ forkOperationId,
1026
+ );
1027
+ let threadId = forkOp?.state === "completed" ? forkOp.thread_id : undefined;
1028
+ if (!threadId) {
1029
+ const forked = await this.port.fork({
1030
+ operationId: forkOperationId,
1031
+ requestKey: input.requestKey,
1032
+ sideChatId,
1033
+ nodeId: input.nodeId,
1034
+ sourceThreadId: input.sourceThreadId,
1035
+ boundary: input.boundary,
1036
+ });
1037
+ threadId = forked.threadId;
1038
+ }
1039
+ requireRuntimeIdentity(threadId, "threadId");
1040
+ if (threadId === input.sourceThreadId)
1041
+ throw runtimeProtocol("runtime returned source thread as side thread");
1042
+ this.applyRuntimeReceipt(() => {
1043
+ const at = this.now();
1044
+ this.settleOperation(forkOperationId, "completed", { threadId });
1045
+ this.store.db.run(
1046
+ "UPDATE side_chats SET derived_thread_id=?1,updated_at=?2 WHERE side_chat_id=?3 AND state IN ('creating','ambiguous','reconciling')",
1047
+ [threadId, at, sideChatId],
1048
+ );
1049
+ this.store.db.run(
1050
+ "UPDATE side_chat_attempts SET thread_id=?1,updated_at=?2 WHERE attempt_id=?3 AND state IN ('starting','ambiguous','reconciling')",
1051
+ [threadId, at, attemptId],
1052
+ );
1053
+ const existingStart = this.store.db.get<any>(
1054
+ "SELECT operation_id FROM side_chat_operations WHERE operation_id=?1",
1055
+ startOperationId,
1056
+ );
1057
+ if (!existingStart)
1058
+ this.insertOperation({ operationId: startOperationId, sideChatId, attemptId, kind: "start", requestKey: input.requestKey, fingerprint, threadId, at });
1059
+ else
1060
+ this.store.db.run(
1061
+ "UPDATE side_chat_operations SET state='reconciling',updated_at=?1 WHERE operation_id=?2 AND state='ambiguous'",
1062
+ [at, startOperationId],
1063
+ );
1064
+ });
1065
+ activeOperationId = startOperationId;
1066
+ const startOp = this.store.db.get<any>(
1067
+ "SELECT state,turn_id FROM side_chat_operations WHERE operation_id=?1",
1068
+ startOperationId,
1069
+ );
1070
+ let turnId = startOp?.state === "completed" ? startOp.turn_id : undefined;
1071
+ if (!turnId) {
1072
+ const started = await this.port.start({ operationId: startOperationId, requestKey: input.requestKey, sideChatId, attemptId, nodeId: input.nodeId, threadId, prompt: input.prompt, attachments });
1073
+ turnId = started.turnId;
1074
+ }
1075
+ requireRuntimeIdentity(turnId, "turnId");
1076
+ this.applyRuntimeReceipt(() => {
1077
+ const at = this.now();
1078
+ this.settleOperation(startOperationId, "completed", { threadId, turnId });
1079
+ this.store.db.run(
1080
+ "UPDATE side_chat_attempts SET state='running',thread_id=?1,turn_id=?2,updated_at=?3 WHERE attempt_id=?4 AND state IN ('starting','ambiguous','reconciling')",
1081
+ [threadId, turnId, at, attemptId],
1082
+ );
1083
+ this.store.db.run(
1084
+ "UPDATE side_chats SET state='running',derived_thread_id=?1,updated_at=?2 WHERE side_chat_id=?3 AND active_attempt_id=?4 AND state IN ('creating','ambiguous','reconciling')",
1085
+ [threadId, at, sideChatId, attemptId],
1086
+ );
1087
+ });
1088
+ return this.mustOwned(sideChatId, actor);
1089
+ } catch (error) {
1090
+ if (isAmbiguousRuntimeError(error)) {
1091
+ if (this.operationState(activeOperationId) === "completed")
1092
+ return this.mustOwned(sideChatId, actor);
1093
+ this.markAmbiguous(sideChatId, attemptId, activeOperationId, error);
1094
+ throw ambiguousError(activeOperationId, sideChatId, attemptId);
1095
+ }
1096
+ this.failStarting(sideChatId, attemptId, error, activeOperationId);
1097
+ throw normalizePortError(error);
1098
+ }
1099
+ }
1100
+
1101
+ private createNeedsReconciliation(record: SideThreadRecord): boolean {
1102
+ if (["creating", "ambiguous", "reconciling"].includes(record.state)) return true;
1103
+ const active = record.attempts.find((attempt) => attempt.attemptId === record.activeAttemptId);
1104
+ if (active && ["starting", "ambiguous", "reconciling"].includes(active.state)) return true;
1105
+ return record.operations.some((operation) =>
1106
+ (operation.kind === "fork" || operation.kind === "start") &&
1107
+ ["pending", "ambiguous", "reconciling"].includes(operation.state),
1108
+ );
1109
+ }
1110
+
1111
+ get(actor: SideThreadActor, sideChatId: string): SideThreadRecord {
1112
+ this.assertEnabled();
1113
+ this.assertActor(actor);
1114
+ requireIdentity(sideChatId, "sideChatId");
1115
+ return this.mustOwned(sideChatId, actor);
1116
+ }
1117
+
1118
+ list(
1119
+ actor: SideThreadActor,
1120
+ filters: { networkId?: string; nodeId?: string; limit?: number } = {},
1121
+ ): SideThreadRecord[] {
1122
+ this.assertEnabled();
1123
+ this.assertActor(actor);
1124
+ if (filters.networkId) requireIdentity(filters.networkId, "networkId");
1125
+ if (filters.nodeId) requireIdentity(filters.nodeId, "nodeId");
1126
+ return this.store
1127
+ .listOwned(actor, filters)
1128
+ .filter((record) =>
1129
+ this.opts.authorizeNode(actor, record.networkId, record.nodeId),
1130
+ );
1131
+ }
1132
+
1133
+ isEnabled(): boolean {
1134
+ return this.opts.enabled;
1135
+ }
1136
+
1137
+ async capability(
1138
+ actor: SideThreadActor,
1139
+ networkId: string,
1140
+ nodeId: string,
1141
+ boundary: SideThreadBoundary,
1142
+ ): Promise<SideThreadCapability> {
1143
+ this.assertEnabled();
1144
+ this.assertActor(actor);
1145
+ requireIdentity(networkId, "networkId");
1146
+ requireIdentity(nodeId, "nodeId");
1147
+ requireIdentity(boundary.turnId, "boundary.turnId");
1148
+ if (!this.opts.authorizeNode(actor, networkId, nodeId))
1149
+ throw new SideThreadError("SIDE_THREAD_NOT_FOUND", "node not found", 404);
1150
+ const capability = await this.port.capability(nodeId);
1151
+ if (!capability.supported)
1152
+ return {
1153
+ supported: false,
1154
+ reason: capabilityReasonCode(capability.reason),
1155
+ };
1156
+ if (capability.mode !== "native-exact-fork")
1157
+ return { supported: false, reason: "native-exact-fork-unavailable" };
1158
+ if (!capability.exactBoundary?.[boundary.kind])
1159
+ return { supported: false, reason: `exact-${boundary.kind}-unavailable` };
1160
+ return capability;
1161
+ }
1162
+
1163
+ async cancel(
1164
+ actor: SideThreadActor,
1165
+ sideChatId: string,
1166
+ input: { requestKey: string },
1167
+ ): Promise<SideThreadRecord> {
1168
+ this.assertEnabled();
1169
+ requireKey(input.requestKey);
1170
+ return this.singleFlight(
1171
+ `cancel:${sideChatId}:${input.requestKey}`,
1172
+ async () => {
1173
+ const record = this.mustOwned(sideChatId, actor);
1174
+ const replay = this.store.db.get<{
1175
+ operation_id: string;
1176
+ attempt_id: string | null;
1177
+ state: SideThreadOperationRecord["state"];
1178
+ error_code: string | null;
1179
+ }>(
1180
+ "SELECT operation_id,attempt_id,state,error_code FROM side_chat_operations WHERE side_chat_id=?1 AND operation_kind='cancel' AND request_key=?2",
1181
+ sideChatId,
1182
+ input.requestKey,
1183
+ );
1184
+ if (replay?.state === "completed" || replay?.state === "pending")
1185
+ return record;
1186
+ if (replay?.state === "failed") throw persistedOperationFailure(replay.error_code);
1187
+ if (replay?.state === "ambiguous" || replay?.state === "reconciling") {
1188
+ const attempt = record.attempts.find((a) => a.attemptId === replay.attempt_id);
1189
+ if (!attempt?.threadId || !attempt.turnId) throw conflict("cancel ownership missing");
1190
+ await this.reconcileLifecycleOperation({
1191
+ sideChatId, attemptId: attempt.attemptId, operationId: replay.operation_id,
1192
+ invoke: () => this.port.cancel({ operationId: replay.operation_id, sideChatId, attemptId: attempt.attemptId, nodeId: record.nodeId, threadId: attempt.threadId!, turnId: attempt.turnId! }),
1193
+ receipt: () => ({ threadId: attempt.threadId, turnId: attempt.turnId }),
1194
+ accepted: () => {
1195
+ const t = this.now();
1196
+ this.store.db.run("UPDATE side_chat_attempts SET state='running',updated_at=?1 WHERE attempt_id=?2 AND state IN ('ambiguous','reconciling')", [t, attempt.attemptId]);
1197
+ this.store.db.run("UPDATE side_chats SET state='running',updated_at=?1 WHERE side_chat_id=?2 AND active_attempt_id=?3 AND state IN ('ambiguous','reconciling')", [t, sideChatId, attempt.attemptId]);
1198
+ },
1199
+ failed: () => {
1200
+ const t = this.now();
1201
+ this.store.db.run("UPDATE side_chat_attempts SET state='running',cancel_requested_at=NULL,updated_at=?1 WHERE attempt_id=?2 AND state IN ('ambiguous','reconciling')", [t, attempt.attemptId]);
1202
+ this.store.db.run("UPDATE side_chats SET state='running',updated_at=?1 WHERE side_chat_id=?2 AND active_attempt_id=?3 AND state IN ('ambiguous','reconciling')", [t, sideChatId, attempt.attemptId]);
1203
+ },
1204
+ });
1205
+ return this.mustOwned(sideChatId, actor);
1206
+ }
1207
+ const attempt = record.attempts.find(
1208
+ (a) => a.attemptId === record.activeAttemptId,
1209
+ );
1210
+ if (
1211
+ !attempt?.threadId ||
1212
+ !attempt.turnId ||
1213
+ !["running", "ambiguous", "reconciling"].includes(attempt.state)
1214
+ )
1215
+ throw conflict("side chat has no cancellable attempt");
1216
+ await this.assertCapability(record.nodeId);
1217
+ const prepared = this.prepareStableOperation({
1218
+ sideChatId,
1219
+ attemptId: attempt.attemptId,
1220
+ kind: "cancel",
1221
+ requestKey: input.requestKey,
1222
+ fingerprint: hashJson([
1223
+ sideChatId,
1224
+ attempt.attemptId,
1225
+ attempt.threadId,
1226
+ attempt.turnId,
1227
+ ]),
1228
+ threadId: attempt.threadId,
1229
+ turnId: attempt.turnId,
1230
+ });
1231
+ if (prepared.state === "completed") return record;
1232
+ if (prepared.state === "ambiguous" || prepared.state === "reconciling")
1233
+ throw ambiguousError(
1234
+ prepared.operationId,
1235
+ sideChatId,
1236
+ attempt.attemptId,
1237
+ );
1238
+ // A second coordinator observing the durable pending claim must never
1239
+ // issue another RPC. Returning its current owner projection is an
1240
+ // honest in-progress replay; ambiguous is handled above and is typed.
1241
+ if (!prepared.callable) return this.mustOwned(sideChatId, actor);
1242
+ const claimedAt = this.now();
1243
+ const claim = this.store.db.run(
1244
+ "UPDATE side_chat_attempts SET cancel_requested_at=?1,updated_at=?1 WHERE attempt_id=?2 AND state='running' AND thread_id=?3 AND turn_id=?4 AND cancel_requested_at IS NULL",
1245
+ [claimedAt, attempt.attemptId, attempt.threadId, attempt.turnId],
1246
+ );
1247
+ if (claim.changes === 0) {
1248
+ this.markAmbiguous(
1249
+ sideChatId,
1250
+ attempt.attemptId,
1251
+ prepared.operationId,
1252
+ { code: "SIDE_THREAD_RESPONSE_LOST" },
1253
+ );
1254
+ throw ambiguousError(
1255
+ prepared.operationId,
1256
+ sideChatId,
1257
+ attempt.attemptId,
1258
+ );
1259
+ }
1260
+ try {
1261
+ await this.port.cancel({
1262
+ operationId: prepared.operationId,
1263
+ sideChatId,
1264
+ attemptId: attempt.attemptId,
1265
+ nodeId: record.nodeId,
1266
+ threadId: attempt.threadId,
1267
+ turnId: attempt.turnId,
1268
+ });
1269
+ } catch (error) {
1270
+ if (isAmbiguousRuntimeError(error)) {
1271
+ this.markAmbiguous(
1272
+ sideChatId,
1273
+ attempt.attemptId,
1274
+ prepared.operationId,
1275
+ error,
1276
+ );
1277
+ throw ambiguousError(
1278
+ prepared.operationId,
1279
+ sideChatId,
1280
+ attempt.attemptId,
1281
+ );
1282
+ }
1283
+ this.settleOperation(prepared.operationId, "failed", {
1284
+ errorCode: auditErrorReason(error),
1285
+ });
1286
+ this.store.db.run(
1287
+ "UPDATE side_chat_attempts SET cancel_requested_at=NULL,updated_at=?1 WHERE attempt_id=?2 AND state='running' AND thread_id=?3 AND turn_id=?4",
1288
+ [this.now(), attempt.attemptId, attempt.threadId, attempt.turnId],
1289
+ );
1290
+ throw normalizePortError(error);
1291
+ }
1292
+ this.store.db.transaction(() => {
1293
+ // Command acceptance only proves that interrupt was accepted. The
1294
+ // authoritative terminal event owns the cancelled/completed race.
1295
+ this.settleOperation(prepared.operationId, "completed", {
1296
+ threadId: attempt.threadId,
1297
+ turnId: attempt.turnId,
1298
+ });
1299
+ });
1300
+ return this.mustOwned(sideChatId, actor);
1301
+ },
1302
+ );
1303
+ }
1304
+
1305
+ async retry(
1306
+ actor: SideThreadActor,
1307
+ sideChatId: string,
1308
+ input: {
1309
+ requestKey: string;
1310
+ prompt: string;
1311
+ attachments?: SideThreadAttachmentRef[];
1312
+ },
1313
+ ): Promise<SideThreadRecord> {
1314
+ this.assertEnabled();
1315
+ requireKey(input.requestKey);
1316
+ requirePrompt(input.prompt);
1317
+ return this.singleFlight(
1318
+ `retry:${sideChatId}:${input.requestKey}`,
1319
+ async () => {
1320
+ const record = this.mustOwned(sideChatId, actor);
1321
+ if (!record.threadId) throw conflict("side chat has no derived thread");
1322
+ const attachments = normalizeAttachments(
1323
+ input.attachments ?? record.attachments,
1324
+ );
1325
+ for (const ref of attachments)
1326
+ if (!this.opts.authorizeAttachment?.(actor, record.networkId, ref))
1327
+ throw new SideThreadError(
1328
+ "SIDE_THREAD_ATTACHMENT_NOT_FOUND",
1329
+ "attachment not found",
1330
+ 404,
1331
+ );
1332
+ const fingerprint = hashJson([sideChatId, input.prompt, attachments]);
1333
+ const old = this.store.db.get<any>(
1334
+ "SELECT attempt_id,request_fingerprint FROM side_chat_attempts WHERE side_chat_id=?1 AND request_key=?2",
1335
+ sideChatId,
1336
+ input.requestKey,
1337
+ );
1338
+ if (old) {
1339
+ if (old.request_fingerprint !== fingerprint)
1340
+ throw conflict(
1341
+ "idempotency key reused with different retry payload",
1342
+ );
1343
+ return this.mustOwned(sideChatId, actor);
1344
+ }
1345
+ if (record.activeAttemptId)
1346
+ throw conflict("side chat already has an active attempt");
1347
+ if (record.state === "archived" || record.state === "purged")
1348
+ throw conflict(`cannot retry ${record.state} side chat`);
1349
+ await this.assertCapability(record.nodeId);
1350
+ const parent = record.attempts.at(-1);
1351
+ if (!parent) throw conflict("side chat has no prior attempt");
1352
+ const attemptId = this.id("sat"),
1353
+ startOperationId = stableOperationId(
1354
+ sideChatId,
1355
+ "start",
1356
+ input.requestKey,
1357
+ ),
1358
+ t = this.now();
1359
+ try {
1360
+ this.store.db.transaction(() => {
1361
+ this.store.db.run(
1362
+ "INSERT INTO side_chat_attempts (attempt_id,side_chat_id,parent_attempt_id,request_key,request_fingerprint,prompt_hash,attachments_json,thread_id,state,created_at,updated_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,'starting',?9,?9)",
1363
+ [
1364
+ attemptId,
1365
+ sideChatId,
1366
+ parent.attemptId,
1367
+ input.requestKey,
1368
+ fingerprint,
1369
+ hashText(input.prompt),
1370
+ JSON.stringify(attachments),
1371
+ record.threadId,
1372
+ t,
1373
+ ],
1374
+ );
1375
+ this.insertOperation({
1376
+ operationId: startOperationId,
1377
+ sideChatId,
1378
+ attemptId,
1379
+ kind: "start",
1380
+ requestKey: input.requestKey,
1381
+ fingerprint,
1382
+ threadId: record.threadId,
1383
+ at: t,
1384
+ });
1385
+ this.store.db.run(
1386
+ "UPDATE side_chats SET state='running',active_attempt_id=?1,updated_at=?2 WHERE side_chat_id=?3 AND active_attempt_id IS NULL",
1387
+ [attemptId, t, sideChatId],
1388
+ );
1389
+ this.emitStored(
1390
+ sideChatId,
1391
+ attemptId,
1392
+ record.threadId,
1393
+ undefined,
1394
+ "attempt.retrying",
1395
+ "starting",
1396
+ undefined,
1397
+ t,
1398
+ );
1399
+ });
1400
+ } catch (error) {
1401
+ const winner = this.store.db.get<any>(
1402
+ "SELECT request_fingerprint FROM side_chat_attempts WHERE side_chat_id=?1 AND request_key=?2",
1403
+ sideChatId,
1404
+ input.requestKey,
1405
+ );
1406
+ if (winner) {
1407
+ if (winner.request_fingerprint !== fingerprint)
1408
+ throw conflict(
1409
+ "idempotency key reused with different retry payload",
1410
+ );
1411
+ return this.mustOwned(sideChatId, actor);
1412
+ }
1413
+ const active = this.store.db.get<{ attempt_id: string }>(
1414
+ "SELECT attempt_id FROM side_chat_attempts WHERE side_chat_id=?1 AND state IN ('starting','running') LIMIT 1",
1415
+ sideChatId,
1416
+ );
1417
+ if (active) throw conflict("side chat already has an active attempt");
1418
+ throw error;
1419
+ }
1420
+ try {
1421
+ const started = await this.port.start({
1422
+ operationId: startOperationId,
1423
+ requestKey: input.requestKey,
1424
+ sideChatId,
1425
+ attemptId,
1426
+ nodeId: record.nodeId,
1427
+ threadId: record.threadId,
1428
+ prompt: input.prompt,
1429
+ attachments,
1430
+ });
1431
+ requireRuntimeIdentity(started.turnId, "turnId");
1432
+ this.store.db.transaction(() => {
1433
+ const now = this.now();
1434
+ const attempt = this.store.db.get<any>(
1435
+ "SELECT state,turn_id FROM side_chat_attempts WHERE attempt_id=?1",
1436
+ attemptId,
1437
+ );
1438
+ if (attempt?.turn_id && attempt.turn_id !== started.turnId)
1439
+ throw runtimeProtocol(
1440
+ "runtime turn identity changed during retry",
1441
+ );
1442
+ this.settleOperation(startOperationId, "completed", {
1443
+ threadId: record.threadId,
1444
+ turnId: started.turnId,
1445
+ });
1446
+ if (attempt?.state === "starting") {
1447
+ this.store.db.run(
1448
+ "UPDATE side_chat_attempts SET state='running',turn_id=?1,updated_at=?2 WHERE attempt_id=?3",
1449
+ [started.turnId, now, attemptId],
1450
+ );
1451
+ this.emitStored(
1452
+ sideChatId,
1453
+ attemptId,
1454
+ record.threadId,
1455
+ started.turnId,
1456
+ "attempt.running",
1457
+ "running",
1458
+ undefined,
1459
+ now,
1460
+ );
1461
+ }
1462
+ });
1463
+ } catch (error) {
1464
+ if (isAmbiguousRuntimeError(error)) {
1465
+ if (this.operationState(startOperationId) === "completed")
1466
+ return this.mustOwned(sideChatId, actor);
1467
+ this.markAmbiguous(sideChatId, attemptId, startOperationId, error);
1468
+ throw ambiguousError(startOperationId, sideChatId, attemptId);
1469
+ }
1470
+ this.failStarting(sideChatId, attemptId, error, startOperationId);
1471
+ throw normalizePortError(error);
1472
+ }
1473
+ return this.mustOwned(sideChatId, actor);
1474
+ },
1475
+ );
1476
+ }
1477
+
1478
+ async archive(
1479
+ actor: SideThreadActor,
1480
+ sideChatId: string,
1481
+ input: { requestKey: string },
1482
+ ): Promise<SideThreadRecord> {
1483
+ return this.lifecycle(actor, sideChatId, "archive", input);
1484
+ }
1485
+ async purge(
1486
+ actor: SideThreadActor,
1487
+ sideChatId: string,
1488
+ input: { requestKey: string },
1489
+ ): Promise<SideThreadRecord> {
1490
+ return this.lifecycle(actor, sideChatId, "purge", input);
1491
+ }
1492
+ private async lifecycle(
1493
+ actor: SideThreadActor,
1494
+ sideChatId: string,
1495
+ action: "archive" | "purge",
1496
+ input: { requestKey: string },
1497
+ ): Promise<SideThreadRecord> {
1498
+ this.assertEnabled();
1499
+ requireKey(input.requestKey);
1500
+ return this.singleFlight(
1501
+ `${action}:${sideChatId}:${input.requestKey}`,
1502
+ async () => {
1503
+ const r = this.mustOwned(sideChatId, actor);
1504
+ if (r.activeAttemptId)
1505
+ throw conflict(`cannot ${action} a running side chat`);
1506
+ if (!r.threadId) throw conflict("side chat has no derived thread");
1507
+ if (r.state === "purged") return r;
1508
+ if (action === "archive" && r.state === "archived") return r;
1509
+ const priorOperation = this.store.db.get<{ state: string; error_code: string | null }>(
1510
+ "SELECT state,error_code FROM side_chat_operations WHERE side_chat_id=?1 AND operation_kind=?2 AND request_key=?3",
1511
+ sideChatId, action, input.requestKey,
1512
+ );
1513
+ if (priorOperation?.state === "failed") throw persistedOperationFailure(priorOperation.error_code);
1514
+ await this.assertCapability(r.nodeId);
1515
+ const prepared = this.prepareStableOperation({
1516
+ sideChatId,
1517
+ kind: action,
1518
+ requestKey: input.requestKey,
1519
+ fingerprint: hashJson([action, sideChatId, r.nodeId, r.threadId]),
1520
+ threadId: r.threadId,
1521
+ });
1522
+ if (prepared.state === "completed") return r;
1523
+ if (prepared.state === "ambiguous" || prepared.state === "reconciling") {
1524
+ await this.reconcileLifecycleOperation({
1525
+ sideChatId, operationId: prepared.operationId,
1526
+ invoke: () => action === "archive"
1527
+ ? this.port.archive({ operationId: prepared.operationId, sideChatId, nodeId: r.nodeId, threadId: r.threadId! })
1528
+ : this.port.purge({ operationId: prepared.operationId, sideChatId, nodeId: r.nodeId, threadId: r.threadId! }),
1529
+ receipt: () => ({ threadId: r.threadId }),
1530
+ accepted: () => this.applyLifecycleAccepted(sideChatId, action, prepared.operationId, r.threadId!),
1531
+ failed: () => this.store.db.run("UPDATE side_chats SET state='failed',lifecycle_operation=NULL,updated_at=?1 WHERE side_chat_id=?2 AND state IN ('ambiguous','reconciling')", [this.now(), sideChatId]),
1532
+ });
1533
+ return this.mustOwned(sideChatId, actor);
1534
+ }
1535
+ if (!prepared.callable)
1536
+ throw conflict("side chat lifecycle operation already in progress");
1537
+ const claim = this.store.db.run(
1538
+ "UPDATE side_chats SET lifecycle_operation=?1,updated_at=?2 WHERE side_chat_id=?3 AND active_attempt_id IS NULL AND lifecycle_operation IS NULL",
1539
+ [action, this.now(), sideChatId],
1540
+ );
1541
+ if (claim.changes === 0) {
1542
+ const current = this.mustOwned(sideChatId, actor);
1543
+ if (current.state === "purged") return current;
1544
+ if (action === "archive" && current.state === "archived")
1545
+ return current;
1546
+ this.markAmbiguous(sideChatId, undefined, prepared.operationId, {
1547
+ code: "SIDE_THREAD_RESPONSE_LOST",
1548
+ });
1549
+ throw ambiguousError(prepared.operationId, sideChatId);
1550
+ }
1551
+ try {
1552
+ if (action === "archive")
1553
+ await this.port.archive({
1554
+ operationId: prepared.operationId,
1555
+ sideChatId,
1556
+ nodeId: r.nodeId,
1557
+ threadId: r.threadId,
1558
+ });
1559
+ else
1560
+ await this.port.purge({
1561
+ operationId: prepared.operationId,
1562
+ sideChatId,
1563
+ nodeId: r.nodeId,
1564
+ threadId: r.threadId,
1565
+ });
1566
+ } catch (error) {
1567
+ if (isAmbiguousRuntimeError(error)) {
1568
+ this.markAmbiguous(
1569
+ sideChatId,
1570
+ undefined,
1571
+ prepared.operationId,
1572
+ error,
1573
+ );
1574
+ throw ambiguousError(prepared.operationId, sideChatId);
1575
+ }
1576
+ this.settleOperation(prepared.operationId, "failed", {
1577
+ errorCode: auditErrorReason(error),
1578
+ });
1579
+ this.store.db.run(
1580
+ "UPDATE side_chats SET lifecycle_operation=NULL,updated_at=?1 WHERE side_chat_id=?2 AND lifecycle_operation=?3",
1581
+ [this.now(), sideChatId, action],
1582
+ );
1583
+ throw normalizePortError(error);
1584
+ }
1585
+ const state = action === "archive" ? "archived" : "purged",
1586
+ t = this.now();
1587
+ this.store.db.transaction(() => {
1588
+ if (action === "purge") {
1589
+ this.store.db.run(
1590
+ "UPDATE side_chats SET state='purged',question_text='',attachments_json='[]',lifecycle_operation=NULL,updated_at=?1,purged_at=?1 WHERE side_chat_id=?2 AND active_attempt_id IS NULL AND lifecycle_operation='purge'",
1591
+ [t, sideChatId],
1592
+ );
1593
+ this.store.db.run(
1594
+ "UPDATE side_chat_attempts SET result_text=NULL,error_text=NULL,attachments_json='[]',updated_at=?1 WHERE side_chat_id=?2",
1595
+ [t, sideChatId],
1596
+ );
1597
+ // Purge removes every persisted free-form content field. Keep only
1598
+ // receipt/operation identities and allow-listed audit codes so a
1599
+ // replay can remain idempotent without retaining user content.
1600
+ this.store.db.run(
1601
+ "UPDATE side_chat_bring_backs SET error_text=NULL,updated_at=?1 WHERE side_chat_id=?2",
1602
+ [t, sideChatId],
1603
+ );
1604
+ } else {
1605
+ this.store.db.run(
1606
+ "UPDATE side_chats SET state='archived',lifecycle_operation=NULL,updated_at=?1,archived_at=?1 WHERE side_chat_id=?2 AND active_attempt_id IS NULL AND lifecycle_operation='archive'",
1607
+ [t, sideChatId],
1608
+ );
1609
+ }
1610
+ this.settleOperation(prepared.operationId, "completed", {
1611
+ threadId: r.threadId,
1612
+ });
1613
+ this.emitStored(
1614
+ sideChatId,
1615
+ undefined,
1616
+ r.threadId,
1617
+ undefined,
1618
+ `side_chat.${state}`,
1619
+ state,
1620
+ undefined,
1621
+ t,
1622
+ );
1623
+ });
1624
+ return this.mustOwned(sideChatId, actor);
1625
+ },
1626
+ );
1627
+ }
1628
+
1629
+ async bringBack(
1630
+ actor: SideThreadActor,
1631
+ sideChatId: string,
1632
+ input: {
1633
+ requestKey: string;
1634
+ destinationThreadId: string;
1635
+ attemptId?: string;
1636
+ },
1637
+ ): Promise<{ bringBackId: string; destinationTurnId: string }> {
1638
+ this.assertEnabled();
1639
+ requireKey(input.requestKey);
1640
+ requireIdentity(input.destinationThreadId, "destinationThreadId");
1641
+ return this.singleFlight(
1642
+ `bring-back:${sideChatId}:${input.requestKey}`,
1643
+ async () => {
1644
+ const r = this.mustOwned(sideChatId, actor);
1645
+ if (r.state === "purged")
1646
+ throw conflict("cannot bring back a purged side chat");
1647
+ if (input.destinationThreadId !== r.sourceThreadId)
1648
+ throw conflict(
1649
+ "bring-back destination must be the original source thread",
1650
+ );
1651
+ const attempt = input.attemptId
1652
+ ? r.attempts.find((a) => a.attemptId === input.attemptId)
1653
+ : [...r.attempts].reverse().find((a) => a.state === "completed");
1654
+ if (
1655
+ !attempt ||
1656
+ attempt.state !== "completed" ||
1657
+ !attempt.result ||
1658
+ !attempt.threadId ||
1659
+ !attempt.turnId
1660
+ )
1661
+ throw conflict("bring-back requires a completed owned attempt");
1662
+ const fp = hashJson([
1663
+ sideChatId,
1664
+ attempt.attemptId,
1665
+ input.destinationThreadId,
1666
+ ]);
1667
+ const old = this.store.db.get<any>(
1668
+ "SELECT bring_back_id,destination_turn_id,request_fingerprint,state FROM side_chat_bring_backs WHERE side_chat_id=?1 AND request_key=?2",
1669
+ sideChatId,
1670
+ input.requestKey,
1671
+ );
1672
+ if (old) {
1673
+ if (old.request_fingerprint !== fp)
1674
+ throw conflict(
1675
+ "idempotency key reused with different bring-back payload",
1676
+ );
1677
+ if (old.state !== "completed" || !old.destination_turn_id) {
1678
+ const operation = this.store.db.get<{
1679
+ operation_id: string;
1680
+ state: string;
1681
+ error_code: string | null;
1682
+ }>(
1683
+ "SELECT operation_id,state,error_code FROM side_chat_operations WHERE side_chat_id=?1 AND operation_kind='bring-back' AND request_key=?2",
1684
+ sideChatId,
1685
+ input.requestKey,
1686
+ );
1687
+ if (operation && (operation.state === "ambiguous" || operation.state === "reconciling")) {
1688
+ const destinationTurnId = await this.reconcileLifecycleOperation({
1689
+ sideChatId, attemptId: attempt.attemptId, operationId: operation.operation_id,
1690
+ invoke: async () => (await this.port.bringBack({ operationId: operation.operation_id, sideChatId, attemptId: attempt.attemptId, nodeId: r.nodeId, sourceThreadId: attempt.threadId!, sourceTurnId: attempt.turnId!, destinationThreadId: input.destinationThreadId, requestKey: input.requestKey, text: attempt.result! })).destinationTurnId,
1691
+ receipt: (turn) => ({ threadId: input.destinationThreadId, turnId: turn }),
1692
+ accepted: (turn) => {
1693
+ requireRuntimeIdentity(turn, "destinationTurnId");
1694
+ const now = this.now();
1695
+ this.store.db.run("UPDATE side_chat_bring_backs SET state='completed',destination_turn_id=?1,error_text=NULL,updated_at=?2,completed_at=?2 WHERE bring_back_id=?3 AND state IN ('starting','ambiguous','reconciling')", [turn, now, old.bring_back_id]);
1696
+ this.store.db.run("UPDATE side_chats SET state='completed',updated_at=?1 WHERE side_chat_id=?2 AND state IN ('ambiguous','reconciling')", [now, sideChatId]);
1697
+ this.emitStored(sideChatId, attempt.attemptId, attempt.threadId, attempt.turnId, "side_chat.brought_back", "completed", undefined, now);
1698
+ },
1699
+ failed: (error) => {
1700
+ const now = this.now();
1701
+ this.store.db.run("UPDATE side_chat_bring_backs SET state='failed',error_text=?1,updated_at=?2 WHERE bring_back_id=?3 AND state IN ('starting','ambiguous','reconciling')", [safeReason(error instanceof Error ? error.message : String(error)), now, old.bring_back_id]);
1702
+ this.store.db.run("UPDATE side_chats SET state='completed',updated_at=?1 WHERE side_chat_id=?2 AND state IN ('ambiguous','reconciling')", [now, sideChatId]);
1703
+ },
1704
+ });
1705
+ return { bringBackId: old.bring_back_id, destinationTurnId };
1706
+ }
1707
+ if (operation?.state === "failed") throw persistedOperationFailure(operation.error_code);
1708
+ throw conflict("bring-back is already in progress or failed");
1709
+ }
1710
+ return {
1711
+ bringBackId: old.bring_back_id,
1712
+ destinationTurnId: old.destination_turn_id,
1713
+ };
1714
+ }
1715
+ const priorDestination = this.store.db.get<any>(
1716
+ "SELECT bring_back_id,destination_turn_id,state FROM side_chat_bring_backs WHERE side_chat_id=?1 AND attempt_id=?2 AND destination_thread_id=?3",
1717
+ sideChatId,
1718
+ attempt.attemptId,
1719
+ input.destinationThreadId,
1720
+ );
1721
+ if (priorDestination) {
1722
+ if (
1723
+ priorDestination.state === "completed" &&
1724
+ priorDestination.destination_turn_id
1725
+ )
1726
+ return {
1727
+ bringBackId: priorDestination.bring_back_id,
1728
+ destinationTurnId: priorDestination.destination_turn_id,
1729
+ };
1730
+ throw conflict(
1731
+ "bring-back for this attempt is already in progress or failed",
1732
+ );
1733
+ }
1734
+ await this.assertCapability(r.nodeId);
1735
+ const prepared = this.prepareStableOperation({
1736
+ sideChatId,
1737
+ attemptId: attempt.attemptId,
1738
+ kind: "bring-back",
1739
+ requestKey: input.requestKey,
1740
+ fingerprint: fp,
1741
+ threadId: attempt.threadId,
1742
+ turnId: attempt.turnId,
1743
+ });
1744
+ if (prepared.state === "completed") {
1745
+ const completed = this.store.db.get<any>(
1746
+ "SELECT bring_back_id,destination_turn_id FROM side_chat_bring_backs WHERE side_chat_id=?1 AND request_key=?2 AND state='completed'",
1747
+ sideChatId,
1748
+ input.requestKey,
1749
+ );
1750
+ if (completed?.destination_turn_id)
1751
+ return {
1752
+ bringBackId: completed.bring_back_id,
1753
+ destinationTurnId: completed.destination_turn_id,
1754
+ };
1755
+ }
1756
+ if (prepared.state === "ambiguous" || prepared.state === "reconciling")
1757
+ throw ambiguousError(
1758
+ prepared.operationId,
1759
+ sideChatId,
1760
+ attempt.attemptId,
1761
+ );
1762
+ if (!prepared.callable)
1763
+ throw conflict("bring-back operation is already in progress");
1764
+ const bringBackId = this.id("sbb"),
1765
+ t = this.now();
1766
+ try {
1767
+ this.store.db.run(
1768
+ "INSERT INTO side_chat_bring_backs (bring_back_id,side_chat_id,attempt_id,owner_user_id,request_key,request_fingerprint,destination_thread_id,state,created_at,updated_at) VALUES (?1,?2,?3,?4,?5,?6,?7,'starting',?8,?8)",
1769
+ [
1770
+ bringBackId,
1771
+ sideChatId,
1772
+ attempt.attemptId,
1773
+ actor.userId,
1774
+ input.requestKey,
1775
+ fp,
1776
+ input.destinationThreadId,
1777
+ t,
1778
+ ],
1779
+ );
1780
+ } catch (error) {
1781
+ const winner = this.store.db.get<any>(
1782
+ "SELECT bring_back_id,destination_turn_id,state,request_key,request_fingerprint FROM side_chat_bring_backs WHERE side_chat_id=?1 AND (request_key=?2 OR (attempt_id=?3 AND destination_thread_id=?4)) LIMIT 1",
1783
+ sideChatId,
1784
+ input.requestKey,
1785
+ attempt.attemptId,
1786
+ input.destinationThreadId,
1787
+ );
1788
+ if (
1789
+ winner?.request_key === input.requestKey &&
1790
+ winner.request_fingerprint !== fp
1791
+ )
1792
+ throw conflict(
1793
+ "idempotency key reused with different bring-back payload",
1794
+ );
1795
+ if (winner?.state === "completed" && winner.destination_turn_id)
1796
+ return {
1797
+ bringBackId: winner.bring_back_id,
1798
+ destinationTurnId: winner.destination_turn_id,
1799
+ };
1800
+ if (winner)
1801
+ throw conflict(
1802
+ "bring-back for this attempt is already in progress or failed",
1803
+ );
1804
+ throw error;
1805
+ }
1806
+ try {
1807
+ const out = await this.port.bringBack({
1808
+ operationId: prepared.operationId,
1809
+ sideChatId,
1810
+ attemptId: attempt.attemptId,
1811
+ nodeId: r.nodeId,
1812
+ sourceThreadId: attempt.threadId,
1813
+ sourceTurnId: attempt.turnId,
1814
+ destinationThreadId: input.destinationThreadId,
1815
+ requestKey: input.requestKey,
1816
+ text: attempt.result,
1817
+ });
1818
+ requireRuntimeIdentity(out.destinationTurnId, "destinationTurnId");
1819
+ const now = this.now();
1820
+ this.store.db.transaction(() => {
1821
+ this.store.db.run(
1822
+ "UPDATE side_chat_bring_backs SET state='completed',destination_turn_id=?1,updated_at=?2,completed_at=?2 WHERE bring_back_id=?3 AND state='starting'",
1823
+ [out.destinationTurnId, now, bringBackId],
1824
+ );
1825
+ this.settleOperation(prepared.operationId, "completed", {
1826
+ threadId: input.destinationThreadId,
1827
+ turnId: out.destinationTurnId,
1828
+ });
1829
+ this.emitStored(
1830
+ sideChatId,
1831
+ attempt.attemptId,
1832
+ attempt.threadId,
1833
+ attempt.turnId,
1834
+ "side_chat.brought_back",
1835
+ "completed",
1836
+ undefined,
1837
+ now,
1838
+ );
1839
+ });
1840
+ return { bringBackId, destinationTurnId: out.destinationTurnId };
1841
+ } catch (error) {
1842
+ const operation = this.store.db.get<{ operation_id: string }>(
1843
+ "SELECT operation_id FROM side_chat_operations WHERE side_chat_id=?1 AND operation_kind='bring-back' AND request_key=?2",
1844
+ sideChatId,
1845
+ input.requestKey,
1846
+ );
1847
+ if (operation && isAmbiguousRuntimeError(error)) {
1848
+ this.markAmbiguous(
1849
+ sideChatId,
1850
+ attempt.attemptId,
1851
+ operation.operation_id,
1852
+ error,
1853
+ );
1854
+ throw ambiguousError(
1855
+ operation.operation_id,
1856
+ sideChatId,
1857
+ attempt.attemptId,
1858
+ );
1859
+ }
1860
+ if (operation)
1861
+ this.settleOperation(operation.operation_id, "failed", {
1862
+ errorCode: auditErrorReason(error),
1863
+ });
1864
+ this.store.db.run(
1865
+ "UPDATE side_chat_bring_backs SET state='failed',error_text=?1,updated_at=?2 WHERE bring_back_id=?3 AND state='starting'",
1866
+ [
1867
+ safeReason(
1868
+ error instanceof Error ? error.message : String(error),
1869
+ ),
1870
+ this.now(),
1871
+ bringBackId,
1872
+ ],
1873
+ );
1874
+ throw normalizePortError(error);
1875
+ }
1876
+ },
1877
+ );
1878
+ }
1879
+
1880
+ private applyRuntimeReceipt(apply: () => void): void {
1881
+ try {
1882
+ this.store.db.transaction(apply);
1883
+ } catch {
1884
+ // The runtime receipt remains authoritative and durable; only the local
1885
+ // projection failed. Callers mark the stable operation ambiguous so a
1886
+ // restart can consume the same receipt without another native command.
1887
+ throw new SideThreadError(
1888
+ "SIDE_THREAD_AMBIGUOUS",
1889
+ "runtime receipt accepted; local apply requires reconciliation",
1890
+ 202,
1891
+ );
1892
+ }
1893
+ }
1894
+
1895
+ private async reconcileLifecycleOperation<T>(input: {
1896
+ sideChatId: string;
1897
+ attemptId?: string;
1898
+ operationId: string;
1899
+ invoke: () => Promise<T>;
1900
+ receipt: (value: T) => { threadId?: string; turnId?: string };
1901
+ accepted: (value: T) => void;
1902
+ failed: (error: unknown) => void;
1903
+ }): Promise<T> {
1904
+ this.store.db.run(
1905
+ "UPDATE side_chat_operations SET state='reconciling',updated_at=?1 WHERE operation_id=?2 AND state='ambiguous'",
1906
+ [this.now(), input.operationId],
1907
+ );
1908
+ let value: T;
1909
+ try {
1910
+ value = await input.invoke();
1911
+ } catch (error) {
1912
+ if (isAmbiguousRuntimeError(error)) {
1913
+ this.markAmbiguous(input.sideChatId, input.attemptId, input.operationId, error);
1914
+ throw ambiguousError(input.operationId, input.sideChatId, input.attemptId);
1915
+ }
1916
+ try {
1917
+ this.store.db.transaction(() => {
1918
+ const won = this.store.db.run(
1919
+ "UPDATE side_chat_operations SET state='failed',error_code=?1,updated_at=?2 WHERE operation_id=?3 AND state IN ('ambiguous','reconciling')",
1920
+ [auditErrorReason(error), this.now(), input.operationId],
1921
+ );
1922
+ if (won.changes === 1) input.failed(error);
1923
+ });
1924
+ } catch {
1925
+ this.markAmbiguous(input.sideChatId, input.attemptId, input.operationId, { code: "SIDE_THREAD_LOCAL_APPLY_FAILED" });
1926
+ throw ambiguousError(input.operationId, input.sideChatId, input.attemptId);
1927
+ }
1928
+ throw normalizePortError(error);
1929
+ }
1930
+ try {
1931
+ this.store.db.transaction(() => {
1932
+ const receipt = input.receipt(value);
1933
+ const won = this.store.db.run(
1934
+ "UPDATE side_chat_operations SET state='completed',thread_id=COALESCE(?1,thread_id),turn_id=COALESCE(?2,turn_id),error_code=NULL,updated_at=?3 WHERE operation_id=?4 AND state IN ('ambiguous','reconciling')",
1935
+ [receipt.threadId ?? null, receipt.turnId ?? null, this.now(), input.operationId],
1936
+ );
1937
+ if (won.changes === 1) input.accepted(value);
1938
+ });
1939
+ return value;
1940
+ } catch {
1941
+ this.markAmbiguous(input.sideChatId, input.attemptId, input.operationId, { code: "SIDE_THREAD_LOCAL_APPLY_FAILED" });
1942
+ throw ambiguousError(input.operationId, input.sideChatId, input.attemptId);
1943
+ }
1944
+ }
1945
+
1946
+ private applyLifecycleAccepted(sideChatId: string, action: "archive" | "purge", operationId: string, threadId: string): void {
1947
+ const t = this.now();
1948
+ if (action === "purge") {
1949
+ this.store.db.run("UPDATE side_chats SET state='purged',question_text='',attachments_json='[]',lifecycle_operation=NULL,updated_at=?1,purged_at=?1 WHERE side_chat_id=?2 AND active_attempt_id IS NULL", [t, sideChatId]);
1950
+ this.store.db.run("UPDATE side_chat_attempts SET result_text=NULL,error_text=NULL,attachments_json='[]',updated_at=?1 WHERE side_chat_id=?2", [t, sideChatId]);
1951
+ this.store.db.run("UPDATE side_chat_bring_backs SET error_text=NULL,updated_at=?1 WHERE side_chat_id=?2", [t, sideChatId]);
1952
+ } else {
1953
+ this.store.db.run("UPDATE side_chats SET state='archived',lifecycle_operation=NULL,updated_at=?1,archived_at=?1 WHERE side_chat_id=?2 AND active_attempt_id IS NULL", [t, sideChatId]);
1954
+ }
1955
+ this.emitStored(sideChatId, undefined, threadId, undefined, `side_chat.${action === "archive" ? "archived" : "purged"}`, action === "archive" ? "archived" : "purged", undefined, t);
1956
+ }
1957
+
1958
+ listEvents(
1959
+ actor: SideThreadActor,
1960
+ sideChatId: string,
1961
+ after = 0,
1962
+ ): SideThreadEventRecord[] {
1963
+ this.mustOwned(sideChatId, actor);
1964
+ return this.store.events(sideChatId, after);
1965
+ }
1966
+ subscribe(
1967
+ sideChatId: string,
1968
+ listener: (event: SideThreadEventRecord) => void,
1969
+ ): () => void {
1970
+ const key = `side:${sideChatId}`;
1971
+ this.emitter.on(key, listener);
1972
+ return () => this.emitter.off(key, listener);
1973
+ }
1974
+
1975
+ private insertOperation(input: {
1976
+ operationId: string;
1977
+ sideChatId: string;
1978
+ attemptId?: string;
1979
+ kind: SideThreadOperationRecord["kind"];
1980
+ requestKey: string;
1981
+ fingerprint: string;
1982
+ threadId?: string;
1983
+ turnId?: string;
1984
+ at: number;
1985
+ }): void {
1986
+ this.store.db.run(
1987
+ "INSERT INTO side_chat_operations (operation_id,side_chat_id,attempt_id,operation_kind,request_key,request_fingerprint,state,thread_id,turn_id,created_at,updated_at) VALUES (?1,?2,?3,?4,?5,?6,'pending',?7,?8,?9,?9)",
1988
+ [
1989
+ input.operationId,
1990
+ input.sideChatId,
1991
+ input.attemptId ?? null,
1992
+ input.kind,
1993
+ input.requestKey,
1994
+ input.fingerprint,
1995
+ input.threadId ?? null,
1996
+ input.turnId ?? null,
1997
+ input.at,
1998
+ ],
1999
+ );
2000
+ }
2001
+
2002
+ private prepareStableOperation(input: {
2003
+ sideChatId: string;
2004
+ attemptId?: string;
2005
+ kind: SideThreadOperationRecord["kind"];
2006
+ requestKey: string;
2007
+ fingerprint: string;
2008
+ threadId?: string;
2009
+ turnId?: string;
2010
+ }): {
2011
+ operationId: string;
2012
+ state: SideThreadOperationRecord["state"];
2013
+ callable: boolean;
2014
+ } {
2015
+ const operationId = stableOperationId(
2016
+ input.sideChatId,
2017
+ input.kind,
2018
+ input.requestKey,
2019
+ );
2020
+ const existing = this.store.db.get<any>(
2021
+ "SELECT operation_id,request_fingerprint,state FROM side_chat_operations WHERE side_chat_id=?1 AND operation_kind=?2 AND request_key=?3",
2022
+ input.sideChatId,
2023
+ input.kind,
2024
+ input.requestKey,
2025
+ );
2026
+ if (existing) {
2027
+ if (existing.request_fingerprint !== input.fingerprint)
2028
+ throw conflict("operation request key reused with different payload");
2029
+ if (existing.state === "failed") {
2030
+ this.store.db.run(
2031
+ "UPDATE side_chat_operations SET state='pending',error_code=NULL,updated_at=?1 WHERE operation_id=?2 AND state='failed'",
2032
+ [this.now(), existing.operation_id],
2033
+ );
2034
+ return {
2035
+ operationId: existing.operation_id,
2036
+ state: "pending",
2037
+ callable: true,
2038
+ };
2039
+ }
2040
+ return {
2041
+ operationId: existing.operation_id,
2042
+ state: existing.state,
2043
+ callable: false,
2044
+ };
2045
+ }
2046
+ this.insertOperation({
2047
+ operationId,
2048
+ sideChatId: input.sideChatId,
2049
+ attemptId: input.attemptId,
2050
+ kind: input.kind,
2051
+ requestKey: input.requestKey,
2052
+ fingerprint: input.fingerprint,
2053
+ threadId: input.threadId,
2054
+ turnId: input.turnId,
2055
+ at: this.now(),
2056
+ });
2057
+ return { operationId, state: "pending", callable: true };
2058
+ }
2059
+
2060
+ private settleOperation(
2061
+ operationId: string,
2062
+ state: "completed" | "failed",
2063
+ fields: { threadId?: string; turnId?: string; errorCode?: string } = {},
2064
+ ): void {
2065
+ this.store.db.run(
2066
+ "UPDATE side_chat_operations SET state=?1,thread_id=COALESCE(?2,thread_id),turn_id=COALESCE(?3,turn_id),error_code=?4,updated_at=?5 WHERE operation_id=?6 AND state IN ('pending','ambiguous','reconciling')",
2067
+ [
2068
+ state,
2069
+ fields.threadId ?? null,
2070
+ fields.turnId ?? null,
2071
+ fields.errorCode ?? null,
2072
+ this.now(),
2073
+ operationId,
2074
+ ],
2075
+ );
2076
+ }
2077
+
2078
+ private operationState(operationId: string): string | undefined {
2079
+ return this.store.db.get<{ state: string }>(
2080
+ "SELECT state FROM side_chat_operations WHERE operation_id=?1",
2081
+ operationId,
2082
+ )?.state;
2083
+ }
2084
+
2085
+ private markAmbiguous(
2086
+ sideChatId: string,
2087
+ attemptId: string | undefined,
2088
+ operationId: string,
2089
+ error: unknown,
2090
+ ): void {
2091
+ const at = this.now();
2092
+ this.store.db.transaction(() => {
2093
+ this.store.db.run(
2094
+ "UPDATE side_chat_operations SET state='ambiguous',error_code=?1,updated_at=?2 WHERE operation_id=?3 AND state IN ('pending','reconciling')",
2095
+ [auditErrorReason(error), at, operationId],
2096
+ );
2097
+ if (attemptId)
2098
+ this.store.db.run(
2099
+ "UPDATE side_chat_attempts SET state='ambiguous',updated_at=?1 WHERE attempt_id=?2 AND state IN ('starting','running','reconciling')",
2100
+ [at, attemptId],
2101
+ );
2102
+ this.store.db.run(
2103
+ "UPDATE side_chats SET state='ambiguous',updated_at=?1 WHERE side_chat_id=?2 AND state != 'purged'",
2104
+ [at, sideChatId],
2105
+ );
2106
+ this.emitStored(
2107
+ sideChatId,
2108
+ attemptId,
2109
+ undefined,
2110
+ undefined,
2111
+ "operation.ambiguous",
2112
+ "ambiguous",
2113
+ "SIDE_THREAD_AMBIGUOUS",
2114
+ at,
2115
+ );
2116
+ });
2117
+ }
2118
+
2119
+ private async acceptRuntimeEvent(
2120
+ event: SideThreadRuntimeEvent,
2121
+ ): Promise<void> {
2122
+ try {
2123
+ requireIdentity(event.sideChatId, "event.sideChatId");
2124
+ requireIdentity(event.attemptId, "event.attemptId");
2125
+ requireIdentity(event.threadId, "event.threadId");
2126
+ requireIdentity(event.turnId, "event.turnId");
2127
+ } catch {
2128
+ // Protocol-invalid runtime events are untrusted input and are ignored.
2129
+ return;
2130
+ }
2131
+ // Database/internal failures must reject the transport apply. Swallowing
2132
+ // them would let the durable terminal receipt advance to applied while the
2133
+ // SideThread record remained running forever.
2134
+ this.store.db.transaction(() => {
2135
+ const owned = this.store.db.get<any>(
2136
+ `SELECT c.active_attempt_id,c.derived_thread_id,a.state,a.turn_id FROM side_chats c JOIN side_chat_attempts a ON a.side_chat_id=c.side_chat_id AND a.attempt_id=?2 WHERE c.side_chat_id=?1`,
2137
+ event.sideChatId,
2138
+ event.attemptId,
2139
+ );
2140
+ const starting =
2141
+ (owned?.state === "starting" ||
2142
+ owned?.state === "ambiguous" ||
2143
+ owned?.state === "reconciling") &&
2144
+ !owned?.turn_id;
2145
+ if (!owned) return;
2146
+ if (
2147
+ owned.active_attempt_id !== event.attemptId ||
2148
+ owned.derived_thread_id !== event.threadId ||
2149
+ (!starting && owned.turn_id !== event.turnId) ||
2150
+ !(
2151
+ owned.state === "running" ||
2152
+ owned.state === "ambiguous" ||
2153
+ owned.state === "reconciling" ||
2154
+ starting
2155
+ )
2156
+ ) {
2157
+ this.emitStored(
2158
+ event.sideChatId,
2159
+ event.attemptId,
2160
+ event.threadId,
2161
+ event.turnId,
2162
+ "runtime_event.dropped",
2163
+ undefined,
2164
+ "ownership-mismatch",
2165
+ this.now(),
2166
+ );
2167
+ return;
2168
+ }
2169
+ const state =
2170
+ event.status === "completed"
2171
+ ? "completed"
2172
+ : event.status === "interrupted"
2173
+ ? "cancelled"
2174
+ : "failed";
2175
+ const t = this.now();
2176
+ const startOperation = this.store.db.get<{ operation_id: string }>(
2177
+ "SELECT operation_id FROM side_chat_operations WHERE side_chat_id=?1 AND attempt_id=?2 AND operation_kind='start' ORDER BY created_at DESC LIMIT 1",
2178
+ event.sideChatId,
2179
+ event.attemptId,
2180
+ );
2181
+ if (startOperation)
2182
+ this.settleOperation(startOperation.operation_id, "completed", {
2183
+ threadId: event.threadId,
2184
+ turnId: event.turnId,
2185
+ });
2186
+ this.store.db.run(
2187
+ "UPDATE side_chat_attempts SET state=?1,turn_id=COALESCE(turn_id,?2),result_text=?3,error_text=?4,updated_at=?5 WHERE attempt_id=?6 AND state IN ('starting','running','ambiguous','reconciling')",
2188
+ [
2189
+ state,
2190
+ event.turnId,
2191
+ event.status === "completed" ? safeResult(event.text) : null,
2192
+ event.status === "failed" ? safeReason(event.error) : null,
2193
+ t,
2194
+ event.attemptId,
2195
+ ],
2196
+ );
2197
+ this.store.db.run(
2198
+ "UPDATE side_chats SET state=?1,active_attempt_id=NULL,updated_at=?2 WHERE side_chat_id=?3 AND active_attempt_id=?4",
2199
+ [state, t, event.sideChatId, event.attemptId],
2200
+ );
2201
+ this.emitStored(
2202
+ event.sideChatId,
2203
+ event.attemptId,
2204
+ event.threadId,
2205
+ event.turnId,
2206
+ "attempt.terminal",
2207
+ state,
2208
+ event.status,
2209
+ t,
2210
+ );
2211
+ });
2212
+ }
2213
+
2214
+ private failStarting(
2215
+ sideChatId: string,
2216
+ attemptId: string,
2217
+ error: unknown,
2218
+ operationId?: string,
2219
+ ): void {
2220
+ const t = this.now(),
2221
+ reason = safeReason(
2222
+ error instanceof Error ? error.message : String(error),
2223
+ );
2224
+ this.store.db.transaction(() => {
2225
+ if (operationId)
2226
+ this.settleOperation(operationId, "failed", {
2227
+ errorCode: auditErrorReason(error),
2228
+ });
2229
+ const changed = this.store.db.run(
2230
+ "UPDATE side_chat_attempts SET state='failed',error_text=?1,updated_at=?2 WHERE attempt_id=?3 AND state IN ('starting','ambiguous','reconciling')",
2231
+ [reason, t, attemptId],
2232
+ );
2233
+ if (changed.changes > 0) {
2234
+ this.store.db.run(
2235
+ "UPDATE side_chats SET state='failed',active_attempt_id=NULL,updated_at=?1 WHERE side_chat_id=?2 AND active_attempt_id=?3",
2236
+ [t, sideChatId, attemptId],
2237
+ );
2238
+ this.emitStored(
2239
+ sideChatId,
2240
+ attemptId,
2241
+ undefined,
2242
+ undefined,
2243
+ "attempt.failed",
2244
+ "failed",
2245
+ auditErrorReason(error),
2246
+ t,
2247
+ );
2248
+ }
2249
+ });
2250
+ }
2251
+ private emitStored(
2252
+ sideChatId: string,
2253
+ attemptId: string | undefined,
2254
+ threadId: string | undefined,
2255
+ turnId: string | undefined,
2256
+ type: string,
2257
+ state: string | undefined,
2258
+ reason: string | undefined,
2259
+ at: number,
2260
+ ): void {
2261
+ this.store.db.run(
2262
+ "INSERT INTO side_chat_events (side_chat_id,attempt_id,thread_id,turn_id,event_type,state,reason,created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
2263
+ [
2264
+ sideChatId,
2265
+ attemptId ?? null,
2266
+ threadId ?? null,
2267
+ turnId ?? null,
2268
+ type,
2269
+ state ?? null,
2270
+ safeReason(reason) ?? null,
2271
+ at,
2272
+ ],
2273
+ );
2274
+ const row = this.store.db.get<any>(
2275
+ "SELECT event_id FROM side_chat_events WHERE side_chat_id=?1 ORDER BY event_id DESC LIMIT 1",
2276
+ sideChatId,
2277
+ );
2278
+ const event = {
2279
+ eventId: row?.event_id ?? 0,
2280
+ sideChatId,
2281
+ attemptId,
2282
+ threadId,
2283
+ turnId,
2284
+ type,
2285
+ state,
2286
+ reason: safeReason(reason),
2287
+ createdAt: at,
2288
+ } satisfies SideThreadEventRecord;
2289
+ // Fanout is deliberately deferred until the current synchronous SQLite
2290
+ // transaction has committed. Observers are never part of durable truth:
2291
+ // one throwing listener cannot roll back state or starve another listener.
2292
+ queueMicrotask(() => {
2293
+ for (const listener of this.emitter.listeners(`side:${sideChatId}`)) {
2294
+ try {
2295
+ listener(event);
2296
+ } catch {
2297
+ // Durable side_chat_events + cursor replay are authoritative.
2298
+ }
2299
+ }
2300
+ });
2301
+ }
2302
+ private mustOwned(
2303
+ sideChatId: string,
2304
+ actor: SideThreadActor,
2305
+ ): SideThreadRecord {
2306
+ const r = this.store.getOwned(sideChatId, actor);
2307
+ if (!r || !this.opts.authorizeNode(actor, r.networkId, r.nodeId))
2308
+ throw new SideThreadError(
2309
+ "SIDE_THREAD_NOT_FOUND",
2310
+ "side chat not found",
2311
+ 404,
2312
+ );
2313
+ return r;
2314
+ }
2315
+ private async singleFlight<T>(key: string, fn: () => Promise<T>): Promise<T> {
2316
+ const prior = this.inFlight.get(key);
2317
+ if (prior) return prior as Promise<T>;
2318
+ const op = fn();
2319
+ this.inFlight.set(key, op);
2320
+ try {
2321
+ return await op;
2322
+ } finally {
2323
+ if (this.inFlight.get(key) === op) this.inFlight.delete(key);
2324
+ }
2325
+ }
2326
+ }
2327
+
2328
+ function validateCreateInput(input: CreateInput): void {
2329
+ requireKey(input.requestKey);
2330
+ requireIdentity(input.networkId, "networkId");
2331
+ requireIdentity(input.nodeId, "nodeId");
2332
+ requireIdentity(input.sourceThreadId, "sourceThreadId");
2333
+ if (input.boundary?.kind !== "through" && input.boundary?.kind !== "before")
2334
+ throw conflict("invalid exact boundary");
2335
+ requireIdentity(input.boundary.turnId, "boundary.turnId");
2336
+ requirePrompt(input.prompt);
2337
+ }
2338
+ function requireIdentity(
2339
+ value: unknown,
2340
+ label: string,
2341
+ ): asserts value is string {
2342
+ if (
2343
+ typeof value !== "string" ||
2344
+ !value ||
2345
+ value.length > 512 ||
2346
+ /[\r\n\0]/.test(value)
2347
+ )
2348
+ throw conflict(`invalid ${label}`);
2349
+ }
2350
+ function requireRuntimeIdentity(
2351
+ value: unknown,
2352
+ label: string,
2353
+ ): asserts value is string {
2354
+ if (
2355
+ typeof value !== "string" ||
2356
+ !value ||
2357
+ value.length > 512 ||
2358
+ /[\r\n\0]/.test(value)
2359
+ )
2360
+ throw runtimeProtocol(`runtime returned invalid ${label}`);
2361
+ }
2362
+ function requireKey(value: unknown): asserts value is string {
2363
+ if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{8,160}$/.test(value))
2364
+ throw conflict("invalid idempotency key");
2365
+ }
2366
+ function requirePrompt(value: unknown): asserts value is string {
2367
+ if (typeof value !== "string" || !value.trim() || value.length > 100_000)
2368
+ throw conflict("invalid prompt");
2369
+ }
2370
+ function normalizeAttachments(value: unknown): SideThreadAttachmentRef[] {
2371
+ if (value === undefined) return [];
2372
+ if (!Array.isArray(value) || value.length > 16)
2373
+ throw conflict("invalid attachments");
2374
+ const seen = new Set<string>();
2375
+ return value.map((v) => {
2376
+ if (
2377
+ !v ||
2378
+ typeof v !== "object" ||
2379
+ Object.keys(v as object).some((k) => k !== "fileId") ||
2380
+ typeof (v as any).fileId !== "string" ||
2381
+ !/^[A-Za-z0-9_-]{8,64}$/.test((v as any).fileId)
2382
+ )
2383
+ throw conflict("attachments must contain fileId references only");
2384
+ const fileId = (v as any).fileId;
2385
+ if (seen.has(fileId)) throw conflict("duplicate attachment reference");
2386
+ seen.add(fileId);
2387
+ return { fileId };
2388
+ });
2389
+ }
2390
+ function parseAttachmentJson(value: string): SideThreadAttachmentRef[] {
2391
+ try {
2392
+ const parsed = JSON.parse(value);
2393
+ if (!Array.isArray(parsed)) return [];
2394
+ return parsed
2395
+ .filter(
2396
+ (entry) =>
2397
+ entry &&
2398
+ typeof entry === "object" &&
2399
+ typeof entry.fileId === "string" &&
2400
+ /^[A-Za-z0-9_-]{8,64}$/.test(entry.fileId),
2401
+ )
2402
+ .map((entry) => ({ fileId: entry.fileId }));
2403
+ } catch {
2404
+ return [];
2405
+ }
2406
+ }
2407
+ function hashText(value: string): string {
2408
+ return createHash("sha256").update(value).digest("hex");
2409
+ }
2410
+ function hashJson(value: unknown): string {
2411
+ return hashText(JSON.stringify(value));
2412
+ }
2413
+ function stableOperationId(
2414
+ sideChatId: string,
2415
+ kind: SideThreadOperationRecord["kind"],
2416
+ requestKey: string,
2417
+ ): string {
2418
+ return `sop_${hashJson([sideChatId, kind, requestKey]).slice(0, 40)}`;
2419
+ }
2420
+ function safeReason(value: unknown): string | undefined {
2421
+ if (typeof value !== "string") return undefined;
2422
+ return value
2423
+ .replace(/[\r\n\t]+/g, " ")
2424
+ .replace(
2425
+ /(?:Bearer\s+)?(?:(?:a|n|u)tok_|sk-)[A-Za-z0-9._-]+/gi,
2426
+ "[redacted]",
2427
+ )
2428
+ .slice(0, 300);
2429
+ }
2430
+ function safeResult(value: unknown): string | undefined {
2431
+ if (typeof value !== "string") return undefined;
2432
+ return value.slice(0, 1_000_000);
2433
+ }
2434
+ function capabilityReason(reason: unknown): string {
2435
+ return typeof reason === "string" && /^[a-z0-9-]{1,80}$/.test(reason)
2436
+ ? `native SideThread unavailable: ${reason}`
2437
+ : "native SideThread capability unavailable";
2438
+ }
2439
+ function capabilityReasonCode(reason: unknown): string {
2440
+ return typeof reason === "string" && /^[a-z0-9-]{1,80}$/.test(reason)
2441
+ ? reason
2442
+ : "runtime-adapter-unavailable";
2443
+ }
2444
+ function auditErrorReason(error: unknown): string {
2445
+ const code = (error as any)?.code;
2446
+ return typeof code === "string" && /^[A-Z][A-Z0-9_]{2,80}$/.test(code)
2447
+ ? code
2448
+ : "runtime-error";
2449
+ }
2450
+ function conflict(message: string): SideThreadError {
2451
+ return new SideThreadError("SIDE_THREAD_CONFLICT", message, 409);
2452
+ }
2453
+ function runtimeProtocol(message: string): SideThreadError {
2454
+ return new SideThreadError("SIDE_THREAD_AMBIGUOUS", message, 202);
2455
+ }
2456
+ function isAmbiguousRuntimeError(error: unknown): boolean {
2457
+ const code = (error as any)?.code;
2458
+ return (
2459
+ code === "SIDE_THREAD_AMBIGUOUS" || code === "SIDE_THREAD_RESPONSE_LOST"
2460
+ );
2461
+ }
2462
+ function ambiguousError(
2463
+ operationId: string,
2464
+ sideChatId: string,
2465
+ attemptId?: string,
2466
+ ): SideThreadError {
2467
+ return new SideThreadError(
2468
+ "SIDE_THREAD_AMBIGUOUS",
2469
+ "runtime acceptance is ambiguous; reconcile this stable operation before retrying",
2470
+ 202,
2471
+ operationId,
2472
+ sideChatId,
2473
+ attemptId,
2474
+ );
2475
+ }
2476
+ function normalizePortError(error: unknown): SideThreadError {
2477
+ if (error instanceof SideThreadError) return error;
2478
+ const code = (error as any)?.code;
2479
+ if (code === "SIDE_THREAD_UNSUPPORTED")
2480
+ return new SideThreadError(
2481
+ "SIDE_THREAD_UNSUPPORTED",
2482
+ "verified native SideThread adapter became unavailable",
2483
+ 501,
2484
+ );
2485
+ return new SideThreadError(
2486
+ "SIDE_THREAD_RUNTIME_ERROR",
2487
+ "SideThread runtime operation failed",
2488
+ 502,
2489
+ );
2490
+ }
2491
+ function persistedOperationFailure(code: string | null | undefined): SideThreadError {
2492
+ if (code === "SIDE_THREAD_UNSUPPORTED")
2493
+ return new SideThreadError("SIDE_THREAD_UNSUPPORTED", "runtime operation is unsupported", 501);
2494
+ return new SideThreadError(code === "SIDE_THREAD_CONFLICT" ? "SIDE_THREAD_CONFLICT" : "SIDE_THREAD_RUNTIME_ERROR", "runtime operation failed authoritatively", code === "SIDE_THREAD_CONFLICT" ? 409 : 502);
2495
+ }