parallel-codex-tui 0.1.3 → 0.1.4
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 +90 -3
- package/README.md +240 -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-recovery.js +70 -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 +221 -23
- package/dist/core/collaboration-timeline.js +261 -0
- package/dist/core/config-errors.js +14 -0
- package/dist/core/config.js +154 -24
- 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 +188 -37
- package/dist/core/session-manager.js +1086 -40
- package/dist/core/task-state-machine.js +17 -0
- package/dist/core/workspace-commit-recovery.js +118 -0
- package/dist/core/workspace.js +19 -11
- package/dist/doctor.js +343 -23
- package/dist/domain/schemas.js +127 -6
- package/dist/orchestrator/collaboration-channel.js +255 -4
- package/dist/orchestrator/feature-plan.js +70 -0
- package/dist/orchestrator/judge-artifacts.js +236 -0
- package/dist/orchestrator/orchestrator.js +1749 -202
- package/dist/orchestrator/prompts.js +126 -2
- package/dist/orchestrator/supervisor-summary.js +56 -2
- package/dist/orchestrator/workspace-sandbox.js +911 -0
- package/dist/tui/App.js +2830 -153
- package/dist/tui/AppShell.js +188 -23
- package/dist/tui/CollaborationTimelineView.js +327 -0
- package/dist/tui/FeatureBoardView.js +227 -0
- package/dist/tui/InputBar.js +514 -57
- package/dist/tui/RouterDiagnosticsView.js +469 -0
- package/dist/tui/StatusBar.js +610 -57
- package/dist/tui/TaskSessionsView.js +207 -0
- package/dist/tui/TerminalOutput.js +53 -9
- package/dist/tui/WorkerOutputView.js +1403 -161
- package/dist/tui/WorkerOverviewView.js +250 -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 +46 -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 +318 -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 +212 -0
- package/dist/workers/live-probe.js +176 -0
- package/dist/workers/mock-adapter.js +39 -6
- package/dist/workers/native-attach.js +78 -3
- package/dist/workers/process-adapter.js +570 -77
- package/dist/workers/registry.js +4 -2
- package/package.json +5 -2
package/dist/core/config.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { parse
|
|
1
|
+
import { parse } from "@iarna/toml";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { pathExists, readTextIfExists, writeText } from "./file-store.js";
|
|
6
|
+
import { normalizeTuiThemeColorValue, normalizeTuiThemeName, TUI_THEME_FIELDS, TUI_THEME_NAMES } from "../tui/theme.js";
|
|
5
7
|
const NativeSessionConfigSchema = z.object({
|
|
6
8
|
enabled: z.boolean().default(true),
|
|
7
9
|
resumeArgs: z.array(z.string()).default([]),
|
|
@@ -14,6 +16,26 @@ const WorkerModelConfigSchema = z.object({
|
|
|
14
16
|
args: z.array(z.string()).default([]),
|
|
15
17
|
env: z.record(z.string()).default({})
|
|
16
18
|
});
|
|
19
|
+
const WorkerCapabilitiesConfigSchema = z.object({
|
|
20
|
+
profile: z.enum(["codex", "claude", "generic"]),
|
|
21
|
+
writableDirArgs: z.array(z.string()).default([]),
|
|
22
|
+
freshSessionArgs: z.array(z.string()).default([])
|
|
23
|
+
}).strict().superRefine((value, context) => {
|
|
24
|
+
if (value.writableDirArgs.length > 0 && !value.writableDirArgs.some((arg) => arg.includes("{dir}"))) {
|
|
25
|
+
context.addIssue({
|
|
26
|
+
code: z.ZodIssueCode.custom,
|
|
27
|
+
path: ["writableDirArgs"],
|
|
28
|
+
message: "writableDirArgs must include a {dir} template"
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (value.freshSessionArgs.length > 0 && !value.freshSessionArgs.some((arg) => arg.includes("{sessionId}"))) {
|
|
32
|
+
context.addIssue({
|
|
33
|
+
code: z.ZodIssueCode.custom,
|
|
34
|
+
path: ["freshSessionArgs"],
|
|
35
|
+
message: "freshSessionArgs must include a {sessionId} template"
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
});
|
|
17
39
|
const InteractiveCommandSchema = z.object({
|
|
18
40
|
command: z.string().min(1),
|
|
19
41
|
args: z.array(z.string()).default([])
|
|
@@ -22,12 +44,45 @@ const RolePromptConfigSchema = z.object({
|
|
|
22
44
|
title: z.string().min(1),
|
|
23
45
|
instructions: z.array(z.string()).default([])
|
|
24
46
|
});
|
|
47
|
+
const TuiColorValueSchema = z.string().min(1).refine((value) => normalizeTuiThemeColorValue(value) !== null, {
|
|
48
|
+
message: "Invalid TUI color value. Use a Chalk color name, #rgb/#rrggbb, rgb(r,g,b), or ansi256(0..255)."
|
|
49
|
+
}).transform((value) => normalizeTuiThemeColorValue(value) ?? value.trim());
|
|
50
|
+
const TuiThemeNameSchema = z.preprocess((value) => typeof value === "string" ? normalizeTuiThemeName(value) ?? value.trim() : value, z.enum(TUI_THEME_NAMES)).default("codex");
|
|
51
|
+
const UiColorOverridesSchema = z.object(Object.fromEntries(TUI_THEME_FIELDS.map((field) => [field, TuiColorValueSchema.optional()]))).strict().default({});
|
|
25
52
|
const CodexRouterConfigSchema = z.object({
|
|
26
53
|
command: z.string().min(1).default("codex"),
|
|
27
|
-
args: z.array(z.string()).default([
|
|
28
|
-
|
|
29
|
-
|
|
54
|
+
args: z.array(z.string()).default([
|
|
55
|
+
"exec",
|
|
56
|
+
"--ephemeral",
|
|
57
|
+
"--ignore-rules",
|
|
58
|
+
"-c",
|
|
59
|
+
"model_reasoning_effort=low",
|
|
60
|
+
"--skip-git-repo-check",
|
|
61
|
+
"--sandbox",
|
|
62
|
+
"read-only",
|
|
63
|
+
"--color",
|
|
64
|
+
"never",
|
|
65
|
+
"-"
|
|
66
|
+
]),
|
|
67
|
+
timeoutMs: z.number().int().positive().default(30000),
|
|
68
|
+
firstOutputTimeoutMs: z.number().int().positive().default(15000),
|
|
69
|
+
idleTimeoutMs: z.number().int().positive().default(15000),
|
|
70
|
+
maxOutputBytes: z.number().int().min(1024).max(16 * 1024 * 1024).default(1024 * 1024),
|
|
71
|
+
maxAttempts: z.number().int().min(1).max(3).default(2),
|
|
72
|
+
retryDelayMs: z.number().int().min(0).max(10000).default(500),
|
|
73
|
+
followUpTimeoutMs: z.number().int().positive().max(120000).default(20000),
|
|
74
|
+
fallback: z.enum(["simple", "complex"]).default("simple"),
|
|
75
|
+
env: z.record(z.string()).default({})
|
|
30
76
|
});
|
|
77
|
+
const OrchestrationConfigSchema = z.object({
|
|
78
|
+
maxParallelFeatures: z.number().int().min(1).max(8)
|
|
79
|
+
}).strict();
|
|
80
|
+
const UiConfigSchema = z.object({
|
|
81
|
+
showStatusBar: z.boolean(),
|
|
82
|
+
autoOpenFailedWorker: z.boolean(),
|
|
83
|
+
theme: TuiThemeNameSchema,
|
|
84
|
+
colors: UiColorOverridesSchema
|
|
85
|
+
}).strict();
|
|
31
86
|
const WorkerCommandSchema = z.object({
|
|
32
87
|
command: z.string().min(1),
|
|
33
88
|
args: z.array(z.string()).default([]),
|
|
@@ -35,6 +90,7 @@ const WorkerCommandSchema = z.object({
|
|
|
35
90
|
idleTimeoutMs: z.number().int().positive().optional(),
|
|
36
91
|
firstOutputTimeoutMs: z.number().int().positive().optional(),
|
|
37
92
|
model: WorkerModelConfigSchema.default({}),
|
|
93
|
+
capabilities: WorkerCapabilitiesConfigSchema,
|
|
38
94
|
nativeSession: NativeSessionConfigSchema,
|
|
39
95
|
interactive: InteractiveCommandSchema
|
|
40
96
|
});
|
|
@@ -45,6 +101,7 @@ const AppConfigSchema = z.object({
|
|
|
45
101
|
defaultMode: z.enum(["auto", "simple", "complex"]),
|
|
46
102
|
codex: CodexRouterConfigSchema.default({})
|
|
47
103
|
}),
|
|
104
|
+
orchestration: OrchestrationConfigSchema,
|
|
48
105
|
workers: z.object({
|
|
49
106
|
codex: WorkerCommandSchema,
|
|
50
107
|
claude: WorkerCommandSchema,
|
|
@@ -62,10 +119,7 @@ const AppConfigSchema = z.object({
|
|
|
62
119
|
actor: RolePromptConfigSchema,
|
|
63
120
|
critic: RolePromptConfigSchema
|
|
64
121
|
}),
|
|
65
|
-
ui:
|
|
66
|
-
showStatusBar: z.boolean(),
|
|
67
|
-
autoOpenFailedWorker: z.boolean()
|
|
68
|
-
})
|
|
122
|
+
ui: UiConfigSchema
|
|
69
123
|
});
|
|
70
124
|
export function defaultConfig(projectRoot) {
|
|
71
125
|
return {
|
|
@@ -75,11 +129,33 @@ export function defaultConfig(projectRoot) {
|
|
|
75
129
|
defaultMode: "auto",
|
|
76
130
|
codex: {
|
|
77
131
|
command: "codex",
|
|
78
|
-
args: [
|
|
79
|
-
|
|
80
|
-
|
|
132
|
+
args: [
|
|
133
|
+
"exec",
|
|
134
|
+
"--ephemeral",
|
|
135
|
+
"--ignore-rules",
|
|
136
|
+
"-c",
|
|
137
|
+
"model_reasoning_effort=low",
|
|
138
|
+
"--skip-git-repo-check",
|
|
139
|
+
"--sandbox",
|
|
140
|
+
"read-only",
|
|
141
|
+
"--color",
|
|
142
|
+
"never",
|
|
143
|
+
"-"
|
|
144
|
+
],
|
|
145
|
+
timeoutMs: 30000,
|
|
146
|
+
firstOutputTimeoutMs: 15000,
|
|
147
|
+
idleTimeoutMs: 15000,
|
|
148
|
+
maxOutputBytes: 1024 * 1024,
|
|
149
|
+
maxAttempts: 2,
|
|
150
|
+
retryDelayMs: 500,
|
|
151
|
+
followUpTimeoutMs: 20000,
|
|
152
|
+
fallback: "simple",
|
|
153
|
+
env: {}
|
|
81
154
|
}
|
|
82
155
|
},
|
|
156
|
+
orchestration: {
|
|
157
|
+
maxParallelFeatures: 3
|
|
158
|
+
},
|
|
83
159
|
workers: {
|
|
84
160
|
codex: {
|
|
85
161
|
command: "codex",
|
|
@@ -93,6 +169,11 @@ export function defaultConfig(projectRoot) {
|
|
|
93
169
|
args: [],
|
|
94
170
|
env: {}
|
|
95
171
|
},
|
|
172
|
+
capabilities: {
|
|
173
|
+
profile: "codex",
|
|
174
|
+
writableDirArgs: ["--add-dir", "{dir}"],
|
|
175
|
+
freshSessionArgs: []
|
|
176
|
+
},
|
|
96
177
|
nativeSession: {
|
|
97
178
|
enabled: true,
|
|
98
179
|
resumeArgs: ["exec", "resume", "{sessionId}", "--skip-git-repo-check", "-"],
|
|
@@ -116,6 +197,11 @@ export function defaultConfig(projectRoot) {
|
|
|
116
197
|
args: [],
|
|
117
198
|
env: {}
|
|
118
199
|
},
|
|
200
|
+
capabilities: {
|
|
201
|
+
profile: "claude",
|
|
202
|
+
writableDirArgs: ["--add-dir", "{dir}"],
|
|
203
|
+
freshSessionArgs: ["--session-id", "{sessionId}"]
|
|
204
|
+
},
|
|
119
205
|
nativeSession: {
|
|
120
206
|
enabled: true,
|
|
121
207
|
resumeArgs: ["--print", "--resume", "{sessionId}", "--permission-mode", "acceptEdits", "--output-format", "text"],
|
|
@@ -136,6 +222,11 @@ export function defaultConfig(projectRoot) {
|
|
|
136
222
|
args: [],
|
|
137
223
|
env: {}
|
|
138
224
|
},
|
|
225
|
+
capabilities: {
|
|
226
|
+
profile: "generic",
|
|
227
|
+
writableDirArgs: [],
|
|
228
|
+
freshSessionArgs: []
|
|
229
|
+
},
|
|
139
230
|
nativeSession: {
|
|
140
231
|
enabled: true,
|
|
141
232
|
resumeArgs: ["resume", "{sessionId}", "-"],
|
|
@@ -174,7 +265,9 @@ export function defaultConfig(projectRoot) {
|
|
|
174
265
|
},
|
|
175
266
|
ui: {
|
|
176
267
|
showStatusBar: true,
|
|
177
|
-
autoOpenFailedWorker: true
|
|
268
|
+
autoOpenFailedWorker: true,
|
|
269
|
+
theme: "codex",
|
|
270
|
+
colors: {}
|
|
178
271
|
}
|
|
179
272
|
};
|
|
180
273
|
}
|
|
@@ -198,9 +291,17 @@ export async function loadConfig(projectRoot) {
|
|
|
198
291
|
...(parsed.router ?? {}),
|
|
199
292
|
codex: {
|
|
200
293
|
...base.router.codex,
|
|
201
|
-
...(parsed.router?.codex ?? {})
|
|
294
|
+
...(parsed.router?.codex ?? {}),
|
|
295
|
+
env: {
|
|
296
|
+
...base.router.codex.env,
|
|
297
|
+
...(parsed.router?.codex?.env ?? {})
|
|
298
|
+
}
|
|
202
299
|
}
|
|
203
300
|
},
|
|
301
|
+
orchestration: {
|
|
302
|
+
...base.orchestration,
|
|
303
|
+
...(parsed.orchestration ?? {})
|
|
304
|
+
},
|
|
204
305
|
workers: {
|
|
205
306
|
codex: {
|
|
206
307
|
...base.workers.codex,
|
|
@@ -213,6 +314,10 @@ export async function loadConfig(projectRoot) {
|
|
|
213
314
|
...(parsed.workers?.codex?.model?.env ?? {})
|
|
214
315
|
}
|
|
215
316
|
},
|
|
317
|
+
capabilities: {
|
|
318
|
+
...base.workers.codex.capabilities,
|
|
319
|
+
...(parsed.workers?.codex?.capabilities ?? {})
|
|
320
|
+
},
|
|
216
321
|
nativeSession: {
|
|
217
322
|
...base.workers.codex.nativeSession,
|
|
218
323
|
...(parsed.workers?.codex?.nativeSession ?? {})
|
|
@@ -233,6 +338,10 @@ export async function loadConfig(projectRoot) {
|
|
|
233
338
|
...(parsed.workers?.claude?.model?.env ?? {})
|
|
234
339
|
}
|
|
235
340
|
},
|
|
341
|
+
capabilities: {
|
|
342
|
+
...base.workers.claude.capabilities,
|
|
343
|
+
...(parsed.workers?.claude?.capabilities ?? {})
|
|
344
|
+
},
|
|
236
345
|
nativeSession: {
|
|
237
346
|
...base.workers.claude.nativeSession,
|
|
238
347
|
...(parsed.workers?.claude?.nativeSession ?? {})
|
|
@@ -253,6 +362,10 @@ export async function loadConfig(projectRoot) {
|
|
|
253
362
|
...(parsed.workers?.mock?.model?.env ?? {})
|
|
254
363
|
}
|
|
255
364
|
},
|
|
365
|
+
capabilities: {
|
|
366
|
+
...base.workers.mock.capabilities,
|
|
367
|
+
...(parsed.workers?.mock?.capabilities ?? {})
|
|
368
|
+
},
|
|
256
369
|
nativeSession: {
|
|
257
370
|
...base.workers.mock.nativeSession,
|
|
258
371
|
...(parsed.workers?.mock?.nativeSession ?? {})
|
|
@@ -287,29 +400,50 @@ export async function loadConfig(projectRoot) {
|
|
|
287
400
|
},
|
|
288
401
|
ui: {
|
|
289
402
|
...base.ui,
|
|
290
|
-
...(parsed.ui ?? {})
|
|
403
|
+
...(parsed.ui ?? {}),
|
|
404
|
+
colors: {
|
|
405
|
+
...base.ui.colors,
|
|
406
|
+
...(parsed.ui?.colors ?? {})
|
|
407
|
+
}
|
|
291
408
|
}
|
|
292
409
|
};
|
|
293
410
|
return AppConfigSchema.parse(merged);
|
|
294
411
|
}
|
|
412
|
+
export function withUiThemeOverride(config, theme) {
|
|
413
|
+
if (!theme) {
|
|
414
|
+
return config;
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
...config,
|
|
418
|
+
ui: {
|
|
419
|
+
...config.ui,
|
|
420
|
+
theme
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
}
|
|
295
424
|
function assertObjectSections(parsed) {
|
|
296
425
|
const sections = [
|
|
297
426
|
["router", parsed.router],
|
|
298
427
|
["router.codex", parsed.router?.codex],
|
|
428
|
+
["router.codex.env", parsed.router?.codex?.env],
|
|
429
|
+
["orchestration", parsed.orchestration],
|
|
299
430
|
["workers", parsed.workers],
|
|
300
431
|
["workers.codex", parsed.workers?.codex],
|
|
301
432
|
["workers.codex.model", parsed.workers?.codex?.model],
|
|
302
433
|
["workers.codex.model.env", parsed.workers?.codex?.model?.env],
|
|
434
|
+
["workers.codex.capabilities", parsed.workers?.codex?.capabilities],
|
|
303
435
|
["workers.codex.nativeSession", parsed.workers?.codex?.nativeSession],
|
|
304
436
|
["workers.codex.interactive", parsed.workers?.codex?.interactive],
|
|
305
437
|
["workers.claude", parsed.workers?.claude],
|
|
306
438
|
["workers.claude.model", parsed.workers?.claude?.model],
|
|
307
439
|
["workers.claude.model.env", parsed.workers?.claude?.model?.env],
|
|
440
|
+
["workers.claude.capabilities", parsed.workers?.claude?.capabilities],
|
|
308
441
|
["workers.claude.nativeSession", parsed.workers?.claude?.nativeSession],
|
|
309
442
|
["workers.claude.interactive", parsed.workers?.claude?.interactive],
|
|
310
443
|
["workers.mock", parsed.workers?.mock],
|
|
311
444
|
["workers.mock.model", parsed.workers?.mock?.model],
|
|
312
445
|
["workers.mock.model.env", parsed.workers?.mock?.model?.env],
|
|
446
|
+
["workers.mock.capabilities", parsed.workers?.mock?.capabilities],
|
|
313
447
|
["workers.mock.nativeSession", parsed.workers?.mock?.nativeSession],
|
|
314
448
|
["workers.mock.interactive", parsed.workers?.mock?.interactive],
|
|
315
449
|
["pairing", parsed.pairing],
|
|
@@ -318,7 +452,8 @@ function assertObjectSections(parsed) {
|
|
|
318
452
|
["roles.judge", parsed.roles?.judge],
|
|
319
453
|
["roles.actor", parsed.roles?.actor],
|
|
320
454
|
["roles.critic", parsed.roles?.critic],
|
|
321
|
-
["ui", parsed.ui]
|
|
455
|
+
["ui", parsed.ui],
|
|
456
|
+
["ui.colors", parsed.ui?.colors]
|
|
322
457
|
];
|
|
323
458
|
for (const [path, value] of sections) {
|
|
324
459
|
if (value !== undefined && !isPlainObject(value)) {
|
|
@@ -330,13 +465,8 @@ function isPlainObject(value) {
|
|
|
330
465
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
331
466
|
}
|
|
332
467
|
export async function writeDefaultConfig(projectRoot) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
pairing: config.pairing,
|
|
338
|
-
roles: config.roles,
|
|
339
|
-
ui: config.ui
|
|
340
|
-
});
|
|
341
|
-
await writeText(configPath(projectRoot), tomlText);
|
|
468
|
+
await writeText(configPath(projectRoot), await readExampleConfig());
|
|
469
|
+
}
|
|
470
|
+
async function readExampleConfig() {
|
|
471
|
+
return readFile(new URL("../../.parallel-codex/config.example.toml", import.meta.url), "utf8");
|
|
342
472
|
}
|
package/dist/core/file-store.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
-
import { basename, dirname, join } from "node:path";
|
|
1
|
+
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
const appendQueues = new Map();
|
|
4
|
+
const defaultRecentJsonLineChunkBytes = 64 * 1024;
|
|
5
|
+
const defaultRecentJsonLineMaxBytes = 2 * 1024 * 1024;
|
|
3
6
|
export async function ensureDir(path) {
|
|
4
7
|
await mkdir(path, { recursive: true });
|
|
5
8
|
}
|
|
@@ -38,16 +41,120 @@ export async function readJson(path, schema) {
|
|
|
38
41
|
return schema.parse(JSON.parse(text));
|
|
39
42
|
}
|
|
40
43
|
export async function appendJsonLine(path, value) {
|
|
41
|
-
await
|
|
42
|
-
await writeFile(path, `${JSON.stringify(value)}\n`, { encoding: "utf8", flag: "a" });
|
|
44
|
+
await appendFile(path, `${JSON.stringify(value)}\n`);
|
|
43
45
|
}
|
|
44
46
|
export async function writeText(path, value) {
|
|
45
47
|
await ensureDir(dirname(path));
|
|
46
48
|
await writeFile(path, value, "utf8");
|
|
47
49
|
}
|
|
48
50
|
export async function appendText(path, value) {
|
|
49
|
-
await
|
|
50
|
-
|
|
51
|
+
await appendFile(path, value);
|
|
52
|
+
}
|
|
53
|
+
export async function readRecentJsonLines(path, schema, limit, options = {}) {
|
|
54
|
+
const targetLimit = Number.isFinite(limit)
|
|
55
|
+
? Math.min(10000, Math.max(0, Math.trunc(limit)))
|
|
56
|
+
: 10000;
|
|
57
|
+
if (targetLimit === 0) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const chunkBytes = boundedPositiveInteger(options.chunkBytes, defaultRecentJsonLineChunkBytes, 4 * 1024 * 1024);
|
|
61
|
+
const maxLineBytes = boundedPositiveInteger(options.maxLineBytes, defaultRecentJsonLineMaxBytes, 16 * 1024 * 1024);
|
|
62
|
+
let handle;
|
|
63
|
+
try {
|
|
64
|
+
handle = await open(path, "r");
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === "ENOENT") {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
let position = (await handle.stat()).size;
|
|
74
|
+
let lineSegments = [];
|
|
75
|
+
let lineBytes = 0;
|
|
76
|
+
let lineTooLong = false;
|
|
77
|
+
const newestFirst = [];
|
|
78
|
+
const addLineSegment = (segment) => {
|
|
79
|
+
if (segment.length === 0 || lineTooLong) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
lineBytes += segment.length;
|
|
83
|
+
if (lineBytes > maxLineBytes) {
|
|
84
|
+
lineSegments = [];
|
|
85
|
+
lineTooLong = true;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
lineSegments.push(Buffer.from(segment));
|
|
89
|
+
};
|
|
90
|
+
const finishLine = () => {
|
|
91
|
+
if (!lineTooLong && lineBytes > 0) {
|
|
92
|
+
const line = Buffer.concat([...lineSegments].reverse(), lineBytes).toString("utf8").trim();
|
|
93
|
+
if (line) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = schema.safeParse(JSON.parse(line));
|
|
96
|
+
if (parsed.success) {
|
|
97
|
+
newestFirst.push(parsed.data);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Invalid or partial rows do not hide earlier valid records.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
lineSegments = [];
|
|
106
|
+
lineBytes = 0;
|
|
107
|
+
lineTooLong = false;
|
|
108
|
+
};
|
|
109
|
+
while (position > 0 && newestFirst.length < targetLimit) {
|
|
110
|
+
const requested = Math.min(chunkBytes, position);
|
|
111
|
+
position -= requested;
|
|
112
|
+
const buffer = Buffer.allocUnsafe(requested);
|
|
113
|
+
const { bytesRead } = await handle.read(buffer, 0, requested, position);
|
|
114
|
+
if (bytesRead === 0) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
let segmentEnd = bytesRead;
|
|
118
|
+
for (let index = bytesRead - 1; index >= 0; index -= 1) {
|
|
119
|
+
if (buffer[index] !== 0x0a) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
addLineSegment(buffer.subarray(index + 1, segmentEnd));
|
|
123
|
+
finishLine();
|
|
124
|
+
segmentEnd = index;
|
|
125
|
+
if (newestFirst.length >= targetLimit) {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (newestFirst.length < targetLimit) {
|
|
130
|
+
addLineSegment(buffer.subarray(0, segmentEnd));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (position === 0 && newestFirst.length < targetLimit && (lineBytes > 0 || lineTooLong)) {
|
|
134
|
+
finishLine();
|
|
135
|
+
}
|
|
136
|
+
return newestFirst.reverse();
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await handle.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function appendFile(path, value) {
|
|
143
|
+
const key = resolve(path);
|
|
144
|
+
const previous = appendQueues.get(key) ?? Promise.resolve();
|
|
145
|
+
const operation = previous.catch(() => undefined).then(async () => {
|
|
146
|
+
await ensureDir(dirname(path));
|
|
147
|
+
await writeFile(path, value, { encoding: "utf8", flag: "a" });
|
|
148
|
+
});
|
|
149
|
+
appendQueues.set(key, operation);
|
|
150
|
+
try {
|
|
151
|
+
await operation;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
if (appendQueues.get(key) === operation) {
|
|
155
|
+
appendQueues.delete(key);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
51
158
|
}
|
|
52
159
|
export async function readTextIfExists(path) {
|
|
53
160
|
if (!(await pathExists(path))) {
|
|
@@ -65,3 +172,9 @@ export async function removeIfExists(path) {
|
|
|
65
172
|
}
|
|
66
173
|
}
|
|
67
174
|
}
|
|
175
|
+
function boundedPositiveInteger(value, fallback, maximum) {
|
|
176
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
177
|
+
return fallback;
|
|
178
|
+
}
|
|
179
|
+
return Math.min(maximum, Math.max(1, Math.trunc(value)));
|
|
180
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export async function runWithLeaseFinalization(subject, lease, run) {
|
|
2
|
+
const outcome = await run().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
|
|
3
|
+
try {
|
|
4
|
+
await lease.release();
|
|
5
|
+
}
|
|
6
|
+
catch (releaseError) {
|
|
7
|
+
const releaseSummary = `${subject} lease release failed: ${errorMessage(releaseError)}`;
|
|
8
|
+
if (!outcome.ok) {
|
|
9
|
+
throw new Error(`${errorMessage(outcome.error)}; ${releaseSummary}`, {
|
|
10
|
+
cause: new AggregateError([outcome.error, releaseError])
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
throw new Error(releaseSummary, { cause: releaseError });
|
|
14
|
+
}
|
|
15
|
+
if (!outcome.ok) {
|
|
16
|
+
throw outcome.error;
|
|
17
|
+
}
|
|
18
|
+
return outcome.value;
|
|
19
|
+
}
|
|
20
|
+
function errorMessage(error) {
|
|
21
|
+
return error instanceof Error ? error.message : String(error);
|
|
22
|
+
}
|
package/dist/core/paths.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import { TaskSessionIdSchema } from "../domain/schemas.js";
|
|
2
3
|
function pad(value) {
|
|
3
4
|
return String(value).padStart(2, "0");
|
|
4
5
|
}
|
|
@@ -16,5 +17,11 @@ export function routerRuntimeDir(appRoot, dataDir) {
|
|
|
16
17
|
return join(appRoot, dataDir, "router");
|
|
17
18
|
}
|
|
18
19
|
export function taskDir(projectRoot, dataDir, taskId) {
|
|
20
|
+
if (!taskSessionIdIsValid(taskId)) {
|
|
21
|
+
throw new Error(`Invalid task session id: ${JSON.stringify(taskId)}`);
|
|
22
|
+
}
|
|
19
23
|
return join(sessionsRoot(projectRoot, dataDir), taskId);
|
|
20
24
|
}
|
|
25
|
+
export function taskSessionIdIsValid(taskId) {
|
|
26
|
+
return TaskSessionIdSchema.safeParse(taskId).success;
|
|
27
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
export function processIsAlive(pid) {
|
|
6
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
return error.code === "EPERM";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function readProcessStartToken(pid) {
|
|
18
|
+
if (!processIsAlive(pid)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (process.platform === "linux") {
|
|
22
|
+
try {
|
|
23
|
+
const stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
24
|
+
const fields = stat.slice(stat.lastIndexOf(") ") + 2).trim().split(/\s+/);
|
|
25
|
+
const startTick = fields[19];
|
|
26
|
+
if (startTick) {
|
|
27
|
+
return `linux:${startTick}`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Fall through to ps when procfs is unavailable.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (process.platform !== "win32") {
|
|
35
|
+
try {
|
|
36
|
+
const result = await execFileAsync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
timeout: 1000
|
|
39
|
+
});
|
|
40
|
+
const value = String(result.stdout).trim().replace(/\s+/g, " ");
|
|
41
|
+
return value ? `ps:${value}` : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ensureDir, readJson, removeIfExists, writeJson } from "./file-store.js";
|
|
6
|
+
import { processIsAlive, readProcessStartToken } from "./process-identity.js";
|
|
7
|
+
const ProcessMutationIntentSchema = z.object({
|
|
8
|
+
version: z.literal(1),
|
|
9
|
+
intent_id: z.string().min(1),
|
|
10
|
+
pid: z.number().int().positive(),
|
|
11
|
+
created_at: z.string().datetime(),
|
|
12
|
+
choosing: z.boolean(),
|
|
13
|
+
ticket: z.number().int().nonnegative(),
|
|
14
|
+
process_start_token: z.string().min(1).optional()
|
|
15
|
+
});
|
|
16
|
+
let currentProcessStartToken = null;
|
|
17
|
+
export async function acquireProcessMutationTurn(directory, options) {
|
|
18
|
+
validateIntentPrefix(options.intentPrefix);
|
|
19
|
+
await ensureDir(directory);
|
|
20
|
+
const identity = await currentMutationIdentity();
|
|
21
|
+
const intentId = randomUUID();
|
|
22
|
+
const path = join(directory, `${options.intentPrefix}${intentId}.json`);
|
|
23
|
+
let intent = ProcessMutationIntentSchema.parse({
|
|
24
|
+
version: 1,
|
|
25
|
+
intent_id: intentId,
|
|
26
|
+
pid: identity.pid,
|
|
27
|
+
created_at: new Date().toISOString(),
|
|
28
|
+
choosing: true,
|
|
29
|
+
ticket: 0,
|
|
30
|
+
...(identity.process_start_token ? { process_start_token: identity.process_start_token } : {})
|
|
31
|
+
});
|
|
32
|
+
await writeJson(path, intent);
|
|
33
|
+
try {
|
|
34
|
+
const existing = await readActiveIntents(directory, options.intentPrefix);
|
|
35
|
+
intent = {
|
|
36
|
+
...intent,
|
|
37
|
+
choosing: false,
|
|
38
|
+
ticket: Math.max(0, ...existing.map((candidate) => candidate.ticket)) + 1
|
|
39
|
+
};
|
|
40
|
+
await writeJson(path, intent);
|
|
41
|
+
const deadline = Date.now() + (options.timeoutMs ?? 5000);
|
|
42
|
+
while (true) {
|
|
43
|
+
const candidates = await readActiveIntents(directory, options.intentPrefix);
|
|
44
|
+
const blocked = candidates.some((candidate) => (candidate.intent_id !== intent.intent_id
|
|
45
|
+
&& (candidate.choosing || intentPrecedes(candidate, intent))));
|
|
46
|
+
if (!blocked) {
|
|
47
|
+
let released = false;
|
|
48
|
+
return {
|
|
49
|
+
release: async () => {
|
|
50
|
+
if (released) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
released = true;
|
|
54
|
+
await removeIfExists(path);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (Date.now() >= deadline) {
|
|
59
|
+
throw new Error(options.timeoutMessage);
|
|
60
|
+
}
|
|
61
|
+
await delay(options.pollMs ?? 5);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
await removeIfExists(path);
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function currentMutationIdentity() {
|
|
70
|
+
currentProcessStartToken ??= readProcessStartToken(process.pid);
|
|
71
|
+
const processStartToken = await currentProcessStartToken;
|
|
72
|
+
return {
|
|
73
|
+
pid: process.pid,
|
|
74
|
+
...(processStartToken ? { process_start_token: processStartToken } : {})
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function readActiveIntents(directory, prefix) {
|
|
78
|
+
const names = await readdir(directory);
|
|
79
|
+
const tokenReads = new Map();
|
|
80
|
+
const active = [];
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
if (!name.startsWith(prefix) || !name.endsWith(".json")) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const path = join(directory, name);
|
|
86
|
+
const intent = await readValidIntent(path);
|
|
87
|
+
if (!intent || !processIsAlive(intent.pid)) {
|
|
88
|
+
await removeIfExists(path);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (intent.process_start_token) {
|
|
92
|
+
let tokenRead = tokenReads.get(intent.pid);
|
|
93
|
+
if (!tokenRead) {
|
|
94
|
+
tokenRead = readProcessStartToken(intent.pid);
|
|
95
|
+
tokenReads.set(intent.pid, tokenRead);
|
|
96
|
+
}
|
|
97
|
+
const currentToken = await tokenRead;
|
|
98
|
+
if (!currentToken || currentToken !== intent.process_start_token) {
|
|
99
|
+
await removeIfExists(path);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
active.push(intent);
|
|
104
|
+
}
|
|
105
|
+
return active;
|
|
106
|
+
}
|
|
107
|
+
async function readValidIntent(path) {
|
|
108
|
+
try {
|
|
109
|
+
return await readJson(path, ProcessMutationIntentSchema);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function intentPrecedes(candidate, current) {
|
|
116
|
+
if (candidate.ticket !== current.ticket) {
|
|
117
|
+
return candidate.ticket < current.ticket;
|
|
118
|
+
}
|
|
119
|
+
return candidate.intent_id < current.intent_id;
|
|
120
|
+
}
|
|
121
|
+
function validateIntentPrefix(prefix) {
|
|
122
|
+
if (!prefix.startsWith(".") || prefix.includes("/") || prefix.includes("\\")) {
|
|
123
|
+
throw new Error(`Invalid process mutation intent prefix: ${prefix}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function delay(milliseconds) {
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
128
|
+
}
|