parallel-codex-tui 0.1.3 → 0.1.5

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 (78) hide show
  1. package/.parallel-codex/config.example.toml +136 -3
  2. package/README.md +299 -21
  3. package/dist/bootstrap.js +37 -17
  4. package/dist/cli-args.js +34 -3
  5. package/dist/cli-help.js +20 -0
  6. package/dist/cli-startup-preflight.js +18 -0
  7. package/dist/cli-startup-recovery.js +82 -0
  8. package/dist/cli-workspace-picker.js +330 -0
  9. package/dist/cli-workspace-transition.js +33 -0
  10. package/dist/cli-workspace.js +7 -71
  11. package/dist/cli.js +234 -24
  12. package/dist/core/collaboration-timeline.js +268 -0
  13. package/dist/core/config-errors.js +14 -0
  14. package/dist/core/config.js +297 -109
  15. package/dist/core/file-store.js +119 -6
  16. package/dist/core/lease-finalization.js +22 -0
  17. package/dist/core/paths.js +7 -0
  18. package/dist/core/process-identity.js +48 -0
  19. package/dist/core/process-mutation-turn.js +128 -0
  20. package/dist/core/process-ownership.js +276 -0
  21. package/dist/core/process-tree.js +90 -0
  22. package/dist/core/router-audit.js +155 -0
  23. package/dist/core/router-redaction.js +31 -0
  24. package/dist/core/router.js +462 -35
  25. package/dist/core/session-index.js +412 -88
  26. package/dist/core/session-manager.js +1110 -40
  27. package/dist/core/task-session-details.js +175 -0
  28. package/dist/core/task-state-machine.js +18 -0
  29. package/dist/core/workspace-commit-recovery.js +118 -0
  30. package/dist/core/workspace.js +19 -11
  31. package/dist/doctor.js +373 -34
  32. package/dist/domain/schemas.js +142 -7
  33. package/dist/orchestrator/collaboration-channel.js +289 -6
  34. package/dist/orchestrator/feature-plan.js +70 -0
  35. package/dist/orchestrator/final-acceptance.js +86 -0
  36. package/dist/orchestrator/judge-artifacts.js +236 -0
  37. package/dist/orchestrator/orchestrator.js +2086 -203
  38. package/dist/orchestrator/prompts.js +168 -5
  39. package/dist/orchestrator/supervisor-summary.js +56 -2
  40. package/dist/orchestrator/workspace-sandbox.js +927 -0
  41. package/dist/tui/App.js +3187 -161
  42. package/dist/tui/AppShell.js +196 -25
  43. package/dist/tui/CollaborationTimelineView.js +327 -0
  44. package/dist/tui/FeatureBoardView.js +232 -0
  45. package/dist/tui/InputBar.js +581 -57
  46. package/dist/tui/RouterDiagnosticsView.js +469 -0
  47. package/dist/tui/StatusBar.js +610 -57
  48. package/dist/tui/StatusDetailView.js +164 -0
  49. package/dist/tui/TaskSessionDetailView.js +222 -0
  50. package/dist/tui/TaskSessionsView.js +210 -0
  51. package/dist/tui/TerminalOutput.js +53 -9
  52. package/dist/tui/WorkerOutputView.js +1404 -161
  53. package/dist/tui/WorkerOverviewView.js +269 -0
  54. package/dist/tui/chat-history.js +25 -0
  55. package/dist/tui/chat-input.js +67 -19
  56. package/dist/tui/chat-paste.js +76 -0
  57. package/dist/tui/display-width.js +41 -3
  58. package/dist/tui/incremental-text-file.js +101 -0
  59. package/dist/tui/keyboard.js +49 -0
  60. package/dist/tui/markdown-text.js +14 -0
  61. package/dist/tui/raw-input-decoder.js +3 -0
  62. package/dist/tui/scrolling.js +2 -1
  63. package/dist/tui/status-line.js +360 -11
  64. package/dist/tui/task-memory.js +13 -1
  65. package/dist/tui/task-result.js +105 -0
  66. package/dist/tui/terminal-screen.js +13 -1
  67. package/dist/tui/theme-contrast.js +144 -0
  68. package/dist/tui/theme-preview.js +109 -0
  69. package/dist/tui/theme.js +158 -0
  70. package/dist/version.js +1 -1
  71. package/dist/workers/capabilities.js +213 -0
  72. package/dist/workers/live-probe.js +177 -0
  73. package/dist/workers/mock-adapter.js +73 -8
  74. package/dist/workers/native-attach.js +106 -16
  75. package/dist/workers/process-adapter.js +572 -77
  76. package/dist/workers/provider.js +26 -0
  77. package/dist/workers/registry.js +12 -20
  78. package/package.json +11 -2
