negotium 0.1.6 → 0.1.8

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 (256) hide show
  1. package/README.md +2 -2
  2. package/dist/chunk-0ay6cjdy.js +1331 -0
  3. package/dist/chunk-0ay6cjdy.js.map +16 -0
  4. package/dist/chunk-1k7t7bnx.js +97 -0
  5. package/dist/chunk-1k7t7bnx.js.map +10 -0
  6. package/dist/chunk-5ahkew9c.js +806 -0
  7. package/dist/chunk-5ahkew9c.js.map +17 -0
  8. package/dist/chunk-6cpn7ht2.js +287 -0
  9. package/dist/chunk-6cpn7ht2.js.map +13 -0
  10. package/dist/chunk-6kctn20e.js +3529 -0
  11. package/dist/chunk-6kctn20e.js.map +20 -0
  12. package/dist/chunk-7xqj7aex.js +77 -0
  13. package/dist/chunk-7xqj7aex.js.map +11 -0
  14. package/dist/chunk-9p0ww3tk.js +41 -0
  15. package/dist/chunk-9p0ww3tk.js.map +10 -0
  16. package/dist/chunk-a92a7nrk.js +252 -0
  17. package/dist/chunk-a92a7nrk.js.map +10 -0
  18. package/dist/chunk-c500xe9z.js +33 -0
  19. package/dist/chunk-c500xe9z.js.map +10 -0
  20. package/dist/chunk-cds1qxsf.js +1644 -0
  21. package/dist/chunk-cds1qxsf.js.map +17 -0
  22. package/dist/chunk-e4407g8x.js +274 -0
  23. package/dist/chunk-e4407g8x.js.map +10 -0
  24. package/dist/chunk-e5pbggsh.js +363 -0
  25. package/dist/chunk-e5pbggsh.js.map +11 -0
  26. package/dist/chunk-edb07sjp.js +567 -0
  27. package/dist/chunk-edb07sjp.js.map +14 -0
  28. package/dist/chunk-exd0v74a.js +3012 -0
  29. package/dist/chunk-exd0v74a.js.map +32 -0
  30. package/dist/chunk-fgrme9tr.js +4398 -0
  31. package/dist/chunk-fgrme9tr.js.map +47 -0
  32. package/dist/chunk-fka9eqe1.js +581 -0
  33. package/dist/chunk-fka9eqe1.js.map +11 -0
  34. package/dist/chunk-g82jygez.js +108 -0
  35. package/dist/chunk-g82jygez.js.map +10 -0
  36. package/dist/chunk-ghs2j1fr.js +312 -0
  37. package/dist/chunk-ghs2j1fr.js.map +10 -0
  38. package/dist/chunk-gmy11efb.js +125 -0
  39. package/dist/chunk-gmy11efb.js.map +10 -0
  40. package/dist/chunk-gxma4c70.js +110 -0
  41. package/dist/chunk-gxma4c70.js.map +10 -0
  42. package/dist/chunk-hkdqhm34.js +61 -0
  43. package/dist/chunk-hkdqhm34.js.map +10 -0
  44. package/dist/chunk-hpa0cpzh.js +134 -0
  45. package/dist/chunk-hpa0cpzh.js.map +10 -0
  46. package/dist/chunk-hwy3n31w.js +21 -0
  47. package/dist/chunk-hwy3n31w.js.map +9 -0
  48. package/dist/chunk-j2behpy7.js +214 -0
  49. package/dist/chunk-j2behpy7.js.map +10 -0
  50. package/dist/chunk-jc0at0tc.js +102 -0
  51. package/dist/chunk-jc0at0tc.js.map +10 -0
  52. package/dist/chunk-jkqq5ef6.js +243 -0
  53. package/dist/chunk-jkqq5ef6.js.map +11 -0
  54. package/dist/chunk-jzkw2f01.js +51 -0
  55. package/dist/chunk-jzkw2f01.js.map +10 -0
  56. package/dist/chunk-jzz91jwh.js +70 -0
  57. package/dist/chunk-jzz91jwh.js.map +10 -0
  58. package/dist/chunk-k9z47mab.js +247 -0
  59. package/dist/chunk-k9z47mab.js.map +12 -0
  60. package/dist/chunk-n17yxpa8.js +23 -0
  61. package/dist/chunk-n17yxpa8.js.map +10 -0
  62. package/dist/chunk-naa09ags.js +2143 -0
  63. package/dist/chunk-naa09ags.js.map +15 -0
  64. package/dist/chunk-ns3ke88n.js +142 -0
  65. package/dist/chunk-ns3ke88n.js.map +10 -0
  66. package/dist/chunk-p3cr2ejg.js +1626 -0
  67. package/dist/chunk-p3cr2ejg.js.map +15 -0
  68. package/dist/chunk-q5bcpf23.js +2925 -0
  69. package/dist/chunk-q5bcpf23.js.map +20 -0
  70. package/dist/chunk-rafc55bs.js +424 -0
  71. package/dist/chunk-rafc55bs.js.map +11 -0
  72. package/dist/chunk-rbrxjvbj.js +2830 -0
  73. package/dist/chunk-rbrxjvbj.js.map +23 -0
  74. package/dist/chunk-sa874j5c.js +9 -0
  75. package/dist/chunk-sa874j5c.js.map +10 -0
  76. package/dist/chunk-sgsz2fpq.js +289 -0
  77. package/dist/chunk-sgsz2fpq.js.map +10 -0
  78. package/dist/chunk-sm6fh5c5.js +569 -0
  79. package/dist/chunk-sm6fh5c5.js.map +14 -0
  80. package/dist/chunk-t9zcg8f1.js +499 -0
  81. package/dist/chunk-t9zcg8f1.js.map +13 -0
  82. package/dist/chunk-vp2db42d.js +121 -0
  83. package/dist/chunk-vp2db42d.js.map +10 -0
  84. package/dist/chunk-w13jjf4a.js +46 -0
  85. package/dist/chunk-w13jjf4a.js.map +10 -0
  86. package/dist/chunk-x1arr7ze.js +63 -0
  87. package/dist/chunk-x1arr7ze.js.map +10 -0
  88. package/dist/chunk-xfq68rch.js +583 -0
  89. package/dist/chunk-xfq68rch.js.map +11 -0
  90. package/dist/main.js +132 -20
  91. package/dist/main.js.map +4 -4
  92. package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
  93. package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
  94. package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
  95. package/dist/runtime/src/agents/archiver.ts +396 -0
  96. package/dist/runtime/src/agents/auth-check.ts +64 -0
  97. package/dist/runtime/src/agents/claude-provider.ts +737 -0
  98. package/dist/runtime/src/agents/claude-registry.ts +115 -0
  99. package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
  100. package/dist/runtime/src/agents/codex-provider.ts +785 -0
  101. package/dist/runtime/src/agents/codex-registry.ts +115 -0
  102. package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
  103. package/dist/runtime/src/agents/contracts.ts +69 -0
  104. package/dist/runtime/src/agents/deep-map.ts +13 -0
  105. package/dist/runtime/src/agents/execution-host.ts +91 -0
  106. package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
  107. package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
  108. package/dist/runtime/src/agents/fork.ts +65 -0
  109. package/dist/runtime/src/agents/hosted-agent.ts +48 -0
  110. package/dist/runtime/src/agents/idle-archiver.ts +128 -0
  111. package/dist/runtime/src/agents/index.ts +205 -0
  112. package/dist/runtime/src/agents/maestro-provider.ts +206 -0
  113. package/dist/runtime/src/agents/maestro-registry.ts +31 -0
  114. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
  115. package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
  116. package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
  117. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
  118. package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
  119. package/dist/runtime/src/agents/model-catalog.ts +151 -0
  120. package/dist/runtime/src/agents/registry.ts +24 -0
  121. package/dist/runtime/src/agents/rollout/claude.ts +331 -0
  122. package/dist/runtime/src/agents/rollout/codex.ts +569 -0
  123. package/dist/runtime/src/agents/rollout/shared.ts +205 -0
  124. package/dist/runtime/src/agents/self-config-core.ts +415 -0
  125. package/dist/runtime/src/agents/task-events.ts +44 -0
  126. package/dist/runtime/src/agents/tool-format.ts +352 -0
  127. package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
  128. package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
  129. package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
  130. package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
  131. package/dist/runtime/src/application/submit-user-message.ts +47 -0
  132. package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
  133. package/dist/runtime/src/application/switch-topic-model.ts +79 -0
  134. package/dist/runtime/src/application/topic-service.ts +139 -0
  135. package/dist/runtime/src/application/vault-command.ts +170 -0
  136. package/dist/runtime/src/bus.ts +319 -0
  137. package/dist/runtime/src/index.ts +333 -0
  138. package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
  139. package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
  140. package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
  141. package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
  142. package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
  143. package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
  144. package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
  145. package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
  146. package/dist/runtime/src/mcp/runtime-spec.ts +134 -0
  147. package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
  148. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
  149. package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
  150. package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
  151. package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
  152. package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
  153. package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
  154. package/dist/runtime/src/mcp/system-health-server.ts +130 -0
  155. package/dist/runtime/src/mcp/task-server.ts +173 -0
  156. package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
  157. package/dist/runtime/src/mcp/vault-http.ts +219 -0
  158. package/dist/runtime/src/mcp/vault-run.ts +153 -0
  159. package/dist/runtime/src/mcp/vault-server.ts +104 -0
  160. package/dist/runtime/src/mcp/wiki-server.ts +755 -0
  161. package/dist/runtime/src/media/file-events.ts +33 -0
  162. package/dist/runtime/src/media/text-extractor.ts +312 -0
  163. package/dist/runtime/src/media/video.ts +92 -0
  164. package/dist/runtime/src/outbox/file-ops.ts +210 -0
  165. package/dist/runtime/src/outbox/index.ts +8 -0
  166. package/dist/runtime/src/outbox/utils.ts +51 -0
  167. package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
  168. package/dist/runtime/src/platform/config.ts +371 -0
  169. package/dist/runtime/src/platform/constants.ts +18 -0
  170. package/dist/runtime/src/platform/debug-users.ts +14 -0
  171. package/dist/runtime/src/platform/delay.ts +1 -0
  172. package/dist/runtime/src/platform/error.ts +9 -0
  173. package/dist/runtime/src/platform/file-utils.ts +16 -0
  174. package/dist/runtime/src/platform/jsonl.ts +202 -0
  175. package/dist/runtime/src/platform/lifecycle.ts +174 -0
  176. package/dist/runtime/src/platform/logger.ts +31 -0
  177. package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
  178. package/dist/runtime/src/platform/mcp-config.ts +865 -0
  179. package/dist/runtime/src/platform/modules.ts +145 -0
  180. package/dist/runtime/src/platform/node-plugins.ts +46 -0
  181. package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
  182. package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
  183. package/dist/runtime/src/prompts/builders.ts +357 -0
  184. package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
  185. package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
  186. package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
  187. package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
  188. package/dist/runtime/src/query/active-rooms.ts +532 -0
  189. package/dist/runtime/src/query/control.ts +313 -0
  190. package/dist/runtime/src/query/index.ts +24 -0
  191. package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
  192. package/dist/runtime/src/query/session-inbox-path.ts +42 -0
  193. package/dist/runtime/src/query/state.ts +94 -0
  194. package/dist/runtime/src/query/types.ts +83 -0
  195. package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
  196. package/dist/runtime/src/runtime/attachments.ts +151 -0
  197. package/dist/runtime/src/runtime/background-sessions.ts +83 -0
  198. package/dist/runtime/src/runtime/channel-context.ts +107 -0
  199. package/dist/runtime/src/runtime/errors.ts +69 -0
  200. package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
  201. package/dist/runtime/src/runtime/file-hooks.ts +81 -0
  202. package/dist/runtime/src/runtime/footer.ts +23 -0
  203. package/dist/runtime/src/runtime/inbox.ts +946 -0
  204. package/dist/runtime/src/runtime/tasks.ts +56 -0
  205. package/dist/runtime/src/runtime/topic-config.ts +29 -0
  206. package/dist/runtime/src/runtime/turn-runner.ts +2286 -0
  207. package/dist/runtime/src/runtime/usage-alert.ts +84 -0
  208. package/dist/runtime/src/runtime/visual-html.ts +109 -0
  209. package/dist/runtime/src/runtime/visual-store.ts +430 -0
  210. package/dist/runtime/src/runtime/visuals.ts +171 -0
  211. package/dist/runtime/src/security/sanitize.ts +27 -0
  212. package/dist/runtime/src/security/sensitive-path.ts +34 -0
  213. package/dist/runtime/src/storage/activity-log.ts +152 -0
  214. package/dist/runtime/src/storage/api-messages.ts +617 -0
  215. package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
  216. package/dist/runtime/src/storage/api-topic-config.ts +192 -0
  217. package/dist/runtime/src/storage/api-topics.ts +844 -0
  218. package/dist/runtime/src/storage/app-settings.ts +44 -0
  219. package/dist/runtime/src/storage/browser-profiles.ts +133 -0
  220. package/dist/runtime/src/storage/conversations.ts +268 -0
  221. package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
  222. package/dist/runtime/src/storage/forum/index.ts +4 -0
  223. package/dist/runtime/src/storage/forum/repository.ts +283 -0
  224. package/dist/runtime/src/storage/forum/schema.ts +300 -0
  225. package/dist/runtime/src/storage/forum-db.ts +23 -0
  226. package/dist/runtime/src/storage/runtime-events.ts +130 -0
  227. package/dist/runtime/src/storage/runtime-leases.ts +189 -0
  228. package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
  229. package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
  230. package/dist/runtime/src/storage/runtime-turn-requests.ts +300 -0
  231. package/dist/runtime/src/storage/self-schedules.ts +298 -0
  232. package/dist/runtime/src/storage/session-asks.ts +347 -0
  233. package/dist/runtime/src/storage/sqlite.ts +129 -0
  234. package/dist/runtime/src/storage/tasks.ts +179 -0
  235. package/dist/runtime/src/storage/token-stats.ts +144 -0
  236. package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
  237. package/dist/runtime/src/storage/topic-archive.ts +74 -0
  238. package/dist/runtime/src/storage/topic-settings.ts +67 -0
  239. package/dist/runtime/src/storage/topic-transcript.ts +98 -0
  240. package/dist/runtime/src/storage/vault-crypto.ts +65 -0
  241. package/dist/runtime/src/storage/vault.ts +225 -0
  242. package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
  243. package/dist/runtime/src/storage/wiki.ts +85 -0
  244. package/dist/runtime/src/topics/create.ts +123 -0
  245. package/dist/runtime/src/topics/derive.ts +398 -0
  246. package/dist/runtime/src/topics/lifecycle.ts +311 -0
  247. package/dist/runtime/src/topics/links.ts +7 -0
  248. package/dist/runtime/src/topics/personal-general.ts +65 -0
  249. package/dist/runtime/src/topics/session.ts +381 -0
  250. package/dist/runtime/src/types/api.ts +497 -0
  251. package/dist/runtime/src/types.ts +255 -0
  252. package/dist/runtime/src/version.ts +1 -0
  253. package/dist/runtime/tsconfig.json +13 -0
  254. package/package.json +16 -3
  255. package/dist/main.d.ts +0 -3
  256. package/dist/main.d.ts.map +0 -1
