@myagentroam/node 0.9.171 → 0.9.173

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/connector.js CHANGED
@@ -1399,6 +1399,7 @@ export class NodeConnector {
1399
1399
  throw new Error('NODE_CONFIG_UNAVAILABLE');
1400
1400
  return this.config;
1401
1401
  }, this.runtime, (workspaceId) => {
1402
+ this.terminalManager.removeWorkspaceAssociation(workspaceId);
1402
1403
  this.workspaceFileService.dispose(workspaceId);
1403
1404
  this.workspaceCoordinator.invalidateChanges(workspaceId);
1404
1405
  this.workspaceSessionService.invalidate(workspaceId);
@@ -0,0 +1,42 @@
1
+ export function assertNodeNoForeignKeys(database) {
2
+ const tables = database
3
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
4
+ .all();
5
+ for (const { name } of tables) {
6
+ if (database.prepare('SELECT 1 FROM pragma_foreign_key_list(?) LIMIT 1').get(name) !== undefined)
7
+ throw new Error(`NODE_DATABASE_SCHEMA_INVALID:foreign_keys:${name}`);
8
+ }
9
+ }
10
+ /** Validate persisted Node associations independently of physical foreign keys. */
11
+ export function assertNodeRelations(database) {
12
+ const checks = {
13
+ agent_sessions: `SELECT 1 FROM agent_sessions child LEFT JOIN workspaces parent
14
+ ON parent.id=child.workspace_id WHERE parent.id IS NULL LIMIT 1`,
15
+ terminal_sessions: `SELECT 1 FROM terminal_sessions child LEFT JOIN workspaces parent
16
+ ON parent.id=child.workspace_id WHERE child.workspace_id IS NOT NULL AND parent.id IS NULL LIMIT 1`,
17
+ conversation_files: `SELECT 1 FROM conversation_files child LEFT JOIN hosted_conversations parent
18
+ ON parent.id=child.conversation_id WHERE parent.id IS NULL LIMIT 1`,
19
+ hosted_conversation_messages: `SELECT 1 FROM hosted_conversation_messages child LEFT JOIN hosted_conversations parent
20
+ ON parent.id=child.conversation_id WHERE parent.id IS NULL LIMIT 1`,
21
+ hosted_conversations: `SELECT 1 FROM hosted_conversations child LEFT JOIN conversation_template_versions parent
22
+ ON parent.id=child.active_version_id WHERE parent.id IS NULL OR parent.template_id<>child.template_id LIMIT 1`,
23
+ conversation_template_publish_jobs: `SELECT 1 FROM conversation_template_publish_jobs child LEFT JOIN conversation_template_versions parent
24
+ ON parent.id=child.version_id WHERE child.version_id IS NOT NULL
25
+ AND (parent.id IS NULL OR parent.template_id<>child.template_id) LIMIT 1`,
26
+ scheduled_session_tasks: `SELECT 1 FROM scheduled_session_tasks child
27
+ LEFT JOIN agent_sessions session ON session.id=child.session_id
28
+ LEFT JOIN workspaces workspace ON workspace.id=child.workspace_id
29
+ WHERE session.id IS NULL OR workspace.id IS NULL OR session.workspace_id<>child.workspace_id
30
+ OR session.runner<>child.runner LIMIT 1`,
31
+ scheduled_session_occurrences: `SELECT 1 FROM scheduled_session_occurrences child LEFT JOIN scheduled_session_tasks parent
32
+ ON parent.id=child.task_id WHERE parent.id IS NULL LIMIT 1`,
33
+ scheduled_session_proposals: `SELECT 1 FROM scheduled_session_proposals child LEFT JOIN scheduled_session_tasks parent
34
+ ON parent.id=child.task_id WHERE parent.id IS NULL LIMIT 1`,
35
+ mcp_installations: `SELECT 1 FROM mcp_installations child LEFT JOIN workspaces parent
36
+ ON parent.id=child.target_id WHERE child.target_kind='WORKSPACE' AND parent.id IS NULL LIMIT 1`
37
+ };
38
+ for (const [table, query] of Object.entries(checks)) {
39
+ if (database.prepare(query).get() !== undefined)
40
+ throw new Error(`NODE_DATABASE_SCHEMA_INVALID:relations:${table}`);
41
+ }
42
+ }
package/dist/database.js CHANGED
@@ -7,10 +7,14 @@ import { mcpCatalogEntrySchema, mcpInstallationConfigurationSchema, mcpRuntimeDe
7
7
  import { assertNodeSchemaV13, NODE_SCHEMA_V13, NODE_SCHEMA_VERSION_V13 } from './migrations/v013.js';
8
8
  import { NODE_SCHEMA_MIGRATION_V14 } from './migrations/v014.js';
9
9
  import { NODE_SCHEMA_MIGRATION_V15 } from './migrations/v015.js';
10
+ import { NODE_SCHEMA_MIGRATION_V16 } from './migrations/v016.js';
11
+ import { ScheduledSessionStore } from './service/scheduled-session-store.js';
12
+ import { assertNodeNoForeignKeys, assertNodeRelations } from './database-relations.js';
10
13
  // Future released migrations must be contiguous, immutable and begin at v14; Demo is excluded.
11
14
  export const NODE_SCHEMA_MIGRATIONS = [
12
15
  NODE_SCHEMA_MIGRATION_V14,
13
- NODE_SCHEMA_MIGRATION_V15
16
+ NODE_SCHEMA_MIGRATION_V15,
17
+ NODE_SCHEMA_MIGRATION_V16
14
18
  ];
15
19
  const LEGACY_NODE_SCHEMA_V12 = NODE_SCHEMA_VERSION_V13 - 1;
16
20
  const OBSOLETE_NODE_SCHEMA_V16 = 16;
@@ -24,14 +28,14 @@ export class NodeDatabase {
24
28
  const releaseMigrationLock = acquireNodeMigrationLock(path);
25
29
  try {
26
30
  this.raw = new DatabaseSync(path, {
27
- enableForeignKeyConstraints: true,
31
+ enableForeignKeyConstraints: false,
28
32
  timeout: 5_000,
29
33
  defensive: true
30
34
  });
31
35
  try {
32
36
  // Refuse unsupported databases before changing the on-disk journal mode.
33
37
  migrate(this.raw);
34
- this.raw.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;');
38
+ this.raw.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;');
35
39
  }
36
40
  catch (error) {
37
41
  this.raw.close();
@@ -42,6 +46,18 @@ export class NodeDatabase {
42
46
  releaseMigrationLock();
43
47
  }
44
48
  }
49
+ write(action) {
50
+ this.raw.exec('BEGIN IMMEDIATE');
51
+ try {
52
+ const result = action();
53
+ this.raw.exec('COMMIT');
54
+ return result;
55
+ }
56
+ catch (error) {
57
+ this.raw.exec('ROLLBACK');
58
+ throw error;
59
+ }
60
+ }
45
61
  close() {
46
62
  this.raw.close();
47
63
  }
@@ -78,33 +94,35 @@ export class NodeDatabase {
78
94
  return rows.map(mcpInstallation);
79
95
  }
80
96
  saveMcpInstallation(input) {
81
- const entry = mcpCatalogEntrySchema.parse(input.entry);
82
- const configuration = mcpInstallationConfigurationSchema.parse(input.configuration);
83
- const targetId = input.targetKind === 'NODE' ? '' : (input.targetId ?? '');
84
- if (input.targetKind === 'WORKSPACE' && this.getWorkspace(targetId) === undefined)
85
- throw new Error('WORKSPACE_NOT_FOUND');
86
- const installedAt = this.now();
87
- this.raw
88
- .prepare(`INSERT INTO mcp_installations
89
- (target_kind,target_id,catalog_entry_id,server_name,display_name,version,enabled,runtime,configuration,installed_at)
90
- VALUES (?,?,?,?,?,?,?,?,?,?)
91
- ON CONFLICT(target_kind,target_id,server_name) DO UPDATE SET
92
- catalog_entry_id=excluded.catalog_entry_id,display_name=excluded.display_name,
93
- version=excluded.version,enabled=excluded.enabled,runtime=excluded.runtime,
94
- configuration=excluded.configuration,installed_at=excluded.installed_at`)
95
- .run(input.targetKind, targetId, entry.id, entry.serverName, entry.displayName, entry.version, input.enabled ? 1 : 0, JSON.stringify(entry.runtime), JSON.stringify(configuration), installedAt);
96
- return {
97
- targetKind: input.targetKind,
98
- targetId,
99
- catalogEntryId: entry.id,
100
- serverName: entry.serverName,
101
- displayName: entry.displayName,
102
- version: entry.version,
103
- enabled: input.enabled,
104
- runtime: entry.runtime,
105
- configuration,
106
- installedAt
107
- };
97
+ return this.write(() => {
98
+ const entry = mcpCatalogEntrySchema.parse(input.entry);
99
+ const configuration = mcpInstallationConfigurationSchema.parse(input.configuration);
100
+ const targetId = input.targetKind === 'NODE' ? '' : (input.targetId ?? '');
101
+ if (input.targetKind === 'WORKSPACE' && this.getWorkspace(targetId) === undefined)
102
+ throw new Error('WORKSPACE_NOT_FOUND');
103
+ const installedAt = this.now();
104
+ this.raw
105
+ .prepare(`INSERT INTO mcp_installations
106
+ (target_kind,target_id,catalog_entry_id,server_name,display_name,version,enabled,runtime,configuration,installed_at)
107
+ VALUES (?,?,?,?,?,?,?,?,?,?)
108
+ ON CONFLICT(target_kind,target_id,server_name) DO UPDATE SET
109
+ catalog_entry_id=excluded.catalog_entry_id,display_name=excluded.display_name,
110
+ version=excluded.version,enabled=excluded.enabled,runtime=excluded.runtime,
111
+ configuration=excluded.configuration,installed_at=excluded.installed_at`)
112
+ .run(input.targetKind, targetId, entry.id, entry.serverName, entry.displayName, entry.version, input.enabled ? 1 : 0, JSON.stringify(entry.runtime), JSON.stringify(configuration), installedAt);
113
+ return {
114
+ targetKind: input.targetKind,
115
+ targetId,
116
+ catalogEntryId: entry.id,
117
+ serverName: entry.serverName,
118
+ displayName: entry.displayName,
119
+ version: entry.version,
120
+ enabled: input.enabled,
121
+ runtime: entry.runtime,
122
+ configuration,
123
+ installedAt
124
+ };
125
+ });
108
126
  }
109
127
  removeMcpInstallation(targetKind, targetId, serverName) {
110
128
  return (Number(this.raw
@@ -125,15 +143,19 @@ export class NodeDatabase {
125
143
  * the Node-local bounded replay buffer and must never be passed here.
126
144
  */
127
145
  saveTerminalSession(input) {
128
- this.raw
129
- .prepare(`INSERT INTO terminal_sessions
130
- (id, workspace_id, profile_id, state, exit_code, rows, cols, latest_sequence, created_at, updated_at)
131
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
132
- ON CONFLICT(id) DO UPDATE SET workspace_id = excluded.workspace_id, profile_id = excluded.profile_id, state = excluded.state,
133
- exit_code = excluded.exit_code, rows = excluded.rows, cols = excluded.cols,
134
- latest_sequence = excluded.latest_sequence, updated_at = excluded.updated_at`)
135
- .run(input.id, input.workspaceId, input.profileId, input.state, input.exitCode, input.rows, input.cols, input.latestSequence, input.createdAt, input.updatedAt);
136
- return this.getTerminalSession(input.id);
146
+ return this.write(() => {
147
+ if (input.workspaceId !== null && this.getWorkspace(input.workspaceId) === undefined)
148
+ throw new Error('WORKSPACE_NOT_FOUND');
149
+ this.raw
150
+ .prepare(`INSERT INTO terminal_sessions
151
+ (id, workspace_id, profile_id, state, exit_code, rows, cols, latest_sequence, created_at, updated_at)
152
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
153
+ ON CONFLICT(id) DO UPDATE SET workspace_id = excluded.workspace_id, profile_id = excluded.profile_id, state = excluded.state,
154
+ exit_code = excluded.exit_code, rows = excluded.rows, cols = excluded.cols,
155
+ latest_sequence = excluded.latest_sequence, updated_at = excluded.updated_at`)
156
+ .run(input.id, input.workspaceId, input.profileId, input.state, input.exitCode, input.rows, input.cols, input.latestSequence, input.createdAt, input.updatedAt);
157
+ return this.getTerminalSession(input.id);
158
+ });
137
159
  }
138
160
  getTerminalSession(id) {
139
161
  const row = this.raw
@@ -203,14 +225,16 @@ export class NodeDatabase {
203
225
  const current = this.getWorkspace(id);
204
226
  if (current === undefined)
205
227
  throw new Error('WORKSPACE_NOT_FOUND');
206
- this.raw.exec('BEGIN');
228
+ this.raw.exec('BEGIN IMMEDIATE');
207
229
  try {
208
- // Session deletion cascades their runs, conversation and attachments. The workspace
209
- // record itself never represents a filesystem deletion.
230
+ new ScheduledSessionStore(this.raw).removeForWorkspace(id);
210
231
  this.raw.prepare('DELETE FROM agent_sessions WHERE workspace_id = ?').run(id);
211
232
  this.raw
212
233
  .prepare("DELETE FROM mcp_installations WHERE target_kind='WORKSPACE' AND target_id = ?")
213
234
  .run(id);
235
+ this.raw
236
+ .prepare('UPDATE terminal_sessions SET workspace_id=NULL WHERE workspace_id=?')
237
+ .run(id);
214
238
  this.raw.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
215
239
  this.raw.exec('COMMIT');
216
240
  return current;
@@ -267,6 +291,16 @@ export class NodeDatabase {
267
291
  });
268
292
  this.raw.exec('BEGIN IMMEDIATE');
269
293
  try {
294
+ if (this.getWorkspace(input.workspaceId) === undefined)
295
+ throw new Error('WORKSPACE_NOT_FOUND');
296
+ const scheduled = this.raw
297
+ .prepare('SELECT workspace_id,runner FROM scheduled_session_tasks WHERE session_id=?')
298
+ .get(input.externalSessionId);
299
+ if (scheduled !== undefined &&
300
+ (scheduled['workspace_id'] !== input.workspaceId || scheduled['runner'] !== input.runner))
301
+ throw Object.assign(new Error('SCHEDULE_SESSION_MISMATCH'), {
302
+ publicMessage: "The scheduled task must use its session's Workspace and Runner."
303
+ });
270
304
  if (pinAtTop)
271
305
  this.raw
272
306
  .prepare('UPDATE agent_sessions SET pin_order = pin_order + 1 WHERE workspace_id = ? AND pin_order IS NOT NULL')
@@ -339,7 +373,18 @@ export class NodeDatabase {
339
373
  }
340
374
  }
341
375
  deleteSessionRecord(sessionId) {
342
- return (Number(this.raw.prepare('DELETE FROM agent_sessions WHERE id = ?').run(sessionId).changes) > 0);
376
+ this.raw.exec('BEGIN IMMEDIATE');
377
+ try {
378
+ new ScheduledSessionStore(this.raw).removeForSession(sessionId);
379
+ const removed = Number(this.raw.prepare('DELETE FROM agent_sessions WHERE id = ?').run(sessionId).changes) >
380
+ 0;
381
+ this.raw.exec('COMMIT');
382
+ return removed;
383
+ }
384
+ catch (error) {
385
+ this.raw.exec('ROLLBACK');
386
+ throw error;
387
+ }
343
388
  }
344
389
  }
345
390
  export function migrate(database, catalog = NODE_SCHEMA_MIGRATIONS) {
@@ -397,10 +442,12 @@ export function migrate(database, catalog = NODE_SCHEMA_MIGRATIONS) {
397
442
  const migratedVersions = database
398
443
  .prepare('SELECT version FROM node_schema_migrations ORDER BY version')
399
444
  .all().map((row) => numberValue(row, 'version'));
400
- if (latest === NODE_SCHEMA_MIGRATION_V15.version &&
445
+ if (latest >= NODE_SCHEMA_MIGRATION_V15.version &&
401
446
  migratedVersions.length === 4 &&
402
447
  migratedVersions.every((version, index) => version === NODE_SCHEMA_VERSION_V13 + index) &&
403
- migratedVersions.at(-1) === OBSOLETE_NODE_SCHEMA_V16) {
448
+ migratedVersions.at(-1) === OBSOLETE_NODE_SCHEMA_V16 &&
449
+ database.prepare('PRAGMA table_info(scheduled_session_tasks)').all().some((column) => column.name === 'notify_user_id') &&
450
+ database.prepare('PRAGMA foreign_key_list(scheduled_session_tasks)').all().length !== 0) {
404
451
  downgradeLegacyRecipientSchema(database);
405
452
  migratedVersions.pop();
406
453
  }
@@ -416,10 +463,18 @@ export function migrate(database, catalog = NODE_SCHEMA_MIGRATIONS) {
416
463
  for (const entry of catalog) {
417
464
  if (entry.version <= current)
418
465
  continue;
466
+ const foreignKeys = database.prepare('PRAGMA foreign_keys').get()?.['foreign_keys'];
467
+ if (entry.rebuildTables)
468
+ database.exec('PRAGMA foreign_keys=OFF');
419
469
  database.exec('BEGIN IMMEDIATE');
420
470
  try {
471
+ entry.preflight?.(database);
421
472
  database.exec(entry.sql);
422
473
  entry.assert(database);
474
+ if (entry.version >= NODE_SCHEMA_MIGRATION_V16.version) {
475
+ assertNodeNoForeignKeys(database);
476
+ assertNodeRelations(database);
477
+ }
423
478
  database
424
479
  .prepare('INSERT INTO node_schema_migrations (version, applied_at) VALUES (?, ?)')
425
480
  .run(entry.version, Date.now());
@@ -429,9 +484,17 @@ export function migrate(database, catalog = NODE_SCHEMA_MIGRATIONS) {
429
484
  database.exec('ROLLBACK');
430
485
  throw error;
431
486
  }
487
+ finally {
488
+ if (entry.rebuildTables && foreignKeys === 1)
489
+ database.exec('PRAGMA foreign_keys=ON');
490
+ }
432
491
  }
433
492
  if (catalog.length > 0)
434
493
  catalog[catalog.length - 1].assert(database);
494
+ if (latest >= NODE_SCHEMA_MIGRATION_V16.version) {
495
+ assertNodeNoForeignKeys(database);
496
+ assertNodeRelations(database);
497
+ }
435
498
  }
436
499
  function downgradeLegacyRecipientSchema(database) {
437
500
  const reference = new DatabaseSync(':memory:');
@@ -0,0 +1,388 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { assertNodeRelations } from '../database-relations.js';
3
+ import { NODE_SCHEMA_V13 } from './v013.js';
4
+ import { NODE_SCHEMA_MIGRATION_V14 } from './v014.js';
5
+ import { NODE_SCHEMA_MIGRATION_V15 } from './v015.js';
6
+ const obsoleteTables = [
7
+ 'share_sessions',
8
+ 'share_invitations',
9
+ 'share_participants',
10
+ 'worktree_snapshots',
11
+ 'worktree_shares'
12
+ ];
13
+ const currentTables = [
14
+ 'agent_sessions',
15
+ 'conversation_files',
16
+ 'conversation_template_publish_jobs',
17
+ 'conversation_template_versions',
18
+ 'hosted_conversation_messages',
19
+ 'hosted_conversations',
20
+ 'mcp_installations',
21
+ 'node_schema_migrations',
22
+ 'runner_defaults',
23
+ 'scheduled_session_occurrences',
24
+ 'scheduled_session_proposals',
25
+ 'scheduled_session_tasks',
26
+ 'terminal_sessions',
27
+ 'workspaces'
28
+ ];
29
+ const rebuiltColumns = {
30
+ agent_sessions: ['id', 'workspace_id', 'runner', 'pin_order', 'metadata'],
31
+ conversation_files: [
32
+ 'id',
33
+ 'conversation_id',
34
+ 'category',
35
+ 'relative_path',
36
+ 'display_name',
37
+ 'mime_type',
38
+ 'size',
39
+ 'source',
40
+ 'turn_id',
41
+ 'item_id',
42
+ 'created_at',
43
+ 'updated_at'
44
+ ],
45
+ conversation_template_publish_jobs: [
46
+ 'id',
47
+ 'template_id',
48
+ 'draft_revision',
49
+ 'stage',
50
+ 'status',
51
+ 'version_id',
52
+ 'error_code',
53
+ 'error_summary',
54
+ 'requested_by_user_id',
55
+ 'draft',
56
+ 'created_at',
57
+ 'updated_at'
58
+ ],
59
+ hosted_conversation_messages: [
60
+ 'conversation_id',
61
+ 'client_message_id',
62
+ 'run_id',
63
+ 'status',
64
+ 'created_at',
65
+ 'updated_at',
66
+ 'user_text_present'
67
+ ],
68
+ hosted_conversations: [
69
+ 'id',
70
+ 'user_storage_key',
71
+ 'conversation_storage_key',
72
+ 'client_request_id',
73
+ 'node_id',
74
+ 'session_id',
75
+ 'template_id',
76
+ 'template_name',
77
+ 'active_version_id',
78
+ 'title',
79
+ 'status',
80
+ 'model_id',
81
+ 'template_deleted',
82
+ 'used_bytes',
83
+ 'max_bytes',
84
+ 'created_at',
85
+ 'updated_at',
86
+ 'last_activity_at',
87
+ 'pinned_at',
88
+ 'title_source'
89
+ ],
90
+ scheduled_session_occurrences: [
91
+ 'id',
92
+ 'task_id',
93
+ 'due_at',
94
+ 'status',
95
+ 'code',
96
+ 'run_id',
97
+ 'created_at',
98
+ 'updated_at'
99
+ ],
100
+ scheduled_session_proposals: [
101
+ 'id',
102
+ 'task_id',
103
+ 'proposed_by_user_id',
104
+ 'base_revision',
105
+ 'objective',
106
+ 'cron',
107
+ 'timezone',
108
+ 'status',
109
+ 'created_at',
110
+ 'resolved_at'
111
+ ],
112
+ scheduled_session_tasks: [
113
+ 'id',
114
+ 'session_id',
115
+ 'workspace_id',
116
+ 'runner',
117
+ 'owner_user_id',
118
+ 'objective',
119
+ 'cron',
120
+ 'timezone',
121
+ 'enabled',
122
+ 'revision',
123
+ 'next_run_at',
124
+ 'created_at',
125
+ 'updated_at',
126
+ 'notify_on_terminal'
127
+ ],
128
+ terminal_sessions: [
129
+ 'id',
130
+ 'workspace_id',
131
+ 'profile_id',
132
+ 'state',
133
+ 'exit_code',
134
+ 'rows',
135
+ 'cols',
136
+ 'latest_sequence',
137
+ 'created_at',
138
+ 'updated_at'
139
+ ]
140
+ };
141
+ export const NODE_SCHEMA_MIGRATION_V16 = {
142
+ version: 16,
143
+ rebuildTables: true,
144
+ preflight(database) {
145
+ const tables = database
146
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
147
+ .all();
148
+ for (const { name } of tables) {
149
+ if (obsoleteTables.some((table) => table === name)) {
150
+ if (database.prepare(`SELECT 1 FROM "${name}" LIMIT 1`).get() !== undefined)
151
+ throw new Error(`NODE_DATABASE_OBSOLETE_TABLE_NOT_EMPTY:${name}`);
152
+ }
153
+ else if (!currentTables.some((table) => table === name)) {
154
+ throw new Error(`NODE_DATABASE_SCHEMA_INVALID:table:${name}`);
155
+ }
156
+ }
157
+ for (const [table, expected] of Object.entries(rebuiltColumns)) {
158
+ const actual = database.prepare(`PRAGMA table_info(${table})`).all();
159
+ if (actual.length !== expected.length ||
160
+ actual.some((column) => !expected.includes(column.name)))
161
+ throw new Error(`NODE_DATABASE_SCHEMA_INVALID:${table}`);
162
+ }
163
+ if (database.prepare("SELECT 1 FROM sqlite_master WHERE type='trigger' LIMIT 1").get() !==
164
+ undefined)
165
+ throw new Error('NODE_DATABASE_SCHEMA_INVALID:triggers');
166
+ const reference = new DatabaseSync(':memory:');
167
+ try {
168
+ reference.exec(NODE_SCHEMA_V13);
169
+ reference.exec(NODE_SCHEMA_MIGRATION_V14.sql);
170
+ reference.exec(NODE_SCHEMA_MIGRATION_V15.sql);
171
+ const normalize = (sql) => sql
172
+ .replace(/\s+REFERENCES\s+\w+\([^)]*\)(?:\s+ON DELETE\s+(?:CASCADE|SET NULL))?/giu, '')
173
+ .replace(/"(\w+)"/gu, '$1')
174
+ .replace(/\s+/gu, ' ')
175
+ .replace(/\s*([(),])\s*/gu, '$1')
176
+ .trim();
177
+ const query = "SELECT type,name,tbl_name,sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type,name";
178
+ const expected = reference.prepare(query).all();
179
+ const actual = database.prepare(query).all().filter((entry) => !obsoleteTables.some((table) => table === entry.tbl_name));
180
+ const definition = (entry) => {
181
+ const sql = normalize(entry.sql);
182
+ // Released upgrades appended this nullable column; new installs declared it second.
183
+ return entry.name === 'terminal_sessions'
184
+ ? sql.replace(/(?:,workspace_id TEXT|workspace_id TEXT,)/u, '')
185
+ : sql;
186
+ };
187
+ if (actual.length !== expected.length ||
188
+ expected.some((entry, index) => entry.type !== actual[index]?.type ||
189
+ entry.name !== actual[index]?.name ||
190
+ definition(entry) !== definition(actual[index])))
191
+ throw new Error('NODE_DATABASE_SCHEMA_INVALID:definitions');
192
+ }
193
+ finally {
194
+ reference.close();
195
+ }
196
+ },
197
+ sql: `DROP TABLE IF EXISTS share_sessions;
198
+ DROP TABLE IF EXISTS share_invitations;
199
+ DROP TABLE IF EXISTS share_participants;
200
+ DROP TABLE IF EXISTS worktree_snapshots;
201
+ DROP TABLE IF EXISTS worktree_shares;
202
+ CREATE TABLE _node_v16_agent_sessions (
203
+ id TEXT PRIMARY KEY,
204
+ workspace_id TEXT NOT NULL,
205
+ runner TEXT NOT NULL CHECK (runner IN ('mar-agent', 'codex', 'claude-code', 'opencode')),
206
+ pin_order INTEGER CHECK (pin_order IS NULL OR pin_order >= 0),
207
+ metadata TEXT NOT NULL CHECK (json_valid(metadata))
208
+ ) STRICT;
209
+ INSERT INTO _node_v16_agent_sessions (id,workspace_id,runner,pin_order,metadata) SELECT id,workspace_id,runner,pin_order,metadata FROM agent_sessions;
210
+ DROP TABLE agent_sessions;
211
+ ALTER TABLE _node_v16_agent_sessions RENAME TO agent_sessions;
212
+ CREATE TABLE _node_v16_conversation_files (
213
+ id TEXT PRIMARY KEY,
214
+ conversation_id TEXT NOT NULL,
215
+ category TEXT NOT NULL CHECK (category IN ('UPLOAD','OUTPUT')),
216
+ relative_path TEXT NOT NULL,
217
+ display_name TEXT NOT NULL,
218
+ mime_type TEXT NOT NULL,
219
+ size INTEGER NOT NULL CHECK (size >= 0),
220
+ source TEXT NOT NULL CHECK (source IN ('USER','AGENT','SYSTEM')),
221
+ turn_id TEXT,
222
+ item_id TEXT,
223
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
224
+ updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
225
+ UNIQUE (conversation_id, category, relative_path)
226
+ ) STRICT;
227
+ INSERT INTO _node_v16_conversation_files (id,conversation_id,category,relative_path,display_name,mime_type,size,source,turn_id,item_id,created_at,updated_at) SELECT id,conversation_id,category,relative_path,display_name,mime_type,size,source,turn_id,item_id,created_at,updated_at FROM conversation_files;
228
+ DROP TABLE conversation_files;
229
+ ALTER TABLE _node_v16_conversation_files RENAME TO conversation_files;
230
+ CREATE INDEX conversation_files_conversation_updated
231
+ ON conversation_files(conversation_id, updated_at DESC, id);
232
+ CREATE TABLE _node_v16_conversation_template_publish_jobs (
233
+ id TEXT PRIMARY KEY,
234
+ template_id TEXT NOT NULL,
235
+ draft_revision INTEGER NOT NULL CHECK (draft_revision > 0),
236
+ stage TEXT NOT NULL CHECK (
237
+ stage IN ('VALIDATING','ENUMERATING','COPYING','SCANNING','MANIFEST','SEALED','REGISTERED')
238
+ ),
239
+ status TEXT NOT NULL CHECK (status IN ('QUEUED','RUNNING','SUCCEEDED','FAILED')),
240
+ version_id TEXT,
241
+ error_code TEXT,
242
+ error_summary TEXT,
243
+ requested_by_user_id INTEGER NOT NULL CHECK (requested_by_user_id > 0),
244
+ draft TEXT NOT NULL CHECK (json_valid(draft)),
245
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
246
+ updated_at INTEGER NOT NULL CHECK (updated_at >= 0)
247
+ ) STRICT;
248
+ INSERT INTO _node_v16_conversation_template_publish_jobs (id,template_id,draft_revision,stage,status,version_id,error_code,error_summary,requested_by_user_id,draft,created_at,updated_at) SELECT id,template_id,draft_revision,stage,status,version_id,error_code,error_summary,requested_by_user_id,draft,created_at,updated_at FROM conversation_template_publish_jobs;
249
+ DROP TABLE conversation_template_publish_jobs;
250
+ ALTER TABLE _node_v16_conversation_template_publish_jobs RENAME TO conversation_template_publish_jobs;
251
+ CREATE INDEX conversation_template_publish_jobs_status
252
+ ON conversation_template_publish_jobs(status, updated_at);
253
+ CREATE INDEX conversation_template_publish_jobs_template
254
+ ON conversation_template_publish_jobs(template_id, created_at, id);
255
+ CREATE TABLE _node_v16_hosted_conversation_messages (
256
+ conversation_id TEXT NOT NULL,
257
+ client_message_id TEXT NOT NULL,
258
+ run_id TEXT NOT NULL,
259
+ status TEXT NOT NULL CHECK (status IN ('RUNNING','SUCCEEDED','FAILED','INTERRUPTED')),
260
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
261
+ updated_at INTEGER NOT NULL CHECK (updated_at >= 0), user_text_present INTEGER NOT NULL DEFAULT 1
262
+ CHECK (user_text_present IN (0,1)),
263
+ PRIMARY KEY (conversation_id, client_message_id)
264
+ ) STRICT;
265
+ INSERT INTO _node_v16_hosted_conversation_messages (conversation_id,client_message_id,run_id,status,created_at,updated_at,user_text_present) SELECT conversation_id,client_message_id,run_id,status,created_at,updated_at,user_text_present FROM hosted_conversation_messages;
266
+ DROP TABLE hosted_conversation_messages;
267
+ ALTER TABLE _node_v16_hosted_conversation_messages RENAME TO hosted_conversation_messages;
268
+ CREATE TABLE _node_v16_hosted_conversations (
269
+ id TEXT PRIMARY KEY,
270
+ user_storage_key TEXT NOT NULL,
271
+ conversation_storage_key TEXT NOT NULL,
272
+ client_request_id TEXT,
273
+ node_id TEXT NOT NULL,
274
+ session_id TEXT NOT NULL,
275
+ template_id TEXT NOT NULL,
276
+ template_name TEXT NOT NULL,
277
+ active_version_id TEXT NOT NULL,
278
+ title TEXT NOT NULL,
279
+ status TEXT NOT NULL CHECK (status IN ('ACTIVE')),
280
+ model_id TEXT NOT NULL,
281
+ template_deleted INTEGER NOT NULL DEFAULT 0 CHECK (template_deleted IN (0,1)),
282
+ used_bytes INTEGER NOT NULL DEFAULT 0 CHECK (used_bytes >= 0),
283
+ max_bytes INTEGER NOT NULL CHECK (max_bytes > 0),
284
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
285
+ updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
286
+ last_activity_at INTEGER NOT NULL CHECK (last_activity_at >= 0),
287
+ pinned_at INTEGER CHECK (pinned_at IS NULL OR pinned_at >= 0),
288
+ title_source TEXT NOT NULL DEFAULT 'CUSTOM'
289
+ CHECK (title_source IN ('DEFAULT','AUTO','CUSTOM')),
290
+ UNIQUE (user_storage_key, conversation_storage_key),
291
+ UNIQUE (user_storage_key, client_request_id)
292
+ ) STRICT;
293
+ INSERT INTO _node_v16_hosted_conversations (id,user_storage_key,conversation_storage_key,client_request_id,node_id,session_id,template_id,template_name,active_version_id,title,status,model_id,template_deleted,used_bytes,max_bytes,created_at,updated_at,last_activity_at,pinned_at,title_source) SELECT id,user_storage_key,conversation_storage_key,client_request_id,node_id,session_id,template_id,template_name,active_version_id,title,status,model_id,template_deleted,used_bytes,max_bytes,created_at,updated_at,last_activity_at,pinned_at,title_source FROM hosted_conversations;
294
+ DROP TABLE hosted_conversations;
295
+ ALTER TABLE _node_v16_hosted_conversations RENAME TO hosted_conversations;
296
+ CREATE INDEX hosted_conversations_template
297
+ ON hosted_conversations(template_id, status, id);
298
+ CREATE INDEX hosted_conversations_user_pin_activity
299
+ ON hosted_conversations(
300
+ user_storage_key,
301
+ (pinned_at IS NOT NULL) DESC,
302
+ last_activity_at DESC,
303
+ id DESC
304
+ );
305
+ CREATE TABLE _node_v16_scheduled_session_occurrences (
306
+ id TEXT PRIMARY KEY,
307
+ task_id TEXT NOT NULL,
308
+ due_at INTEGER NOT NULL CHECK (due_at >= 0),
309
+ status TEXT NOT NULL CHECK (status IN ('CLAIMED','SUBMITTED','SUCCEEDED','SKIPPED','FAILED')),
310
+ code TEXT,
311
+ run_id TEXT,
312
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
313
+ updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
314
+ UNIQUE(task_id, due_at)
315
+ ) STRICT;
316
+ INSERT INTO _node_v16_scheduled_session_occurrences (id,task_id,due_at,status,code,run_id,created_at,updated_at) SELECT id,task_id,due_at,status,code,run_id,created_at,updated_at FROM scheduled_session_occurrences;
317
+ DROP TABLE scheduled_session_occurrences;
318
+ ALTER TABLE _node_v16_scheduled_session_occurrences RENAME TO scheduled_session_occurrences;
319
+ CREATE INDEX scheduled_session_occurrences_recent
320
+ ON scheduled_session_occurrences(task_id, due_at DESC);
321
+ CREATE TABLE _node_v16_scheduled_session_proposals (
322
+ id TEXT PRIMARY KEY,
323
+ task_id TEXT NOT NULL,
324
+ proposed_by_user_id INTEGER NOT NULL CHECK (proposed_by_user_id > 0),
325
+ base_revision INTEGER NOT NULL CHECK (base_revision > 0),
326
+ objective TEXT CHECK (objective IS NULL OR length(objective) BETWEEN 1 AND 20000),
327
+ cron TEXT,
328
+ timezone TEXT,
329
+ status TEXT NOT NULL CHECK (status IN ('PENDING','ACCEPTED','REJECTED','STALE')),
330
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
331
+ resolved_at INTEGER
332
+ ) STRICT;
333
+ INSERT INTO _node_v16_scheduled_session_proposals (id,task_id,proposed_by_user_id,base_revision,objective,cron,timezone,status,created_at,resolved_at) SELECT id,task_id,proposed_by_user_id,base_revision,objective,cron,timezone,status,created_at,resolved_at FROM scheduled_session_proposals;
334
+ DROP TABLE scheduled_session_proposals;
335
+ ALTER TABLE _node_v16_scheduled_session_proposals RENAME TO scheduled_session_proposals;
336
+ CREATE INDEX scheduled_session_proposals_pending
337
+ ON scheduled_session_proposals(task_id, status, created_at DESC);
338
+ CREATE TABLE _node_v16_scheduled_session_tasks (
339
+ id TEXT PRIMARY KEY,
340
+ session_id TEXT NOT NULL UNIQUE,
341
+ workspace_id TEXT NOT NULL,
342
+ runner TEXT NOT NULL CHECK (runner IN ('mar-agent','codex','claude-code','opencode')),
343
+ owner_user_id INTEGER NOT NULL CHECK (owner_user_id > 0),
344
+ objective TEXT NOT NULL CHECK (length(objective) BETWEEN 1 AND 20000),
345
+ cron TEXT NOT NULL,
346
+ timezone TEXT NOT NULL,
347
+ enabled INTEGER NOT NULL CHECK (enabled IN (0,1)),
348
+ revision INTEGER NOT NULL CHECK (revision > 0),
349
+ next_run_at INTEGER CHECK (next_run_at IS NULL OR next_run_at >= 0),
350
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
351
+ updated_at INTEGER NOT NULL CHECK (updated_at >= 0)
352
+ , notify_on_terminal INTEGER NOT NULL DEFAULT 0 CHECK (notify_on_terminal IN (0,1))) STRICT;
353
+ INSERT INTO _node_v16_scheduled_session_tasks (id,session_id,workspace_id,runner,owner_user_id,objective,cron,timezone,enabled,revision,next_run_at,created_at,updated_at,notify_on_terminal) SELECT id,session_id,workspace_id,runner,owner_user_id,objective,cron,timezone,enabled,revision,next_run_at,created_at,updated_at,notify_on_terminal FROM scheduled_session_tasks;
354
+ DROP TABLE scheduled_session_tasks;
355
+ ALTER TABLE _node_v16_scheduled_session_tasks RENAME TO scheduled_session_tasks;
356
+ CREATE INDEX scheduled_session_tasks_due
357
+ ON scheduled_session_tasks(enabled, next_run_at);
358
+ CREATE TABLE _node_v16_terminal_sessions (
359
+ id TEXT PRIMARY KEY, workspace_id TEXT,
360
+ profile_id TEXT NOT NULL,
361
+ state TEXT NOT NULL CHECK (state IN ('RUNNING', 'EXITED', 'CLOSED')),
362
+ exit_code INTEGER, rows INTEGER NOT NULL CHECK (rows > 0 AND rows <= 1000),
363
+ cols INTEGER NOT NULL CHECK (cols > 0 AND cols <= 1000),
364
+ latest_sequence INTEGER NOT NULL CHECK (latest_sequence >= 0),
365
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
366
+ ) STRICT;
367
+ INSERT INTO _node_v16_terminal_sessions (id,workspace_id,profile_id,state,exit_code,rows,cols,latest_sequence,created_at,updated_at) SELECT id,workspace_id,profile_id,state,exit_code,rows,cols,latest_sequence,created_at,updated_at FROM terminal_sessions;
368
+ DROP TABLE terminal_sessions;
369
+ ALTER TABLE _node_v16_terminal_sessions RENAME TO terminal_sessions;`,
370
+ assert(database) {
371
+ NODE_SCHEMA_MIGRATION_V15.assert(database);
372
+ NODE_SCHEMA_MIGRATION_V16.preflight(database);
373
+ const tables = database
374
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
375
+ .all();
376
+ if (tables.length !== currentTables.length ||
377
+ tables.some(({ name }) => !currentTables.some((table) => table === name)))
378
+ throw new Error('NODE_DATABASE_SCHEMA_INVALID:tables');
379
+ for (const { name } of tables) {
380
+ if (database.prepare(`PRAGMA foreign_key_list(${name})`).all().length !== 0)
381
+ throw new Error(`NODE_DATABASE_SCHEMA_INVALID:foreign_keys:${name}`);
382
+ }
383
+ if (database.prepare("SELECT 1 FROM sqlite_master WHERE type='trigger' LIMIT 1").get() !==
384
+ undefined)
385
+ throw new Error('NODE_DATABASE_SCHEMA_INVALID:triggers');
386
+ assertNodeRelations(database);
387
+ }
388
+ };
@@ -1,4 +1,4 @@
1
- import { buildCompactEvidence, countModelTokens, generateModelText } from '@myagentroam/agent';
1
+ import { buildCompactEvidence, countModelTokens, generateModelText, MarAgentError } from '@myagentroam/agent';
2
2
  import { marAgentRuntimeCompactConfigurationSchema } from '@myagentroam/protocol';
3
3
  /** Resolve optional dedicated credentials in the same factory batch without widening the SDK catalog. */
4
4
  export function compactRuntimeConfigurations(models, raw) {
@@ -58,6 +58,7 @@ export function marAgentCompactExtensionSource(state) {
58
58
  }
59
59
  export async function generateMarAgentCompact(context, selected, signal) {
60
60
  signal.throwIfAborted();
61
+ const startedAt = Date.now();
61
62
  const instruction = context.getPrompt();
62
63
  const overhead = countModelTokens(system) + countModelTokens(instruction) + 128;
63
64
  const full = buildCompactEvidence(context.messages, {
@@ -66,14 +67,39 @@ export async function generateMarAgentCompact(context, selected, signal) {
66
67
  });
67
68
  const window = selected.model.contextWindowTokens;
68
69
  const budget = Math.floor(window * 0.9) - overhead;
69
- if (budget <= 0)
70
+ let modelRequests = 0;
71
+ let inputTokensEstimateAfter = overhead;
72
+ const report = async (state, reason, errorCode) => {
73
+ try {
74
+ await context.reportDiagnostic?.({
75
+ modelId: selected.model.id,
76
+ providerModelId: selected.model.modelId,
77
+ reasoningEffort: selected.reasoningEffort,
78
+ state,
79
+ inputTokensEstimateBefore: countModelTokens(full) + overhead,
80
+ inputTokensEstimateAfter,
81
+ modelRequests,
82
+ elapsedMs: Math.max(0, Date.now() - startedAt),
83
+ ...(reason === undefined ? {} : { reason }),
84
+ ...(errorCode === undefined ? {} : { errorCode })
85
+ });
86
+ }
87
+ catch {
88
+ // Telemetry is best effort and cannot turn a successful generation into a fallback.
89
+ }
90
+ };
91
+ if (budget <= 0) {
92
+ await report('DECLINED', 'INPUT_BUDGET_EXHAUSTED');
70
93
  return undefined;
94
+ }
71
95
  const evidence = countModelTokens(full) + overhead <= window
72
96
  ? full
73
97
  : buildCompactEvidence(context.messages, {
74
98
  maxTokens: budget,
75
99
  ...(context.focus === undefined ? {} : { focus: context.focus })
76
100
  });
101
+ inputTokensEstimateAfter = countModelTokens(evidence) + overhead;
102
+ await report('STARTED');
77
103
  try {
78
104
  const result = await generateModelText({
79
105
  model: selected.model,
@@ -84,14 +110,22 @@ export async function generateMarAgentCompact(context, selected, signal) {
84
110
  { role: 'user', content: instruction }
85
111
  ],
86
112
  outputLimit: Math.min(selected.model.maxOutputTokens, context.summaryTokenBudget),
113
+ onAttemptDiagnostic: async (diagnostic) => {
114
+ if (diagnostic.state === 'STARTED') {
115
+ modelRequests++;
116
+ await report('STARTED');
117
+ }
118
+ },
87
119
  signal
88
120
  });
121
+ await report(result ? 'SUCCEEDED' : 'DECLINED', result ? undefined : 'MODEL_INCOMPLETE');
89
122
  return result
90
123
  ? { summary: result.text, ...(result.usage === undefined ? {} : { usage: result.usage }) }
91
124
  : undefined;
92
125
  }
93
- catch {
126
+ catch (error) {
94
127
  signal.throwIfAborted();
128
+ await report('FAILED', 'MODEL_FAILED', error instanceof MarAgentError ? error.code : 'MAR_AGENT_COMPACT_MODEL_FAILED');
95
129
  return undefined;
96
130
  }
97
131
  }
@@ -739,7 +739,16 @@ function sameConversationTurn(left, right) {
739
739
  if (left.id === right.id)
740
740
  return true;
741
741
  const leftMessageId = conversationClientMessageId(left);
742
- return leftMessageId !== null && leftMessageId === conversationClientMessageId(right);
742
+ if (leftMessageId !== null)
743
+ return leftMessageId === conversationClientMessageId(right);
744
+ if (left.userItemId !== null || right.userItemId !== null)
745
+ return false;
746
+ // Item-only live Turns have no input receipt. Runtime namespaces the stable
747
+ // Runner Item identity by Run, so reconcile it with the unprefixed history Item.
748
+ const [native, local] = left.runId === null ? [left, right] : [right, left];
749
+ if (native.runId !== null || local.runId === null)
750
+ return false;
751
+ return native.items.some((item) => local.items.some((candidate) => candidate.kind === item.kind && candidate.id === `${local.runId}:${item.id}`));
743
752
  }
744
753
  function terminalConversationTurn(status) {
745
754
  return ['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED', 'LOST'].includes(status);
@@ -3,6 +3,7 @@ import { lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join, relative, resolve, sep } from 'node:path';
5
5
  import { conversationFileProjectionSchema, hostedConversationManifestSchema, hostedConversationSummarySchema } from '@myagentroam/protocol';
6
+ import { assertNodeRelations } from '../database-relations.js';
6
7
  const STORAGE_KEY = /^[A-Za-z0-9][A-Za-z0-9_-]{7,159}$/u;
7
8
  const PROTOCOL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u;
8
9
  const CURSOR_BINDING = /^[A-Za-z0-9_-]{43}$/u;
@@ -194,6 +195,12 @@ export class HostedConversationStore {
194
195
  database.exec('BEGIN IMMEDIATE');
195
196
  try {
196
197
  this.adjustVersionReferences(versionReferences(conversation), []);
198
+ database
199
+ .prepare('DELETE FROM conversation_files WHERE conversation_id=?')
200
+ .run(conversation.id);
201
+ database
202
+ .prepare('DELETE FROM hosted_conversation_messages WHERE conversation_id=?')
203
+ .run(conversation.id);
197
204
  const deleted = database
198
205
  .prepare('DELETE FROM hosted_conversations WHERE id=?')
199
206
  .run(conversation.id);
@@ -626,11 +633,13 @@ export class HostedConversationStore {
626
633
  .get(conversationId) !== undefined);
627
634
  }
628
635
  createMessage(record) {
629
- this.options.database.raw
636
+ const result = this.options.database.raw
630
637
  .prepare(`INSERT INTO hosted_conversation_messages
631
638
  (conversation_id,client_message_id,run_id,user_text_present,status,created_at,updated_at)
632
- VALUES (?,?,?,?,?,?,?)`)
633
- .run(record.conversationId, record.clientMessageId, record.runId, record.userTextPresent ? 1 : 0, record.status, record.createdAt, record.updatedAt);
639
+ SELECT ?,?,?,?,?,?,? FROM hosted_conversations WHERE id=?`)
640
+ .run(record.conversationId, record.clientMessageId, record.runId, record.userTextPresent ? 1 : 0, record.status, record.createdAt, record.updatedAt, record.conversationId);
641
+ if (Number(result.changes) !== 1)
642
+ throw new Error('HOSTED_CONVERSATION_NOT_FOUND');
634
643
  return record;
635
644
  }
636
645
  finishMessage(conversationId, clientMessageId, status, updatedAt) {
@@ -1006,6 +1015,7 @@ export class HostedConversationStore {
1006
1015
  }
1007
1016
  }
1008
1017
  insertConversation(manifest) {
1018
+ this.assertConversationVersion(manifest);
1009
1019
  this.options.database.raw
1010
1020
  .prepare(`INSERT INTO hosted_conversations
1011
1021
  (id,user_storage_key,conversation_storage_key,client_request_id,node_id,session_id,
@@ -1016,6 +1026,7 @@ export class HostedConversationStore {
1016
1026
  .run(manifest.id, manifest.userStorageKey, manifest.conversationStorageKey, manifest.clientRequestId ?? null, manifest.nodeId, manifest.sessionId, manifest.templateId, manifest.templateName, manifest.activeVersionId, manifest.title, manifest.titleSource, manifest.pinnedAt, manifest.status, manifest.modelId, manifest.templateDeleted ? 1 : 0, manifest.quota.usedBytes, manifest.quota.maxBytes, manifest.createdAt, manifest.updatedAt, manifest.lastActivityAt);
1017
1027
  }
1018
1028
  updateConversationRow(manifest) {
1029
+ this.assertConversationVersion(manifest);
1019
1030
  const result = this.options.database.raw
1020
1031
  .prepare(`UPDATE hosted_conversations
1021
1032
  SET template_id=?,template_name=?,active_version_id=?,title=?,title_source=?,pinned_at=?,
@@ -1026,6 +1037,14 @@ export class HostedConversationStore {
1026
1037
  if (Number(result.changes) !== 1)
1027
1038
  throw new Error('HOSTED_CONVERSATION_STORAGE_INVALID');
1028
1039
  }
1040
+ assertConversationVersion(manifest) {
1041
+ if (this.options.database.raw
1042
+ .prepare('SELECT 1 FROM conversation_template_versions WHERE id=? AND template_id=?')
1043
+ .get(manifest.activeVersionId, manifest.templateId) === undefined)
1044
+ throw Object.assign(new Error('CONVERSATION_TEMPLATE_VERSION_CORRUPTED'), {
1045
+ publicDetails: 'The active template version does not exist or belongs to another template.'
1046
+ });
1047
+ }
1029
1048
  adjustVersionReferences(previous, next) {
1030
1049
  const counts = new Map();
1031
1050
  for (const id of previous)
@@ -1098,6 +1117,7 @@ export class HostedConversationStore {
1098
1117
  const addReference = database.prepare('UPDATE conversation_template_versions SET reference_count=reference_count+1 WHERE id=?');
1099
1118
  for (const item of rebuilt) {
1100
1119
  const manifest = item.manifest;
1120
+ this.assertConversationVersion(manifest);
1101
1121
  upsert.run(manifest.id, manifest.userStorageKey, manifest.conversationStorageKey, manifest.clientRequestId ?? null, manifest.nodeId, manifest.sessionId, manifest.templateId, manifest.templateName, manifest.activeVersionId, manifest.title, manifest.titleSource, manifest.pinnedAt, manifest.status, manifest.modelId, manifest.templateDeleted ? 1 : 0, manifest.quota.usedBytes, manifest.quota.maxBytes, manifest.createdAt, manifest.updatedAt, manifest.lastActivityAt);
1102
1122
  for (const file of item.files)
1103
1123
  insertConversationFile(insertFile, file);
@@ -1108,13 +1128,7 @@ export class HostedConversationStore {
1108
1128
  .prepare(`UPDATE hosted_conversation_messages
1109
1129
  SET status='INTERRUPTED',updated_at=? WHERE status='RUNNING'`)
1110
1130
  .run(Date.now());
1111
- for (const table of [
1112
- 'hosted_conversations',
1113
- 'conversation_files',
1114
- 'hosted_conversation_messages'
1115
- ])
1116
- if (database.prepare(`PRAGMA foreign_key_check(${table})`).get() !== undefined)
1117
- throw new Error('HOSTED_CONVERSATION_STORAGE_INVALID');
1131
+ assertNodeRelations(database);
1118
1132
  database.exec('COMMIT');
1119
1133
  }
1120
1134
  catch (error) {
@@ -51,55 +51,100 @@ export class ScheduledSessionStore {
51
51
  createdAt: now,
52
52
  updatedAt: now
53
53
  };
54
- this.database
55
- .prepare(`INSERT INTO scheduled_session_tasks
54
+ return this.write(() => {
55
+ const session = this.database
56
+ .prepare('SELECT workspace_id,runner FROM agent_sessions WHERE id=?')
57
+ .get(task.sessionId);
58
+ if (session === undefined)
59
+ throw new Error('SESSION_NOT_FOUND');
60
+ if (session['workspace_id'] !== task.workspaceId || session['runner'] !== task.runner)
61
+ throw Object.assign(new Error('SCHEDULE_SESSION_MISMATCH'), {
62
+ publicMessage: "The scheduled task must use its session's Workspace and Runner."
63
+ });
64
+ if (this.database.prepare('SELECT 1 FROM workspaces WHERE id=?').get(task.workspaceId) ===
65
+ undefined)
66
+ throw new Error('WORKSPACE_NOT_FOUND');
67
+ this.database
68
+ .prepare(`INSERT INTO scheduled_session_tasks
56
69
  (id,session_id,workspace_id,runner,owner_user_id,notify_on_terminal,objective,cron,timezone,enabled,revision,next_run_at,created_at,updated_at)
57
70
  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
58
- .run(task.id, task.sessionId, task.workspaceId, task.runner, task.ownerUserId, task.notifyOnTerminal ? 1 : 0, task.objective, task.cron, task.timezone, task.enabled ? 1 : 0, 1, task.nextRunAt, now, now);
59
- return task;
71
+ .run(task.id, task.sessionId, task.workspaceId, task.runner, task.ownerUserId, task.notifyOnTerminal ? 1 : 0, task.objective, task.cron, task.timezone, task.enabled ? 1 : 0, 1, task.nextRunAt, now, now);
72
+ return task;
73
+ });
60
74
  }
61
75
  update(sessionId, expectedRevision, patch) {
62
- const current = this.get(sessionId);
63
- if (current === undefined)
64
- throw new Error('SCHEDULE_NOT_FOUND');
65
- if (current.revision !== expectedRevision)
66
- throw new Error('SCHEDULE_REVISION_CONFLICT');
67
- if (patch.ownerUserId !== undefined &&
68
- (!Number.isSafeInteger(patch.ownerUserId) || patch.ownerUserId < 1))
69
- throw new Error('SCHEDULE_OWNER_INVALID');
70
- const objective = patch.objective ?? current.objective;
71
- const cron = patch.cron ?? current.cron;
72
- const timezone = patch.timezone ?? current.timezone;
73
- validate(objective, cron, timezone);
74
- const enabled = patch.enabled ?? current.enabled;
75
- const ownerUserId = patch.ownerUserId ?? current.ownerUserId;
76
- const nextRunAt = enabled
77
- ? patch.enabled === true || patch.cron !== undefined || patch.timezone !== undefined
78
- ? nextScheduledAt(cron, timezone, this.now())
79
- : current.nextRunAt
80
- : null;
81
- const now = this.now();
82
- const result = this.database
83
- .prepare(`UPDATE scheduled_session_tasks SET objective=?,cron=?,timezone=?,enabled=?,owner_user_id=?,notify_on_terminal=?,revision=?,next_run_at=?,updated_at=?
76
+ return this.write(() => {
77
+ const current = this.get(sessionId);
78
+ if (current === undefined)
79
+ throw new Error('SCHEDULE_NOT_FOUND');
80
+ if (current.revision !== expectedRevision)
81
+ throw new Error('SCHEDULE_REVISION_CONFLICT');
82
+ if (patch.ownerUserId !== undefined &&
83
+ (!Number.isSafeInteger(patch.ownerUserId) || patch.ownerUserId < 1))
84
+ throw new Error('SCHEDULE_OWNER_INVALID');
85
+ const objective = patch.objective ?? current.objective;
86
+ const cron = patch.cron ?? current.cron;
87
+ const timezone = patch.timezone ?? current.timezone;
88
+ validate(objective, cron, timezone);
89
+ const enabled = patch.enabled ?? current.enabled;
90
+ const ownerUserId = patch.ownerUserId ?? current.ownerUserId;
91
+ const nextRunAt = enabled
92
+ ? patch.enabled === true || patch.cron !== undefined || patch.timezone !== undefined
93
+ ? nextScheduledAt(cron, timezone, this.now())
94
+ : current.nextRunAt
95
+ : null;
96
+ const now = this.now();
97
+ const result = this.database
98
+ .prepare(`UPDATE scheduled_session_tasks SET objective=?,cron=?,timezone=?,enabled=?,owner_user_id=?,notify_on_terminal=?,revision=?,next_run_at=?,updated_at=?
84
99
  WHERE session_id=? AND revision=?`)
85
- .run(objective.trim(), cron, timezone, enabled ? 1 : 0, ownerUserId, (patch.notifyOnTerminal ?? current.notifyOnTerminal) ? 1 : 0, expectedRevision + 1, nextRunAt, now, sessionId, expectedRevision);
86
- if (result.changes !== 1)
87
- throw new Error('SCHEDULE_REVISION_CONFLICT');
88
- this.database
89
- .prepare(`UPDATE scheduled_session_proposals SET status='STALE',resolved_at=?
100
+ .run(objective.trim(), cron, timezone, enabled ? 1 : 0, ownerUserId, (patch.notifyOnTerminal ?? current.notifyOnTerminal) ? 1 : 0, expectedRevision + 1, nextRunAt, now, sessionId, expectedRevision);
101
+ if (result.changes !== 1)
102
+ throw new Error('SCHEDULE_REVISION_CONFLICT');
103
+ this.database
104
+ .prepare(`UPDATE scheduled_session_proposals SET status='STALE',resolved_at=?
90
105
  WHERE task_id=? AND status='PENDING' AND base_revision<?`)
91
- .run(now, current.id, expectedRevision + 1);
92
- this.database
93
- .prepare("DELETE FROM scheduled_session_proposals WHERE task_id=? AND status='STALE'")
94
- .run(current.id);
95
- return this.get(sessionId);
106
+ .run(now, current.id, expectedRevision + 1);
107
+ this.database
108
+ .prepare("DELETE FROM scheduled_session_proposals WHERE task_id=? AND status='STALE'")
109
+ .run(current.id);
110
+ return this.get(sessionId);
111
+ });
96
112
  }
97
113
  remove(sessionId, expectedRevision) {
98
- const result = this.database
99
- .prepare('DELETE FROM scheduled_session_tasks WHERE session_id=? AND revision=?')
100
- .run(sessionId, expectedRevision);
101
- if (result.changes !== 1)
102
- throw new Error('SCHEDULE_REVISION_CONFLICT');
114
+ this.write(() => {
115
+ const task = this.get(sessionId);
116
+ if (task === undefined || task.revision !== expectedRevision)
117
+ throw new Error('SCHEDULE_REVISION_CONFLICT');
118
+ this.deleteTasks('id', task.id);
119
+ });
120
+ }
121
+ removeForSession(sessionId) {
122
+ this.write(() => this.deleteTasks('session_id', sessionId));
123
+ }
124
+ removeForWorkspace(workspaceId) {
125
+ this.write(() => this.deleteTasks('workspace_id', workspaceId));
126
+ }
127
+ deleteTasks(column, value) {
128
+ for (const table of ['scheduled_session_occurrences', 'scheduled_session_proposals']) {
129
+ this.database
130
+ .prepare(`DELETE FROM ${table} WHERE task_id IN
131
+ (SELECT id FROM scheduled_session_tasks WHERE ${column}=?)`)
132
+ .run(value);
133
+ }
134
+ this.database.prepare(`DELETE FROM scheduled_session_tasks WHERE ${column}=?`).run(value);
135
+ }
136
+ write(action) {
137
+ // A savepoint keeps this operation atomic both alone and inside a parent deletion.
138
+ this.database.exec('SAVEPOINT scheduled_session_write');
139
+ try {
140
+ const result = action();
141
+ this.database.exec('RELEASE scheduled_session_write');
142
+ return result;
143
+ }
144
+ catch (error) {
145
+ this.database.exec('ROLLBACK TO scheduled_session_write; RELEASE scheduled_session_write');
146
+ throw error;
147
+ }
103
148
  }
104
149
  claim(sessionId, dueAt, expectedRevision) {
105
150
  const current = this.get(sessionId);
@@ -176,18 +221,25 @@ export class ScheduledSessionStore {
176
221
  if (patch.objective === undefined && patch.cron === undefined && patch.timezone === undefined)
177
222
  throw new Error('SCHEDULE_PROPOSAL_INVALID');
178
223
  validate(patch.objective ?? task.objective, patch.cron ?? task.cron, patch.timezone ?? task.timezone);
179
- const pending = this.database
180
- .prepare("SELECT COUNT(*) AS count FROM scheduled_session_proposals WHERE task_id=? AND status='PENDING'")
181
- .get(task.id);
182
- if (pending.count >= 50)
183
- throw new Error('SCHEDULE_PROPOSAL_LIMIT');
184
- const id = randomUUID();
185
- this.database
186
- .prepare(`INSERT INTO scheduled_session_proposals
224
+ return this.write(() => {
225
+ const current = this.getById(task.id);
226
+ if (current === undefined)
227
+ throw new Error('SCHEDULE_NOT_FOUND');
228
+ if (current.revision !== task.revision || current.ownerUserId !== task.ownerUserId)
229
+ throw new Error('SCHEDULE_REVISION_CONFLICT');
230
+ const pending = this.database
231
+ .prepare("SELECT COUNT(*) AS count FROM scheduled_session_proposals WHERE task_id=? AND status='PENDING'")
232
+ .get(task.id);
233
+ if (pending.count >= 50)
234
+ throw new Error('SCHEDULE_PROPOSAL_LIMIT');
235
+ const id = randomUUID();
236
+ this.database
237
+ .prepare(`INSERT INTO scheduled_session_proposals
187
238
  (id,task_id,proposed_by_user_id,base_revision,objective,cron,timezone,status,created_at)
188
239
  VALUES (?,?,?,?,?,?,?,'PENDING',?)`)
189
- .run(id, task.id, actorUserId, task.revision, patch.objective ?? null, patch.cron ?? null, patch.timezone ?? null, this.now());
190
- return id;
240
+ .run(id, task.id, actorUserId, task.revision, patch.objective ?? null, patch.cron ?? null, patch.timezone ?? null, this.now());
241
+ return id;
242
+ });
191
243
  }
192
244
  resolveProposal(sessionId, proposalId, ownerUserId, accept) {
193
245
  const task = this.get(sessionId);
package/dist/terminal.js CHANGED
@@ -186,6 +186,16 @@ export class TerminalManager {
186
186
  get(terminalId) {
187
187
  return this.summary(this.requireTerminal(terminalId));
188
188
  }
189
+ /** A removed registration no longer owns live or historical Terminal metadata. */
190
+ removeWorkspaceAssociation(workspaceId) {
191
+ for (const terminal of this.terminals.values()) {
192
+ if (terminal.workspaceId !== workspaceId)
193
+ continue;
194
+ terminal.workspaceId = null;
195
+ terminal.updatedAt = this.now();
196
+ this.notifySummary(terminal);
197
+ }
198
+ }
189
199
  /** One attachment may write; active writers cannot be replaced. */
190
200
  authorizeAttach(input) {
191
201
  if (typeof input.terminalId !== 'string')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.171",
3
+ "version": "0.9.173",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -36,10 +36,10 @@
36
36
  "playwright": "1.62.1",
37
37
  "ws": "^8.21.3",
38
38
  "zod": "4.4.3",
39
- "@myagentroam/agent": "0.9.171",
39
+ "@myagentroam/agent": "0.9.173",
40
40
  "@myagentroam/code-navigation": "0.9.129",
41
41
  "@myagentroam/marctl": "0.9.123",
42
- "@myagentroam/protocol": "0.9.171"
42
+ "@myagentroam/protocol": "0.9.173"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/ws": "^8.18.1"