@nowcrew/daemon 0.5.18 → 0.5.20
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/README.md +23 -0
- package/dist/attachments.js +196 -0
- package/dist/computer-cli.js +72 -12
- package/dist/computer-profile-lock.js +395 -0
- package/dist/computer-profile.js +189 -20
- package/dist/config.js +2 -1
- package/dist/console.js +175 -9
- package/dist/execution-event-limit.js +1 -1
- package/dist/execution-journal-lock.js +199 -40
- package/dist/execution-journal.js +42 -4
- package/dist/execution-protocol.js +21 -1
- package/dist/execution-recovery.js +71 -0
- package/dist/execution-runner.js +68 -77
- package/dist/execution-supervisor.js +79 -31
- package/dist/external-output.js +114 -0
- package/dist/i18n.js +5 -5
- package/dist/list-models.js +41 -5
- package/dist/local-executor.js +103 -14
- package/dist/machine-info.js +6 -1
- package/dist/main.js +23 -8
- package/dist/origin-decision.js +3 -1
- package/dist/prompt.js +4 -1
- package/dist/runner.js +14 -9
- package/dist/runtime-cancellation.js +74 -0
- package/dist/runtime-capabilities.js +38 -0
- package/dist/runtime-path.js +60 -0
- package/dist/runtimes/claude.js +9 -4
- package/dist/runtimes/codex-app-server-runner.js +340 -0
- package/dist/runtimes/codex.js +10 -4
- package/dist/runtimes/kimi-acp-runner.js +117 -17
- package/dist/runtimes/kimi.js +2 -0
- package/dist/runtimes/progress-watchdog.js +26 -0
- package/dist/serve-lifecycle.js +82 -0
- package/dist/serve.js +212 -212
- package/dist/session.js +1 -1
- package/dist/shared-execution-slots.js +68 -0
- package/dist/shutdown-deadline.js +32 -0
- package/dist/slog.js +34 -20
- package/dist/supervised-runtime.js +104 -0
- package/dist/websocket-shutdown.js +53 -0
- package/package.json +3 -3
package/dist/console.js
CHANGED
|
@@ -5,14 +5,24 @@
|
|
|
5
5
|
* 做**原文透传**——保留思考文本、工具调用、工具返回正文,尽量还原原生 claude CLI 的观感。
|
|
6
6
|
* 纯函数,无 IO,完整单测。
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* 除纯文本 text 外,可识别的工具调用(Edit/Write/Bash/TodoWrite/ExitPlanMode/Task…)
|
|
9
|
+
* 会附带结构化 payload(diff/命令/todo 清单/计划…),供前端做富渲染(红绿 diff、清单勾选);
|
|
10
|
+
* 前端不识别 payload.kind 时降级为纯文本行,协议向后兼容。
|
|
11
|
+
*
|
|
12
|
+
* 覆盖三种 runtime 的流:claude(stream-json)、codex(exec --json 的 item.* 事件)、
|
|
13
|
+
* kimi(OpenAI 消息风格行)。
|
|
10
14
|
*/
|
|
11
15
|
import { detectDaemonLang, translateDaemon } from "./i18n.js";
|
|
12
16
|
/** 单条工具返回正文上限(超出截断并标注),避免单条把终端/DB 撑爆。 */
|
|
13
17
|
export const TOOL_RESULT_CAP = 4000;
|
|
14
18
|
/** 工具输入摘要上限(标题行那一段)。 */
|
|
15
19
|
const TOOL_INPUT_CAP = 160;
|
|
20
|
+
/** payload 里单段文本(diff 单侧/plan/命令)上限:富渲染要保留足够内容,但不能无界。 */
|
|
21
|
+
export const PAYLOAD_TEXT_CAP = 4000;
|
|
22
|
+
/** todo 清单条数上限。 */
|
|
23
|
+
const TODOS_MAX = 50;
|
|
24
|
+
/** MultiEdit 拆分出的 diff 块上限。 */
|
|
25
|
+
const MULTI_EDIT_MAX = 20;
|
|
16
26
|
/** kimi 工具 arguments(JSON 字符串)容错解析为对象;失败返回 undefined。 */
|
|
17
27
|
function parseKimiToolArgs(args) {
|
|
18
28
|
if (!args)
|
|
@@ -25,19 +35,106 @@ function parseKimiToolArgs(args) {
|
|
|
25
35
|
return undefined;
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
|
-
/** 把工具输入压成一行摘要:Bash 取 command
|
|
38
|
+
/** 把工具输入压成一行摘要:Bash 取 command,文件类工具取路径,其它取首个字符串字段或紧凑 JSON。 */
|
|
29
39
|
function summarizeToolInput(name, input) {
|
|
30
40
|
if (!input)
|
|
31
41
|
return `⏺ ${name}`;
|
|
32
42
|
if (name === "Bash" && typeof input.command === "string") {
|
|
33
43
|
return `⏺ Bash(${clip(input.command.replace(/\s+/g, " "), TOOL_INPUT_CAP)})`;
|
|
34
44
|
}
|
|
45
|
+
// 文件编辑类:old/new 全文在 payload 里富渲染,摘要只报文件路径,不把整段代码挤进标题行。
|
|
46
|
+
if ((name === "Edit" || name === "Write" || name === "MultiEdit") && typeof input.file_path === "string") {
|
|
47
|
+
return `⏺ ${name}(${clip(input.file_path, TOOL_INPUT_CAP)})`;
|
|
48
|
+
}
|
|
49
|
+
if (name === "TodoWrite" && Array.isArray(input.todos)) {
|
|
50
|
+
return `⏺ TodoWrite(${input.todos.length})`;
|
|
51
|
+
}
|
|
52
|
+
if (name === "ExitPlanMode")
|
|
53
|
+
return `⏺ ExitPlanMode`;
|
|
35
54
|
const entries = Object.entries(input);
|
|
36
55
|
const head = entries
|
|
37
56
|
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
38
57
|
.join(", ");
|
|
39
58
|
return `⏺ ${name}(${clip(head.replace(/\s+/g, " "), TOOL_INPUT_CAP)})`;
|
|
40
59
|
}
|
|
60
|
+
const TODO_STATUSES = new Set(["pending", "in_progress", "completed"]);
|
|
61
|
+
/** claude TodoWrite 的 todos 数组归一为 ConsoleTodo[](脏数据条目丢弃)。 */
|
|
62
|
+
function normalizeTodos(raw) {
|
|
63
|
+
if (!Array.isArray(raw))
|
|
64
|
+
return [];
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const t of raw.slice(0, TODOS_MAX)) {
|
|
67
|
+
if (!t || typeof t !== "object")
|
|
68
|
+
continue;
|
|
69
|
+
const rec = t;
|
|
70
|
+
const text = typeof rec.content === "string" && rec.content.trim()
|
|
71
|
+
? rec.content.trim()
|
|
72
|
+
: typeof rec.subject === "string" ? rec.subject.trim() : "";
|
|
73
|
+
if (!text)
|
|
74
|
+
continue;
|
|
75
|
+
const status = typeof rec.status === "string" && TODO_STATUSES.has(rec.status)
|
|
76
|
+
? rec.status
|
|
77
|
+
: "pending";
|
|
78
|
+
out.push({ text: clip(text, TOOL_INPUT_CAP), status });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
function diffPayload(file, oldText, newText) {
|
|
83
|
+
return {
|
|
84
|
+
kind: "diff",
|
|
85
|
+
file,
|
|
86
|
+
oldText: clip(typeof oldText === "string" ? oldText : "", PAYLOAD_TEXT_CAP),
|
|
87
|
+
newText: clip(typeof newText === "string" ? newText : "", PAYLOAD_TEXT_CAP),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** 已识别工具 → 结构化 payload;其余返回 undefined(仅纯文本摘要)。 */
|
|
91
|
+
function toolPayload(name, input) {
|
|
92
|
+
if (!input)
|
|
93
|
+
return undefined;
|
|
94
|
+
if (name === "Bash" && typeof input.command === "string") {
|
|
95
|
+
return { kind: "command", command: clip(input.command, PAYLOAD_TEXT_CAP) };
|
|
96
|
+
}
|
|
97
|
+
if (name === "Edit" && typeof input.file_path === "string") {
|
|
98
|
+
return diffPayload(input.file_path, input.old_string, input.new_string);
|
|
99
|
+
}
|
|
100
|
+
if (name === "Write" && typeof input.file_path === "string" && typeof input.content === "string") {
|
|
101
|
+
return diffPayload(input.file_path, "", input.content);
|
|
102
|
+
}
|
|
103
|
+
if (name === "TodoWrite") {
|
|
104
|
+
const todos = normalizeTodos(input.todos);
|
|
105
|
+
if (todos.length)
|
|
106
|
+
return { kind: "todos", todos };
|
|
107
|
+
}
|
|
108
|
+
if (name === "ExitPlanMode" && typeof input.plan === "string" && input.plan.trim()) {
|
|
109
|
+
return { kind: "plan", plan: clip(input.plan.trim(), PAYLOAD_TEXT_CAP) };
|
|
110
|
+
}
|
|
111
|
+
if ((name === "Task" || name === "Agent") && typeof input.description === "string" && input.description.trim()) {
|
|
112
|
+
return { kind: "subagent", description: clip(input.description.trim(), TOOL_INPUT_CAP) };
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
/** 一个 tool_use 块 → 1..N 条 console 行(MultiEdit 拆成每处编辑一块 diff)。 */
|
|
117
|
+
function toolUseChunks(name, input) {
|
|
118
|
+
if (name === "MultiEdit" && input && typeof input.file_path === "string" && Array.isArray(input.edits)) {
|
|
119
|
+
const file = input.file_path;
|
|
120
|
+
const chunks = [];
|
|
121
|
+
for (const ed of input.edits.slice(0, MULTI_EDIT_MAX)) {
|
|
122
|
+
if (!ed || typeof ed !== "object")
|
|
123
|
+
continue;
|
|
124
|
+
const rec = ed;
|
|
125
|
+
chunks.push({
|
|
126
|
+
stream: "tool",
|
|
127
|
+
text: `⏺ Edit(${clip(file, TOOL_INPUT_CAP)})`,
|
|
128
|
+
payload: diffPayload(file, rec.old_string, rec.new_string),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (chunks.length)
|
|
132
|
+
return chunks;
|
|
133
|
+
}
|
|
134
|
+
const text = summarizeToolInput(name, input);
|
|
135
|
+
const payload = toolPayload(name, input);
|
|
136
|
+
return [payload ? { stream: "tool", text, payload } : { stream: "tool", text }];
|
|
137
|
+
}
|
|
41
138
|
/** 把 tool_result 的 content(string | block[])抽成纯文本。 */
|
|
42
139
|
function extractToolResult(content) {
|
|
43
140
|
if (typeof content === "string")
|
|
@@ -53,6 +150,72 @@ function extractToolResult(content) {
|
|
|
53
150
|
function clip(s, cap) {
|
|
54
151
|
return s.length > cap ? s.slice(0, cap) + `… (+${s.length - cap})` : s;
|
|
55
152
|
}
|
|
153
|
+
/** codex exec --json 的 item.completed → console 行(agent_message/reasoning/命令/文件变更/todo…)。 */
|
|
154
|
+
function codexItemChunks(item, td) {
|
|
155
|
+
if (item.type === "agent_message" && item.text?.trim()) {
|
|
156
|
+
return [{ stream: "text", text: item.text.trim() }];
|
|
157
|
+
}
|
|
158
|
+
if (item.type === "reasoning" && item.text?.trim()) {
|
|
159
|
+
return [{ stream: "thinking", text: item.text.trim() }];
|
|
160
|
+
}
|
|
161
|
+
if (item.type === "command_execution" && item.command?.trim()) {
|
|
162
|
+
const command = item.command.trim();
|
|
163
|
+
const out = [{
|
|
164
|
+
stream: "tool",
|
|
165
|
+
text: `⏺ Bash(${clip(command.replace(/\s+/g, " "), TOOL_INPUT_CAP)})`,
|
|
166
|
+
payload: {
|
|
167
|
+
kind: "command",
|
|
168
|
+
command: clip(command, PAYLOAD_TEXT_CAP),
|
|
169
|
+
...(typeof item.exit_code === "number" ? { exitCode: item.exit_code } : {}),
|
|
170
|
+
},
|
|
171
|
+
}];
|
|
172
|
+
const output = item.aggregated_output?.trim();
|
|
173
|
+
if (output)
|
|
174
|
+
out.push({ stream: "tool_result", text: clip(output, TOOL_RESULT_CAP) });
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
if (item.type === "file_change" && Array.isArray(item.changes)) {
|
|
178
|
+
const files = item.changes
|
|
179
|
+
.filter((c) => !!c && typeof c === "object")
|
|
180
|
+
.map((c) => ({
|
|
181
|
+
path: typeof c.path === "string" ? c.path : "",
|
|
182
|
+
change: typeof c.kind === "string" ? c.kind : "update",
|
|
183
|
+
}))
|
|
184
|
+
.filter((f) => f.path);
|
|
185
|
+
if (!files.length)
|
|
186
|
+
return [];
|
|
187
|
+
return [{
|
|
188
|
+
stream: "tool",
|
|
189
|
+
text: `⏺ ${td("Files changed")}: ${clip(files.map((f) => f.path).join(", "), TOOL_INPUT_CAP)}`,
|
|
190
|
+
payload: { kind: "files", files },
|
|
191
|
+
}];
|
|
192
|
+
}
|
|
193
|
+
if (item.type === "todo_list" && Array.isArray(item.items)) {
|
|
194
|
+
const todos = item.items
|
|
195
|
+
.filter((t) => !!t && typeof t === "object")
|
|
196
|
+
.slice(0, TODOS_MAX)
|
|
197
|
+
.map((t) => ({
|
|
198
|
+
text: clip(typeof t.text === "string" ? t.text.trim() : "", TOOL_INPUT_CAP),
|
|
199
|
+
status: (t.completed === true ? "completed" : "pending"),
|
|
200
|
+
}))
|
|
201
|
+
.filter((t) => t.text);
|
|
202
|
+
if (!todos.length)
|
|
203
|
+
return [];
|
|
204
|
+
const done = todos.filter((t) => t.status === "completed").length;
|
|
205
|
+
return [{ stream: "tool", text: `⏺ Todos(${done}/${todos.length})`, payload: { kind: "todos", todos } }];
|
|
206
|
+
}
|
|
207
|
+
if (item.type === "web_search" && item.query?.trim()) {
|
|
208
|
+
return [{ stream: "tool", text: `⏺ WebSearch(${clip(item.query.trim(), TOOL_INPUT_CAP)})` }];
|
|
209
|
+
}
|
|
210
|
+
if (item.type === "mcp_tool_call" && (item.tool || item.server)) {
|
|
211
|
+
const label = [item.server, item.tool].filter(Boolean).join(".");
|
|
212
|
+
return [{ stream: "tool", text: `⏺ MCP(${clip(label, TOOL_INPUT_CAP)})` }];
|
|
213
|
+
}
|
|
214
|
+
if (item.type === "error" && item.message?.trim()) {
|
|
215
|
+
return [{ stream: "error", text: item.message.trim() }];
|
|
216
|
+
}
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
56
219
|
/** 把一个 stream-json 事件转成 0..N 条 console 行(完全透传)。 */
|
|
57
220
|
export function toConsoleLines(event) {
|
|
58
221
|
const e = (event ?? {});
|
|
@@ -72,13 +235,16 @@ export function toConsoleLines(event) {
|
|
|
72
235
|
return [{ stream: "system", text: `● ${td("Claude session started")}` }];
|
|
73
236
|
}
|
|
74
237
|
if (e.type === "thread.started") {
|
|
75
|
-
return [{ stream: "system", text: "
|
|
238
|
+
return [{ stream: "system", text: `● ${td("Codex session started")}` }];
|
|
76
239
|
}
|
|
77
|
-
if (e.type === "item.completed" && e.item
|
|
78
|
-
return
|
|
240
|
+
if (e.type === "item.completed" && e.item) {
|
|
241
|
+
return codexItemChunks(e.item, td);
|
|
79
242
|
}
|
|
80
243
|
if (e.type === "turn.completed") {
|
|
81
|
-
return [{ stream: "result", text: "
|
|
244
|
+
return [{ stream: "result", text: td("Run finished") }];
|
|
245
|
+
}
|
|
246
|
+
if (e.type === "turn.failed") {
|
|
247
|
+
return [{ stream: "error", text: e.error?.message?.trim() || td("Run failed") }];
|
|
82
248
|
}
|
|
83
249
|
if (e.type === "result") {
|
|
84
250
|
const text = e.result?.trim() || (e.is_error ? td("Run failed") : td("Run finished"));
|
|
@@ -94,7 +260,7 @@ export function toConsoleLines(event) {
|
|
|
94
260
|
out.push({ stream: "text", text: block.text.trim() });
|
|
95
261
|
}
|
|
96
262
|
else if (block.type === "tool_use" && block.name) {
|
|
97
|
-
out.push(
|
|
263
|
+
out.push(...toolUseChunks(block.name, block.input));
|
|
98
264
|
}
|
|
99
265
|
}
|
|
100
266
|
return out;
|
|
@@ -119,7 +285,7 @@ export function toConsoleLines(event) {
|
|
|
119
285
|
for (const call of Array.isArray(e.tool_calls) ? e.tool_calls : []) {
|
|
120
286
|
const name = call.function?.name;
|
|
121
287
|
if (name)
|
|
122
|
-
out.push(
|
|
288
|
+
out.push(...toolUseChunks(name, parseKimiToolArgs(call.function?.arguments)));
|
|
123
289
|
}
|
|
124
290
|
return out;
|
|
125
291
|
}
|
|
@@ -36,7 +36,7 @@ export function boundExecutionFrame(input, maxBytes) {
|
|
|
36
36
|
if (input.type === "execution:activity") {
|
|
37
37
|
frame = withBoundedString(frame, "detail", maxBytes, false);
|
|
38
38
|
}
|
|
39
|
-
else if (input.type === "execution:console") {
|
|
39
|
+
else if (input.type === "execution:console" || input.type === "execution:output") {
|
|
40
40
|
frame = withBoundedString(frame, "text", maxBytes, false);
|
|
41
41
|
}
|
|
42
42
|
else if (input.type === "execution:rejected") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink, } from "node:fs/promises";
|
|
2
|
+
import { renameSync } from "node:fs";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
const OwnerSchema = z.object({
|
|
6
7
|
pid: z.number().int().positive(),
|
|
@@ -8,9 +9,15 @@ const OwnerSchema = z.object({
|
|
|
8
9
|
token: z.string().uuid(),
|
|
9
10
|
}).strict();
|
|
10
11
|
export class JournalLockedError extends Error {
|
|
11
|
-
|
|
12
|
+
journalPath;
|
|
13
|
+
ownerPid;
|
|
14
|
+
constructor(message, diagnostics = {}) {
|
|
12
15
|
super(message);
|
|
13
16
|
this.name = "JournalLockedError";
|
|
17
|
+
if (diagnostics.journalPath !== undefined)
|
|
18
|
+
this.journalPath = diagnostics.journalPath;
|
|
19
|
+
if (diagnostics.ownerPid !== undefined)
|
|
20
|
+
this.ownerPid = diagnostics.ownerPid;
|
|
14
21
|
}
|
|
15
22
|
}
|
|
16
23
|
export class JournalLockCorruptionError extends Error {
|
|
@@ -24,6 +31,7 @@ export const defaultJournalLockFileSystem = {
|
|
|
24
31
|
readFile: (path) => readFile(path, "utf8"),
|
|
25
32
|
readdir,
|
|
26
33
|
rename,
|
|
34
|
+
renameSync,
|
|
27
35
|
rm,
|
|
28
36
|
rmdir,
|
|
29
37
|
stat,
|
|
@@ -44,6 +52,141 @@ export function createJournalLeaseRegistry() {
|
|
|
44
52
|
return { leases: new Map() };
|
|
45
53
|
}
|
|
46
54
|
const codeOf = (error) => error instanceof Error && "code" in error ? error.code : undefined;
|
|
55
|
+
const ownerFileName = (token) => `owner.${token}.json`;
|
|
56
|
+
const releasedLockName = (token) => `.journal.released.${token}.lock`;
|
|
57
|
+
const RELEASED_LOCK_PATTERN = /^\.journal\.released\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.lock$/i;
|
|
58
|
+
const MAX_RELEASED_LOCK_CLEANUP = 8;
|
|
59
|
+
const throwIfAborted = (signal) => {
|
|
60
|
+
if (signal?.aborted)
|
|
61
|
+
throw signal.reason ?? new Error("Journal close aborted");
|
|
62
|
+
};
|
|
63
|
+
function awaitAbortable(operation, signal) {
|
|
64
|
+
if (signal === undefined)
|
|
65
|
+
return operation;
|
|
66
|
+
throwIfAborted(signal);
|
|
67
|
+
return new Promise((resolveOperation, rejectOperation) => {
|
|
68
|
+
let settled = false;
|
|
69
|
+
const settle = (continuation) => {
|
|
70
|
+
if (settled)
|
|
71
|
+
return;
|
|
72
|
+
settled = true;
|
|
73
|
+
signal.removeEventListener("abort", onAbort);
|
|
74
|
+
continuation();
|
|
75
|
+
};
|
|
76
|
+
const onAbort = () => settle(() => {
|
|
77
|
+
rejectOperation(signal.reason ?? new Error("Journal close aborted"));
|
|
78
|
+
});
|
|
79
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
80
|
+
if (signal.aborted)
|
|
81
|
+
onAbort();
|
|
82
|
+
operation.then((value) => settle(() => resolveOperation(value)), (error) => settle(() => rejectOperation(error)));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async function parseLockOwner(lockDirectory, fileName, fileSystem) {
|
|
86
|
+
const path = join(lockDirectory, fileName);
|
|
87
|
+
try {
|
|
88
|
+
const owner = OwnerSchema.parse(JSON.parse(await fileSystem.readFile(path)));
|
|
89
|
+
if (fileName !== ownerFileName(owner.token))
|
|
90
|
+
throw new Error("owner token does not match filename");
|
|
91
|
+
return owner;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new JournalLockCorruptionError(path, error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function inspectJournalLock(options) {
|
|
98
|
+
const fileSystem = options.fileSystem ?? defaultJournalLockFileSystem;
|
|
99
|
+
const lockDirectory = join(options.directory, ".journal.lock");
|
|
100
|
+
const orphanGraceMs = options.orphanGraceMs ?? 30_000;
|
|
101
|
+
const now = options.now ?? (() => new Date());
|
|
102
|
+
const missingDuringOwnerRead = (error) => codeOf(error) === "ENOENT"
|
|
103
|
+
|| (error instanceof Error && codeOf(error.cause) === "ENOENT");
|
|
104
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
105
|
+
let names;
|
|
106
|
+
try {
|
|
107
|
+
names = (await fileSystem.readdir(lockDirectory)).map(String).sort();
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (codeOf(error) === "ENOENT") {
|
|
111
|
+
return { status: "unlocked", detail: "lock directory does not exist" };
|
|
112
|
+
}
|
|
113
|
+
return { status: "corrupt", detail: `lock directory cannot be read: ${error.message}` };
|
|
114
|
+
}
|
|
115
|
+
if (names.length === 0) {
|
|
116
|
+
try {
|
|
117
|
+
const lockStat = await fileSystem.stat(lockDirectory);
|
|
118
|
+
const ageMs = now().valueOf() - lockStat.mtimeMs;
|
|
119
|
+
return ageMs < orphanGraceMs
|
|
120
|
+
? { status: "installing", detail: "lock owner installation is in progress" }
|
|
121
|
+
: { status: "stale", detail: "empty lock directory exceeded the owner installation grace period" };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (codeOf(error) === "ENOENT" && attempt === 0)
|
|
125
|
+
continue;
|
|
126
|
+
if (codeOf(error) === "ENOENT") {
|
|
127
|
+
return { status: "unlocked", detail: "lock directory disappeared during inspection" };
|
|
128
|
+
}
|
|
129
|
+
return { status: "corrupt", detail: `empty lock directory cannot be inspected: ${error.message}` };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (names.length !== 1 || !/^owner\.[0-9a-f-]+\.json$/i.test(names[0])) {
|
|
133
|
+
return { status: "corrupt", detail: "lock directory must contain one owner" };
|
|
134
|
+
}
|
|
135
|
+
let owner;
|
|
136
|
+
try {
|
|
137
|
+
owner = await parseLockOwner(lockDirectory, names[0], fileSystem);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (attempt === 0 && missingDuringOwnerRead(error))
|
|
141
|
+
continue;
|
|
142
|
+
return { status: "corrupt", detail: error.message };
|
|
143
|
+
}
|
|
144
|
+
let inspection;
|
|
145
|
+
try {
|
|
146
|
+
const identity = await options.inspectIdentity(owner.pid);
|
|
147
|
+
inspection = identity === owner.processIdentity
|
|
148
|
+
? {
|
|
149
|
+
status: "owned",
|
|
150
|
+
ownerPid: owner.pid,
|
|
151
|
+
ownerAlive: true,
|
|
152
|
+
detail: `owned by live process ${owner.pid}`,
|
|
153
|
+
}
|
|
154
|
+
: {
|
|
155
|
+
status: "stale",
|
|
156
|
+
ownerPid: owner.pid,
|
|
157
|
+
ownerAlive: false,
|
|
158
|
+
detail: `owner process ${owner.pid} is absent or its identity changed`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
inspection = {
|
|
163
|
+
status: "corrupt",
|
|
164
|
+
ownerPid: owner.pid,
|
|
165
|
+
detail: `owner process cannot be inspected: ${error.message}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const confirmedOwner = await parseLockOwner(lockDirectory, names[0], fileSystem);
|
|
170
|
+
const confirmedNames = (await fileSystem.readdir(lockDirectory)).map(String).sort();
|
|
171
|
+
const unchanged = JSON.stringify(confirmedOwner) === JSON.stringify(owner)
|
|
172
|
+
&& JSON.stringify(confirmedNames) === JSON.stringify(names);
|
|
173
|
+
if (!unchanged && attempt === 0)
|
|
174
|
+
continue;
|
|
175
|
+
if (!unchanged)
|
|
176
|
+
return { status: "corrupt", detail: "lock snapshot changed during bounded inspection" };
|
|
177
|
+
return inspection;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (attempt === 0 && missingDuringOwnerRead(error))
|
|
181
|
+
continue;
|
|
182
|
+
if (missingDuringOwnerRead(error)) {
|
|
183
|
+
return { status: "corrupt", detail: "lock snapshot remained unstable after bounded inspection" };
|
|
184
|
+
}
|
|
185
|
+
return { status: "corrupt", detail: error.message };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { status: "corrupt", detail: "lock snapshot remained unstable after bounded inspection" };
|
|
189
|
+
}
|
|
47
190
|
function processLeaseMap(registry) {
|
|
48
191
|
return registry.leases;
|
|
49
192
|
}
|
|
@@ -52,21 +195,29 @@ export function createJournalLease(options) {
|
|
|
52
195
|
const registry = processLeaseMap(options.registry ?? defaultRegistry);
|
|
53
196
|
const now = options.now ?? (() => new Date());
|
|
54
197
|
const orphanGraceMs = options.orphanGraceMs ?? 30_000;
|
|
198
|
+
const journalPath = resolve(options.directory);
|
|
55
199
|
const lockDirectory = join(options.directory, ".journal.lock");
|
|
56
200
|
let attached = false;
|
|
57
201
|
let status = "open";
|
|
58
|
-
const
|
|
59
|
-
const parseOwner = async (fileName) => {
|
|
60
|
-
const path = join(lockDirectory, fileName);
|
|
202
|
+
const cleanupReleasedDirectory = async (path) => {
|
|
61
203
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
throw new Error("owner token does not match filename");
|
|
65
|
-
return owner;
|
|
204
|
+
await fileSystem.rm(path, { recursive: true, force: true });
|
|
205
|
+
await options.syncDirectory(options.directory);
|
|
66
206
|
}
|
|
67
|
-
catch
|
|
68
|
-
|
|
207
|
+
catch { /* released tombstones never block ownership or reverse a committed close */ }
|
|
208
|
+
};
|
|
209
|
+
const cleanupReleasedDirectories = async () => {
|
|
210
|
+
let names;
|
|
211
|
+
try {
|
|
212
|
+
names = (await fileSystem.readdir(options.directory)).map(String).sort();
|
|
69
213
|
}
|
|
214
|
+
catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
await Promise.all(names
|
|
218
|
+
.filter((name) => RELEASED_LOCK_PATTERN.test(name))
|
|
219
|
+
.slice(0, MAX_RELEASED_LOCK_CLEANUP)
|
|
220
|
+
.map((name) => cleanupReleasedDirectory(join(options.directory, name))));
|
|
70
221
|
};
|
|
71
222
|
const readOwner = async () => {
|
|
72
223
|
let names;
|
|
@@ -81,7 +232,7 @@ export function createJournalLease(options) {
|
|
|
81
232
|
if (names.length === 0) {
|
|
82
233
|
const lockStat = await fileSystem.stat(lockDirectory);
|
|
83
234
|
if (now().valueOf() - lockStat.mtimeMs < orphanGraceMs) {
|
|
84
|
-
throw new JournalLockedError("Execution journal lock owner installation is in progress");
|
|
235
|
+
throw new JournalLockedError("Execution journal lock owner installation is in progress", { journalPath });
|
|
85
236
|
}
|
|
86
237
|
try {
|
|
87
238
|
await fileSystem.rmdir(lockDirectory);
|
|
@@ -96,7 +247,7 @@ export function createJournalLease(options) {
|
|
|
96
247
|
if (names.length !== 1 || !/^owner\.[0-9a-f-]+\.json$/i.test(names[0])) {
|
|
97
248
|
throw new JournalLockCorruptionError(lockDirectory, new Error("lock directory must contain one owner"));
|
|
98
249
|
}
|
|
99
|
-
return { owner: await
|
|
250
|
+
return { owner: await parseLockOwner(lockDirectory, names[0], fileSystem), fileName: names[0] };
|
|
100
251
|
};
|
|
101
252
|
const validateInstalledOwner = async (lease) => {
|
|
102
253
|
const observed = await readOwner();
|
|
@@ -151,9 +302,6 @@ export function createJournalLease(options) {
|
|
|
151
302
|
lockDirectorySynced: false,
|
|
152
303
|
executionsDirectorySynced: false,
|
|
153
304
|
releasing: false,
|
|
154
|
-
ownerRemoved: false,
|
|
155
|
-
lockDirectoryRemoved: false,
|
|
156
|
-
releaseSynced: false,
|
|
157
305
|
};
|
|
158
306
|
registry.set(options.directory, lease);
|
|
159
307
|
attached = true;
|
|
@@ -169,7 +317,7 @@ export function createJournalLease(options) {
|
|
|
169
317
|
continue;
|
|
170
318
|
const identity = await options.inspectIdentity(observed.owner.pid);
|
|
171
319
|
if (identity === observed.owner.processIdentity) {
|
|
172
|
-
throw new JournalLockedError(`Execution journal is locked by process ${observed.owner.pid}
|
|
320
|
+
throw new JournalLockedError(`Execution journal is locked by process ${observed.owner.pid}`, { journalPath, ownerPid: observed.owner.pid });
|
|
173
321
|
}
|
|
174
322
|
await options.hooks?.beforeRemoveObservedOwner?.(observed.owner);
|
|
175
323
|
try {
|
|
@@ -196,37 +344,48 @@ export function createJournalLease(options) {
|
|
|
196
344
|
}
|
|
197
345
|
};
|
|
198
346
|
const acquire = async () => {
|
|
199
|
-
if (status !== "open")
|
|
200
|
-
throw new JournalLockedError(`Journal lease is ${status}
|
|
347
|
+
if (status !== "open") {
|
|
348
|
+
throw new JournalLockedError(`Journal lease is ${status}`, { journalPath });
|
|
349
|
+
}
|
|
201
350
|
if (attached) {
|
|
202
351
|
const lease = registry.get(options.directory);
|
|
203
|
-
if (lease === undefined)
|
|
204
|
-
throw new JournalLockedError("In-process journal lease is missing");
|
|
352
|
+
if (lease === undefined) {
|
|
353
|
+
throw new JournalLockedError("In-process journal lease is missing", { journalPath });
|
|
354
|
+
}
|
|
205
355
|
await finishInstall(lease);
|
|
206
356
|
return;
|
|
207
357
|
}
|
|
208
358
|
const existing = registry.get(options.directory);
|
|
209
359
|
if (existing !== undefined) {
|
|
210
|
-
if (existing.releasing)
|
|
211
|
-
throw new JournalLockedError("Journal lease release is pending"
|
|
360
|
+
if (existing.releasing) {
|
|
361
|
+
throw new JournalLockedError("Journal lease release is pending", {
|
|
362
|
+
journalPath,
|
|
363
|
+
ownerPid: existing.owner.pid,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
212
366
|
await finishInstall(existing);
|
|
213
367
|
existing.refs += 1;
|
|
214
368
|
attached = true;
|
|
215
369
|
return;
|
|
216
370
|
}
|
|
371
|
+
void cleanupReleasedDirectories();
|
|
217
372
|
await install();
|
|
218
373
|
};
|
|
219
|
-
const close = async () => {
|
|
374
|
+
const close = async (closeOptions = {}) => {
|
|
220
375
|
if (status === "closed")
|
|
221
376
|
return;
|
|
377
|
+
const signal = closeOptions.signal;
|
|
378
|
+
throwIfAborted(signal);
|
|
222
379
|
if (!attached) {
|
|
223
380
|
status = "closed";
|
|
224
381
|
return;
|
|
225
382
|
}
|
|
226
383
|
const lease = registry.get(options.directory);
|
|
227
|
-
if (lease === undefined)
|
|
228
|
-
throw new JournalLockedError("In-process journal lease is missing");
|
|
384
|
+
if (lease === undefined) {
|
|
385
|
+
throw new JournalLockedError("In-process journal lease is missing", { journalPath });
|
|
386
|
+
}
|
|
229
387
|
if (status === "open" && lease.refs > 1) {
|
|
388
|
+
throwIfAborted(signal);
|
|
230
389
|
lease.refs -= 1;
|
|
231
390
|
attached = false;
|
|
232
391
|
status = "closed";
|
|
@@ -234,28 +393,28 @@ export function createJournalLease(options) {
|
|
|
234
393
|
}
|
|
235
394
|
status = "closing";
|
|
236
395
|
lease.releasing = true;
|
|
237
|
-
|
|
238
|
-
await
|
|
239
|
-
lease.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
await
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
lease.releaseSynced = true;
|
|
248
|
-
}
|
|
396
|
+
await awaitAbortable((async () => {
|
|
397
|
+
await validateInstalledOwner(lease);
|
|
398
|
+
await options.hooks?.beforeReleaseOwner?.(lease.owner, {
|
|
399
|
+
...(signal === undefined ? {} : { signal }),
|
|
400
|
+
});
|
|
401
|
+
await validateInstalledOwner(lease);
|
|
402
|
+
})(), signal);
|
|
403
|
+
throwIfAborted(signal);
|
|
404
|
+
const releasedDirectory = join(options.directory, releasedLockName(lease.owner.token));
|
|
405
|
+
fileSystem.renameSync(lockDirectory, releasedDirectory);
|
|
249
406
|
lease.refs -= 1;
|
|
250
407
|
registry.delete(options.directory);
|
|
251
408
|
attached = false;
|
|
252
409
|
status = "closed";
|
|
410
|
+
void cleanupReleasedDirectory(releasedDirectory);
|
|
253
411
|
};
|
|
254
412
|
return {
|
|
255
413
|
acquire,
|
|
256
414
|
assertUsable: () => {
|
|
257
|
-
if (status !== "open")
|
|
258
|
-
throw new JournalLockedError(`Execution journal lease is ${status}
|
|
415
|
+
if (status !== "open") {
|
|
416
|
+
throw new JournalLockedError(`Execution journal lease is ${status}`, { journalPath });
|
|
417
|
+
}
|
|
259
418
|
},
|
|
260
419
|
close,
|
|
261
420
|
};
|