parallel-codex-tui 0.1.4 → 0.1.6

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 (41) hide show
  1. package/.parallel-codex/config.example.toml +46 -0
  2. package/README.md +96 -19
  3. package/dist/cli-startup-preflight.js +18 -0
  4. package/dist/cli-startup-recovery.js +13 -1
  5. package/dist/cli.js +19 -7
  6. package/dist/core/clipboard.js +97 -0
  7. package/dist/core/collaboration-timeline.js +9 -2
  8. package/dist/core/config.js +161 -103
  9. package/dist/core/router.js +1 -1
  10. package/dist/core/session-index.js +234 -61
  11. package/dist/core/session-manager.js +25 -1
  12. package/dist/core/task-session-details.js +175 -0
  13. package/dist/core/task-state-machine.js +10 -9
  14. package/dist/doctor.js +58 -39
  15. package/dist/domain/schemas.js +15 -1
  16. package/dist/orchestrator/collaboration-channel.js +35 -3
  17. package/dist/orchestrator/final-acceptance.js +86 -0
  18. package/dist/orchestrator/orchestrator.js +405 -69
  19. package/dist/orchestrator/prompts.js +42 -3
  20. package/dist/orchestrator/workspace-sandbox.js +16 -0
  21. package/dist/tui/App.js +514 -56
  22. package/dist/tui/AppShell.js +9 -3
  23. package/dist/tui/FeatureBoardView.js +7 -2
  24. package/dist/tui/InputBar.js +87 -15
  25. package/dist/tui/StatusBar.js +1 -1
  26. package/dist/tui/StatusDetailView.js +164 -0
  27. package/dist/tui/TaskSessionDetailView.js +222 -0
  28. package/dist/tui/TaskSessionsView.js +5 -2
  29. package/dist/tui/WorkerOutputView.js +6 -1
  30. package/dist/tui/WorkerOverviewView.js +23 -4
  31. package/dist/tui/keyboard.js +6 -0
  32. package/dist/tui/status-line.js +42 -0
  33. package/dist/version.js +1 -1
  34. package/dist/workers/capabilities.js +4 -3
  35. package/dist/workers/live-probe.js +4 -3
  36. package/dist/workers/mock-adapter.js +37 -5
  37. package/dist/workers/native-attach.js +32 -17
  38. package/dist/workers/process-adapter.js +2 -0
  39. package/dist/workers/provider.js +26 -0
  40. package/dist/workers/registry.js +12 -22
  41. package/package.json +7 -1
