@bridge_gpt/mcp-server 0.2.2 → 0.2.4

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 (113) hide show
  1. package/README.md +97 -15
  2. package/build/agent-config-credential-migration.js +272 -0
  3. package/build/agents.generated.js +1 -1
  4. package/build/chain-orchestrator.js +16 -1
  5. package/build/commands.generated.js +9 -7
  6. package/build/conductor/bridge-api-client.js +625 -0
  7. package/build/conductor/claude-hook.js +251 -0
  8. package/build/conductor/cli.js +1048 -0
  9. package/build/conductor/data-normalization.js +114 -0
  10. package/build/conductor/doctor.js +164 -0
  11. package/build/conductor/done-gate.js +325 -0
  12. package/build/conductor/epic-reconcile.js +139 -0
  13. package/build/conductor/epic-runtime.js +611 -0
  14. package/build/conductor/epic-state.js +125 -0
  15. package/build/conductor/errors.js +85 -0
  16. package/build/conductor/git-ci-types.js +129 -0
  17. package/build/conductor/git-hooks.js +218 -0
  18. package/build/conductor/git-inspection.js +185 -0
  19. package/build/conductor/git-producer.js +137 -0
  20. package/build/conductor/merge-ledger.js +198 -0
  21. package/build/conductor/paths.js +224 -0
  22. package/build/conductor/plan.js +77 -0
  23. package/build/conductor/pr-ci-producer.js +427 -0
  24. package/build/conductor/pr-discovery.js +135 -0
  25. package/build/conductor/producer-ledger.js +125 -0
  26. package/build/conductor/redaction.js +112 -0
  27. package/build/conductor/store.js +1156 -0
  28. package/build/conductor/supervisor-config.js +150 -0
  29. package/build/conductor/supervisor-escalation.js +244 -0
  30. package/build/conductor/supervisor-judgment-python.js +141 -0
  31. package/build/conductor/supervisor-judgment.js +215 -0
  32. package/build/conductor/supervisor-ledger.js +119 -0
  33. package/build/conductor/supervisor-merge.js +127 -0
  34. package/build/conductor/supervisor-message-relay.js +61 -0
  35. package/build/conductor/supervisor-notification.js +39 -0
  36. package/build/conductor/supervisor-runtime.js +351 -0
  37. package/build/conductor/supervisor-state.js +572 -0
  38. package/build/conductor/supervisor-types.js +16 -0
  39. package/build/conductor/taxonomy.js +58 -0
  40. package/build/conductor/tools.js +367 -0
  41. package/build/conductor/types.js +9 -0
  42. package/build/conductor-bin.js +21 -0
  43. package/build/conductor-claude-hook-bin.js +21 -0
  44. package/build/credential-store.js +175 -4
  45. package/build/credentials-cli.js +223 -0
  46. package/build/decision-page-schema.js +60 -0
  47. package/build/decision-page-template.js +262 -10
  48. package/build/doctor.js +5 -1
  49. package/build/index.js +554 -66
  50. package/build/pipeline-orchestrator.js +5 -1
  51. package/build/pipeline-utils.js +45 -5
  52. package/build/pipelines.generated.js +37 -9
  53. package/build/readme.generated.js +1 -1
  54. package/build/review-tickets.js +596 -0
  55. package/build/scheduled-prompt.js +16 -10
  56. package/build/start-tickets-conductor.js +496 -0
  57. package/build/start-tickets-prereqs.js +32 -23
  58. package/build/start-tickets-repo.js +49 -0
  59. package/build/start-tickets.js +682 -81
  60. package/build/version.generated.js +1 -1
  61. package/design-assets/favicon/android-chrome-192x192.png +0 -0
  62. package/design-assets/favicon/android-chrome-512x512.png +0 -0
  63. package/design-assets/favicon/apple-touch-icon.png +0 -0
  64. package/design-assets/favicon/favicon-16x16.png +0 -0
  65. package/design-assets/favicon/favicon-32x32.png +0 -0
  66. package/design-assets/favicon/favicon.ico +0 -0
  67. package/design-assets/favicon/site.webmanifest +1 -0
  68. package/design-assets/just-logo-rough-draft.png +0 -0
  69. package/package.json +17 -5
  70. package/pipelines/idea-to-ticket.json +5 -0
  71. package/pipelines/plan-epic.json +16 -1
  72. package/pipelines/review-ticket.json +2 -1
  73. package/public/css/main.min.css +2 -0
  74. package/public/css/main.min.css.map +1 -0
  75. package/public/fonts/OFL.txt +93 -0
  76. package/public/fonts/SourceSansPro-Black.ttf +0 -0
  77. package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
  78. package/public/fonts/SourceSansPro-Bold.ttf +0 -0
  79. package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
  80. package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
  81. package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
  82. package/public/fonts/SourceSansPro-Italic.ttf +0 -0
  83. package/public/fonts/SourceSansPro-Light.ttf +0 -0
  84. package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
  85. package/public/fonts/SourceSansPro-Regular.ttf +0 -0
  86. package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
  87. package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
  88. package/public/img/bridge-logo-160x51.webp +0 -0
  89. package/public/img/bridge-logo-300x92.webp +0 -0
  90. package/public/img/favicon/android-chrome-192x192.png +0 -0
  91. package/public/img/favicon/android-chrome-512x512.png +0 -0
  92. package/public/img/favicon/apple-touch-icon.png +0 -0
  93. package/public/img/favicon/favicon-16x16.png +0 -0
  94. package/public/img/favicon/favicon-32x32.png +0 -0
  95. package/public/img/favicon/favicon.ico +0 -0
  96. package/public/img/favicon/site.webmanifest +1 -0
  97. package/public/img/installation/bitbucket/app-password-1.png +0 -0
  98. package/public/img/installation/bitbucket/app-password-2.png +0 -0
  99. package/public/img/installation/bitbucket/create-token-1.png +0 -0
  100. package/public/img/installation/bitbucket/create-token-2.png +0 -0
  101. package/public/img/installation/bitbucket/webhook-1.png +0 -0
  102. package/public/img/installation/github/github-review-webhook.png +0 -0
  103. package/public/img/installation/jira/credentials/api-key.png +0 -0
  104. package/public/img/installation/jira/webhook/create-rule.png +0 -0
  105. package/public/img/installation/jira/webhook/project-settings.png +0 -0
  106. package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
  107. package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
  108. package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
  109. package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
  110. package/public/img/installation/pinecone/pinecone-index.png +0 -0
  111. package/public/js/main.min.js +2 -0
  112. package/public/js/main.min.js.map +1 -0
  113. package/smoke-test/SMOKE-TEST.md +17 -9
