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.
- package/.parallel-codex/config.example.toml +136 -3
- package/README.md +299 -21
- package/dist/bootstrap.js +37 -17
- package/dist/cli-args.js +34 -3
- package/dist/cli-help.js +20 -0
- package/dist/cli-startup-preflight.js +18 -0
- package/dist/cli-startup-recovery.js +82 -0
- package/dist/cli-workspace-picker.js +330 -0
- package/dist/cli-workspace-transition.js +33 -0
- package/dist/cli-workspace.js +7 -71
- package/dist/cli.js +234 -24
- package/dist/core/collaboration-timeline.js +268 -0
- package/dist/core/config-errors.js +14 -0
- package/dist/core/config.js +297 -109
- package/dist/core/file-store.js +119 -6
- package/dist/core/lease-finalization.js +22 -0
- package/dist/core/paths.js +7 -0
- package/dist/core/process-identity.js +48 -0
- package/dist/core/process-mutation-turn.js +128 -0
- package/dist/core/process-ownership.js +276 -0
- package/dist/core/process-tree.js +90 -0
- package/dist/core/router-audit.js +155 -0
- package/dist/core/router-redaction.js +31 -0
- package/dist/core/router.js +462 -35
- package/dist/core/session-index.js +412 -88
- package/dist/core/session-manager.js +1110 -40
- package/dist/core/task-session-details.js +175 -0
- package/dist/core/task-state-machine.js +18 -0
- package/dist/core/workspace-commit-recovery.js +118 -0
- package/dist/core/workspace.js +19 -11
- package/dist/doctor.js +373 -34
- package/dist/domain/schemas.js +142 -7
- package/dist/orchestrator/collaboration-channel.js +289 -6
- package/dist/orchestrator/feature-plan.js +70 -0
- package/dist/orchestrator/final-acceptance.js +86 -0
- package/dist/orchestrator/judge-artifacts.js +236 -0
- package/dist/orchestrator/orchestrator.js +2086 -203
- package/dist/orchestrator/prompts.js +168 -5
- package/dist/orchestrator/supervisor-summary.js +56 -2
- package/dist/orchestrator/workspace-sandbox.js +927 -0
- package/dist/tui/App.js +3187 -161
- package/dist/tui/AppShell.js +196 -25
- package/dist/tui/CollaborationTimelineView.js +327 -0
- package/dist/tui/FeatureBoardView.js +232 -0
- package/dist/tui/InputBar.js +581 -57
- package/dist/tui/RouterDiagnosticsView.js +469 -0
- package/dist/tui/StatusBar.js +610 -57
- package/dist/tui/StatusDetailView.js +164 -0
- package/dist/tui/TaskSessionDetailView.js +222 -0
- package/dist/tui/TaskSessionsView.js +210 -0
- package/dist/tui/TerminalOutput.js +53 -9
- package/dist/tui/WorkerOutputView.js +1404 -161
- package/dist/tui/WorkerOverviewView.js +269 -0
- package/dist/tui/chat-history.js +25 -0
- package/dist/tui/chat-input.js +67 -19
- package/dist/tui/chat-paste.js +76 -0
- package/dist/tui/display-width.js +41 -3
- package/dist/tui/incremental-text-file.js +101 -0
- package/dist/tui/keyboard.js +49 -0
- package/dist/tui/markdown-text.js +14 -0
- package/dist/tui/raw-input-decoder.js +3 -0
- package/dist/tui/scrolling.js +2 -1
- package/dist/tui/status-line.js +360 -11
- package/dist/tui/task-memory.js +13 -1
- package/dist/tui/task-result.js +105 -0
- package/dist/tui/terminal-screen.js +13 -1
- package/dist/tui/theme-contrast.js +144 -0
- package/dist/tui/theme-preview.js +109 -0
- package/dist/tui/theme.js +158 -0
- package/dist/version.js +1 -1
- package/dist/workers/capabilities.js +213 -0
- package/dist/workers/live-probe.js +177 -0
- package/dist/workers/mock-adapter.js +73 -8
- package/dist/workers/native-attach.js +106 -16
- package/dist/workers/process-adapter.js +572 -77
- package/dist/workers/provider.js +26 -0
- package/dist/workers/registry.js +12 -20
- 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
|
-
|
|
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
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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("
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
continue;
|
|
252
|
+
catch (error) {
|
|
253
|
+
try {
|
|
254
|
+
this.db.exec("ROLLBACK");
|
|
147
255
|
}
|
|
148
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
401
|
+
const status = await this.readRebuildWorkerStatus(statusPath, nativeSession);
|
|
185
402
|
if (status) {
|
|
186
|
-
await this.upsertWorker(
|
|
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 (
|
|
194
|
-
|
|
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,
|
|
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 &&
|
|
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))) {
|