@myagentroam/node 0.1.0 → 0.1.1

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/dist/database.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdirSync } from 'node:fs';
2
+ import { closeSync, existsSync, mkdirSync, openSync, rmSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { DatabaseSync } from 'node:sqlite';
5
5
  import { runnerDefaultConfigurationSchema } from '@myagentroam/protocol';
6
+ import { assertNodeSchema, NODE_SCHEMA_V1 } from './migrations/v001.js';
7
+ import { assertNodeSchemaV2, migrateNodeSchemaV2, NODE_SCHEMA_VERSION_V2 } from './migrations/v002.js';
8
+ const NODE_SCHEMA_VERSION = NODE_SCHEMA_VERSION_V2;
6
9
  export class NodeDatabase {
7
10
  now;
8
11
  activeWorkspaceLeaseCount() {
@@ -17,13 +20,20 @@ export class NodeDatabase {
17
20
  this.nodeIdProvider = typeof nodeId === 'string' ? () => nodeId : nodeId;
18
21
  if (path !== ':memory:')
19
22
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
20
- this.raw = new DatabaseSync(path, {
21
- enableForeignKeyConstraints: true,
22
- timeout: 5_000,
23
- defensive: true
24
- });
25
- this.raw.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;');
26
- migrate(this.raw);
23
+ const releaseMigrationLock = acquireNodeMigrationLock(path);
24
+ try {
25
+ this.raw = new DatabaseSync(path, {
26
+ enableForeignKeyConstraints: true,
27
+ timeout: 5_000,
28
+ defensive: true
29
+ });
30
+ this.raw.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;');
31
+ migrate(this.raw);
32
+ initializeEphemeralRuntimeTables(this.raw);
33
+ }
34
+ finally {
35
+ releaseMigrationLock();
36
+ }
27
37
  }
28
38
  close() {
29
39
  this.raw.close();
@@ -126,6 +136,7 @@ export class NodeDatabase {
126
136
  // Session deletion cascades their runs, conversation and attachments. The workspace
127
137
  // record itself never represents a filesystem deletion.
128
138
  this.raw.prepare('DELETE FROM agent_sessions WHERE workspace_id = ?').run(id);
139
+ this.raw.prepare('DELETE FROM legacy_agent_sessions WHERE workspace_id = ?').run(id);
129
140
  this.raw.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
130
141
  this.raw.exec('COMMIT');
131
142
  return current;
@@ -158,7 +169,7 @@ export class NodeDatabase {
158
169
  createdAt: this.now()
159
170
  };
160
171
  this.raw
161
- .prepare(`INSERT INTO agent_sessions
172
+ .prepare(`INSERT INTO legacy_agent_sessions
162
173
  (id, workspace_id, runner, external_session_id, native_control, channel_token, cwd, model, effort, access, custom_title, runner_title, pinned_at, title_source, last_activity_at, created_at, updated_at)
163
174
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
164
175
  .run(session.id, session.workspaceId, session.runner, session.externalSessionId, session.nativeControl, session.channelToken, session.cwd, session.model, session.effort, session.access, session.customTitle, session.runnerTitle, session.pinnedAt, session.titleSource, session.lastActivityAt, session.createdAt, session.createdAt);
@@ -166,13 +177,13 @@ export class NodeDatabase {
166
177
  }
167
178
  getAgentSession(id) {
168
179
  const row = this.raw
169
- .prepare('SELECT id, workspace_id, runner, external_session_id, native_control, channel_token, cwd, model, effort, access, custom_title, runner_title, pinned_at, title_source, last_activity_at, created_at FROM agent_sessions WHERE id = ?')
180
+ .prepare('SELECT id, workspace_id, runner, external_session_id, native_control, channel_token, cwd, model, effort, access, custom_title, runner_title, pinned_at, title_source, last_activity_at, created_at FROM legacy_agent_sessions WHERE id = ?')
170
181
  .get(id);
171
182
  return row === undefined ? undefined : session(row, this.nodeIdProvider());
172
183
  }
173
184
  updateAgentSessionConfiguration(input) {
174
185
  const result = this.raw
175
- .prepare(`UPDATE agent_sessions
186
+ .prepare(`UPDATE legacy_agent_sessions
176
187
  SET model = ?, effort = ?, access = ?, updated_at = ?
177
188
  WHERE id = ?`)
178
189
  .run(input.model, input.effort, input.access, this.now(), input.sessionId);
@@ -182,20 +193,20 @@ export class NodeDatabase {
182
193
  }
183
194
  listAgentSessions() {
184
195
  return this.raw
185
- .prepare('SELECT id, workspace_id, runner, external_session_id, native_control, channel_token, cwd, model, effort, access, custom_title, runner_title, pinned_at, title_source, last_activity_at, created_at FROM agent_sessions ORDER BY pinned_at IS NULL, pinned_at DESC, last_activity_at DESC, id DESC')
196
+ .prepare('SELECT id, workspace_id, runner, external_session_id, native_control, channel_token, cwd, model, effort, access, custom_title, runner_title, pinned_at, title_source, last_activity_at, created_at FROM legacy_agent_sessions ORDER BY pinned_at IS NULL, pinned_at DESC, last_activity_at DESC, id DESC')
186
197
  .all()
187
198
  .map((row) => session(row, this.nodeIdProvider()));
188
199
  }
189
200
  findAgentSessionByExternalSession(runner, externalSessionId) {
190
201
  const row = this.raw
191
202
  .prepare(`SELECT id, workspace_id, runner, external_session_id, native_control, channel_token, cwd, model, effort, access, custom_title, runner_title, pinned_at, title_source, last_activity_at, created_at
192
- FROM agent_sessions WHERE runner = ? AND external_session_id = ?`)
203
+ FROM legacy_agent_sessions WHERE runner = ? AND external_session_id = ?`)
193
204
  .get(runner, externalSessionId);
194
205
  return row === undefined ? undefined : session(row, this.nodeIdProvider());
195
206
  }
196
207
  setNativeControl(sessionId, nativeControl) {
197
208
  const result = this.raw
198
- .prepare('UPDATE agent_sessions SET native_control = ?, updated_at = ? WHERE id = ?')
209
+ .prepare('UPDATE legacy_agent_sessions SET native_control = ?, updated_at = ? WHERE id = ?')
199
210
  .run(nativeControl, this.now(), sessionId);
200
211
  if (Number(result.changes) === 0)
201
212
  throw new Error('SESSION_NOT_FOUND');
@@ -204,7 +215,7 @@ export class NodeDatabase {
204
215
  if (externalSessionId.length === 0)
205
216
  throw new Error('SESSION_EXTERNAL_ID_INVALID');
206
217
  const result = this.raw
207
- .prepare("UPDATE agent_sessions SET external_session_id = ?, native_control = 'MAR_MANAGED', updated_at = ? WHERE id = ?")
218
+ .prepare("UPDATE legacy_agent_sessions SET external_session_id = ?, native_control = 'MAR_MANAGED', updated_at = ? WHERE id = ?")
208
219
  .run(externalSessionId, this.now(), sessionId);
209
220
  if (Number(result.changes) === 0)
210
221
  throw new Error('SESSION_NOT_FOUND');
@@ -213,7 +224,7 @@ export class NodeDatabase {
213
224
  if (token.length < 16)
214
225
  throw new Error('SESSION_CHANNEL_TOKEN_INVALID');
215
226
  const result = this.raw
216
- .prepare('UPDATE agent_sessions SET channel_token = ?, updated_at = ? WHERE id = ?')
227
+ .prepare('UPDATE legacy_agent_sessions SET channel_token = ?, updated_at = ? WHERE id = ?')
217
228
  .run(token, this.now(), sessionId);
218
229
  if (Number(result.changes) === 0)
219
230
  throw new Error('SESSION_NOT_FOUND');
@@ -228,26 +239,21 @@ export class NodeDatabase {
228
239
  const pinnedAt = input.pinned === undefined ? current.pinnedAt : input.pinned ? this.now() : null;
229
240
  const titleSource = title === null ? 'AUTO' : 'CUSTOM';
230
241
  this.raw
231
- .prepare('UPDATE agent_sessions SET custom_title = ?, pinned_at = ?, title_source = ?, updated_at = ? WHERE id = ?')
242
+ .prepare('UPDATE legacy_agent_sessions SET custom_title = ?, pinned_at = ?, title_source = ?, updated_at = ? WHERE id = ?')
232
243
  .run(title, pinnedAt, titleSource, this.now(), input.sessionId);
233
244
  return this.getAgentSession(input.sessionId);
234
245
  }
235
246
  getNativeSessionMetadata(runner, externalSessionId, workspaceId) {
236
247
  const row = this.raw
237
- .prepare(`SELECT custom_title, runner_title, runner_title_pending, pinned_at, model, effort, access FROM native_session_metadata
238
- WHERE runner = ? AND external_session_id = ? AND workspace_id = ?`)
239
- .get(runner, externalSessionId, workspaceId);
240
- return row === undefined
241
- ? undefined
242
- : {
243
- customTitle: nullableString(row, 'custom_title'),
244
- runnerTitle: nullableString(row, 'runner_title'),
245
- runnerTitlePending: row['runner_title_pending'] === 1,
246
- pinnedAt: nullableNumber(row, 'pinned_at'),
247
- model: nullableString(row, 'model'),
248
- effort: nullableString(row, 'effort'),
249
- access: nullableString(row, 'access')
250
- };
248
+ .prepare('SELECT pin_order, metadata FROM agent_sessions WHERE id = ? AND runner = ? AND workspace_id = ?')
249
+ .get(externalSessionId, runner, workspaceId);
250
+ if (row === undefined)
251
+ return undefined;
252
+ return {
253
+ ...sessionMetadata(row),
254
+ pinnedAt: row['pin_order'] === null ? null : 1,
255
+ pinOrder: nullableNumber(row, 'pin_order')
256
+ };
251
257
  }
252
258
  saveNativeSessionMetadata(input) {
253
259
  const current = this.getNativeSessionMetadata(input.runner, input.externalSessionId, input.workspaceId);
@@ -264,21 +270,99 @@ export class NodeDatabase {
264
270
  throw new Error('SESSION_TITLE_INVALID');
265
271
  if (runnerTitle !== null && runnerTitle.length > 160)
266
272
  throw new Error('SESSION_TITLE_INVALID');
267
- const pinnedAt = input.pinned === undefined ? (current?.pinnedAt ?? null) : input.pinned ? this.now() : null;
273
+ let pinOrder = current?.pinOrder ?? null;
274
+ const pinAtTop = input.pinned === true && pinOrder === null;
275
+ if (pinAtTop)
276
+ pinOrder = 0;
277
+ else if (input.pinned === false)
278
+ pinOrder = null;
268
279
  const model = input.model === undefined ? (current?.model ?? null) : input.model;
269
280
  const effort = input.effort === undefined ? (current?.effort ?? null) : input.effort;
270
281
  const access = input.access === undefined ? (current?.access ?? null) : input.access;
271
- this.raw
272
- .prepare(`INSERT INTO native_session_metadata
273
- (runner, external_session_id, workspace_id, custom_title, runner_title, runner_title_pending, pinned_at, model, effort, access, updated_at)
274
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
275
- ON CONFLICT(runner, external_session_id, workspace_id) DO UPDATE SET
276
- custom_title = excluded.custom_title, runner_title = excluded.runner_title,
277
- runner_title_pending = excluded.runner_title_pending, pinned_at = excluded.pinned_at,
278
- model = excluded.model, effort = excluded.effort, access = excluded.access,
279
- updated_at = excluded.updated_at`)
280
- .run(input.runner, input.externalSessionId, input.workspaceId, customTitle, runnerTitle, runnerTitlePending ? 1 : 0, pinnedAt, model, effort, access, this.now());
281
- return { customTitle, runnerTitle, runnerTitlePending, pinnedAt, model, effort, access };
282
+ const metadata = JSON.stringify({
283
+ version: 1,
284
+ customTitle,
285
+ runnerTitle,
286
+ runnerTitlePending,
287
+ model,
288
+ effort,
289
+ access
290
+ });
291
+ this.raw.exec('BEGIN IMMEDIATE');
292
+ try {
293
+ if (pinAtTop)
294
+ this.raw
295
+ .prepare('UPDATE agent_sessions SET pin_order = pin_order + 1 WHERE workspace_id = ? AND pin_order IS NOT NULL')
296
+ .run(input.workspaceId);
297
+ this.raw
298
+ .prepare(`INSERT INTO agent_sessions (id, workspace_id, runner, pin_order, metadata)
299
+ VALUES (?, ?, ?, ?, ?)
300
+ ON CONFLICT(id) DO UPDATE SET workspace_id = excluded.workspace_id,
301
+ runner = excluded.runner, pin_order = excluded.pin_order, metadata = excluded.metadata`)
302
+ .run(input.externalSessionId, input.workspaceId, input.runner, pinOrder, metadata);
303
+ this.raw.exec('COMMIT');
304
+ }
305
+ catch (error) {
306
+ this.raw.exec('ROLLBACK');
307
+ throw error;
308
+ }
309
+ return {
310
+ customTitle,
311
+ runnerTitle,
312
+ runnerTitlePending,
313
+ pinnedAt: pinOrder === null ? null : 1,
314
+ pinOrder,
315
+ model,
316
+ effort,
317
+ access
318
+ };
319
+ }
320
+ listSessionRecords(workspaceId) {
321
+ return this.raw
322
+ .prepare('SELECT id, workspace_id, runner, pin_order, metadata FROM agent_sessions WHERE workspace_id = ? ORDER BY pin_order IS NULL, pin_order, id')
323
+ .all(workspaceId).map((row) => ({
324
+ id: stringValue(row, 'id'),
325
+ workspaceId: stringValue(row, 'workspace_id'),
326
+ runner: stringValue(row, 'runner'),
327
+ pinOrder: nullableNumber(row, 'pin_order'),
328
+ metadata: {
329
+ ...sessionMetadata(row),
330
+ pinnedAt: row['pin_order'] === null ? null : 1,
331
+ pinOrder: nullableNumber(row, 'pin_order')
332
+ }
333
+ }));
334
+ }
335
+ movePinnedSession(sessionId, direction) {
336
+ const current = this.raw
337
+ .prepare('SELECT workspace_id, pin_order FROM agent_sessions WHERE id = ?')
338
+ .get(sessionId);
339
+ if (current === undefined || current['pin_order'] === null)
340
+ throw new Error('SESSION_NOT_PINNED');
341
+ const order = numberValue(current, 'pin_order');
342
+ const adjacent = this.raw
343
+ .prepare(direction === 'up'
344
+ ? 'SELECT id, pin_order FROM agent_sessions WHERE workspace_id = ? AND pin_order < ? ORDER BY pin_order DESC LIMIT 1'
345
+ : 'SELECT id, pin_order FROM agent_sessions WHERE workspace_id = ? AND pin_order > ? ORDER BY pin_order ASC LIMIT 1')
346
+ .get(stringValue(current, 'workspace_id'), order);
347
+ if (adjacent === undefined)
348
+ return;
349
+ this.raw.exec('BEGIN IMMEDIATE');
350
+ try {
351
+ this.raw
352
+ .prepare('UPDATE agent_sessions SET pin_order = ? WHERE id = ?')
353
+ .run(order, stringValue(adjacent, 'id'));
354
+ this.raw
355
+ .prepare('UPDATE agent_sessions SET pin_order = ? WHERE id = ?')
356
+ .run(numberValue(adjacent, 'pin_order'), sessionId);
357
+ this.raw.exec('COMMIT');
358
+ }
359
+ catch (error) {
360
+ this.raw.exec('ROLLBACK');
361
+ throw error;
362
+ }
363
+ }
364
+ deleteSessionRecord(sessionId) {
365
+ return (Number(this.raw.prepare('DELETE FROM agent_sessions WHERE id = ?').run(sessionId).changes) > 0);
282
366
  }
283
367
  /**
284
368
  * Replaces only transcript-derived, run-less history. Managed Run turns are
@@ -319,7 +403,7 @@ export class NodeDatabase {
319
403
  }
320
404
  const runnerTitle = history.title?.trim().slice(0, 160) || current.runnerTitle;
321
405
  this.raw
322
- .prepare(`UPDATE agent_sessions
406
+ .prepare(`UPDATE legacy_agent_sessions
323
407
  SET runner_title = ?, title_source = CASE WHEN custom_title IS NULL THEN 'RUNNER' ELSE title_source END,
324
408
  last_activity_at = CASE WHEN ? > last_activity_at THEN ? ELSE last_activity_at END, updated_at = ?
325
409
  WHERE id = ?`)
@@ -409,7 +493,7 @@ export class NodeDatabase {
409
493
  }), value.createdAt, value.createdAt, value.createdAt, value.createdAt);
410
494
  this.recordConversationItemChange(value.sessionId, userItemId, 0);
411
495
  this.raw
412
- .prepare('UPDATE agent_sessions SET last_activity_at = ?, updated_at = ? WHERE id = ?')
496
+ .prepare('UPDATE legacy_agent_sessions SET last_activity_at = ?, updated_at = ? WHERE id = ?')
413
497
  .run(value.createdAt, value.createdAt, value.sessionId);
414
498
  this.recordWorkspaceActivity(value.workspaceId, 'run.created', {
415
499
  runId: value.id,
@@ -909,210 +993,171 @@ export class NodeDatabase {
909
993
  }
910
994
  }
911
995
  this.raw
912
- .prepare('UPDATE agent_sessions SET last_activity_at = ?, updated_at = ? WHERE id = ?')
996
+ .prepare('UPDATE legacy_agent_sessions SET last_activity_at = ?, updated_at = ? WHERE id = ?')
913
997
  .run(timestamp, timestamp, sessionId);
914
998
  }
915
999
  nodeIdProvider;
916
1000
  }
917
1001
  function migrate(database) {
918
- database.exec(`CREATE TABLE IF NOT EXISTS node_schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL) STRICT;
919
- CREATE TABLE IF NOT EXISTS workspaces (
920
- id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, kind TEXT NOT NULL CHECK (kind IN ('DIRECTORY', 'GIT_WORKSPACE')),
921
- git_summary TEXT,
922
- created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
923
- ) STRICT;
924
- CREATE TABLE IF NOT EXISTS agent_sessions (
925
- id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id),
926
- runner TEXT NOT NULL CHECK (runner IN ('codex', 'claude-code')), external_session_id TEXT,
927
- native_control TEXT NOT NULL DEFAULT 'EXTERNAL' CHECK (native_control IN ('MAR_MANAGED', 'EXTERNAL')), channel_token TEXT,
928
- cwd TEXT NOT NULL, model TEXT, effort TEXT, access TEXT, custom_title TEXT, runner_title TEXT, pinned_at INTEGER,
929
- title_source TEXT NOT NULL DEFAULT 'AUTO' CHECK (title_source IN ('AUTO', 'RUNNER', 'CUSTOM')),
930
- last_activity_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
931
- ) STRICT;
932
- CREATE TABLE IF NOT EXISTS native_session_checkpoints (
933
- session_id TEXT PRIMARY KEY REFERENCES agent_sessions(id) ON DELETE CASCADE,
934
- digest TEXT NOT NULL, updated_at INTEGER NOT NULL
935
- ) STRICT;
936
- CREATE TABLE IF NOT EXISTS native_session_metadata (
937
- runner TEXT NOT NULL CHECK (runner IN ('codex', 'claude-code')),
938
- external_session_id TEXT NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
939
- custom_title TEXT, runner_title TEXT, runner_title_pending INTEGER NOT NULL DEFAULT 0 CHECK (runner_title_pending IN (0, 1)), pinned_at INTEGER,
940
- model TEXT, effort TEXT, access TEXT, updated_at INTEGER NOT NULL,
941
- PRIMARY KEY (runner, external_session_id, workspace_id)
942
- ) STRICT;
943
- CREATE TABLE IF NOT EXISTS session_messages (
944
- id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
945
- client_message_id TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('USER', 'ASSISTANT', 'SYSTEM')),
946
- content TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(session_id, client_message_id)
947
- ) STRICT;
948
- CREATE TABLE IF NOT EXISTS message_attachments (
949
- id TEXT PRIMARY KEY, message_id TEXT NOT NULL REFERENCES session_messages(id) ON DELETE CASCADE,
950
- name TEXT NOT NULL, mime TEXT NOT NULL, byte_size INTEGER NOT NULL CHECK (byte_size >= 0),
951
- data BLOB NOT NULL, created_at INTEGER NOT NULL
952
- ) STRICT;
953
- CREATE TABLE IF NOT EXISTS agent_runs (
954
- id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
955
- message_id TEXT NOT NULL UNIQUE REFERENCES session_messages(id) ON DELETE CASCADE,
956
- workspace_id TEXT NOT NULL REFERENCES workspaces(id), runner TEXT NOT NULL CHECK (runner IN ('codex', 'claude-code')),
957
- status TEXT NOT NULL CHECK (status IN ('QUEUED', 'STARTING', 'RUNNING', 'CANCELLING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED')),
958
- recovery_state TEXT NOT NULL CHECK (recovery_state IN ('ACTIVE', 'SUSPECT', 'LOST')),
959
- recovery_updated_at INTEGER NOT NULL DEFAULT 0, version INTEGER NOT NULL CHECK (version >= 0),
960
- created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
961
- ) STRICT;
962
- CREATE TABLE IF NOT EXISTS run_events (
963
- id INTEGER PRIMARY KEY, run_id TEXT NOT NULL REFERENCES agent_runs(id) ON DELETE CASCADE,
964
- sequence INTEGER NOT NULL CHECK (sequence > 0), event_type TEXT NOT NULL, payload TEXT NOT NULL,
965
- created_at INTEGER NOT NULL, UNIQUE(run_id, sequence)
966
- ) STRICT;
967
- CREATE TABLE IF NOT EXISTS conversation_turns (
968
- id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
969
- run_id TEXT UNIQUE REFERENCES agent_runs(id) ON DELETE SET NULL, user_item_id TEXT,
970
- status TEXT NOT NULL, model TEXT, effort TEXT, access TEXT,
971
- started_at INTEGER, completed_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
972
- ) STRICT;
973
- CREATE TABLE IF NOT EXISTS conversation_items (
974
- id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
975
- turn_id TEXT NOT NULL REFERENCES conversation_turns(id) ON DELETE CASCADE,
976
- run_id TEXT REFERENCES agent_runs(id) ON DELETE SET NULL, parent_item_id TEXT,
977
- source_sequence INTEGER, revision INTEGER NOT NULL CHECK (revision >= 0), kind TEXT NOT NULL,
978
- status TEXT NOT NULL, payload TEXT NOT NULL, started_at INTEGER, completed_at INTEGER,
979
- created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(turn_id, id)
980
- ) STRICT;
981
- CREATE TABLE IF NOT EXISTS conversation_item_events (
982
- id INTEGER PRIMARY KEY, session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
983
- item_id TEXT NOT NULL REFERENCES conversation_items(id) ON DELETE CASCADE,
984
- revision INTEGER NOT NULL CHECK (revision >= 0), created_at INTEGER NOT NULL
985
- ) STRICT;
986
- CREATE TABLE IF NOT EXISTS workspace_activity_events (
987
- id INTEGER PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
988
- event_type TEXT NOT NULL, payload TEXT NOT NULL, created_at INTEGER NOT NULL
989
- ) STRICT;
990
- CREATE TABLE IF NOT EXISTS approvals (
991
- id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES agent_runs(id) ON DELETE CASCADE,
992
- status TEXT NOT NULL CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED', 'EXPIRED')), payload TEXT NOT NULL,
993
- expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, decided_at INTEGER
994
- ) STRICT;
995
- CREATE TABLE IF NOT EXISTS workspace_leases (
996
- id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
997
- run_id TEXT NOT NULL UNIQUE REFERENCES agent_runs(id) ON DELETE CASCADE,
998
- state TEXT NOT NULL CHECK (state IN ('ACTIVE', 'SUSPECT', 'RELEASED')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
999
- ) STRICT;
1000
- CREATE UNIQUE INDEX IF NOT EXISTS workspace_leases_active_workspace_idx
1001
- ON workspace_leases(workspace_id) WHERE state IN ('ACTIVE', 'SUSPECT');
1002
- CREATE TABLE IF NOT EXISTS run_snapshots (
1003
- run_id TEXT PRIMARY KEY REFERENCES agent_runs(id) ON DELETE CASCADE, snapshot TEXT NOT NULL, created_at INTEGER NOT NULL
1004
- ) STRICT;
1005
- CREATE TABLE IF NOT EXISTS terminal_sessions (
1006
- id TEXT PRIMARY KEY, workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL, profile_id TEXT NOT NULL,
1007
- state TEXT NOT NULL CHECK (state IN ('RUNNING', 'EXITED', 'CLOSED')),
1008
- exit_code INTEGER, rows INTEGER NOT NULL CHECK (rows > 0 AND rows <= 1000),
1009
- cols INTEGER NOT NULL CHECK (cols > 0 AND cols <= 1000),
1010
- latest_sequence INTEGER NOT NULL CHECK (latest_sequence >= 0),
1011
- created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
1012
- ) STRICT;
1013
- CREATE TABLE IF NOT EXISTS runner_defaults (
1014
- runner TEXT PRIMARY KEY CHECK (runner IN ('codex', 'claude-code')),
1015
- model TEXT NOT NULL, effort TEXT NOT NULL, access TEXT NOT NULL
1016
- ) STRICT;
1017
- CREATE INDEX IF NOT EXISTS agent_runs_session_idx ON agent_runs(session_id, created_at);
1018
- CREATE INDEX IF NOT EXISTS run_events_cursor_idx ON run_events(run_id, sequence);
1019
- CREATE INDEX IF NOT EXISTS conversation_turns_session_idx ON conversation_turns(session_id, created_at, id);
1020
- CREATE INDEX IF NOT EXISTS conversation_items_turn_idx ON conversation_items(turn_id, source_sequence);
1021
- CREATE INDEX IF NOT EXISTS conversation_items_session_cursor_idx ON conversation_items(session_id, updated_at, id);
1022
- CREATE INDEX IF NOT EXISTS conversation_item_events_session_cursor_idx ON conversation_item_events(session_id, id);
1023
- CREATE INDEX IF NOT EXISTS workspace_activity_cursor_idx ON workspace_activity_events(workspace_id, id);`);
1024
- database.exec('DROP TABLE IF EXISTS run_change_snapshots');
1025
- const workspaceColumns = database
1026
- .prepare('PRAGMA table_info(workspaces)')
1027
- .all();
1028
- // A short-lived legacy schema required this field without a default. The
1029
- // current Workspace model has no sharing flag, so retaining it would reject
1030
- // every subsequent registration performed by the current INSERT statement.
1031
- if (workspaceColumns.some((column) => column['name'] === 'share_allowed'))
1032
- database.exec('ALTER TABLE workspaces DROP COLUMN share_allowed');
1033
- const nativeMetadataColumns = database
1034
- .prepare('PRAGMA table_info(native_session_metadata)')
1035
- .all();
1036
- if (!nativeMetadataColumns.some((column) => column['name'] === 'runner_title'))
1037
- database.exec('ALTER TABLE native_session_metadata ADD COLUMN runner_title TEXT');
1038
- if (!nativeMetadataColumns.some((column) => column['name'] === 'runner_title_pending'))
1039
- database.exec('ALTER TABLE native_session_metadata ADD COLUMN runner_title_pending INTEGER NOT NULL DEFAULT 0 CHECK (runner_title_pending IN (0, 1))');
1040
- for (const column of ['model', 'effort', 'access']) {
1041
- if (!nativeMetadataColumns.some((value) => value['name'] === column))
1042
- database.exec(`ALTER TABLE native_session_metadata ADD COLUMN ${column} TEXT`);
1043
- }
1044
- const runnerDefaultColumns = database
1045
- .prepare('PRAGMA table_info(runner_defaults)')
1046
- .all();
1047
- if (runnerDefaultColumns.some((column) => column['name'] === 'singleton')) {
1048
- database.exec(`BEGIN IMMEDIATE;
1049
- CREATE TABLE runner_defaults_next (
1050
- runner TEXT PRIMARY KEY CHECK (runner IN ('codex', 'claude-code')),
1051
- model TEXT NOT NULL, effort TEXT NOT NULL, access TEXT NOT NULL
1052
- ) STRICT;
1053
- INSERT INTO runner_defaults_next (runner, model, effort, access)
1054
- SELECT runner, model, effort, access FROM runner_defaults;
1055
- DROP TABLE runner_defaults;
1056
- ALTER TABLE runner_defaults_next RENAME TO runner_defaults;
1057
- COMMIT;`);
1058
- }
1059
- const columns = database.prepare('PRAGMA table_info(agent_sessions)').all();
1060
- if (!columns.some((column) => column['name'] === 'native_control')) {
1061
- // Existing databases cannot prove how an old native ID was obtained. The
1062
- // safe migration is therefore OBSERVE_ONLY until the user creates or
1063
- // resumes it through this Node again.
1064
- database.exec("ALTER TABLE agent_sessions ADD COLUMN native_control TEXT NOT NULL DEFAULT 'EXTERNAL' CHECK (native_control IN ('MAR_MANAGED', 'EXTERNAL'))");
1065
- }
1066
- if (!columns.some((column) => column['name'] === 'custom_title')) {
1067
- database.exec('ALTER TABLE agent_sessions ADD COLUMN custom_title TEXT');
1068
- }
1069
- if (!columns.some((column) => column['name'] === 'runner_title')) {
1070
- database.exec('ALTER TABLE agent_sessions ADD COLUMN runner_title TEXT');
1071
- }
1072
- if (!columns.some((column) => column['name'] === 'pinned_at')) {
1073
- database.exec('ALTER TABLE agent_sessions ADD COLUMN pinned_at INTEGER');
1074
- }
1075
- if (!columns.some((column) => column['name'] === 'title_source')) {
1076
- database.exec("ALTER TABLE agent_sessions ADD COLUMN title_source TEXT NOT NULL DEFAULT 'AUTO'");
1077
- }
1078
- if (!columns.some((column) => column['name'] === 'last_activity_at')) {
1079
- database.exec('ALTER TABLE agent_sessions ADD COLUMN last_activity_at INTEGER NOT NULL DEFAULT 0');
1080
- }
1081
- for (const column of ['model', 'effort', 'access']) {
1082
- if (!columns.some((value) => value['name'] === column)) {
1083
- database.exec(`ALTER TABLE agent_sessions ADD COLUMN ${column} TEXT`);
1002
+ const existingApplicationTables = database
1003
+ .prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN ('workspaces', 'agent_sessions', 'runner_defaults')")
1004
+ .get();
1005
+ const existingMigrationTable = database
1006
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'node_schema_migrations'")
1007
+ .get();
1008
+ const existingMigrationRows = existingMigrationTable === undefined
1009
+ ? 0
1010
+ : numberValue(database.prepare('SELECT COUNT(*) AS count FROM node_schema_migrations').get(), 'count');
1011
+ let currentUnversionedSchema = false;
1012
+ if (numberValue(existingApplicationTables, 'count') > 0 && existingMigrationRows === 0) {
1013
+ try {
1014
+ assertNodeSchemaV2(database);
1015
+ currentUnversionedSchema = true;
1016
+ }
1017
+ catch {
1018
+ assertNodeSchema(database);
1019
+ }
1020
+ }
1021
+ if (currentUnversionedSchema) {
1022
+ database.exec('BEGIN IMMEDIATE');
1023
+ try {
1024
+ database
1025
+ .prepare('INSERT INTO node_schema_migrations (version, applied_at) VALUES (?, ?), (?, ?)')
1026
+ .run(1, Date.now(), 2, Date.now());
1027
+ database.exec('COMMIT');
1028
+ return;
1029
+ }
1030
+ catch (error) {
1031
+ database.exec('ROLLBACK');
1032
+ throw error;
1084
1033
  }
1085
1034
  }
1086
- // Pre-existing projections did not retain a detail cursor stream. Seed one
1087
- // final snapshot event per item; native runner payload is never reconstructed.
1088
- database.exec(`INSERT INTO conversation_item_events (session_id, item_id, revision, created_at)
1089
- SELECT ci.session_id, ci.id, ci.revision, ci.updated_at
1090
- FROM conversation_items ci
1091
- WHERE NOT EXISTS (
1092
- SELECT 1 FROM conversation_item_events cie WHERE cie.item_id = ci.id
1093
- )`);
1094
- const terminalColumns = database
1095
- .prepare('PRAGMA table_info(terminal_sessions)')
1035
+ if (numberValue(existingApplicationTables, 'count') === 0)
1036
+ database.exec(NODE_SCHEMA_V1);
1037
+ const migrations = database
1038
+ .prepare('SELECT version FROM node_schema_migrations ORDER BY version')
1096
1039
  .all();
1097
- if (!terminalColumns.some((column) => column['name'] === 'workspace_id')) {
1098
- database.exec('ALTER TABLE terminal_sessions ADD COLUMN workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL');
1040
+ const versions = migrations.map((row) => numberValue(row, 'version'));
1041
+ const current = versions.at(-1) ?? 0;
1042
+ if (versions.some((version) => version > NODE_SCHEMA_VERSION))
1043
+ throw new Error('NODE_DATABASE_VERSION_UNSUPPORTED');
1044
+ if (versions.some((version, index) => version !== index + 1))
1045
+ throw new Error('NODE_DATABASE_MIGRATION_HISTORY_INVALID');
1046
+ const hasApplicationTables = database
1047
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name IN ('workspaces', 'agent_sessions', 'runner_defaults') LIMIT 1")
1048
+ .get();
1049
+ if (current === 0 && hasApplicationTables !== undefined) {
1050
+ assertNodeSchema(database);
1051
+ database.exec('BEGIN IMMEDIATE');
1052
+ try {
1053
+ database
1054
+ .prepare('INSERT INTO node_schema_migrations (version, applied_at) VALUES (?, ?)')
1055
+ .run(1, Date.now());
1056
+ database.exec('COMMIT');
1057
+ }
1058
+ catch (error) {
1059
+ database.exec('ROLLBACK');
1060
+ throw error;
1061
+ }
1062
+ migrateNodeSchemaV2(database);
1063
+ assertNodeSchemaV2(database);
1064
+ return;
1065
+ }
1066
+ if (current === 0) {
1067
+ database.exec('BEGIN IMMEDIATE');
1068
+ try {
1069
+ database.exec('CREATE TABLE IF NOT EXISTS node_schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL) STRICT');
1070
+ database
1071
+ .prepare('INSERT INTO node_schema_migrations (version, applied_at) VALUES (?, ?)')
1072
+ .run(1, Date.now());
1073
+ database.exec('COMMIT');
1074
+ }
1075
+ catch (error) {
1076
+ database.exec('ROLLBACK');
1077
+ throw error;
1078
+ }
1099
1079
  }
1100
- const approvalDefinition = database
1101
- .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'approvals'")
1080
+ const afterBaseline = database
1081
+ .prepare('SELECT MAX(version) AS version FROM node_schema_migrations')
1102
1082
  .get();
1103
- if (approvalDefinition !== undefined &&
1104
- !stringValue(approvalDefinition, 'sql').includes("'EXPIRED'")) {
1105
- database.exec(`BEGIN IMMEDIATE;
1106
- CREATE TABLE approvals_next (
1107
- id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES agent_runs(id) ON DELETE CASCADE,
1108
- status TEXT NOT NULL CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED', 'EXPIRED')), payload TEXT NOT NULL,
1109
- expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, decided_at INTEGER
1110
- ) STRICT;
1111
- INSERT INTO approvals_next (id, run_id, status, payload, expires_at, created_at, updated_at, decided_at)
1112
- SELECT id, run_id, status, payload, expires_at, created_at, updated_at, decided_at FROM approvals;
1113
- DROP TABLE approvals;
1114
- ALTER TABLE approvals_next RENAME TO approvals;
1115
- COMMIT;`);
1083
+ if (numberValue(afterBaseline, 'version') === 1)
1084
+ migrateNodeSchemaV2(database);
1085
+ if (NODE_SCHEMA_VERSION === 2) {
1086
+ assertNodeSchemaV2(database);
1087
+ }
1088
+ else {
1089
+ assertNodeSchema(database);
1090
+ }
1091
+ }
1092
+ function sessionMetadata(row) {
1093
+ const raw = JSON.parse(stringValue(row, 'metadata'));
1094
+ if (raw['version'] !== 1)
1095
+ throw new Error('SESSION_METADATA_VERSION_UNSUPPORTED');
1096
+ const value = (key) => typeof raw[key] === 'string' ? raw[key] : null;
1097
+ return {
1098
+ customTitle: value('customTitle'),
1099
+ runnerTitle: value('runnerTitle'),
1100
+ runnerTitlePending: raw['runnerTitlePending'] === true,
1101
+ model: value('model'),
1102
+ effort: value('effort'),
1103
+ access: value('access')
1104
+ };
1105
+ }
1106
+ /**
1107
+ * Legacy database helpers are still used by focused lifecycle tests, but the
1108
+ * product runtime lives in NodeRuntimeState. Keep those helpers explicitly
1109
+ * process-local so no Session/Run/Conversation state reaches the Node file.
1110
+ */
1111
+ function initializeEphemeralRuntimeTables(database) {
1112
+ const runtimeTables = new Set([
1113
+ 'agent_sessions',
1114
+ 'native_session_checkpoints',
1115
+ 'session_messages',
1116
+ 'message_attachments',
1117
+ 'agent_runs',
1118
+ 'run_events',
1119
+ 'conversation_turns',
1120
+ 'conversation_items',
1121
+ 'conversation_item_events',
1122
+ 'workspace_activity_events',
1123
+ 'approvals',
1124
+ 'workspace_leases',
1125
+ 'run_snapshots'
1126
+ ]);
1127
+ for (const raw of NODE_SCHEMA_V1.split(';')) {
1128
+ const statement = raw.trim();
1129
+ const match = statement.match(/^CREATE TABLE IF NOT EXISTS ([a-z_]+)/);
1130
+ if (match === null || !runtimeTables.has(match[1] ?? ''))
1131
+ continue;
1132
+ database.exec(statement
1133
+ .replace(/^CREATE TABLE IF NOT EXISTS/, 'CREATE TEMP TABLE IF NOT EXISTS')
1134
+ .replace('CREATE TEMP TABLE IF NOT EXISTS agent_sessions', 'CREATE TEMP TABLE IF NOT EXISTS legacy_agent_sessions')
1135
+ .replaceAll('REFERENCES agent_sessions', 'REFERENCES legacy_agent_sessions')
1136
+ .replaceAll(/ REFERENCES workspaces\(id\)(?: ON DELETE (?:CASCADE|SET NULL))?/g, '') + ';');
1137
+ }
1138
+ database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS temp.workspace_leases_active_workspace_idx
1139
+ ON workspace_leases(workspace_id) WHERE state IN ('ACTIVE', 'SUSPECT')`);
1140
+ }
1141
+ function acquireNodeMigrationLock(databasePath) {
1142
+ if (databasePath === ':memory:')
1143
+ return () => undefined;
1144
+ const lockPath = `${databasePath}.migration.lock`;
1145
+ const deadline = Date.now() + 5_000;
1146
+ while (true) {
1147
+ try {
1148
+ const descriptor = openSync(lockPath, 'wx', 0o600);
1149
+ return () => {
1150
+ closeSync(descriptor);
1151
+ if (existsSync(lockPath))
1152
+ rmSync(lockPath);
1153
+ };
1154
+ }
1155
+ catch (error) {
1156
+ const code = error instanceof Error && 'code' in error ? error.code : undefined;
1157
+ if (code !== 'EEXIST' || Date.now() >= deadline)
1158
+ throw new Error('NODE_DATABASE_MIGRATION_LOCK_UNAVAILABLE');
1159
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
1160
+ }
1116
1161
  }
1117
1162
  }
1118
1163
  function workspace(row) {