@@ -0,0 +1,130 @@
1
+ import { db } from "#storage/forum-db";
2
+
3
+ export const RUNTIME_EVENT_TYPES = [
4
+ "message",
5
+ "message-updated",
6
+ "ai-status",
7
+ "topic-created",
8
+ "topic-updated",
9
+ "topic-deleted",
10
+ ] as const;
11
+
12
+ export type StoredRuntimeEventType = (typeof RUNTIME_EVENT_TYPES)[number];
13
+
14
+ export interface StoredRuntimeEvent {
15
+ seq: number;
16
+ sourceId: string;
17
+ type: StoredRuntimeEventType;
18
+ topicId: string;
19
+ payload: unknown;
20
+ createdAt: string;
21
+ }
22
+
23
+ interface RuntimeEventRow {
24
+ seq: number | bigint;
25
+ source_id: string;
26
+ event_type: string;
27
+ topic_id: string;
28
+ payload_json: string;
29
+ created_at: string;
30
+ }
31
+
32
+ db.exec(`
33
+ CREATE TABLE IF NOT EXISTS runtime_events (
34
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ source_id TEXT NOT NULL,
36
+ event_type TEXT NOT NULL CHECK (
37
+ event_type IN (
38
+ 'message',
39
+ 'message-updated',
40
+ 'ai-status',
41
+ 'topic-created',
42
+ 'topic-updated',
43
+ 'topic-deleted'
44
+ )
45
+ ),
46
+ topic_id TEXT NOT NULL,
47
+ payload_json TEXT NOT NULL,
48
+ created_at TEXT NOT NULL
49
+ )
50
+ `);
51
+ db.exec("CREATE INDEX IF NOT EXISTS idx_runtime_events_topic_seq ON runtime_events(topic_id, seq)");
52
+
53
+ const types = new Set<string>(RUNTIME_EVENT_TYPES);
54
+
55
+ function rowToEvent(row: RuntimeEventRow): StoredRuntimeEvent | null {
56
+ if (!types.has(row.event_type)) return null;
57
+ try {
58
+ return {
59
+ seq: Number(row.seq),
60
+ sourceId: row.source_id,
61
+ type: row.event_type as StoredRuntimeEventType,
62
+ topicId: row.topic_id,
63
+ payload: JSON.parse(row.payload_json),
64
+ createdAt: row.created_at,
65
+ };
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export function latestRuntimeEventSeq(): number {
72
+ const row = db
73
+ .query<{ seq: number | bigint | null }, []>("SELECT MAX(seq) AS seq FROM runtime_events")
74
+ .get();
75
+ return Number(row?.seq ?? 0);
76
+ }
77
+
78
+ export function appendRuntimeEvent(
79
+ sourceId: string,
80
+ event: {
81
+ type: StoredRuntimeEventType;
82
+ topicId: string;
83
+ payload: unknown;
84
+ },
85
+ ): StoredRuntimeEvent {
86
+ const createdAt = new Date().toISOString();
87
+ const result = db
88
+ .query(
89
+ `INSERT INTO runtime_events
90
+ (source_id, event_type, topic_id, payload_json, created_at)
91
+ VALUES (?, ?, ?, ?, ?)`,
92
+ )
93
+ .run(sourceId, event.type, event.topicId, JSON.stringify(event.payload ?? null), createdAt);
94
+ const seq = Number(result.lastInsertRowid);
95
+ return { seq, sourceId, ...event, createdAt };
96
+ }
97
+
98
+ export function listRuntimeEventsAfter(seq: number, limit = 500): StoredRuntimeEvent[] {
99
+ const rows = db
100
+ .query<RuntimeEventRow, [number, number]>(
101
+ `SELECT seq, source_id, event_type, topic_id, payload_json, created_at
102
+ FROM runtime_events
103
+ WHERE seq > ?
104
+ ORDER BY seq ASC
105
+ LIMIT ?`,
106
+ )
107
+ .all(Math.max(0, seq), Math.max(1, Math.min(limit, 5_000)));
108
+ return rows.map(rowToEvent).filter((event): event is StoredRuntimeEvent => event !== null);
109
+ }
110
+
111
+ /** Recent topic history used to hydrate a newly opened channel surface. */
112
+ export function listRecentRuntimeEventsForTopic(
113
+ topicId: string,
114
+ limit = 300,
115
+ ): StoredRuntimeEvent[] {
116
+ const rows = db
117
+ .query<RuntimeEventRow, [string, number]>(
118
+ `SELECT seq, source_id, event_type, topic_id, payload_json, created_at
119
+ FROM (
120
+ SELECT seq, source_id, event_type, topic_id, payload_json, created_at
121
+ FROM runtime_events
122
+ WHERE topic_id = ? AND event_type IN ('ai-status', 'message-updated')
123
+ ORDER BY seq DESC
124
+ LIMIT ?
125
+ )
126
+ ORDER BY seq ASC`,
127
+ )
128
+ .all(topicId, Math.max(1, Math.min(limit, 2_000)));
129
+ return rows.map(rowToEvent).filter((event): event is StoredRuntimeEvent => event !== null);
130
+ }
@@ -0,0 +1,189 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { db } from "#storage/forum-db";
3
+ import { TOPIC_MAINTENANCE_STALE_MS } from "#storage/runtime-topic-state";
4
+
5
+ export const RUNTIME_INSTANCE_ID = `${process.pid}-${randomUUID()}`;
6
+ export const TURN_LEASE_STALE_MS = 10_000;
7
+
8
+ export interface RuntimeTurnLease {
9
+ topicId: string;
10
+ queryId: string;
11
+ ownerId: string;
12
+ origin: string;
13
+ startedAt: number;
14
+ heartbeatAt: number;
15
+ abortRequested: boolean;
16
+ abortReason?: "internal" | "external";
17
+ }
18
+
19
+ interface RuntimeTurnLeaseRow {
20
+ topic_id: string;
21
+ query_id: string;
22
+ owner_id: string;
23
+ origin: string;
24
+ started_at: number | bigint;
25
+ heartbeat_at: number | bigint;
26
+ abort_requested: number;
27
+ abort_reason: string | null;
28
+ }
29
+
30
+ db.exec(`
31
+ CREATE TABLE IF NOT EXISTS runtime_turn_leases (
32
+ topic_id TEXT PRIMARY KEY,
33
+ query_id TEXT NOT NULL UNIQUE,
34
+ owner_id TEXT NOT NULL,
35
+ origin TEXT NOT NULL,
36
+ started_at INTEGER NOT NULL,
37
+ heartbeat_at INTEGER NOT NULL,
38
+ abort_requested INTEGER NOT NULL DEFAULT 0 CHECK (abort_requested IN (0, 1)),
39
+ abort_reason TEXT CHECK (abort_reason IS NULL OR abort_reason IN ('internal', 'external'))
40
+ )
41
+ `);
42
+ db.exec(
43
+ "CREATE INDEX IF NOT EXISTS idx_runtime_turn_leases_heartbeat ON runtime_turn_leases(heartbeat_at)",
44
+ );
45
+
46
+ function rowToLease(row: RuntimeTurnLeaseRow): RuntimeTurnLease {
47
+ const abortReason =
48
+ row.abort_reason === "internal" || row.abort_reason === "external"
49
+ ? row.abort_reason
50
+ : undefined;
51
+ return {
52
+ topicId: row.topic_id,
53
+ queryId: row.query_id,
54
+ ownerId: row.owner_id,
55
+ origin: row.origin,
56
+ startedAt: Number(row.started_at),
57
+ heartbeatAt: Number(row.heartbeat_at),
58
+ abortRequested: row.abort_requested !== 0,
59
+ abortReason,
60
+ };
61
+ }
62
+
63
+ export function getRuntimeTurnLease(topicId: string, now = Date.now()): RuntimeTurnLease | null {
64
+ const row = db
65
+ .query<RuntimeTurnLeaseRow, [string]>("SELECT * FROM runtime_turn_leases WHERE topic_id = ?")
66
+ .get(topicId);
67
+ if (!row) return null;
68
+ const lease = rowToLease(row);
69
+ return now - lease.heartbeatAt <= TURN_LEASE_STALE_MS ? lease : null;
70
+ }
71
+
72
+ export function listRuntimeTurnLeases(now = Date.now()): RuntimeTurnLease[] {
73
+ return db
74
+ .query<RuntimeTurnLeaseRow, []>("SELECT * FROM runtime_turn_leases ORDER BY started_at ASC")
75
+ .all()
76
+ .map(rowToLease)
77
+ .filter((lease) => now - lease.heartbeatAt <= TURN_LEASE_STALE_MS);
78
+ }
79
+
80
+ export function claimRuntimeTurnLease(
81
+ input: {
82
+ topicId: string;
83
+ queryId: string;
84
+ origin: string;
85
+ ownerId?: string;
86
+ },
87
+ now = Date.now(),
88
+ ): boolean {
89
+ const ownerId = input.ownerId ?? RUNTIME_INSTANCE_ID;
90
+ const result = db
91
+ .query(
92
+ `INSERT INTO runtime_turn_leases
93
+ (topic_id, query_id, owner_id, origin, started_at, heartbeat_at, abort_requested, abort_reason)
94
+ SELECT ?, ?, ?, ?, ?, ?, 0, NULL
95
+ WHERE NOT EXISTS (
96
+ SELECT 1 FROM runtime_topic_state
97
+ WHERE topic_id = ? AND maintenance = 1 AND heartbeat_at >= ?
98
+ )
99
+ ON CONFLICT(topic_id) DO UPDATE SET
100
+ query_id = excluded.query_id,
101
+ owner_id = excluded.owner_id,
102
+ origin = excluded.origin,
103
+ started_at = excluded.started_at,
104
+ heartbeat_at = excluded.heartbeat_at,
105
+ abort_requested = 0,
106
+ abort_reason = NULL
107
+ WHERE (runtime_turn_leases.owner_id = excluded.owner_id
108
+ OR runtime_turn_leases.heartbeat_at < ?)
109
+ AND NOT EXISTS (
110
+ SELECT 1 FROM runtime_topic_state
111
+ WHERE topic_id = excluded.topic_id AND maintenance = 1 AND heartbeat_at >= ?
112
+ )`,
113
+ )
114
+ .run(
115
+ input.topicId,
116
+ input.queryId,
117
+ ownerId,
118
+ input.origin,
119
+ now,
120
+ now,
121
+ input.topicId,
122
+ now - TOPIC_MAINTENANCE_STALE_MS,
123
+ now - TURN_LEASE_STALE_MS,
124
+ now - TOPIC_MAINTENANCE_STALE_MS,
125
+ );
126
+ return Number(result.changes ?? 0) > 0;
127
+ }
128
+
129
+ export function heartbeatRuntimeTurnLease(
130
+ topicId: string,
131
+ queryId: string,
132
+ ownerId = RUNTIME_INSTANCE_ID,
133
+ now = Date.now(),
134
+ ): { owned: boolean; abortRequested: boolean; abortReason?: "internal" | "external" } {
135
+ const updated = db
136
+ .query(
137
+ `UPDATE runtime_turn_leases
138
+ SET heartbeat_at = ?
139
+ WHERE topic_id = ? AND query_id = ? AND owner_id = ?`,
140
+ )
141
+ .run(now, topicId, queryId, ownerId);
142
+ if (Number(updated.changes ?? 0) === 0) return { owned: false, abortRequested: false };
143
+
144
+ const row = db
145
+ .query<{ abort_requested: number; abort_reason: string | null }, [string, string, string]>(
146
+ `SELECT abort_requested, abort_reason
147
+ FROM runtime_turn_leases
148
+ WHERE topic_id = ? AND query_id = ? AND owner_id = ?`,
149
+ )
150
+ .get(topicId, queryId, ownerId);
151
+ const abortReason =
152
+ row?.abort_reason === "internal" || row?.abort_reason === "external"
153
+ ? row.abort_reason
154
+ : undefined;
155
+ return {
156
+ owned: Boolean(row),
157
+ abortRequested: row?.abort_requested !== 0,
158
+ abortReason,
159
+ };
160
+ }
161
+
162
+ export function requestRuntimeTurnAbort(topicId: string, reason: "internal" | "external"): boolean {
163
+ const result = db
164
+ .query(
165
+ `UPDATE runtime_turn_leases
166
+ SET abort_requested = 1,
167
+ abort_reason = CASE
168
+ WHEN abort_reason = 'external' THEN abort_reason
169
+ ELSE ?
170
+ END
171
+ WHERE topic_id = ? AND heartbeat_at >= ?`,
172
+ )
173
+ .run(reason, topicId, Date.now() - TURN_LEASE_STALE_MS);
174
+ return Number(result.changes ?? 0) > 0;
175
+ }
176
+
177
+ export function releaseRuntimeTurnLease(
178
+ topicId: string,
179
+ queryId: string,
180
+ ownerId = RUNTIME_INSTANCE_ID,
181
+ ): boolean {
182
+ const result = db
183
+ .query(
184
+ `DELETE FROM runtime_turn_leases
185
+ WHERE topic_id = ? AND query_id = ? AND owner_id = ?`,
186
+ )
187
+ .run(topicId, queryId, ownerId);
188
+ return Number(result.changes ?? 0) > 0;
189
+ }
@@ -0,0 +1,216 @@
1
+ import { db } from "#storage/forum-db";
2
+ import { RUNTIME_INSTANCE_ID } from "#storage/runtime-leases";
3
+
4
+ export const PROCESS_LEASE_STALE_MS = 5_000;
5
+ export const PROCESS_LEASE_HEARTBEAT_MS = 1_000;
6
+
7
+ export interface RuntimeProcessLease {
8
+ role: string;
9
+ ownerId: string;
10
+ pid: number;
11
+ startedAt: number;
12
+ heartbeatAt: number;
13
+ }
14
+
15
+ export interface RuntimeProcessLeaseHandle extends RuntimeProcessLease {
16
+ stop(): void;
17
+ }
18
+
19
+ export interface AcquireRuntimeProcessLeaseOptions {
20
+ ownerId?: string;
21
+ pid?: number;
22
+ now?: number;
23
+ staleMs?: number;
24
+ heartbeatMs?: number;
25
+ onLost?: () => void;
26
+ }
27
+
28
+ export interface WaitForRuntimeProcessLeaseOptions extends AcquireRuntimeProcessLeaseOptions {
29
+ waitMs?: number;
30
+ retryMs?: number;
31
+ }
32
+
33
+ export interface WaitForRequiredRuntimeProcessLeaseOptions
34
+ extends WaitForRuntimeProcessLeaseOptions {
35
+ workloadName?: string;
36
+ }
37
+
38
+ interface RuntimeProcessLeaseRow {
39
+ role: string;
40
+ owner_id: string;
41
+ pid: number | bigint;
42
+ started_at: number | bigint;
43
+ heartbeat_at: number | bigint;
44
+ }
45
+
46
+ db.exec(`
47
+ CREATE TABLE IF NOT EXISTS runtime_process_leases (
48
+ role TEXT PRIMARY KEY,
49
+ owner_id TEXT NOT NULL UNIQUE,
50
+ pid INTEGER NOT NULL,
51
+ started_at INTEGER NOT NULL,
52
+ heartbeat_at INTEGER NOT NULL
53
+ )
54
+ `);
55
+ db.exec(
56
+ "CREATE INDEX IF NOT EXISTS idx_runtime_process_leases_heartbeat ON runtime_process_leases(heartbeat_at)",
57
+ );
58
+
59
+ function rowToLease(row: RuntimeProcessLeaseRow): RuntimeProcessLease {
60
+ return {
61
+ role: row.role,
62
+ ownerId: row.owner_id,
63
+ pid: Number(row.pid),
64
+ startedAt: Number(row.started_at),
65
+ heartbeatAt: Number(row.heartbeat_at),
66
+ };
67
+ }
68
+
69
+ function isProcessAlive(pid: number): boolean {
70
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
71
+ try {
72
+ process.kill(pid, 0);
73
+ return true;
74
+ } catch (error) {
75
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
76
+ }
77
+ }
78
+
79
+ function removeDeadRuntimeProcessLease(role: string): void {
80
+ const row = db
81
+ .query<RuntimeProcessLeaseRow, [string]>("SELECT * FROM runtime_process_leases WHERE role = ?")
82
+ .get(role);
83
+ if (!row) return;
84
+ const lease = rowToLease(row);
85
+ if (isProcessAlive(lease.pid)) return;
86
+ db.query(
87
+ `DELETE FROM runtime_process_leases
88
+ WHERE role = ? AND owner_id = ? AND pid = ? AND heartbeat_at = ?`,
89
+ ).run(lease.role, lease.ownerId, lease.pid, lease.heartbeatAt);
90
+ }
91
+
92
+ export function getRuntimeProcessLease(
93
+ role: string,
94
+ now = Date.now(),
95
+ staleMs = PROCESS_LEASE_STALE_MS,
96
+ ): RuntimeProcessLease | null {
97
+ const row = db
98
+ .query<RuntimeProcessLeaseRow, [string]>("SELECT * FROM runtime_process_leases WHERE role = ?")
99
+ .get(role);
100
+ if (!row) return null;
101
+ const lease = rowToLease(row);
102
+ return now - lease.heartbeatAt <= staleMs ? lease : null;
103
+ }
104
+
105
+ export function heartbeatRuntimeProcessLease(
106
+ role: string,
107
+ ownerId: string,
108
+ now = Date.now(),
109
+ ): boolean {
110
+ const result = db
111
+ .query(
112
+ `UPDATE runtime_process_leases
113
+ SET heartbeat_at = ?
114
+ WHERE role = ? AND owner_id = ?`,
115
+ )
116
+ .run(now, role, ownerId);
117
+ return Number(result.changes ?? 0) > 0;
118
+ }
119
+
120
+ export function releaseRuntimeProcessLease(role: string, ownerId: string): boolean {
121
+ const result = db
122
+ .query("DELETE FROM runtime_process_leases WHERE role = ? AND owner_id = ?")
123
+ .run(role, ownerId);
124
+ return Number(result.changes ?? 0) > 0;
125
+ }
126
+
127
+ /**
128
+ * Claim one cross-process role and keep it alive with a SQLite heartbeat.
129
+ * A process that exits without cleanup is replaceable after `staleMs`.
130
+ */
131
+ export function acquireRuntimeProcessLease(
132
+ role: string,
133
+ options: AcquireRuntimeProcessLeaseOptions = {},
134
+ ): RuntimeProcessLeaseHandle | null {
135
+ const normalizedRole = role.trim();
136
+ if (!normalizedRole) throw new Error("runtime process lease role must not be empty");
137
+ removeDeadRuntimeProcessLease(normalizedRole);
138
+ const ownerId = options.ownerId ?? `${RUNTIME_INSTANCE_ID}:${normalizedRole}`;
139
+ const pid = options.pid ?? process.pid;
140
+ const now = options.now ?? Date.now();
141
+ const staleMs = options.staleMs ?? PROCESS_LEASE_STALE_MS;
142
+ const result = db
143
+ .query(
144
+ `INSERT INTO runtime_process_leases (role, owner_id, pid, started_at, heartbeat_at)
145
+ VALUES (?, ?, ?, ?, ?)
146
+ ON CONFLICT(role) DO UPDATE SET
147
+ owner_id = excluded.owner_id,
148
+ pid = excluded.pid,
149
+ started_at = excluded.started_at,
150
+ heartbeat_at = excluded.heartbeat_at
151
+ WHERE runtime_process_leases.heartbeat_at < ?`,
152
+ )
153
+ .run(normalizedRole, ownerId, pid, now, now, now - staleMs);
154
+ if (Number(result.changes ?? 0) === 0) return null;
155
+
156
+ let stopped = false;
157
+ const heartbeatMs = options.heartbeatMs ?? PROCESS_LEASE_HEARTBEAT_MS;
158
+ const timer = setInterval(() => {
159
+ if (stopped) return;
160
+ if (heartbeatRuntimeProcessLease(normalizedRole, ownerId)) return;
161
+ stopped = true;
162
+ clearInterval(timer);
163
+ options.onLost?.();
164
+ }, heartbeatMs);
165
+ timer.unref?.();
166
+
167
+ return {
168
+ role: normalizedRole,
169
+ ownerId,
170
+ pid,
171
+ startedAt: now,
172
+ heartbeatAt: now,
173
+ stop() {
174
+ if (stopped) return;
175
+ stopped = true;
176
+ clearInterval(timer);
177
+ releaseRuntimeProcessLease(normalizedRole, ownerId);
178
+ },
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Wait through a graceful handoff or stale-heartbeat window instead of making
184
+ * process supervisors burn through their restart budget on a transient clash.
185
+ */
186
+ export async function waitForRuntimeProcessLease(
187
+ role: string,
188
+ options: WaitForRuntimeProcessLeaseOptions = {},
189
+ ): Promise<RuntimeProcessLeaseHandle | null> {
190
+ const {
191
+ waitMs = PROCESS_LEASE_STALE_MS + PROCESS_LEASE_HEARTBEAT_MS,
192
+ retryMs = 100,
193
+ ...acquireOptions
194
+ } = options;
195
+ const deadline = Date.now() + Math.max(0, waitMs);
196
+ const interval = Math.max(1, retryMs);
197
+ while (true) {
198
+ const lease = acquireRuntimeProcessLease(role, acquireOptions);
199
+ if (lease) return lease;
200
+ const remaining = deadline - Date.now();
201
+ if (remaining <= 0) return null;
202
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.min(interval, remaining)));
203
+ }
204
+ }
205
+
206
+ /** Claim a process role or report the live owner in a user-facing error. */
207
+ export async function waitForRequiredRuntimeProcessLease(
208
+ role: string,
209
+ options: WaitForRequiredRuntimeProcessLeaseOptions = {},
210
+ ): Promise<RuntimeProcessLeaseHandle> {
211
+ const { workloadName = role, ...leaseOptions } = options;
212
+ const lease = await waitForRuntimeProcessLease(role, leaseOptions);
213
+ if (lease) return lease;
214
+ const current = getRuntimeProcessLease(role);
215
+ throw new Error(`${workloadName} is already running${current ? ` (pid ${current.pid})` : ""}`);
216
+ }
@@ -0,0 +1,148 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { db } from "#storage/forum-db";
3
+
4
+ export const TOPIC_MAINTENANCE_STALE_MS = 30_000;
5
+ export const TOPIC_MAINTENANCE_HEARTBEAT_MS = 1_000;
6
+
7
+ export interface RuntimeTopicState {
8
+ topicId: string;
9
+ epoch: number;
10
+ maintenance: boolean;
11
+ maintenanceOwner?: string;
12
+ heartbeatAt?: number;
13
+ }
14
+
15
+ export interface RuntimeTopicMaintenanceHandle {
16
+ topicId: string;
17
+ epoch: number;
18
+ ownerId: string;
19
+ isOwned(): boolean;
20
+ finish(options?: { deleteState?: boolean }): void;
21
+ }
22
+
23
+ interface RuntimeTopicStateRow {
24
+ topic_id: string;
25
+ epoch: number | bigint;
26
+ maintenance: number;
27
+ maintenance_owner: string | null;
28
+ heartbeat_at: number | bigint | null;
29
+ }
30
+
31
+ db.exec(`
32
+ CREATE TABLE IF NOT EXISTS runtime_topic_state (
33
+ topic_id TEXT PRIMARY KEY,
34
+ epoch INTEGER NOT NULL DEFAULT 0,
35
+ maintenance INTEGER NOT NULL DEFAULT 0 CHECK (maintenance IN (0, 1)),
36
+ maintenance_owner TEXT,
37
+ heartbeat_at INTEGER
38
+ )
39
+ `);
40
+
41
+ function rowToState(row: RuntimeTopicStateRow, now = Date.now()): RuntimeTopicState {
42
+ const heartbeatAt = row.heartbeat_at === null ? undefined : Number(row.heartbeat_at);
43
+ const maintenance =
44
+ row.maintenance !== 0 &&
45
+ heartbeatAt !== undefined &&
46
+ now - heartbeatAt <= TOPIC_MAINTENANCE_STALE_MS;
47
+ return {
48
+ topicId: row.topic_id,
49
+ epoch: Number(row.epoch),
50
+ maintenance,
51
+ maintenanceOwner: maintenance ? (row.maintenance_owner ?? undefined) : undefined,
52
+ heartbeatAt,
53
+ };
54
+ }
55
+
56
+ export function getRuntimeTopicState(topicId: string, now = Date.now()): RuntimeTopicState {
57
+ const row = db
58
+ .query<RuntimeTopicStateRow, [string]>("SELECT * FROM runtime_topic_state WHERE topic_id = ?")
59
+ .get(topicId);
60
+ return row ? rowToState(row, now) : { topicId, epoch: 0, maintenance: false };
61
+ }
62
+
63
+ export function getRuntimeTopicEpoch(topicId: string): number {
64
+ return getRuntimeTopicState(topicId).epoch;
65
+ }
66
+
67
+ export function isRuntimeTopicMaintenance(topicId: string, now = Date.now()): boolean {
68
+ return getRuntimeTopicState(topicId, now).maintenance;
69
+ }
70
+
71
+ /**
72
+ * Advance the topic execution epoch and hold an exclusive maintenance fence.
73
+ * Turns from an older epoch can be identified and discarded in every process.
74
+ */
75
+ export function beginRuntimeTopicMaintenance(
76
+ topicId: string,
77
+ options: { ownerId?: string; now?: number; heartbeatMs?: number } = {},
78
+ ): RuntimeTopicMaintenanceHandle | null {
79
+ const ownerId = options.ownerId ?? `${process.pid}-${randomUUID()}`;
80
+ const now = options.now ?? Date.now();
81
+ const result = db
82
+ .query(
83
+ `INSERT INTO runtime_topic_state
84
+ (topic_id, epoch, maintenance, maintenance_owner, heartbeat_at)
85
+ VALUES (?, 1, 1, ?, ?)
86
+ ON CONFLICT(topic_id) DO UPDATE SET
87
+ epoch = runtime_topic_state.epoch + 1,
88
+ maintenance = 1,
89
+ maintenance_owner = excluded.maintenance_owner,
90
+ heartbeat_at = excluded.heartbeat_at
91
+ WHERE runtime_topic_state.maintenance = 0
92
+ OR runtime_topic_state.heartbeat_at IS NULL
93
+ OR runtime_topic_state.heartbeat_at < ?`,
94
+ )
95
+ .run(topicId, ownerId, now, now - TOPIC_MAINTENANCE_STALE_MS);
96
+ if (Number(result.changes ?? 0) === 0) return null;
97
+
98
+ const epoch = getRuntimeTopicEpoch(topicId);
99
+ let finished = false;
100
+ const heartbeatMs = options.heartbeatMs ?? TOPIC_MAINTENANCE_HEARTBEAT_MS;
101
+ const timer = setInterval(() => {
102
+ if (finished) return;
103
+ const updated = db
104
+ .query(
105
+ `UPDATE runtime_topic_state
106
+ SET heartbeat_at = ?
107
+ WHERE topic_id = ? AND epoch = ? AND maintenance_owner = ? AND maintenance = 1`,
108
+ )
109
+ .run(Date.now(), topicId, epoch, ownerId);
110
+ if (Number(updated.changes ?? 0) > 0) return;
111
+ finished = true;
112
+ clearInterval(timer);
113
+ }, heartbeatMs);
114
+ timer.unref?.();
115
+
116
+ return {
117
+ topicId,
118
+ epoch,
119
+ ownerId,
120
+ isOwned() {
121
+ if (finished) return false;
122
+ const row = db
123
+ .query<{ owned: number }, [string, number, string]>(
124
+ `SELECT 1 AS owned FROM runtime_topic_state
125
+ WHERE topic_id = ? AND epoch = ? AND maintenance_owner = ? AND maintenance = 1`,
126
+ )
127
+ .get(topicId, epoch, ownerId);
128
+ return Boolean(row);
129
+ },
130
+ finish(finishOptions = {}) {
131
+ if (finished) return;
132
+ finished = true;
133
+ clearInterval(timer);
134
+ if (finishOptions.deleteState) {
135
+ db.query(
136
+ `DELETE FROM runtime_topic_state
137
+ WHERE topic_id = ? AND epoch = ? AND maintenance_owner = ?`,
138
+ ).run(topicId, epoch, ownerId);
139
+ return;
140
+ }
141
+ db.query(
142
+ `UPDATE runtime_topic_state
143
+ SET maintenance = 0, maintenance_owner = NULL, heartbeat_at = NULL
144
+ WHERE topic_id = ? AND epoch = ? AND maintenance_owner = ?`,
145
+ ).run(topicId, epoch, ownerId);
146
+ },
147
+ };
148
+ }