@@ -1,79 +1,77 @@
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
4
  import { NativeSessionSchema, RetiredNativeSessionSchema, RouteDecisionSchema, TaskIdSchema, TaskMetaSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
5
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
- PRAGMA busy_timeout = 5000;
24
-
25
- CREATE TABLE IF NOT EXISTS tasks (
26
- id TEXT PRIMARY KEY,
27
- title TEXT NOT NULL,
28
- created_at TEXT NOT NULL,
29
- cwd TEXT NOT NULL,
30
- mode TEXT NOT NULL,
31
- status TEXT NOT NULL,
32
- archived_at TEXT
33
- );
34
-
35
- CREATE TABLE IF NOT EXISTS turns (
36
- task_id TEXT NOT NULL,
37
- turn_id TEXT NOT NULL,
38
- created_at TEXT NOT NULL,
39
- request_path TEXT NOT NULL,
40
- PRIMARY KEY (task_id, turn_id)
41
- );
42
-
43
- CREATE TABLE IF NOT EXISTS workers (
44
- task_id TEXT NOT NULL,
45
- worker_id TEXT NOT NULL,
46
- role TEXT NOT NULL,
47
- engine TEXT NOT NULL,
48
- state TEXT NOT NULL,
49
- phase TEXT NOT NULL,
50
- summary TEXT NOT NULL,
51
- status_path TEXT NOT NULL,
52
- output_log_path TEXT NOT NULL,
53
- dir TEXT NOT NULL,
54
- native_session_id TEXT,
55
- PRIMARY KEY (task_id, worker_id)
56
- );
57
-
58
- CREATE TABLE IF NOT EXISTS native_sessions (
59
- task_id TEXT NOT NULL,
60
- worker_id TEXT NOT NULL,
61
- engine TEXT NOT NULL,
62
- role TEXT NOT NULL,
63
- session_id TEXT NOT NULL,
64
- cwd TEXT NOT NULL,
65
- created_at TEXT NOT NULL,
66
- last_used_at TEXT NOT NULL,
67
- source TEXT NOT NULL,
68
- PRIMARY KEY (task_id, worker_id)
69
- );
70
-
71
- CREATE TABLE IF NOT EXISTS workspace_state (
72
- key TEXT PRIMARY KEY,
73
- value TEXT NOT NULL
74
- );
75
- `);
76
- this.ensureColumn("tasks", "archived_at", "TEXT");
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;
77
75
  }
78
76
  async upsertTask(task) {
79
77
  this.db
@@ -260,6 +258,7 @@ export class SessionIndex {
260
258
  }
261
259
  throw error;
262
260
  }
261
+ await this.writeBackup(`${this.databasePath}.backup`);
263
262
  }
264
263
  close() {
265
264
  this.db.close();
@@ -277,6 +276,78 @@ export class SessionIndex {
277
276
  }
278
277
  }
279
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
+ }
280
351
  tableHasColumn(table, column) {
281
352
  const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
282
353
  return columns.some((entry) => entry.name === column);
@@ -362,6 +433,108 @@ export class SessionIndex {
362
433
  return retired?.session_id === active.session_id ? null : active;
363
434
  }
364
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
+ }
537
+ }
365
538
  async function readJsonIfValid(path, schema) {
366
539
  if (!(await pathExists(path))) {
367
540
  return null;
@@ -27,7 +27,7 @@ export class InterruptedTaskRecoveryBlockedError extends Error {
27
27
  this.name = "InterruptedTaskRecoveryBlockedError";
28
28
  }
29
29
  }
30
- const TERMINAL_TASK_STATES = new Set(["done", "failed", "cancelled"]);
30
+ const TERMINAL_TASK_STATES = new Set(["done", "paused", "failed", "cancelled"]);
31
31
  const ACTIVE_WORKER_STATES = new Set(["idle", "starting", "running", "waiting"]);
32
32
  const ACTIVE_FEATURE_STATES = new Set([
33
33
  "queued",
@@ -41,6 +41,19 @@ const ACTIVE_FEATURE_STATES = new Set([
41
41
  ]);
42
42
  const PENDING_TURN_DIRECTORY = /^\.turn-(\d{4})-.+\.pending$/;
43
43
  const PENDING_TASK_CREATION_CLAIM = /^\.(task-.+)\.creating\.json$/;
44
+ const CompletionContractSchema = z.object({
45
+ version: z.literal(1),
46
+ final_judge_required: z.literal(true)
47
+ });
48
+ const FinalAcceptanceEvidenceSchema = z.object({
49
+ decision: z.literal("approved")
50
+ }).passthrough();
51
+ const FinalAcceptanceValidationSchema = z.object({
52
+ version: z.literal(1),
53
+ state: z.literal("valid"),
54
+ decision: z.literal("approved"),
55
+ issues: z.array(z.string()).length(0)
56
+ });
44
57
  const TaskCreationOwnerSchema = z.object({
45
58
  version: z.literal(1),
46
59
  task_id: TaskIdSchema,
@@ -852,6 +865,17 @@ export class SessionManager {
852
865
  if (!(await readTextIfExists(join(latestTurn.dir, "supervisor-summary.md"))).trim()) {
853
866
  return false;
854
867
  }
868
+ const completionContractPath = join(latestTurn.dir, "completion-contract.json");
869
+ if (await pathExists(completionContractPath)) {
870
+ try {
871
+ await readJson(completionContractPath, CompletionContractSchema);
872
+ await readJson(join(latestTurn.dir, "final-acceptance.json"), FinalAcceptanceEvidenceSchema);
873
+ await readJson(join(latestTurn.dir, "final-acceptance-validation.json"), FinalAcceptanceValidationSchema);
874
+ }
875
+ catch {
876
+ return false;
877
+ }
878
+ }
855
879
  const featuresRoot = join(task.dir, "features");
856
880
  if (!(await pathExists(featuresRoot))) {
857
881
  return true;
@@ -0,0 +1,175 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { NativeSessionSchema, RetiredNativeSessionSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
4
+ import { pathExists, readJson, readTextIfExists } from "./file-store.js";
5
+ export async function loadTaskSessionDetails(input) {
6
+ const [turns, workers] = await Promise.all([
7
+ readTaskTurns(input.taskDir),
8
+ readTaskWorkers(input.taskDir, input.modelNames ?? {})
9
+ ]);
10
+ const turnById = new Map(turns.map((turn) => [turn.turnId, turn]));
11
+ for (const worker of workers) {
12
+ let turn = turnById.get(worker.turnId);
13
+ if (!turn) {
14
+ turn = {
15
+ turnId: worker.turnId,
16
+ createdAt: worker.lastActivityAt,
17
+ request: "",
18
+ workers: []
19
+ };
20
+ turnById.set(worker.turnId, turn);
21
+ turns.push(turn);
22
+ }
23
+ turn.workers.push(worker);
24
+ }
25
+ turns.sort((left, right) => left.turnId.localeCompare(right.turnId));
26
+ for (const turn of turns) {
27
+ turn.workers.sort(compareTaskSessionWorkers);
28
+ }
29
+ return {
30
+ task: input.task,
31
+ projectName: basename(input.task.cwd) || input.task.cwd,
32
+ projectPath: input.task.cwd,
33
+ turns,
34
+ workers
35
+ };
36
+ }
37
+ async function readTaskTurns(taskDir) {
38
+ const turnsDir = join(taskDir, "turns");
39
+ let entries;
40
+ try {
41
+ entries = await readdir(turnsDir, { withFileTypes: true });
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ const turns = await Promise.all(entries
47
+ .filter((entry) => entry.isDirectory() && /^\d{4}$/.test(entry.name))
48
+ .map(async (entry) => {
49
+ const dir = join(turnsDir, entry.name);
50
+ try {
51
+ const meta = await readJson(join(dir, "turn.json"), TurnMetaSchema);
52
+ if (meta.turn_id !== entry.name) {
53
+ return null;
54
+ }
55
+ return {
56
+ turnId: meta.turn_id,
57
+ createdAt: meta.created_at,
58
+ request: compactTaskRequest(await readTextIfExists(join(dir, "user.md"))),
59
+ workers: []
60
+ };
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }));
66
+ return turns
67
+ .filter((turn) => turn !== null)
68
+ .sort((left, right) => left.turnId.localeCompare(right.turnId));
69
+ }
70
+ async function readTaskWorkers(taskDir, modelNames) {
71
+ let entries;
72
+ try {
73
+ entries = await readdir(taskDir, { withFileTypes: true });
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ const workers = await Promise.all(entries
79
+ .filter((entry) => entry.isDirectory())
80
+ .map(async (entry) => {
81
+ const dir = join(taskDir, entry.name);
82
+ const statusPath = join(dir, "status.json");
83
+ if (!(await pathExists(statusPath))) {
84
+ return null;
85
+ }
86
+ try {
87
+ const status = await readJson(statusPath, WorkerStatusSchema);
88
+ const nativeSession = await readActiveNativeSession(dir, status.worker_id);
89
+ return {
90
+ id: status.worker_id,
91
+ turnId: taskSessionWorkerTurnId(status.worker_id, status.feature_id),
92
+ ...(status.feature_id ? { featureId: status.feature_id } : {}),
93
+ ...(status.feature_title ? { featureTitle: status.feature_title } : {}),
94
+ role: status.role,
95
+ engine: status.engine,
96
+ model: status.model_name?.trim() || modelNames[status.engine]?.trim() || "",
97
+ ...(status.model_provider?.trim() ? { modelProvider: status.model_provider.trim() } : {}),
98
+ state: status.state,
99
+ phase: status.phase,
100
+ summary: status.summary,
101
+ lastActivityAt: laterTimestamp(status.last_event_at, nativeSession?.lastUsedAt),
102
+ dir,
103
+ statusPath,
104
+ outputLogPath: join(dir, "output.log"),
105
+ nativeSession
106
+ };
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }));
112
+ return workers
113
+ .filter((worker) => worker !== null)
114
+ .sort((left, right) => (left.turnId.localeCompare(right.turnId)
115
+ || compareTaskSessionWorkers(left, right)));
116
+ }
117
+ async function readActiveNativeSession(workerDir, workerId) {
118
+ const activePath = join(workerDir, "native-session.json");
119
+ if (!(await pathExists(activePath))) {
120
+ return null;
121
+ }
122
+ try {
123
+ const active = await readJson(activePath, NativeSessionSchema);
124
+ if (active.worker_id !== workerId) {
125
+ return null;
126
+ }
127
+ const retiredPath = join(workerDir, "native-session.retired.json");
128
+ if (await pathExists(retiredPath)) {
129
+ try {
130
+ const retired = await readJson(retiredPath, RetiredNativeSessionSchema);
131
+ if (retired.session_id === active.session_id) {
132
+ return null;
133
+ }
134
+ }
135
+ catch {
136
+ // A malformed retirement record cannot hide a valid active session.
137
+ }
138
+ }
139
+ return {
140
+ sessionId: active.session_id,
141
+ cwd: active.cwd,
142
+ writableDirs: active.writable_dirs ?? [],
143
+ createdAt: active.created_at,
144
+ lastUsedAt: active.last_used_at,
145
+ source: active.source
146
+ };
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ export function taskSessionWorkerTurnId(workerId, featureId) {
153
+ const featureTurn = featureId?.match(/^(\d{4})(?:-|$)/)?.[1];
154
+ const waveTurn = workerId.match(/-wave-(\d{4})-/)?.[1];
155
+ const finalTurn = workerId.match(/-final-(\d{4})$/)?.[1];
156
+ const taskTurn = workerId.match(/-(\d{4})$/)?.[1];
157
+ return featureTurn ?? waveTurn ?? finalTurn ?? taskTurn ?? "0001";
158
+ }
159
+ function compareTaskSessionWorkers(left, right) {
160
+ return taskSessionWorkerStage(left) - taskSessionWorkerStage(right)
161
+ || left.id.localeCompare(right.id);
162
+ }
163
+ function taskSessionWorkerStage(worker) {
164
+ if (worker.role === "judge" && /-final-\d{4}$/.test(worker.id)) {
165
+ return 4;
166
+ }
167
+ return ["main", "judge", "actor", "critic"].indexOf(worker.role);
168
+ }
169
+ function compactTaskRequest(request) {
170
+ const value = request.replace(/\s+/g, " ").trim();
171
+ return value.length > 160 ? `${value.slice(0, 157)}...` : value;
172
+ }
173
+ function laterTimestamp(left, right) {
174
+ return right && right.localeCompare(left) > 0 ? right : left;
175
+ }
@@ -1,13 +1,14 @@
1
1
  const TASK_STATE_TRANSITIONS = {
2
- created: new Set(["routed", "failed", "cancelled"]),
3
- routed: new Set(["judging", "failed", "cancelled"]),
4
- judging: new Set(["ready_for_pair", "failed", "cancelled"]),
5
- ready_for_pair: new Set(["actor_running", "done", "failed", "cancelled"]),
6
- actor_running: new Set(["critic_running", "verifying", "failed", "cancelled"]),
7
- critic_running: new Set(["revision_needed", "integrating", "failed", "cancelled"]),
8
- revision_needed: new Set(["actor_running", "failed", "cancelled"]),
9
- integrating: new Set(["actor_running", "verifying", "done", "failed", "cancelled"]),
10
- verifying: new Set(["revision_needed", "integrating", "failed", "cancelled"]),
2
+ created: new Set(["routed", "paused", "failed", "cancelled"]),
3
+ routed: new Set(["judging", "paused", "failed", "cancelled"]),
4
+ judging: new Set(["ready_for_pair", "paused", "failed", "cancelled"]),
5
+ ready_for_pair: new Set(["actor_running", "paused", "done", "failed", "cancelled"]),
6
+ actor_running: new Set(["critic_running", "verifying", "paused", "failed", "cancelled"]),
7
+ critic_running: new Set(["revision_needed", "integrating", "paused", "failed", "cancelled"]),
8
+ revision_needed: new Set(["actor_running", "paused", "failed", "cancelled"]),
9
+ integrating: new Set(["actor_running", "verifying", "paused", "done", "failed", "cancelled"]),
10
+ verifying: new Set(["revision_needed", "integrating", "paused", "done", "failed", "cancelled"]),
11
+ paused: new Set(["routed", "judging", "ready_for_pair", "failed", "cancelled"]),
11
12
  done: new Set(["routed", "cancelled"]),
12
13
  failed: new Set(["routed", "judging", "ready_for_pair"]),
13
14
  cancelled: new Set(["routed", "judging", "ready_for_pair"])