@@ -1,82 +1,90 @@
1
- import { readdir } from "node:fs/promises";
1
+ import { copyFile, readdir, rename, rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { DatabaseSync } from "node:sqlite";
4
- import { NativeSessionSchema, TaskMetaSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
5
- import { ensureDir, pathExists, readJson } from "./file-store.js";
4
+ import { NativeSessionSchema, RetiredNativeSessionSchema, RouteDecisionSchema, TaskIdSchema, TaskMetaSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
5
+ import { ensureDir, pathExists, readJson, readTextIfExists } from "./file-store.js";
6
+ const SESSION_INDEX_SCHEMA_VERSION = 2;
7
+ const SESSION_INDEX_FILENAME = "session-index.sqlite";
6
8
  export class SessionIndex {
7
9
  db;
8
10
  projectRoot;
9
11
  dataDir;
10
- constructor(db, projectRoot, dataDir) {
12
+ databasePath;
13
+ recovery;
14
+ constructor(db, projectRoot, dataDir, databasePath, recovery) {
11
15
  this.db = db;
12
16
  this.projectRoot = projectRoot;
13
17
  this.dataDir = dataDir;
18
+ this.databasePath = databasePath;
19
+ this.recovery = recovery;
14
20
  }
15
21
  static async open(projectRoot, dataDir) {
16
- await ensureDir(join(projectRoot, dataDir));
17
- const index = new SessionIndex(new DatabaseSync(join(projectRoot, dataDir, "session-index.sqlite")), projectRoot, dataDir);
18
- index.initialize();
19
- return index;
22
+ const runtimeDir = join(projectRoot, dataDir);
23
+ const databasePath = join(runtimeDir, SESSION_INDEX_FILENAME);
24
+ const recoveryBackupPath = `${databasePath}.backup`;
25
+ await ensureDir(runtimeDir);
26
+ const databaseExisted = await pathExists(databasePath);
27
+ const opened = await openSessionDatabase(databasePath, recoveryBackupPath);
28
+ const index = new SessionIndex(opened.db, projectRoot, dataDir, databasePath, opened.recovery);
29
+ try {
30
+ const previousVersion = index.schemaVersion();
31
+ if (databaseExisted && previousVersion < SESSION_INDEX_SCHEMA_VERSION) {
32
+ await index.writeBackup(`${databasePath}.pre-migration-v${previousVersion}.backup`);
33
+ }
34
+ index.initialize();
35
+ await index.writeBackup(recoveryBackupPath);
36
+ return index;
37
+ }
38
+ catch (error) {
39
+ try {
40
+ index.close();
41
+ }
42
+ catch {
43
+ // Preserve the migration or backup failure that prevented startup.
44
+ }
45
+ throw error;
46
+ }
20
47
  }
21
48
  initialize() {
22
- this.db.exec(`
23
- CREATE TABLE IF NOT EXISTS tasks (
24
- id TEXT PRIMARY KEY,
25
- title TEXT NOT NULL,
26
- created_at TEXT NOT NULL,
27
- cwd TEXT NOT NULL,
28
- mode TEXT NOT NULL,
29
- status TEXT NOT NULL
30
- );
31
-
32
- CREATE TABLE IF NOT EXISTS turns (
33
- task_id TEXT NOT NULL,
34
- turn_id TEXT NOT NULL,
35
- created_at TEXT NOT NULL,
36
- request_path TEXT NOT NULL,
37
- PRIMARY KEY (task_id, turn_id)
38
- );
39
-
40
- CREATE TABLE IF NOT EXISTS workers (
41
- task_id TEXT NOT NULL,
42
- worker_id TEXT NOT NULL,
43
- role TEXT NOT NULL,
44
- engine TEXT NOT NULL,
45
- state TEXT NOT NULL,
46
- phase TEXT NOT NULL,
47
- summary TEXT NOT NULL,
48
- status_path TEXT NOT NULL,
49
- output_log_path TEXT NOT NULL,
50
- dir TEXT NOT NULL,
51
- native_session_id TEXT,
52
- PRIMARY KEY (task_id, worker_id)
53
- );
54
-
55
- CREATE TABLE IF NOT EXISTS native_sessions (
56
- task_id TEXT NOT NULL,
57
- worker_id TEXT NOT NULL,
58
- engine TEXT NOT NULL,
59
- role TEXT NOT NULL,
60
- session_id TEXT NOT NULL,
61
- cwd TEXT NOT NULL,
62
- created_at TEXT NOT NULL,
63
- last_used_at TEXT NOT NULL,
64
- source TEXT NOT NULL,
65
- PRIMARY KEY (task_id, worker_id)
66
- );
67
- `);
49
+ this.db.exec("BEGIN IMMEDIATE");
50
+ try {
51
+ const currentVersion = this.schemaVersion();
52
+ if (currentVersion > SESSION_INDEX_SCHEMA_VERSION) {
53
+ throw new Error(`Session index schema v${currentVersion} is newer than supported v${SESSION_INDEX_SCHEMA_VERSION}`);
54
+ }
55
+ for (let targetVersion = currentVersion + 1; targetVersion <= SESSION_INDEX_SCHEMA_VERSION; targetVersion += 1) {
56
+ this.applyMigration(targetVersion);
57
+ this.db.exec(`PRAGMA user_version = ${targetVersion}`);
58
+ }
59
+ this.db.exec("COMMIT");
60
+ }
61
+ catch (error) {
62
+ try {
63
+ this.db.exec("ROLLBACK");
64
+ }
65
+ catch {
66
+ // Preserve the migration failure if SQLite already rolled the transaction back.
67
+ }
68
+ throw error;
69
+ }
70
+ assertHealthyDatabase(this.db);
71
+ }
72
+ schemaVersion() {
73
+ const row = this.db.prepare("PRAGMA user_version").get();
74
+ return Number(row?.user_version) || 0;
68
75
  }
69
76
  async upsertTask(task) {
70
77
  this.db
71
- .prepare(`INSERT INTO tasks (id, title, created_at, cwd, mode, status)
72
- VALUES (?, ?, ?, ?, ?, ?)
78
+ .prepare(`INSERT INTO tasks (id, title, created_at, cwd, mode, status, archived_at)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?)
73
80
  ON CONFLICT(id) DO UPDATE SET
74
81
  title=excluded.title,
75
82
  created_at=excluded.created_at,
76
83
  cwd=excluded.cwd,
77
84
  mode=excluded.mode,
78
- status=excluded.status`)
79
- .run(task.id, task.title, task.created_at, task.cwd, task.mode, task.status);
85
+ status=excluded.status,
86
+ archived_at=excluded.archived_at`)
87
+ .run(task.id, task.title, task.created_at, task.cwd, task.mode, task.status, task.archived_at ?? null);
80
88
  }
81
89
  async upsertTurn(taskId, turn) {
82
90
  this.db
@@ -124,10 +132,94 @@ export class SessionIndex {
124
132
  async deleteNativeSession(taskId, workerId) {
125
133
  this.db.prepare("DELETE FROM native_sessions WHERE task_id = ? AND worker_id = ?").run(taskId, workerId);
126
134
  }
135
+ async deleteTask(taskId) {
136
+ const id = TaskIdSchema.parse(taskId);
137
+ this.db.exec("BEGIN IMMEDIATE");
138
+ try {
139
+ this.db.prepare("DELETE FROM native_sessions WHERE task_id = ?").run(id);
140
+ this.db.prepare("DELETE FROM workers WHERE task_id = ?").run(id);
141
+ this.db.prepare("DELETE FROM turns WHERE task_id = ?").run(id);
142
+ this.db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
143
+ this.db.prepare("UPDATE workspace_state SET value = '' WHERE key = 'active_task_id' AND value = ?").run(id);
144
+ this.db.exec("COMMIT");
145
+ }
146
+ catch (error) {
147
+ this.db.exec("ROLLBACK");
148
+ throw error;
149
+ }
150
+ }
127
151
  async countRows(table) {
128
152
  const row = this.db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get();
129
153
  return row.count;
130
154
  }
155
+ async listTasks(limit = 50, options = {}) {
156
+ const boundedLimit = Number.isFinite(limit)
157
+ ? Math.min(500, Math.max(0, Math.trunc(limit)))
158
+ : 50;
159
+ if (boundedLimit === 0) {
160
+ return [];
161
+ }
162
+ const archivedFilter = options.includeArchived ? "" : "WHERE tasks.archived_at IS NULL";
163
+ const rows = this.db.prepare(`SELECT
164
+ tasks.id,
165
+ tasks.title,
166
+ tasks.created_at,
167
+ tasks.cwd,
168
+ tasks.mode,
169
+ tasks.status,
170
+ tasks.archived_at,
171
+ (SELECT COUNT(*) FROM turns WHERE turns.task_id = tasks.id) AS turn_count,
172
+ (SELECT COUNT(*) FROM workers WHERE workers.task_id = tasks.id) AS worker_count,
173
+ (SELECT COUNT(DISTINCT engine || char(31) || session_id)
174
+ FROM native_sessions
175
+ WHERE native_sessions.task_id = tasks.id) AS native_session_count
176
+ FROM tasks
177
+ ${archivedFilter}
178
+ ORDER BY tasks.created_at DESC, tasks.id DESC
179
+ LIMIT ?`).all(boundedLimit);
180
+ return rows.flatMap((row) => {
181
+ const task = TaskMetaSchema.safeParse({
182
+ id: row.id,
183
+ title: row.title,
184
+ created_at: row.created_at,
185
+ cwd: row.cwd,
186
+ mode: row.mode,
187
+ status: row.status,
188
+ archived_at: row.archived_at ?? undefined
189
+ });
190
+ if (!task.success) {
191
+ return [];
192
+ }
193
+ return [{
194
+ ...task.data,
195
+ turnCount: Number(row.turn_count) || 0,
196
+ workerCount: Number(row.worker_count) || 0,
197
+ nativeSessionCount: Number(row.native_session_count) || 0
198
+ }];
199
+ });
200
+ }
201
+ async activeTaskId() {
202
+ const row = this.db
203
+ .prepare("SELECT value FROM workspace_state WHERE key = 'active_task_id'")
204
+ .get();
205
+ if (!row) {
206
+ return undefined;
207
+ }
208
+ const value = row.value.trim();
209
+ if (!value) {
210
+ return null;
211
+ }
212
+ return TaskIdSchema.safeParse(value).success ? value : undefined;
213
+ }
214
+ async setActiveTaskId(taskId) {
215
+ const value = taskId?.trim() ?? "";
216
+ if (value && !TaskIdSchema.safeParse(value).success) {
217
+ throw new Error(`Invalid active task id: ${JSON.stringify(taskId)}`);
218
+ }
219
+ this.db.prepare(`INSERT INTO workspace_state (key, value)
220
+ VALUES ('active_task_id', ?)
221
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(value);
222
+ }
131
223
  async workerNativeSessionId(taskId, workerId) {
132
224
  const row = this.db
133
225
  .prepare("SELECT native_session_id FROM workers WHERE task_id = ? AND worker_id = ?")
@@ -135,81 +227,313 @@ export class SessionIndex {
135
227
  return row?.native_session_id ?? null;
136
228
  }
137
229
  async rebuildFromFiles() {
138
- this.db.exec("DELETE FROM native_sessions; DELETE FROM workers; DELETE FROM turns; DELETE FROM tasks;");
139
- const sessions = join(this.projectRoot, this.dataDir, "sessions");
140
- if (!(await pathExists(sessions))) {
141
- return;
230
+ this.db.exec("BEGIN IMMEDIATE");
231
+ try {
232
+ this.db.exec("DELETE FROM native_sessions; DELETE FROM workers; DELETE FROM turns; DELETE FROM tasks;");
233
+ const sessions = join(this.projectRoot, this.dataDir, "sessions");
234
+ if (await pathExists(sessions)) {
235
+ const taskEntries = await readdir(sessions, { withFileTypes: true });
236
+ for (const taskEntry of taskEntries) {
237
+ if (!taskEntry.isDirectory()) {
238
+ continue;
239
+ }
240
+ if (taskEntry.name === "main") {
241
+ await this.rebuildWorkers(join(sessions, taskEntry.name), "main");
242
+ continue;
243
+ }
244
+ if (!TaskIdSchema.safeParse(taskEntry.name).success) {
245
+ continue;
246
+ }
247
+ await this.rebuildTask(join(sessions, taskEntry.name), taskEntry.name);
248
+ }
249
+ }
250
+ this.db.exec("COMMIT");
142
251
  }
143
- const taskEntries = await readdir(sessions, { withFileTypes: true });
144
- for (const taskEntry of taskEntries) {
145
- if (!taskEntry.isDirectory() || !taskEntry.name.startsWith("task-")) {
146
- continue;
252
+ catch (error) {
253
+ try {
254
+ this.db.exec("ROLLBACK");
147
255
  }
148
- await this.rebuildTask(join(sessions, taskEntry.name), taskEntry.name);
256
+ catch {
257
+ // Preserve the filesystem or SQLite failure that interrupted rebuilding.
258
+ }
259
+ throw error;
149
260
  }
261
+ await this.writeBackup(`${this.databasePath}.backup`);
150
262
  }
151
263
  close() {
152
264
  this.db.close();
153
265
  }
266
+ ensureColumn(table, column, declaration) {
267
+ if (this.tableHasColumn(table, column)) {
268
+ return;
269
+ }
270
+ try {
271
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${declaration}`);
272
+ }
273
+ catch (error) {
274
+ if (!this.tableHasColumn(table, column)) {
275
+ throw error;
276
+ }
277
+ }
278
+ }
279
+ applyMigration(targetVersion) {
280
+ if (targetVersion === 1) {
281
+ this.db.exec(`
282
+ CREATE TABLE IF NOT EXISTS tasks (
283
+ id TEXT PRIMARY KEY,
284
+ title TEXT NOT NULL,
285
+ created_at TEXT NOT NULL,
286
+ cwd TEXT NOT NULL,
287
+ mode TEXT NOT NULL,
288
+ status TEXT NOT NULL
289
+ );
290
+
291
+ CREATE TABLE IF NOT EXISTS turns (
292
+ task_id TEXT NOT NULL,
293
+ turn_id TEXT NOT NULL,
294
+ created_at TEXT NOT NULL,
295
+ request_path TEXT NOT NULL,
296
+ PRIMARY KEY (task_id, turn_id)
297
+ );
298
+
299
+ CREATE TABLE IF NOT EXISTS workers (
300
+ task_id TEXT NOT NULL,
301
+ worker_id TEXT NOT NULL,
302
+ role TEXT NOT NULL,
303
+ engine TEXT NOT NULL,
304
+ state TEXT NOT NULL,
305
+ phase TEXT NOT NULL,
306
+ summary TEXT NOT NULL,
307
+ status_path TEXT NOT NULL,
308
+ output_log_path TEXT NOT NULL,
309
+ dir TEXT NOT NULL,
310
+ native_session_id TEXT,
311
+ PRIMARY KEY (task_id, worker_id)
312
+ );
313
+
314
+ CREATE TABLE IF NOT EXISTS native_sessions (
315
+ task_id TEXT NOT NULL,
316
+ worker_id TEXT NOT NULL,
317
+ engine TEXT NOT NULL,
318
+ role TEXT NOT NULL,
319
+ session_id TEXT NOT NULL,
320
+ cwd TEXT NOT NULL,
321
+ created_at TEXT NOT NULL,
322
+ last_used_at TEXT NOT NULL,
323
+ source TEXT NOT NULL,
324
+ PRIMARY KEY (task_id, worker_id)
325
+ );
326
+
327
+ CREATE TABLE IF NOT EXISTS workspace_state (
328
+ key TEXT PRIMARY KEY,
329
+ value TEXT NOT NULL
330
+ );
331
+ `);
332
+ return;
333
+ }
334
+ if (targetVersion === 2) {
335
+ this.ensureColumn("tasks", "archived_at", "TEXT");
336
+ return;
337
+ }
338
+ throw new Error(`Missing session index migration for schema v${targetVersion}`);
339
+ }
340
+ async writeBackup(targetPath) {
341
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
342
+ try {
343
+ this.db.prepare("VACUUM INTO ?").run(tempPath);
344
+ await replaceFile(tempPath, targetPath);
345
+ }
346
+ catch (error) {
347
+ await rm(tempPath, { force: true }).catch(() => undefined);
348
+ throw error;
349
+ }
350
+ }
351
+ tableHasColumn(table, column) {
352
+ const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
353
+ return columns.some((entry) => entry.name === column);
354
+ }
154
355
  async rebuildTask(taskDir, taskId) {
155
356
  const metaPath = join(taskDir, "meta.json");
156
- if (await pathExists(metaPath)) {
157
- const meta = await readJsonIfValid(metaPath, TaskMetaSchema);
158
- if (meta) {
159
- await this.upsertTask(meta);
160
- }
357
+ const meta = await pathExists(metaPath)
358
+ ? await readJsonIfValid(metaPath, TaskMetaSchema)
359
+ : null;
360
+ if (!meta || meta.id !== taskId) {
361
+ return;
161
362
  }
363
+ await this.upsertTask(meta);
162
364
  const turnsDir = join(taskDir, "turns");
163
365
  if (await pathExists(turnsDir)) {
164
366
  const turnEntries = await readdir(turnsDir, { withFileTypes: true });
165
367
  for (const turnEntry of turnEntries) {
368
+ if (!turnEntry.isDirectory() || !/^\d{4}$/.test(turnEntry.name)) {
369
+ continue;
370
+ }
166
371
  const turnPath = join(turnsDir, turnEntry.name, "turn.json");
167
- if (turnEntry.isDirectory() && (await pathExists(turnPath))) {
168
- const turn = await readJsonIfValid(turnPath, TurnMetaSchema);
169
- if (turn) {
170
- await this.upsertTurn(taskId, turn);
171
- }
372
+ const [turn, route, request] = await Promise.all([
373
+ readJsonIfValid(turnPath, TurnMetaSchema),
374
+ readJsonIfValid(join(turnsDir, turnEntry.name, "route.json"), RouteDecisionSchema),
375
+ readTextIfExists(join(turnsDir, turnEntry.name, "user.md"))
376
+ ]);
377
+ if (turn
378
+ && route
379
+ && request.trim()
380
+ && turn.task_id === taskId
381
+ && turn.turn_id === turnEntry.name
382
+ && turn.request_path === `turns/${turnEntry.name}/user.md`) {
383
+ await this.upsertTurn(taskId, turn);
172
384
  }
173
385
  }
174
386
  }
175
- const entries = await readdir(taskDir, { withFileTypes: true });
387
+ await this.rebuildWorkers(taskDir, taskId);
388
+ }
389
+ async rebuildWorkers(sessionDir, sessionId) {
390
+ const entries = await readdir(sessionDir, { withFileTypes: true });
176
391
  for (const entry of entries) {
177
392
  if (!entry.isDirectory() || entry.name === "turns") {
178
393
  continue;
179
394
  }
180
- const workerDir = join(taskDir, entry.name);
395
+ const workerDir = join(sessionDir, entry.name);
181
396
  const statusPath = join(workerDir, "status.json");
182
397
  const nativePath = join(workerDir, "native-session.json");
398
+ const retiredNativePath = join(workerDir, "native-session.retired.json");
399
+ const nativeSession = await this.readRebuildNativeSession(nativePath, retiredNativePath);
183
400
  if (await pathExists(statusPath)) {
184
- const status = await this.readRebuildWorkerStatus(statusPath, nativePath);
401
+ const status = await this.readRebuildWorkerStatus(statusPath, nativeSession);
185
402
  if (status) {
186
- await this.upsertWorker(taskId, status, {
403
+ await this.upsertWorker(sessionId, status, {
187
404
  dir: workerDir,
188
405
  statusPath,
189
406
  outputLogPath: join(workerDir, "output.log")
190
407
  });
191
408
  }
192
409
  }
193
- if (await pathExists(nativePath)) {
194
- const nativeSession = await readJsonIfValid(nativePath, NativeSessionSchema);
195
- if (nativeSession) {
196
- await this.upsertNativeSession(taskId, nativeSession);
197
- }
410
+ if (nativeSession) {
411
+ await this.upsertNativeSession(sessionId, nativeSession);
198
412
  }
199
413
  }
200
414
  }
201
- async readRebuildWorkerStatus(statusPath, nativePath) {
415
+ async readRebuildWorkerStatus(statusPath, nativeSession) {
202
416
  const status = await readJsonIfValid(statusPath, WorkerStatusSchema);
203
417
  if (!status) {
204
418
  return null;
205
419
  }
206
- if (status.native_session_id && !(await readJsonIfValid(nativePath, NativeSessionSchema))) {
420
+ if (status.native_session_id && status.native_session_id !== nativeSession?.session_id) {
207
421
  const nextStatus = { ...status };
208
422
  delete nextStatus.native_session_id;
209
423
  return WorkerStatusSchema.parse(nextStatus);
210
424
  }
211
425
  return status;
212
426
  }
427
+ async readRebuildNativeSession(nativePath, retiredNativePath) {
428
+ const active = await readJsonIfValid(nativePath, NativeSessionSchema);
429
+ if (!active) {
430
+ return null;
431
+ }
432
+ const retired = await readJsonIfValid(retiredNativePath, RetiredNativeSessionSchema);
433
+ return retired?.session_id === active.session_id ? null : active;
434
+ }
435
+ }
436
+ async function openSessionDatabase(databasePath, backupPath) {
437
+ let db = null;
438
+ try {
439
+ db = openCheckedDatabase(databasePath);
440
+ return { db, recovery: null };
441
+ }
442
+ catch (primaryError) {
443
+ try {
444
+ db?.close();
445
+ }
446
+ catch {
447
+ // Continue into file-backed recovery.
448
+ }
449
+ const backupIsHealthy = await isHealthyDatabaseFile(backupPath);
450
+ const quarantinedPath = await quarantineDatabase(databasePath);
451
+ await Promise.all([
452
+ rm(`${databasePath}-wal`, { force: true }),
453
+ rm(`${databasePath}-shm`, { force: true })
454
+ ]);
455
+ if (backupIsHealthy) {
456
+ await copyFile(backupPath, databasePath);
457
+ }
458
+ try {
459
+ return {
460
+ db: openCheckedDatabase(databasePath),
461
+ recovery: {
462
+ source: backupIsHealthy ? "backup" : "empty",
463
+ quarantinedPath
464
+ }
465
+ };
466
+ }
467
+ catch (recoveryError) {
468
+ throw new AggregateError([primaryError, recoveryError], `Unable to open or recover session index: ${databasePath}`);
469
+ }
470
+ }
471
+ }
472
+ function openCheckedDatabase(path) {
473
+ const db = new DatabaseSync(path);
474
+ try {
475
+ db.exec("PRAGMA busy_timeout = 5000");
476
+ assertHealthyDatabase(db);
477
+ return db;
478
+ }
479
+ catch (error) {
480
+ try {
481
+ db.close();
482
+ }
483
+ catch {
484
+ // Preserve the database validation failure.
485
+ }
486
+ throw error;
487
+ }
488
+ }
489
+ function assertHealthyDatabase(db) {
490
+ const rows = db.prepare("PRAGMA quick_check").all();
491
+ if (rows.length !== 1
492
+ || Object.values(rows[0] ?? {}).length !== 1
493
+ || Object.values(rows[0] ?? {})[0] !== "ok") {
494
+ throw new Error(`Session index integrity check failed: ${JSON.stringify(rows)}`);
495
+ }
496
+ }
497
+ async function isHealthyDatabaseFile(path) {
498
+ if (!(await pathExists(path))) {
499
+ return false;
500
+ }
501
+ let db = null;
502
+ try {
503
+ db = new DatabaseSync(path);
504
+ assertHealthyDatabase(db);
505
+ return true;
506
+ }
507
+ catch {
508
+ return false;
509
+ }
510
+ finally {
511
+ try {
512
+ db?.close();
513
+ }
514
+ catch {
515
+ // A failed validation can leave no open handle to close.
516
+ }
517
+ }
518
+ }
519
+ async function quarantineDatabase(path) {
520
+ const quarantinedPath = `${path}.corrupt-${Date.now()}-${process.pid}`;
521
+ if (await pathExists(path)) {
522
+ await rename(path, quarantinedPath);
523
+ }
524
+ return quarantinedPath;
525
+ }
526
+ async function replaceFile(source, target) {
527
+ try {
528
+ await rename(source, target);
529
+ }
530
+ catch (error) {
531
+ if (error.code !== "EEXIST" && error.code !== "EPERM") {
532
+ throw error;
533
+ }
534
+ await rm(target, { force: true });
535
+ await rename(source, target);
536
+ }
213
537
  }
214
538
  async function readJsonIfValid(path, schema) {
215
539
  if (!(await pathExists(path))) {