negotium 0.1.7 → 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.
- package/README.md +2 -2
- package/dist/chunk-0ay6cjdy.js +1331 -0
- package/dist/chunk-0ay6cjdy.js.map +16 -0
- package/dist/chunk-1k7t7bnx.js +97 -0
- package/dist/chunk-1k7t7bnx.js.map +10 -0
- package/dist/chunk-5ahkew9c.js +806 -0
- package/dist/chunk-5ahkew9c.js.map +17 -0
- package/dist/chunk-6cpn7ht2.js +287 -0
- package/dist/chunk-6cpn7ht2.js.map +13 -0
- package/dist/chunk-6kctn20e.js +3529 -0
- package/dist/chunk-6kctn20e.js.map +20 -0
- package/dist/chunk-7xqj7aex.js +77 -0
- package/dist/chunk-7xqj7aex.js.map +11 -0
- package/dist/chunk-9p0ww3tk.js +41 -0
- package/dist/chunk-9p0ww3tk.js.map +10 -0
- package/dist/chunk-a92a7nrk.js +252 -0
- package/dist/chunk-a92a7nrk.js.map +10 -0
- package/dist/chunk-c500xe9z.js +33 -0
- package/dist/chunk-c500xe9z.js.map +10 -0
- package/dist/chunk-cds1qxsf.js +1644 -0
- package/dist/chunk-cds1qxsf.js.map +17 -0
- package/dist/chunk-e4407g8x.js +274 -0
- package/dist/chunk-e4407g8x.js.map +10 -0
- package/dist/chunk-e5pbggsh.js +363 -0
- package/dist/chunk-e5pbggsh.js.map +11 -0
- package/dist/chunk-edb07sjp.js +567 -0
- package/dist/chunk-edb07sjp.js.map +14 -0
- package/dist/chunk-exd0v74a.js +3012 -0
- package/dist/chunk-exd0v74a.js.map +32 -0
- package/dist/chunk-fgrme9tr.js +4398 -0
- package/dist/chunk-fgrme9tr.js.map +47 -0
- package/dist/chunk-fka9eqe1.js +581 -0
- package/dist/chunk-fka9eqe1.js.map +11 -0
- package/dist/chunk-g82jygez.js +108 -0
- package/dist/chunk-g82jygez.js.map +10 -0
- package/dist/chunk-ghs2j1fr.js +312 -0
- package/dist/chunk-ghs2j1fr.js.map +10 -0
- package/dist/chunk-gmy11efb.js +125 -0
- package/dist/chunk-gmy11efb.js.map +10 -0
- package/dist/chunk-gxma4c70.js +110 -0
- package/dist/chunk-gxma4c70.js.map +10 -0
- package/dist/chunk-hkdqhm34.js +61 -0
- package/dist/chunk-hkdqhm34.js.map +10 -0
- package/dist/chunk-hpa0cpzh.js +134 -0
- package/dist/chunk-hpa0cpzh.js.map +10 -0
- package/dist/chunk-hwy3n31w.js +21 -0
- package/dist/chunk-hwy3n31w.js.map +9 -0
- package/dist/chunk-j2behpy7.js +214 -0
- package/dist/chunk-j2behpy7.js.map +10 -0
- package/dist/chunk-jc0at0tc.js +102 -0
- package/dist/chunk-jc0at0tc.js.map +10 -0
- package/dist/chunk-jkqq5ef6.js +243 -0
- package/dist/chunk-jkqq5ef6.js.map +11 -0
- package/dist/chunk-jzkw2f01.js +51 -0
- package/dist/chunk-jzkw2f01.js.map +10 -0
- package/dist/chunk-jzz91jwh.js +70 -0
- package/dist/chunk-jzz91jwh.js.map +10 -0
- package/dist/chunk-k9z47mab.js +247 -0
- package/dist/chunk-k9z47mab.js.map +12 -0
- package/dist/chunk-n17yxpa8.js +23 -0
- package/dist/chunk-n17yxpa8.js.map +10 -0
- package/dist/chunk-naa09ags.js +2143 -0
- package/dist/chunk-naa09ags.js.map +15 -0
- package/dist/chunk-ns3ke88n.js +142 -0
- package/dist/chunk-ns3ke88n.js.map +10 -0
- package/dist/chunk-p3cr2ejg.js +1626 -0
- package/dist/chunk-p3cr2ejg.js.map +15 -0
- package/dist/chunk-q5bcpf23.js +2925 -0
- package/dist/chunk-q5bcpf23.js.map +20 -0
- package/dist/chunk-rafc55bs.js +424 -0
- package/dist/chunk-rafc55bs.js.map +11 -0
- package/dist/chunk-rbrxjvbj.js +2830 -0
- package/dist/chunk-rbrxjvbj.js.map +23 -0
- package/dist/chunk-sa874j5c.js +9 -0
- package/dist/chunk-sa874j5c.js.map +10 -0
- package/dist/chunk-sgsz2fpq.js +289 -0
- package/dist/chunk-sgsz2fpq.js.map +10 -0
- package/dist/chunk-sm6fh5c5.js +569 -0
- package/dist/chunk-sm6fh5c5.js.map +14 -0
- package/dist/chunk-t9zcg8f1.js +499 -0
- package/dist/chunk-t9zcg8f1.js.map +13 -0
- package/dist/chunk-vp2db42d.js +121 -0
- package/dist/chunk-vp2db42d.js.map +10 -0
- package/dist/chunk-w13jjf4a.js +46 -0
- package/dist/chunk-w13jjf4a.js.map +10 -0
- package/dist/chunk-x1arr7ze.js +63 -0
- package/dist/chunk-x1arr7ze.js.map +10 -0
- package/dist/chunk-xfq68rch.js +583 -0
- package/dist/chunk-xfq68rch.js.map +11 -0
- package/dist/main.js +132 -20
- package/dist/main.js.map +4 -4
- package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
- package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
- package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
- package/dist/runtime/src/agents/archiver.ts +396 -0
- package/dist/runtime/src/agents/auth-check.ts +64 -0
- package/dist/runtime/src/agents/claude-provider.ts +737 -0
- package/dist/runtime/src/agents/claude-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
- package/dist/runtime/src/agents/codex-provider.ts +785 -0
- package/dist/runtime/src/agents/codex-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
- package/dist/runtime/src/agents/contracts.ts +69 -0
- package/dist/runtime/src/agents/deep-map.ts +13 -0
- package/dist/runtime/src/agents/execution-host.ts +91 -0
- package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
- package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
- package/dist/runtime/src/agents/fork.ts +65 -0
- package/dist/runtime/src/agents/hosted-agent.ts +48 -0
- package/dist/runtime/src/agents/idle-archiver.ts +128 -0
- package/dist/runtime/src/agents/index.ts +205 -0
- package/dist/runtime/src/agents/maestro-provider.ts +206 -0
- package/dist/runtime/src/agents/maestro-registry.ts +31 -0
- package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
- package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
- package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
- package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
- package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
- package/dist/runtime/src/agents/model-catalog.ts +151 -0
- package/dist/runtime/src/agents/registry.ts +24 -0
- package/dist/runtime/src/agents/rollout/claude.ts +331 -0
- package/dist/runtime/src/agents/rollout/codex.ts +569 -0
- package/dist/runtime/src/agents/rollout/shared.ts +205 -0
- package/dist/runtime/src/agents/self-config-core.ts +415 -0
- package/dist/runtime/src/agents/task-events.ts +44 -0
- package/dist/runtime/src/agents/tool-format.ts +352 -0
- package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
- package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
- package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
- package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
- package/dist/runtime/src/application/submit-user-message.ts +47 -0
- package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
- package/dist/runtime/src/application/switch-topic-model.ts +79 -0
- package/dist/runtime/src/application/topic-service.ts +139 -0
- package/dist/runtime/src/application/vault-command.ts +170 -0
- package/dist/runtime/src/bus.ts +319 -0
- package/dist/runtime/src/index.ts +333 -0
- package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
- package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
- package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
- package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
- package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
- package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
- package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
- package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
- package/dist/runtime/src/mcp/runtime-spec.ts +134 -0
- package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
- package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
- package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
- package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
- package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
- package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
- package/dist/runtime/src/mcp/system-health-server.ts +130 -0
- package/dist/runtime/src/mcp/task-server.ts +173 -0
- package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
- package/dist/runtime/src/mcp/vault-http.ts +219 -0
- package/dist/runtime/src/mcp/vault-run.ts +153 -0
- package/dist/runtime/src/mcp/vault-server.ts +104 -0
- package/dist/runtime/src/mcp/wiki-server.ts +755 -0
- package/dist/runtime/src/media/file-events.ts +33 -0
- package/dist/runtime/src/media/text-extractor.ts +312 -0
- package/dist/runtime/src/media/video.ts +92 -0
- package/dist/runtime/src/outbox/file-ops.ts +210 -0
- package/dist/runtime/src/outbox/index.ts +8 -0
- package/dist/runtime/src/outbox/utils.ts +51 -0
- package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
- package/dist/runtime/src/platform/config.ts +371 -0
- package/dist/runtime/src/platform/constants.ts +18 -0
- package/dist/runtime/src/platform/debug-users.ts +14 -0
- package/dist/runtime/src/platform/delay.ts +1 -0
- package/dist/runtime/src/platform/error.ts +9 -0
- package/dist/runtime/src/platform/file-utils.ts +16 -0
- package/dist/runtime/src/platform/jsonl.ts +202 -0
- package/dist/runtime/src/platform/lifecycle.ts +174 -0
- package/dist/runtime/src/platform/logger.ts +31 -0
- package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
- package/dist/runtime/src/platform/mcp-config.ts +865 -0
- package/dist/runtime/src/platform/modules.ts +145 -0
- package/dist/runtime/src/platform/node-plugins.ts +46 -0
- package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
- package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
- package/dist/runtime/src/prompts/builders.ts +357 -0
- package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
- package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
- package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
- package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
- package/dist/runtime/src/query/active-rooms.ts +532 -0
- package/dist/runtime/src/query/control.ts +313 -0
- package/dist/runtime/src/query/index.ts +24 -0
- package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
- package/dist/runtime/src/query/session-inbox-path.ts +42 -0
- package/dist/runtime/src/query/state.ts +94 -0
- package/dist/runtime/src/query/types.ts +83 -0
- package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
- package/dist/runtime/src/runtime/attachments.ts +151 -0
- package/dist/runtime/src/runtime/background-sessions.ts +83 -0
- package/dist/runtime/src/runtime/channel-context.ts +107 -0
- package/dist/runtime/src/runtime/errors.ts +69 -0
- package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
- package/dist/runtime/src/runtime/file-hooks.ts +81 -0
- package/dist/runtime/src/runtime/footer.ts +23 -0
- package/dist/runtime/src/runtime/inbox.ts +946 -0
- package/dist/runtime/src/runtime/tasks.ts +56 -0
- package/dist/runtime/src/runtime/topic-config.ts +29 -0
- package/dist/runtime/src/runtime/turn-runner.ts +2286 -0
- package/dist/runtime/src/runtime/usage-alert.ts +84 -0
- package/dist/runtime/src/runtime/visual-html.ts +109 -0
- package/dist/runtime/src/runtime/visual-store.ts +430 -0
- package/dist/runtime/src/runtime/visuals.ts +171 -0
- package/dist/runtime/src/security/sanitize.ts +27 -0
- package/dist/runtime/src/security/sensitive-path.ts +34 -0
- package/dist/runtime/src/storage/activity-log.ts +152 -0
- package/dist/runtime/src/storage/api-messages.ts +617 -0
- package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
- package/dist/runtime/src/storage/api-topic-config.ts +192 -0
- package/dist/runtime/src/storage/api-topics.ts +844 -0
- package/dist/runtime/src/storage/app-settings.ts +44 -0
- package/dist/runtime/src/storage/browser-profiles.ts +133 -0
- package/dist/runtime/src/storage/conversations.ts +268 -0
- package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
- package/dist/runtime/src/storage/forum/index.ts +4 -0
- package/dist/runtime/src/storage/forum/repository.ts +283 -0
- package/dist/runtime/src/storage/forum/schema.ts +300 -0
- package/dist/runtime/src/storage/forum-db.ts +23 -0
- package/dist/runtime/src/storage/runtime-events.ts +130 -0
- package/dist/runtime/src/storage/runtime-leases.ts +189 -0
- package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
- package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +300 -0
- package/dist/runtime/src/storage/self-schedules.ts +298 -0
- package/dist/runtime/src/storage/session-asks.ts +347 -0
- package/dist/runtime/src/storage/sqlite.ts +129 -0
- package/dist/runtime/src/storage/tasks.ts +179 -0
- package/dist/runtime/src/storage/token-stats.ts +144 -0
- package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
- package/dist/runtime/src/storage/topic-archive.ts +74 -0
- package/dist/runtime/src/storage/topic-settings.ts +67 -0
- package/dist/runtime/src/storage/topic-transcript.ts +98 -0
- package/dist/runtime/src/storage/vault-crypto.ts +65 -0
- package/dist/runtime/src/storage/vault.ts +225 -0
- package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
- package/dist/runtime/src/storage/wiki.ts +85 -0
- package/dist/runtime/src/topics/create.ts +123 -0
- package/dist/runtime/src/topics/derive.ts +398 -0
- package/dist/runtime/src/topics/lifecycle.ts +311 -0
- package/dist/runtime/src/topics/links.ts +7 -0
- package/dist/runtime/src/topics/personal-general.ts +65 -0
- package/dist/runtime/src/topics/session.ts +381 -0
- package/dist/runtime/src/types/api.ts +497 -0
- package/dist/runtime/src/types.ts +255 -0
- package/dist/runtime/src/version.ts +1 -0
- package/dist/runtime/tsconfig.json +13 -0
- package/package.json +16 -3
- package/dist/main.d.ts +0 -3
- 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
|
+
}
|