@@ -0,0 +1,1156 @@
1
+ /**
2
+ * Local SQLite event store for the conductor ledger.
3
+ *
4
+ * This is the reusable infrastructure layer consumed by both the conductor MCP
5
+ * tools and the conductor CLI. It owns the WAL-mode SQLite connection lifecycle,
6
+ * the append-only `events` table (plus schema-only `messages` and
7
+ * `supervisor_projection` tables reserved for later tickets), and the
8
+ * emit/poll/wait/snapshot/doctor/purge operations.
9
+ *
10
+ * Concurrency & safety invariants:
11
+ * - Public event writes are append-only: {@link emitConductorEvent} only ever
12
+ * INSERTs. The only deletes live behind {@link applyEventRetention}
13
+ * (maintenance) and {@link purgeConductorLedger} (explicit purge).
14
+ * - Payload size and secret validation happen BEFORE any write transaction.
15
+ * - Reads default to compact, reference-oriented summaries; full payloads are
16
+ * returned only on explicit `data_mode="full"`.
17
+ * - Connections are always closed in a `finally`.
18
+ */
19
+ import { randomUUID } from "node:crypto";
20
+ import { existsSync } from "node:fs";
21
+ import Database from "better-sqlite3";
22
+ import { SEMANTIC_EVENT_TYPES, assertSemanticEventType, } from "./taxonomy.js";
23
+ import { ConductorValidationError } from "./errors.js";
24
+ import { prepareDataJsonForStorage } from "./data-normalization.js";
25
+ import { ensureConductorDatabaseFile, getConductorLedgerPath, getConductorPathHealth, detectNetworkMountedHome, } from "./paths.js";
26
+ const BUSY_TIMEOUT_DEFAULT = 10_000;
27
+ const BUSY_TIMEOUT_MIN = 250;
28
+ const BUSY_TIMEOUT_MAX = 120_000;
29
+ const RETENTION_DAYS_DEFAULT = 30;
30
+ const RETENTION_DAYS_MAX = 3650;
31
+ const RETENTION_MAX_ROWS_DEFAULT = 50_000;
32
+ const RETENTION_MAX_ROWS_MIN = 100;
33
+ const RETENTION_MAX_ROWS_MAX = 10_000_000;
34
+ const POLL_LIMIT_DEFAULT = 100;
35
+ const POLL_LIMIT_MAX = 1000;
36
+ // Message relay per-type cooldown bounds (BAPI-397). A value <= 0 disables the
37
+ // cooldown; positive values are clamped to [1s, 24h].
38
+ const MESSAGE_COOLDOWN_DEFAULT_MS = 300_000;
39
+ const MESSAGE_COOLDOWN_MIN_MS = 1_000;
40
+ const MESSAGE_COOLDOWN_MAX_MS = 86_400_000;
41
+ // Worker-poll delivery bounds (BAPI-397).
42
+ const CHECK_MESSAGES_LIMIT_DEFAULT = 10;
43
+ const CHECK_MESSAGES_LIMIT_MAX = 100;
44
+ const WAIT_TIMEOUT_MAX_MS = 120_000;
45
+ const WAIT_POLL_INTERVAL_MS = 500;
46
+ const SUMMARY_FIELD_MAX_CHARS = 500;
47
+ function parseBoundedInt(raw, fallback, min, max) {
48
+ if (raw === undefined || raw.trim().length === 0)
49
+ return fallback;
50
+ const parsed = Number.parseInt(raw.trim(), 10);
51
+ if (!Number.isFinite(parsed))
52
+ return fallback;
53
+ return Math.min(max, Math.max(min, parsed));
54
+ }
55
+ /**
56
+ * Resolve store configuration from environment with safe defaults and bounds.
57
+ * - `BAPI_CONDUCTOR_BUSY_TIMEOUT_MS` (default 10000, clamped [250, 120000]).
58
+ * - `BAPI_CONDUCTOR_RETENTION_DAYS` (default 30). A value <= 0 DISABLES
59
+ * age-based retention; positive values are clamped to [1, 3650].
60
+ * - `BAPI_CONDUCTOR_RETENTION_MAX_ROWS` (default 50000). A value <= 0 DISABLES
61
+ * the row cap; positive values are clamped to [100, 10000000].
62
+ */
63
+ export function resolveConductorStoreConfig(env = process.env) {
64
+ const busy_timeout_ms = parseBoundedInt(env.BAPI_CONDUCTOR_BUSY_TIMEOUT_MS, BUSY_TIMEOUT_DEFAULT, BUSY_TIMEOUT_MIN, BUSY_TIMEOUT_MAX);
65
+ // Retention days: <= 0 disables (sentinel 0); positive values clamped.
66
+ const rawDays = env.BAPI_CONDUCTOR_RETENTION_DAYS;
67
+ let retention_days = RETENTION_DAYS_DEFAULT;
68
+ if (rawDays !== undefined && rawDays.trim().length > 0) {
69
+ const parsed = Number.parseInt(rawDays.trim(), 10);
70
+ if (!Number.isFinite(parsed)) {
71
+ retention_days = RETENTION_DAYS_DEFAULT;
72
+ }
73
+ else if (parsed <= 0) {
74
+ retention_days = 0; // disabled
75
+ }
76
+ else {
77
+ retention_days = Math.min(RETENTION_DAYS_MAX, parsed);
78
+ }
79
+ }
80
+ // Retention max rows: <= 0 disables (sentinel 0); positive values clamped.
81
+ const rawRows = env.BAPI_CONDUCTOR_RETENTION_MAX_ROWS;
82
+ let retention_max_rows = RETENTION_MAX_ROWS_DEFAULT;
83
+ if (rawRows !== undefined && rawRows.trim().length > 0) {
84
+ const parsed = Number.parseInt(rawRows.trim(), 10);
85
+ if (!Number.isFinite(parsed)) {
86
+ retention_max_rows = RETENTION_MAX_ROWS_DEFAULT;
87
+ }
88
+ else if (parsed <= 0) {
89
+ retention_max_rows = 0; // disabled
90
+ }
91
+ else {
92
+ retention_max_rows = Math.min(RETENTION_MAX_ROWS_MAX, Math.max(RETENTION_MAX_ROWS_MIN, parsed));
93
+ }
94
+ }
95
+ // Message relay cooldown: <= 0 disables (sentinel 0); positive values clamped.
96
+ const rawCooldown = env.BAPI_CONDUCTOR_MESSAGE_COOLDOWN_MS;
97
+ let message_cooldown_ms = MESSAGE_COOLDOWN_DEFAULT_MS;
98
+ if (rawCooldown !== undefined && rawCooldown.trim().length > 0) {
99
+ const parsed = Number.parseInt(rawCooldown.trim(), 10);
100
+ if (!Number.isFinite(parsed)) {
101
+ message_cooldown_ms = MESSAGE_COOLDOWN_DEFAULT_MS;
102
+ }
103
+ else if (parsed <= 0) {
104
+ message_cooldown_ms = 0; // disabled
105
+ }
106
+ else {
107
+ message_cooldown_ms = Math.min(MESSAGE_COOLDOWN_MAX_MS, Math.max(MESSAGE_COOLDOWN_MIN_MS, parsed));
108
+ }
109
+ }
110
+ return { busy_timeout_ms, retention_days, retention_max_rows, message_cooldown_ms };
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // Schema
114
+ // ---------------------------------------------------------------------------
115
+ /**
116
+ * Current conductor SQLite schema version. Bumped to 2 in BAPI-395 because the
117
+ * `events.type` CHECK constraint vocabulary grew (git/PR/CI/gate taxonomy).
118
+ * Bumped to 3 in BAPI-397 because the message relay adds the `message.sent`,
119
+ * `message.delivered`, and `message.acked` audit event types to the same
120
+ * `events.type` CHECK vocabulary (and adds message relay indexes). Bumped to 4 in
121
+ * BAPI-398 because the C6 conditional-merge pipeline adds the `merge.dry_run`,
122
+ * `merge.attempted`, `merge.succeeded`, and `merge.failed` lifecycle event types
123
+ * to the same `events.type` CHECK vocabulary. Bumped to 5 in BAPI-413 because
124
+ * the per-merge human approval feature adds the `merge.pending_approval` lifecycle
125
+ * event type to the same CHECK vocabulary. Older ledgers stamped at a lower
126
+ * version are rebuilt by {@link migrateConductorSchemaIfNeeded} so their CHECK
127
+ * clause accepts the current taxonomy.
128
+ */
129
+ export const CURRENT_CONDUCTOR_SCHEMA_VERSION = 5;
130
+ /** Render the taxonomy `CHECK (type IN (...))` clause from the single source of truth. */
131
+ function buildTypeCheckClause() {
132
+ const list = SEMANTIC_EVENT_TYPES.map((t) => `'${t}'`).join(", ");
133
+ return `CHECK (type IN (${list}))`;
134
+ }
135
+ /**
136
+ * Render the `CREATE TABLE` SQL for the events table (or a differently-named
137
+ * migration scratch table) using the CURRENT taxonomy CHECK clause. Shared by
138
+ * initialization and migration so the accepted event-type vocabulary is rendered
139
+ * from a single source.
140
+ */
141
+ function buildEventsTableSql(tableName) {
142
+ return `
143
+ CREATE TABLE IF NOT EXISTS ${tableName} (
144
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
145
+ id TEXT NOT NULL UNIQUE,
146
+ source TEXT NOT NULL,
147
+ type TEXT NOT NULL ${buildTypeCheckClause()},
148
+ subject TEXT,
149
+ run_id TEXT,
150
+ worker_id TEXT,
151
+ producer TEXT,
152
+ schema_version INTEGER NOT NULL DEFAULT 1,
153
+ time TEXT NOT NULL,
154
+ data_json TEXT NOT NULL,
155
+ confidence REAL,
156
+ observed_via TEXT,
157
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
158
+ );
159
+ `;
160
+ }
161
+ /** Render the `CREATE INDEX` SQL for the events table indexes (idempotent). */
162
+ function buildEventsIndexesSql() {
163
+ return `
164
+ CREATE INDEX IF NOT EXISTS idx_events_seq ON events (seq);
165
+ CREATE INDEX IF NOT EXISTS idx_events_time ON events (time);
166
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events (type);
167
+ CREATE INDEX IF NOT EXISTS idx_events_run_id ON events (run_id);
168
+ CREATE INDEX IF NOT EXISTS idx_events_worker_id ON events (worker_id);
169
+ CREATE INDEX IF NOT EXISTS idx_events_source ON events (source);
170
+ `;
171
+ }
172
+ /** Render the auxiliary (schema-only) tables reserved for later tickets. */
173
+ function buildAuxiliaryTablesSql() {
174
+ return `
175
+ CREATE TABLE IF NOT EXISTS messages (
176
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
177
+ id TEXT NOT NULL UNIQUE,
178
+ run_id TEXT NOT NULL,
179
+ worker_id TEXT NOT NULL,
180
+ type TEXT NOT NULL,
181
+ payload_json TEXT NOT NULL DEFAULT '{}',
182
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'delivered', 'acked', 'failed')),
183
+ available_at TEXT NOT NULL DEFAULT (datetime('now')),
184
+ acked_at TEXT,
185
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
186
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
187
+ );
188
+
189
+ -- The supervisor projection is actively maintained by the conductor
190
+ -- supervisor runtime (BAPI-396, \`conductor supervise --run-id <id>\`): the
191
+ -- foreground loop upserts a row after every deterministic iteration. It is
192
+ -- the OPERATIONAL state mirror of the run; raw events remain the source of
193
+ -- truth. The existing JSON columns are sufficient for resumable state, so the
194
+ -- table shape is unchanged.
195
+ CREATE TABLE IF NOT EXISTS supervisor_projection (
196
+ run_id TEXT PRIMARY KEY,
197
+ status TEXT NOT NULL DEFAULT 'unknown',
198
+ last_seq INTEGER,
199
+ last_event_time TEXT,
200
+ active_workers_json TEXT NOT NULL DEFAULT '[]',
201
+ gates_json TEXT NOT NULL DEFAULT '{}',
202
+ assessment_json TEXT,
203
+ summary_json TEXT,
204
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
205
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
206
+ );
207
+ `;
208
+ }
209
+ /**
210
+ * Render the auxiliary message relay indexes (idempotent). Two access patterns
211
+ * drive the column order:
212
+ * - per-worker pending poll (`checkWorkerMessages`): filter run_id+worker_id,
213
+ * state='pending', available_at gate, ordered by seq ->
214
+ * `(run_id, worker_id, state, available_at, seq)`.
215
+ * - per-type cooldown probe (`sendWorkerMessage`): filter run_id+worker_id+type
216
+ * over a recent created_at window -> `(run_id, worker_id, type, created_at)`.
217
+ */
218
+ function buildAuxiliaryIndexesSql() {
219
+ return `
220
+ CREATE INDEX IF NOT EXISTS idx_messages_worker_pending
221
+ ON messages (run_id, worker_id, state, available_at, seq);
222
+ CREATE INDEX IF NOT EXISTS idx_messages_cooldown
223
+ ON messages (run_id, worker_id, type, created_at);
224
+ `;
225
+ }
226
+ /** Read the SQLite `user_version` pragma as a number (0 when unreadable). */
227
+ function readUserVersion(db) {
228
+ const uv = db.pragma("user_version", { simple: true });
229
+ if (typeof uv === "number")
230
+ return uv;
231
+ const parsed = Number.parseInt(String(uv), 10);
232
+ return Number.isFinite(parsed) ? parsed : 0;
233
+ }
234
+ /** Return true when a table with the given name exists. */
235
+ function conductorTableExists(db, name) {
236
+ const row = db
237
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
238
+ .get(name);
239
+ return !!row?.name;
240
+ }
241
+ /**
242
+ * Create all conductor tables and indexes (idempotent) and stamp
243
+ * `PRAGMA user_version = CURRENT_CONDUCTOR_SCHEMA_VERSION`. Only `events` is
244
+ * exercised by tools/CLI today; `messages` and `supervisor_projection` are
245
+ * schema-only scaffolding.
246
+ */
247
+ export function initializeConductorSchema(db) {
248
+ db.exec(`${buildEventsTableSql("events")}${buildEventsIndexesSql()}${buildAuxiliaryTablesSql()}${buildAuxiliaryIndexesSql()}`);
249
+ db.pragma(`user_version = ${CURRENT_CONDUCTOR_SCHEMA_VERSION}`);
250
+ }
251
+ /**
252
+ * Upgrade a pre-existing ledger whose `events.type` CHECK constraint predates the
253
+ * current taxonomy. Detects `user_version < CURRENT_CONDUCTOR_SCHEMA_VERSION` and,
254
+ * when an `events` table already exists, rebuilds it inside a single transaction
255
+ * using the CURRENT taxonomy CHECK clause: copy all rows into a scratch table,
256
+ * drop the old table, rename the scratch table to `events`, and recreate the
257
+ * indexes. `user_version` is stamped to the current version ONLY after the
258
+ * rebuild commits — a failed migration leaves the original data and version
259
+ * intact. A ledger with no `events` table is freshly initialized instead.
260
+ */
261
+ export function migrateConductorSchemaIfNeeded(db) {
262
+ if (readUserVersion(db) >= CURRENT_CONDUCTOR_SCHEMA_VERSION)
263
+ return;
264
+ if (!conductorTableExists(db, "events")) {
265
+ initializeConductorSchema(db);
266
+ return;
267
+ }
268
+ const migrate = db.transaction(() => {
269
+ // Scratch table named for the current schema version (v4, BAPI-398) so the
270
+ // migration rebuild never reads as a stale pre-merge-taxonomy artifact.
271
+ db.exec(buildEventsTableSql("events_migrated_v5"));
272
+ db.exec(`INSERT INTO events_migrated_v5
273
+ (seq, id, source, type, subject, run_id, worker_id, producer, schema_version, time, data_json, confidence, observed_via, created_at)
274
+ SELECT seq, id, source, type, subject, run_id, worker_id, producer, schema_version, time, data_json, confidence, observed_via, created_at
275
+ FROM events`);
276
+ db.exec("DROP TABLE events");
277
+ db.exec("ALTER TABLE events_migrated_v5 RENAME TO events");
278
+ db.exec(buildEventsIndexesSql());
279
+ // Ensure auxiliary tables and their relay indexes exist on older ledgers too.
280
+ db.exec(buildAuxiliaryTablesSql());
281
+ db.exec(buildAuxiliaryIndexesSql());
282
+ });
283
+ migrate();
284
+ // Stamp the version only after the rebuild has committed successfully.
285
+ db.pragma(`user_version = ${CURRENT_CONDUCTOR_SCHEMA_VERSION}`);
286
+ }
287
+ // ---------------------------------------------------------------------------
288
+ // Connection lifecycle
289
+ // ---------------------------------------------------------------------------
290
+ /**
291
+ * Open the ledger for writes: ensure the directory/file exist with restrictive
292
+ * permissions, open SQLite, apply WAL/concurrency pragmas, and initialize the
293
+ * schema before returning the live connection.
294
+ */
295
+ export function openWritableConductorDatabase(config = resolveConductorStoreConfig()) {
296
+ ensureConductorDatabaseFile();
297
+ const db = new Database(getConductorLedgerPath());
298
+ // busy_timeout MUST be set first: `journal_mode = WAL` and the schema writes
299
+ // below take a lock, and with the default 0ms timeout a concurrent writer
300
+ // would get an immediate SQLITE_BUSY instead of waiting. Setting it first lets
301
+ // every subsequent locking operation honor the timeout (verified under a
302
+ // 5-process concurrent-emit integration test).
303
+ db.pragma(`busy_timeout = ${config.busy_timeout_ms}`);
304
+ // Switching to WAL takes a brief EXCLUSIVE lock and returns SQLITE_BUSY
305
+ // *immediately* (ignoring busy_timeout) when other connections are already
306
+ // open. WAL is a persistent file-level property, so tolerating a transient
307
+ // busy here is safe: whichever concurrent opener wins establishes WAL for all
308
+ // connections, and busy_timeout covers the actual write transactions
309
+ // regardless of journal mode. (Verified under a 5-process concurrent emit.)
310
+ try {
311
+ db.pragma("journal_mode = WAL");
312
+ }
313
+ catch {
314
+ /* another connection is mid-conversion / already owns the file lock */
315
+ }
316
+ db.pragma("synchronous = NORMAL");
317
+ db.pragma("foreign_keys = ON");
318
+ // Skip all schema work on connections that find an already-current ledger —
319
+ // this removes per-emit write contention. A fresh ledger is initialized; an
320
+ // older ledger (user_version < current) is migrated to the current taxonomy
321
+ // BEFORE any write happens against the returned connection.
322
+ if (readUserVersion(db) !== CURRENT_CONDUCTOR_SCHEMA_VERSION) {
323
+ if (conductorTableExists(db, "events")) {
324
+ migrateConductorSchemaIfNeeded(db);
325
+ }
326
+ else {
327
+ initializeConductorSchema(db);
328
+ }
329
+ }
330
+ return db;
331
+ }
332
+ /**
333
+ * Open the ledger read-only when it already exists; otherwise return `null`.
334
+ * Never creates files and never runs schema migrations.
335
+ */
336
+ export function openReadonlyConductorDatabaseIfExists(config = resolveConductorStoreConfig()) {
337
+ const dbPath = getConductorLedgerPath();
338
+ let db;
339
+ try {
340
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ db.pragma(`busy_timeout = ${config.busy_timeout_ms}`);
346
+ return db;
347
+ }
348
+ // ---------------------------------------------------------------------------
349
+ // Row projections
350
+ // ---------------------------------------------------------------------------
351
+ function truncateField(value) {
352
+ if (typeof value === "string" && value.length > SUMMARY_FIELD_MAX_CHARS) {
353
+ return `${value.slice(0, SUMMARY_FIELD_MAX_CHARS)}… [truncated ${value.length - SUMMARY_FIELD_MAX_CHARS} chars]`;
354
+ }
355
+ return value;
356
+ }
357
+ function parseDataJson(row) {
358
+ try {
359
+ const parsed = JSON.parse(row.data_json);
360
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
361
+ ? parsed
362
+ : {};
363
+ }
364
+ catch {
365
+ return {};
366
+ }
367
+ }
368
+ /**
369
+ * Compact, metadata-first projection. Excludes `data.raw`; when raw payload data
370
+ * exists, surfaces its key names via `raw_keys`. Long normalized string fields
371
+ * are truncated. `references` / `payload_ref` are preserved when present.
372
+ */
373
+ export function rowToEventSummary(row) {
374
+ const data = parseDataJson(row);
375
+ const { raw, ...rest } = data;
376
+ const compactData = {};
377
+ for (const [key, value] of Object.entries(rest)) {
378
+ compactData[key] = truncateField(value);
379
+ }
380
+ const summary = {
381
+ seq: row.seq,
382
+ id: row.id,
383
+ source: row.source,
384
+ type: row.type,
385
+ subject: row.subject,
386
+ run_id: row.run_id,
387
+ worker_id: row.worker_id,
388
+ producer: row.producer,
389
+ schema_version: row.schema_version,
390
+ time: row.time,
391
+ confidence: row.confidence,
392
+ observed_via: row.observed_via,
393
+ data: compactData,
394
+ };
395
+ if (raw !== undefined && raw !== null && typeof raw === "object") {
396
+ summary.raw_keys = Object.keys(raw);
397
+ }
398
+ return summary;
399
+ }
400
+ /** Full projection: complete (already-redacted) `data_json` plus all envelope fields. */
401
+ export function rowToEventFull(row) {
402
+ return {
403
+ seq: row.seq,
404
+ id: row.id,
405
+ source: row.source,
406
+ type: row.type,
407
+ subject: row.subject,
408
+ run_id: row.run_id,
409
+ worker_id: row.worker_id,
410
+ producer: row.producer,
411
+ schema_version: row.schema_version,
412
+ time: row.time,
413
+ confidence: row.confidence,
414
+ observed_via: row.observed_via,
415
+ data: parseDataJson(row),
416
+ };
417
+ }
418
+ function projectRow(row, dataMode) {
419
+ return dataMode === "full" ? rowToEventFull(row) : rowToEventSummary(row);
420
+ }
421
+ // ---------------------------------------------------------------------------
422
+ // Emit (append-only)
423
+ // ---------------------------------------------------------------------------
424
+ /**
425
+ * Append exactly one event to the ledger. Validates taxonomy + confidence and
426
+ * prepares/redacts the data JSON BEFORE opening the write transaction. Defaults
427
+ * `id`, `time`, and `schema_version`. Closes the connection in a `finally`.
428
+ */
429
+ export function emitConductorEvent(input, config = resolveConductorStoreConfig()) {
430
+ // --- Validation & preparation OUTSIDE any SQLite transaction. ---
431
+ const type = assertSemanticEventType(input.type);
432
+ if (typeof input.source !== "string" || input.source.trim().length === 0) {
433
+ throw new ConductorValidationError("Event 'source' is required and must be a non-empty string.");
434
+ }
435
+ let confidence = null;
436
+ if (input.confidence !== undefined && input.confidence !== null) {
437
+ if (typeof input.confidence !== "number" || !Number.isFinite(input.confidence)) {
438
+ throw new ConductorValidationError("Event 'confidence' must be a finite number between 0 and 1.");
439
+ }
440
+ if (input.confidence < 0 || input.confidence > 1) {
441
+ throw new ConductorValidationError("Event 'confidence' must be between 0 and 1 (inclusive).");
442
+ }
443
+ confidence = input.confidence;
444
+ }
445
+ const dataJson = prepareDataJsonForStorage(input.data);
446
+ const id = typeof input.id === "string" && input.id.trim().length > 0 ? input.id : randomUUID();
447
+ const time = typeof input.time === "string" && input.time.trim().length > 0
448
+ ? input.time
449
+ : new Date().toISOString();
450
+ const schemaVersion = typeof input.schema_version === "number" && Number.isFinite(input.schema_version)
451
+ ? input.schema_version
452
+ : 1;
453
+ const db = openWritableConductorDatabase(config);
454
+ try {
455
+ const insert = db.prepare(`INSERT INTO events
456
+ (id, source, type, subject, run_id, worker_id, producer, schema_version, time, data_json, confidence, observed_via)
457
+ VALUES
458
+ (@id, @source, @type, @subject, @run_id, @worker_id, @producer, @schema_version, @time, @data_json, @confidence, @observed_via)`);
459
+ const runInsert = db.transaction(() => {
460
+ insert.run({
461
+ id,
462
+ source: input.source,
463
+ type,
464
+ subject: input.subject ?? null,
465
+ run_id: input.run_id ?? null,
466
+ worker_id: input.worker_id ?? null,
467
+ producer: input.producer ?? null,
468
+ schema_version: schemaVersion,
469
+ time,
470
+ data_json: dataJson,
471
+ confidence,
472
+ observed_via: input.observed_via ?? null,
473
+ });
474
+ });
475
+ runInsert();
476
+ const row = db.prepare("SELECT * FROM events WHERE id = ?").get(id);
477
+ // Retention runs AFTER the insert has committed, in its own transaction, and
478
+ // is BEST-EFFORT: the write is already durable, so a transient failure here
479
+ // (e.g. SQLITE_BUSY on the DELETE under concurrency) must NOT turn a
480
+ // committed emit into a reported failure — that would invite a retrying
481
+ // producer to append a duplicate event with a new UUID.
482
+ try {
483
+ applyEventRetention(db, config);
484
+ }
485
+ catch {
486
+ /* retention is maintenance; the emit already committed */
487
+ }
488
+ return { ok: true, event: rowToEventSummary(row) };
489
+ }
490
+ finally {
491
+ db.close();
492
+ }
493
+ }
494
+ /**
495
+ * Retention maintenance: delete events older than the configured age and trim
496
+ * the oldest events beyond the row cap. Each cleanup runs in its own short
497
+ * transaction. A retention setting of 0 disables that dimension.
498
+ *
499
+ * Age is measured on `created_at` (server ingestion time), NOT the caller-
500
+ * supplied `time`: retaining on `time` would let a backfilled/backdated event be
501
+ * INSERTed and then immediately deleted within the same emit call (a "phantom"
502
+ * that no later poll could observe). `created_at` is also stored in the exact
503
+ * `datetime('now')` format, so the lexicographic comparison below is correct
504
+ * (caller `time` is ISO-8601 with `T`/`Z`, which does not compare cleanly).
505
+ */
506
+ export function applyEventRetention(db, config) {
507
+ if (config.retention_days > 0) {
508
+ const deleteByAge = db.transaction(() => {
509
+ db.prepare(`DELETE FROM events WHERE created_at < datetime('now', @cutoff)`).run({ cutoff: `-${config.retention_days} days` });
510
+ });
511
+ deleteByAge();
512
+ }
513
+ if (config.retention_max_rows > 0) {
514
+ const deleteByCap = db.transaction(() => {
515
+ db.prepare(`DELETE FROM events
516
+ WHERE seq <= (
517
+ SELECT seq FROM events ORDER BY seq DESC LIMIT 1 OFFSET @cap
518
+ )`).run({ cap: config.retention_max_rows });
519
+ });
520
+ deleteByCap();
521
+ }
522
+ }
523
+ // ---------------------------------------------------------------------------
524
+ // Read: poll / wait
525
+ // ---------------------------------------------------------------------------
526
+ /** Build an allowlisted, parameterized WHERE fragment from a filter. */
527
+ function buildFilterClause(filter) {
528
+ const conditions = [];
529
+ const params = {};
530
+ if (!filter)
531
+ return { clause: "", params };
532
+ if (filter.type) {
533
+ // Defense-in-depth: reject non-taxonomy type filters before building SQL
534
+ // (the Zod tool layer already enforces this, but the store is the boundary).
535
+ assertSemanticEventType(filter.type);
536
+ conditions.push("type = @f_type");
537
+ params.f_type = filter.type;
538
+ }
539
+ if (filter.types && filter.types.length > 0) {
540
+ const placeholders = filter.types.map((_t, i) => `@f_types_${i}`);
541
+ filter.types.forEach((t, i) => {
542
+ assertSemanticEventType(t);
543
+ params[`f_types_${i}`] = t;
544
+ });
545
+ conditions.push(`type IN (${placeholders.join(", ")})`);
546
+ }
547
+ if (filter.source) {
548
+ conditions.push("source = @f_source");
549
+ params.f_source = filter.source;
550
+ }
551
+ if (filter.run_id) {
552
+ conditions.push("run_id = @f_run_id");
553
+ params.f_run_id = filter.run_id;
554
+ }
555
+ if (filter.worker_id) {
556
+ conditions.push("worker_id = @f_worker_id");
557
+ params.f_worker_id = filter.worker_id;
558
+ }
559
+ if (filter.subject) {
560
+ conditions.push("subject = @f_subject");
561
+ params.f_subject = filter.subject;
562
+ }
563
+ if (filter.producer) {
564
+ conditions.push("producer = @f_producer");
565
+ params.f_producer = filter.producer;
566
+ }
567
+ return {
568
+ clause: conditions.length > 0 ? ` AND ${conditions.join(" AND ")}` : "",
569
+ params,
570
+ };
571
+ }
572
+ /**
573
+ * Read events with `seq >= since_seq` (inclusive cursor), ordered ascending,
574
+ * applying allowlisted filters and a bounded limit. Returns compact summaries
575
+ * unless `data_mode="full"`. `next_seq` is the cursor to pass on the next call.
576
+ */
577
+ export function pollConductorEvents(options = {}, config = resolveConductorStoreConfig()) {
578
+ const sinceSeq = typeof options.since_seq === "number" && options.since_seq >= 0
579
+ ? Math.floor(options.since_seq)
580
+ : 1;
581
+ const dataMode = options.data_mode === "full" ? "full" : "summary";
582
+ const limit = Math.min(POLL_LIMIT_MAX, Math.max(1, Math.floor(options.limit ?? POLL_LIMIT_DEFAULT)));
583
+ const db = openReadonlyConductorDatabaseIfExists(config);
584
+ if (!db) {
585
+ return { events: [], next_seq: sinceSeq, count: 0 };
586
+ }
587
+ try {
588
+ const { clause, params } = buildFilterClause(options.filter);
589
+ const rows = db
590
+ .prepare(`SELECT * FROM events WHERE seq >= @since_seq${clause} ORDER BY seq ASC LIMIT @limit`)
591
+ .all({ since_seq: sinceSeq, limit, ...params });
592
+ const events = rows.map((row) => projectRow(row, dataMode));
593
+ const nextSeq = rows.length > 0 ? rows[rows.length - 1].seq + 1 : sinceSeq;
594
+ return { events, next_seq: nextSeq, count: rows.length };
595
+ }
596
+ finally {
597
+ db.close();
598
+ }
599
+ }
600
+ function sleep(ms) {
601
+ return new Promise((resolve) => setTimeout(resolve, ms));
602
+ }
603
+ /**
604
+ * Bounded server-side wait: poll immediately, then sleep and re-poll until
605
+ * matching events appear or `timeout_ms` elapses. SQLite locks are never held
606
+ * between polls (each poll opens/closes a read-only connection). The timeout is
607
+ * clamped to {@link WAIT_TIMEOUT_MAX_MS}.
608
+ */
609
+ export async function waitForConductorEvent(options = {}, config = resolveConductorStoreConfig()) {
610
+ const timeoutMs = Math.min(WAIT_TIMEOUT_MAX_MS, Math.max(0, Math.floor(options.timeout_ms ?? WAIT_TIMEOUT_MAX_MS)));
611
+ const deadline = Date.now() + timeoutMs;
612
+ // Poll immediately at least once.
613
+ let result = pollConductorEvents(options, config);
614
+ while (result.count === 0 && Date.now() < deadline) {
615
+ const remaining = deadline - Date.now();
616
+ await sleep(Math.min(WAIT_POLL_INTERVAL_MS, Math.max(1, remaining)));
617
+ result = pollConductorEvents(options, config);
618
+ }
619
+ return { ...result, timed_out: result.count === 0 };
620
+ }
621
+ function safeJsonParse(value, fallback) {
622
+ if (value === null)
623
+ return fallback;
624
+ try {
625
+ return JSON.parse(value);
626
+ }
627
+ catch {
628
+ return fallback;
629
+ }
630
+ }
631
+ /**
632
+ * Parse a raw `supervisor_projection` row into a {@link SupervisorProjection},
633
+ * decoding the JSON columns with safe fallbacks. Shared by the read path
634
+ * ({@link getSupervisorSnapshot}) and the write path
635
+ * ({@link upsertSupervisorProjection}) so projection parsing lives in exactly
636
+ * one place. Malformed JSON in any column never throws — it falls back to an
637
+ * empty structure / null while preserving the row's scalar identity fields.
638
+ */
639
+ export function rowToSupervisorProjection(row) {
640
+ return {
641
+ run_id: row.run_id,
642
+ status: row.status,
643
+ last_seq: row.last_seq,
644
+ last_event_time: row.last_event_time,
645
+ active_workers: safeJsonParse(row.active_workers_json, []),
646
+ gates: safeJsonParse(row.gates_json, {}),
647
+ assessment: safeJsonParse(row.assessment_json, null),
648
+ summary: safeJsonParse(row.summary_json, null),
649
+ created_at: row.created_at,
650
+ updated_at: row.updated_at,
651
+ };
652
+ }
653
+ /**
654
+ * Read the supervisor projection for `run_id` (read-only). Returns
655
+ * `{ run_id, status: "unknown", projection: null }` when no projection exists.
656
+ * The projection is maintained by the conductor supervisor runtime
657
+ * (`conductor supervise`); this read never derives state from raw events.
658
+ */
659
+ export function getSupervisorSnapshot(runId, config = resolveConductorStoreConfig()) {
660
+ const db = openReadonlyConductorDatabaseIfExists(config);
661
+ if (!db) {
662
+ return { run_id: runId, status: "unknown", projection: null };
663
+ }
664
+ try {
665
+ const row = db
666
+ .prepare("SELECT * FROM supervisor_projection WHERE run_id = ?")
667
+ .get(runId);
668
+ if (!row) {
669
+ return { run_id: runId, status: "unknown", projection: null };
670
+ }
671
+ return { run_id: row.run_id, status: row.status, projection: rowToSupervisorProjection(row) };
672
+ }
673
+ finally {
674
+ db.close();
675
+ }
676
+ }
677
+ /**
678
+ * Upsert the supervisor projection row for a run (BAPI-396). Stores the
679
+ * arbitrary JSON inputs (`active_workers`, `gates`, `assessment`, `summary`) as
680
+ * JSON text, stamps `updated_at = datetime('now')` on every write, then re-reads
681
+ * the row and returns it parsed through {@link rowToSupervisorProjection}. The
682
+ * connection is always closed in a `finally`. The statement is fully
683
+ * parameterized — run-supplied values never enter the SQL string.
684
+ */
685
+ export function upsertSupervisorProjection(input, config = resolveConductorStoreConfig()) {
686
+ if (typeof input.run_id !== "string" || input.run_id.trim().length === 0) {
687
+ throw new ConductorValidationError("Supervisor projection 'run_id' is required.");
688
+ }
689
+ const activeWorkersJson = JSON.stringify(input.active_workers ?? []);
690
+ const gatesJson = JSON.stringify(input.gates ?? {});
691
+ const assessmentJson = input.assessment == null ? null : JSON.stringify(input.assessment);
692
+ const summaryJson = input.summary == null ? null : JSON.stringify(input.summary);
693
+ const db = openWritableConductorDatabase(config);
694
+ try {
695
+ const upsert = db.prepare(`INSERT INTO supervisor_projection
696
+ (run_id, status, last_seq, last_event_time, active_workers_json, gates_json, assessment_json, summary_json, updated_at)
697
+ VALUES
698
+ (@run_id, @status, @last_seq, @last_event_time, @active_workers_json, @gates_json, @assessment_json, @summary_json, datetime('now'))
699
+ ON CONFLICT(run_id) DO UPDATE SET
700
+ status = excluded.status,
701
+ last_seq = excluded.last_seq,
702
+ last_event_time = excluded.last_event_time,
703
+ active_workers_json = excluded.active_workers_json,
704
+ gates_json = excluded.gates_json,
705
+ assessment_json = excluded.assessment_json,
706
+ summary_json = excluded.summary_json,
707
+ updated_at = datetime('now')`);
708
+ const runUpsert = db.transaction(() => {
709
+ upsert.run({
710
+ run_id: input.run_id,
711
+ status: input.status,
712
+ last_seq: input.last_seq,
713
+ last_event_time: input.last_event_time,
714
+ active_workers_json: activeWorkersJson,
715
+ gates_json: gatesJson,
716
+ assessment_json: assessmentJson,
717
+ summary_json: summaryJson,
718
+ });
719
+ });
720
+ runUpsert();
721
+ const row = db
722
+ .prepare("SELECT * FROM supervisor_projection WHERE run_id = ?")
723
+ .get(input.run_id);
724
+ return { ok: true, projection: rowToSupervisorProjection(row) };
725
+ }
726
+ finally {
727
+ db.close();
728
+ }
729
+ }
730
+ // ---------------------------------------------------------------------------
731
+ // Doctor (read-only)
732
+ // ---------------------------------------------------------------------------
733
+ /**
734
+ * Read-only health inspection. Reports path/permission health, schema presence,
735
+ * journal mode, event count, max seq, resolved retention/concurrency settings,
736
+ * and network-home degradation. Never creates the DB, inserts a diagnostic
737
+ * event, or mutates WAL/schema state.
738
+ */
739
+ export function doctorConductorLedger(config = resolveConductorStoreConfig()) {
740
+ const health = getConductorPathHealth();
741
+ const network = detectNetworkMountedHome();
742
+ const warnings = [...health.warnings];
743
+ if (network.network_mounted && network.warning) {
744
+ warnings.push(network.warning);
745
+ }
746
+ let schemaPresent = false;
747
+ let journalMode = null;
748
+ let eventCount = null;
749
+ let maxSeq = null;
750
+ let userVersion = null;
751
+ const db = openReadonlyConductorDatabaseIfExists(config);
752
+ if (db) {
753
+ try {
754
+ const tableRow = db
755
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'events'")
756
+ .get();
757
+ schemaPresent = !!tableRow?.name;
758
+ const journal = db.pragma("journal_mode", { simple: true });
759
+ journalMode = typeof journal === "string" ? journal : journal != null ? String(journal) : null;
760
+ const uv = db.pragma("user_version", { simple: true });
761
+ userVersion = typeof uv === "number" ? uv : Number.parseInt(String(uv), 10) || null;
762
+ if (schemaPresent) {
763
+ const countRow = db.prepare("SELECT COUNT(*) AS c, MAX(seq) AS m FROM events").get();
764
+ eventCount = countRow.c;
765
+ maxSeq = countRow.m;
766
+ }
767
+ }
768
+ finally {
769
+ db.close();
770
+ }
771
+ }
772
+ return {
773
+ path: health.path,
774
+ directory_path: health.directory_path,
775
+ directory_exists: health.directory_exists,
776
+ database_exists: health.database_exists,
777
+ directory_mode: health.directory_mode,
778
+ database_mode: health.database_mode,
779
+ directory_permissions_ok: health.directory_permissions_ok,
780
+ database_permissions_ok: health.database_permissions_ok,
781
+ schema_present: schemaPresent,
782
+ journal_mode: journalMode,
783
+ event_count: eventCount,
784
+ max_seq: maxSeq,
785
+ user_version: userVersion,
786
+ retention: {
787
+ busy_timeout_ms: config.busy_timeout_ms,
788
+ retention_days: config.retention_days,
789
+ retention_max_rows: config.retention_max_rows,
790
+ },
791
+ message_relay: {
792
+ message_cooldown_ms: config.message_cooldown_ms,
793
+ },
794
+ degraded: network.network_mounted,
795
+ warnings,
796
+ };
797
+ }
798
+ // ---------------------------------------------------------------------------
799
+ // Purge (explicit, isolated full deletion)
800
+ // ---------------------------------------------------------------------------
801
+ /**
802
+ * Explicitly delete ALL rows from `events`, `messages`, and
803
+ * `supervisor_projection`. If the DB does not exist, returns a successful empty
804
+ * purge. Runs `wal_checkpoint(TRUNCATE)` after deletion when possible.
805
+ */
806
+ export function purgeConductorLedger(config = resolveConductorStoreConfig()) {
807
+ if (!existsSync(getConductorLedgerPath())) {
808
+ return {
809
+ ok: true,
810
+ existed: false,
811
+ deleted: { events: 0, messages: 0, supervisor_projection: 0 },
812
+ };
813
+ }
814
+ const db = openWritableConductorDatabase(config);
815
+ try {
816
+ const purge = db.transaction(() => {
817
+ const events = db.prepare("DELETE FROM events").run().changes;
818
+ const messages = db.prepare("DELETE FROM messages").run().changes;
819
+ const supervisor = db.prepare("DELETE FROM supervisor_projection").run().changes;
820
+ return { events, messages, supervisor_projection: supervisor };
821
+ });
822
+ const deleted = purge();
823
+ try {
824
+ db.pragma("wal_checkpoint(TRUNCATE)");
825
+ }
826
+ catch {
827
+ /* checkpoint is best-effort */
828
+ }
829
+ return { ok: true, existed: true, deleted };
830
+ }
831
+ finally {
832
+ db.close();
833
+ }
834
+ }
835
+ // ---------------------------------------------------------------------------
836
+ // Cooperative message relay (BAPI-397, conductor C5)
837
+ // ---------------------------------------------------------------------------
838
+ /** Conservative allowlist for a relay message `type` token. */
839
+ const MESSAGE_TYPE_PATTERN = /^[A-Za-z0-9._:-]{1,100}$/;
840
+ /**
841
+ * Build the ticket-required idempotency key for a relayed message:
842
+ * `worker_message:{run_id}:{worker_id}:{type}:{cause_seq}`. Identity tokens are
843
+ * trimmed; `cause_seq` is rendered as an integer. NO payload ever enters the key.
844
+ */
845
+ export function makeWorkerMessageId(input) {
846
+ const runId = String(input.run_id).trim();
847
+ const workerId = String(input.worker_id).trim();
848
+ const type = String(input.type).trim();
849
+ const causeSeq = Math.trunc(input.cause_seq);
850
+ return `worker_message:${runId}:${workerId}:${type}:${causeSeq}`;
851
+ }
852
+ /**
853
+ * Validate the identity tuple of a relay message. Throws a
854
+ * {@link ConductorValidationError} (sanitized, never echoing payload) for empty
855
+ * identity fields, an unsafe/oversized `type`, or a non-integer/negative
856
+ * `cause_seq`.
857
+ */
858
+ export function validateWorkerMessageIdentity(input) {
859
+ if (typeof input.run_id !== "string" || input.run_id.trim().length === 0) {
860
+ throw new ConductorValidationError("Message 'run_id' is required and must be a non-empty string.");
861
+ }
862
+ if (typeof input.worker_id !== "string" || input.worker_id.trim().length === 0) {
863
+ throw new ConductorValidationError("Message 'worker_id' is required and must be a non-empty string.");
864
+ }
865
+ if (typeof input.type !== "string" || input.type.trim().length === 0) {
866
+ throw new ConductorValidationError("Message 'type' is required and must be a non-empty string.");
867
+ }
868
+ if (!MESSAGE_TYPE_PATTERN.test(input.type.trim())) {
869
+ throw new ConductorValidationError("Message 'type' must be 1-100 characters from [A-Za-z0-9._:-].");
870
+ }
871
+ if (typeof input.cause_seq !== "number" ||
872
+ !Number.isFinite(input.cause_seq) ||
873
+ !Number.isInteger(input.cause_seq) ||
874
+ input.cause_seq < 0) {
875
+ throw new ConductorValidationError("Message 'cause_seq' must be a finite non-negative integer.");
876
+ }
877
+ }
878
+ /**
879
+ * Project a raw `messages` row into the worker-facing {@link ConductorWorkerMessage}.
880
+ * `payload_json` is parsed safely; malformed JSON falls back to `{}` without
881
+ * leaking a parse exception or the raw row.
882
+ */
883
+ export function rowToConductorWorkerMessage(row) {
884
+ let payload = {};
885
+ try {
886
+ const parsed = JSON.parse(row.payload_json);
887
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
888
+ payload = parsed;
889
+ }
890
+ }
891
+ catch {
892
+ payload = {};
893
+ }
894
+ return {
895
+ seq: row.seq,
896
+ id: row.id,
897
+ run_id: row.run_id,
898
+ worker_id: row.worker_id,
899
+ type: row.type,
900
+ payload,
901
+ state: row.state,
902
+ available_at: row.available_at,
903
+ acked_at: row.acked_at,
904
+ created_at: row.created_at,
905
+ updated_at: row.updated_at,
906
+ };
907
+ }
908
+ /**
909
+ * Narrow duplicate-classifier for a SQLite UNIQUE-constraint violation on a
910
+ * relay insert. Mirrors the approach in `supervisor-ledger.ts` — it does NOT
911
+ * swallow unrelated storage errors.
912
+ */
913
+ export function isDuplicateConstraintError(error) {
914
+ if (!error || typeof error !== "object")
915
+ return false;
916
+ const code = error.code;
917
+ if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT"))
918
+ return true;
919
+ const message = error.message;
920
+ if (typeof message === "string") {
921
+ const lowered = message.toLowerCase();
922
+ if (lowered.includes("unique constraint") || lowered.includes("constraint failed"))
923
+ return true;
924
+ }
925
+ return false;
926
+ }
927
+ /** Insert a single audit event into `events` using an ALREADY-open connection. */
928
+ function insertRelayAuditEvent(db, audit) {
929
+ const dataJson = prepareDataJsonForStorage(audit.data);
930
+ db.prepare(`INSERT INTO events
931
+ (id, source, type, subject, run_id, worker_id, producer, schema_version, time, data_json, confidence, observed_via)
932
+ VALUES
933
+ (@id, @source, @type, @subject, @run_id, @worker_id, @producer, @schema_version, @time, @data_json, @confidence, @observed_via)`).run({
934
+ id: audit.id,
935
+ source: audit.source,
936
+ type: audit.type,
937
+ subject: null,
938
+ run_id: audit.run_id,
939
+ worker_id: audit.worker_id,
940
+ producer: audit.producer,
941
+ schema_version: 1,
942
+ time: new Date().toISOString(),
943
+ data_json: dataJson,
944
+ confidence: null,
945
+ observed_via: audit.observed_via,
946
+ });
947
+ }
948
+ /**
949
+ * Enqueue a supervisor→worker message exactly once per idempotency key and
950
+ * suppress repeated same-type sends inside the configured cooldown window.
951
+ *
952
+ * Ordering: validate → compute id → normalize payload (redaction boundary) →
953
+ * open writable DB → (1) exact-id duplicate check, (2) per-type cooldown probe,
954
+ * (3) INSERT pending row + `message.sent` audit event in one transaction. An
955
+ * insert race on `messages.id` is reclassified as a duplicate. Message payloads
956
+ * are NEVER logged; unexpected storage errors are re-thrown after the connection
957
+ * is closed in `finally`.
958
+ */
959
+ export function sendWorkerMessage(input, config = resolveConductorStoreConfig()) {
960
+ validateWorkerMessageIdentity(input);
961
+ const runId = input.run_id.trim();
962
+ const workerId = input.worker_id.trim();
963
+ const type = input.type.trim();
964
+ const messageId = makeWorkerMessageId({
965
+ run_id: runId,
966
+ worker_id: workerId,
967
+ type,
968
+ cause_seq: input.cause_seq,
969
+ });
970
+ // Normalize + redact the payload BEFORE any transaction (same boundary as events).
971
+ const payloadJson = prepareDataJsonForStorage(input.payload ?? {});
972
+ // A caller-supplied available_at must be a parseable timestamp: an unparseable
973
+ // value makes SQLite's `julianday(available_at)` return NULL, so the message
974
+ // would never satisfy the `<= julianday('now')` delivery gate and would be stuck
975
+ // pending forever. Reject it at the boundary rather than silently black-holing it.
976
+ if (typeof input.available_at === "string" && input.available_at.trim().length > 0) {
977
+ if (!Number.isFinite(Date.parse(input.available_at))) {
978
+ throw new ConductorValidationError("Message 'available_at' must be a parseable ISO-8601 timestamp.");
979
+ }
980
+ }
981
+ const availableAt = typeof input.available_at === "string" && input.available_at.trim().length > 0
982
+ ? input.available_at
983
+ : new Date().toISOString();
984
+ const cooldownMs = typeof input.cooldown_ms === "number" && Number.isFinite(input.cooldown_ms) && input.cooldown_ms >= 0
985
+ ? Math.trunc(input.cooldown_ms)
986
+ : config.message_cooldown_ms;
987
+ const source = typeof input.source === "string" && input.source.trim().length > 0
988
+ ? input.source.trim()
989
+ : "conductor-supervisor";
990
+ const producer = typeof input.producer === "string" && input.producer.trim().length > 0
991
+ ? input.producer.trim()
992
+ : "worker-message-relay";
993
+ const db = openWritableConductorDatabase(config);
994
+ try {
995
+ // (1) Exact idempotency-key hit: never insert or audit a second time.
996
+ const existing = db
997
+ .prepare("SELECT * FROM messages WHERE id = ?")
998
+ .get(messageId);
999
+ if (existing) {
1000
+ return { status: "duplicate", message: rowToConductorWorkerMessage(existing) };
1001
+ }
1002
+ // (2) Per-type cooldown: a recent non-failed same-type message suppresses a
1003
+ // re-send. SQLite julianday arithmetic avoids ISO-vs-datetime string-compare
1004
+ // bugs. Disabled when cooldownMs <= 0.
1005
+ if (cooldownMs > 0) {
1006
+ const cooldownHit = db
1007
+ .prepare(`SELECT * FROM messages
1008
+ WHERE run_id = @run_id AND worker_id = @worker_id AND type = @type
1009
+ AND state != 'failed'
1010
+ AND (julianday('now') - julianday(created_at)) * 86400000.0 < @cooldown_ms
1011
+ ORDER BY seq DESC
1012
+ LIMIT 1`)
1013
+ .get({ run_id: runId, worker_id: workerId, type, cooldown_ms: cooldownMs });
1014
+ if (cooldownHit) {
1015
+ return { status: "cooldown_suppressed", message: rowToConductorWorkerMessage(cooldownHit) };
1016
+ }
1017
+ }
1018
+ // (3) Insert the pending message + its `message.sent` audit event atomically.
1019
+ const insertMessage = db.prepare(`INSERT INTO messages (id, run_id, worker_id, type, payload_json, state, available_at)
1020
+ VALUES (@id, @run_id, @worker_id, @type, @payload_json, 'pending', @available_at)`);
1021
+ const runInsert = db.transaction(() => {
1022
+ insertMessage.run({
1023
+ id: messageId,
1024
+ run_id: runId,
1025
+ worker_id: workerId,
1026
+ type,
1027
+ payload_json: payloadJson,
1028
+ available_at: availableAt,
1029
+ });
1030
+ insertRelayAuditEvent(db, {
1031
+ id: `message.sent:${messageId}`,
1032
+ source,
1033
+ type: "message.sent",
1034
+ run_id: runId,
1035
+ worker_id: workerId,
1036
+ producer,
1037
+ observed_via: "message-relay",
1038
+ data: {
1039
+ summary: "supervisor message enqueued",
1040
+ status: "sent",
1041
+ details: { message_id: messageId, message_type: type, cause_seq: Math.trunc(input.cause_seq) },
1042
+ },
1043
+ });
1044
+ });
1045
+ try {
1046
+ runInsert();
1047
+ }
1048
+ catch (error) {
1049
+ // A racing insert on the same idempotency key collides on messages.id —
1050
+ // reclassify as an idempotent duplicate rather than a failure.
1051
+ if (isDuplicateConstraintError(error)) {
1052
+ const raced = db
1053
+ .prepare("SELECT * FROM messages WHERE id = ?")
1054
+ .get(messageId);
1055
+ if (raced) {
1056
+ return { status: "duplicate", message: rowToConductorWorkerMessage(raced) };
1057
+ }
1058
+ }
1059
+ throw error;
1060
+ }
1061
+ const row = db
1062
+ .prepare("SELECT * FROM messages WHERE id = ?")
1063
+ .get(messageId);
1064
+ return { status: "enqueued", message: rowToConductorWorkerMessage(row) };
1065
+ }
1066
+ finally {
1067
+ db.close();
1068
+ }
1069
+ }
1070
+ /**
1071
+ * Cooperative worker poll: read pending, available messages for one worker and
1072
+ * acknowledge each in the SAME write transaction (pending → delivered → acked),
1073
+ * writing `message.delivered` and `message.acked` audit events. Acked messages
1074
+ * are never redelivered because the selection predicate matches `pending` only.
1075
+ *
1076
+ * The whole select+transition runs inside a single write transaction so a worker
1077
+ * always reads each pending message exactly once. Returned payloads are NEVER
1078
+ * logged; the connection is closed in `finally`.
1079
+ */
1080
+ export function checkWorkerMessages(input, config = resolveConductorStoreConfig()) {
1081
+ if (typeof input.run_id !== "string" || input.run_id.trim().length === 0) {
1082
+ throw new ConductorValidationError("Message poll 'run_id' is required and must be a non-empty string.");
1083
+ }
1084
+ if (typeof input.worker_id !== "string" || input.worker_id.trim().length === 0) {
1085
+ throw new ConductorValidationError("Message poll 'worker_id' is required and must be a non-empty string.");
1086
+ }
1087
+ const runId = input.run_id.trim();
1088
+ const workerId = input.worker_id.trim();
1089
+ const limit = typeof input.limit === "number" && Number.isFinite(input.limit) && input.limit > 0
1090
+ ? Math.min(CHECK_MESSAGES_LIMIT_MAX, Math.floor(input.limit))
1091
+ : CHECK_MESSAGES_LIMIT_DEFAULT;
1092
+ const db = openWritableConductorDatabase(config);
1093
+ try {
1094
+ const selectPending = db.prepare(`SELECT * FROM messages
1095
+ WHERE run_id = @run_id AND worker_id = @worker_id AND state = 'pending'
1096
+ AND julianday(available_at) <= julianday('now')
1097
+ ORDER BY seq ASC
1098
+ LIMIT @limit`);
1099
+ const toDelivered = db.prepare("UPDATE messages SET state = 'delivered', updated_at = datetime('now') WHERE seq = @seq AND state = 'pending'");
1100
+ const toAcked = db.prepare("UPDATE messages SET state = 'acked', acked_at = datetime('now'), updated_at = datetime('now') WHERE seq = @seq AND state = 'delivered'");
1101
+ const reread = db.prepare("SELECT * FROM messages WHERE seq = ?");
1102
+ const delivered = [];
1103
+ const runDelivery = db.transaction(() => {
1104
+ const pending = selectPending.all({ run_id: runId, worker_id: workerId, limit });
1105
+ for (const row of pending) {
1106
+ // Race-safe: only proceed when THIS call won the pending → delivered move.
1107
+ const claimed = toDelivered.run({ seq: row.seq });
1108
+ if (claimed.changes !== 1)
1109
+ continue;
1110
+ insertRelayAuditEvent(db, {
1111
+ id: `message.delivered:${row.id}`,
1112
+ source: "conductor-worker",
1113
+ type: "message.delivered",
1114
+ run_id: row.run_id,
1115
+ worker_id: row.worker_id,
1116
+ producer: "worker-message-relay",
1117
+ observed_via: "message-relay",
1118
+ data: {
1119
+ summary: "supervisor message delivered to worker",
1120
+ status: "delivered",
1121
+ details: { message_id: row.id, message_type: row.type },
1122
+ },
1123
+ });
1124
+ toAcked.run({ seq: row.seq });
1125
+ insertRelayAuditEvent(db, {
1126
+ id: `message.acked:${row.id}`,
1127
+ source: "conductor-worker",
1128
+ type: "message.acked",
1129
+ run_id: row.run_id,
1130
+ worker_id: row.worker_id,
1131
+ producer: "worker-message-relay",
1132
+ observed_via: "message-relay",
1133
+ data: {
1134
+ summary: "supervisor message acknowledged by worker",
1135
+ status: "acked",
1136
+ details: { message_id: row.id, message_type: row.type },
1137
+ },
1138
+ });
1139
+ const finalRow = reread.get(row.seq);
1140
+ delivered.push(rowToConductorWorkerMessage(finalRow));
1141
+ }
1142
+ });
1143
+ // BEGIN IMMEDIATE: this is a read-then-write transaction (SELECT pending, then
1144
+ // UPDATE). A default/deferred transaction takes only a WAL read snapshot on the
1145
+ // first SELECT and upgrades on the first UPDATE; if another connection commits
1146
+ // in between, SQLite throws SQLITE_BUSY_SNAPSHOT *without* honoring busy_timeout.
1147
+ // In the real conductor run the supervisor + N workers all write the same
1148
+ // ledger, so check_messages is the hottest concurrent path — acquiring the write
1149
+ // lock up front lets busy_timeout cover the contention and avoids the snapshot race.
1150
+ runDelivery.immediate();
1151
+ return { messages: delivered, count: delivered.length, acked_count: delivered.length };
1152
+ }
1153
+ finally {
1154
+ db.close();
1155
+ }
1156
+ }