@nowcrew/daemon 0.6.101-beta.2 → 0.6.101-beta.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/dist/cloud-report-upload.js +10 -1
- package/dist/computer-main.js +0 -0
- package/dist/execution-journal-lock.js +51 -2
- package/dist/execution-journal.js +1 -3
- package/dist/execution-protocol.js +1 -1
- package/dist/execution-runner.js +1 -4
- package/dist/host-execution-coordinator.js +10 -0
- package/dist/local-execution-prompt.js +3 -8
- package/dist/local-executor.js +55 -78
- package/dist/local-memory/index-budget.js +3 -9
- package/dist/local-memory/inventory-cli.js +8 -52
- package/dist/local-memory/inventory-source.js +8 -15
- package/dist/local-memory/pending-query.js +2 -5
- package/dist/local-memory/pending-schema.js +1 -1
- package/dist/local-memory/pending.js +2 -16
- package/dist/local-memory/prompt.js +5 -21
- package/dist/local-memory/read-cli.js +76 -98
- package/dist/local-memory/rollout.js +11 -79
- package/dist/local-memory/runtime.js +17 -191
- package/dist/local-memory/write.js +21 -190
- package/dist/local-memory-diagnostics.js +15 -96
- package/dist/local-memory-telemetry.js +3 -20
- package/dist/machine-info.js +3 -4
- package/dist/machine-runtime-environment.js +1 -0
- package/dist/main.js +0 -0
- package/dist/marketplace-protocol.js +1 -1
- package/dist/marketplace-remote.js +1 -1
- package/dist/marketplace-runner.js +1 -1
- package/dist/memory-prune-diagnostics.js +4 -74
- package/dist/message-file-preview.js +1 -11
- package/dist/prompt.js +17 -9
- package/dist/runner.js +1 -1
- package/dist/runtime-tool-update-bridge.js +126 -0
- package/dist/runtime-tool-update-controller.js +151 -0
- package/dist/runtime-tool-update-protocol.js +24 -0
- package/dist/runtime-tool-update-serve.js +54 -0
- package/dist/runtimes/claude-thread-runner.js +4 -25
- package/dist/serve.js +12 -38
- package/dist/token.js +0 -1
- package/dist/workspace-control-handler.js +3 -16
- package/dist/workspace.js +2 -13
- package/package.json +10 -9
- package/dist/local-memory/commit-telemetry.js +0 -30
- package/dist/local-memory/execution-workspace.js +0 -62
- package/dist/local-memory/full-index-search.js +0 -47
- package/dist/local-memory/initialization-identity.js +0 -36
- package/dist/local-memory/inventory-v2.js +0 -362
- package/dist/local-memory/legacy-adapter.js +0 -233
- package/dist/local-memory/migration-access.js +0 -242
- package/dist/local-memory/migration-cli.js +0 -54
- package/dist/local-memory/migration-io.js +0 -199
- package/dist/local-memory/migration-pending.js +0 -9
- package/dist/local-memory/migration-plan.js +0 -432
- package/dist/local-memory/migration-schema.js +0 -55
- package/dist/local-memory/migration-state-invariants.js +0 -65
- package/dist/local-memory/migration-store.js +0 -414
- package/dist/local-memory/migration-tool-entry.js +0 -21
- package/dist/local-memory/release-artifact-guard.js +0 -55
- package/dist/local-memory/source-path.js +0 -13
- package/dist/local-memory/storage-format.js +0 -249
- package/dist/local-memory/writer-mode.js +0 -13
- package/dist/memory-migration-controller.js +0 -89
- package/dist/memory-migration-inspect.js +0 -116
- package/dist/memory-migration-journal.js +0 -14
- package/dist/memory-migration-protocol.js +0 -14
- package/dist/memory-migration-runtime.js +0 -305
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
// 与另一个进程中的 marketplace-protocol.ts 保持一致;跨进程契约测试校验摘要。
|
|
3
3
|
export const MARKETPLACE_CAPABILITY = "marketplace_update_v1";
|
|
4
4
|
export const MARKETPLACE = "nowwork-pm";
|
|
5
|
-
export const PLUGIN = "
|
|
5
|
+
export const PLUGIN = "AIinterview-plugin";
|
|
6
6
|
export const MarketplaceModeSchema = z.enum(["inspect", "refresh", "update"]);
|
|
7
7
|
export const MarketplaceStatusSchema = z.enum(["queued", "running", "completed", "partial", "failed", "unknown"]);
|
|
8
8
|
export const MarketplaceTargetSchema = z.object({
|
|
@@ -10,7 +10,7 @@ const Target = z.object({
|
|
|
10
10
|
version: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._+-]{0,79}$/),
|
|
11
11
|
source: z.object({ source: z.literal("git-subdir"),
|
|
12
12
|
url: z.literal("git@codeup.aliyun.com:6128d2e757e7cd986dfae5ab/pm/AIinterview-agent-skill.git"),
|
|
13
|
-
path: z.literal("plugins/
|
|
13
|
+
path: z.literal("plugins/AIinterview-plugin"), sha: z.string().regex(/^[a-f0-9]{40}$/) }),
|
|
14
14
|
});
|
|
15
15
|
export async function readRemoteMarketplace(runtime, registered, env, command) {
|
|
16
16
|
const source = validateMarketplaceSource(registered.url, registered.ref);
|
|
@@ -12,7 +12,7 @@ import { readMarketplaceSource } from "./marketplace-source.js";
|
|
|
12
12
|
export { MarketplaceFailure, runMarketplaceCommand } from "./marketplace-command.js";
|
|
13
13
|
const SELECTOR = `${PLUGIN}@${MARKETPLACE}`;
|
|
14
14
|
const SOURCE = "git@codeup.aliyun.com:6128d2e757e7cd986dfae5ab/pm/AIinterview-agent-skill.git";
|
|
15
|
-
const SOURCE_PATH = "plugins/
|
|
15
|
+
const SOURCE_PATH = "plugins/AIinterview-plugin";
|
|
16
16
|
const obj = (x) => x !== null && typeof x === "object" && !Array.isArray(x) ? x : {};
|
|
17
17
|
const parse = (text) => { try {
|
|
18
18
|
return JSON.parse(text);
|
|
@@ -7,25 +7,10 @@ import { dslog } from "./slog.js";
|
|
|
7
7
|
const TRACE_PATTERN = /\[memory-prune trace_id=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]/i;
|
|
8
8
|
export const MEMORY_PRUNE_HASH_LIMIT_BYTES = 10 * 1024 * 1024;
|
|
9
9
|
export const MEMORY_PRUNE_NOTES_MANIFEST_LIMIT = 128;
|
|
10
|
-
export const MEMORY_PRUNE_PENDING_MANIFEST_LIMIT = 128;
|
|
11
10
|
export const MEMORY_PRUNE_NOTE_CHANGE_LIMIT = 128;
|
|
12
11
|
function compareCodeUnits(left, right) {
|
|
13
12
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
14
13
|
}
|
|
15
|
-
/**
|
|
16
|
-
* Release evidence is a task artifact, not a durable memory note. Keep this
|
|
17
|
-
* deliberately narrow: only conventional delivery-artifact filename families
|
|
18
|
-
* are blocked; unrelated note names are still handled by the normal candidate
|
|
19
|
-
* protocol instead of being deleted or rewritten.
|
|
20
|
-
*/
|
|
21
|
-
const RELEASE_ARTIFACT_NOTE_PATTERN = /^(?:release|publish|build|deploy|artifact)(?:[-_.].*)?\.md$/iu;
|
|
22
|
-
export function isReleaseArtifactNotePath(relativePath) {
|
|
23
|
-
const basename = relativePath.split("/").pop() ?? relativePath;
|
|
24
|
-
return RELEASE_ARTIFACT_NOTE_PATTERN.test(basename);
|
|
25
|
-
}
|
|
26
|
-
export function releaseArtifactNoteChanges(diff) {
|
|
27
|
-
return diff.changes.filter((change) => isReleaseArtifactNotePath(change.relative_path));
|
|
28
|
-
}
|
|
29
14
|
export function parseMemoryPruneTraceId(wakePrompt) {
|
|
30
15
|
return TRACE_PATTERN.exec(wakePrompt)?.[1] ?? null;
|
|
31
16
|
}
|
|
@@ -207,50 +192,6 @@ export async function inspectMemoryPruneNotes(homeDir, signal) {
|
|
|
207
192
|
...(signal?.aborted ? { error: "ABORT_ERR" } : {}),
|
|
208
193
|
};
|
|
209
194
|
}
|
|
210
|
-
export async function inspectMemoryPrunePending(homeDir, signal) {
|
|
211
|
-
const pendingDir = join(homeDir, ".crew", "memory", "pending");
|
|
212
|
-
try {
|
|
213
|
-
const metadata = await lstat(pendingDir);
|
|
214
|
-
if (metadata.isSymbolicLink())
|
|
215
|
-
return { files: [], total_count: 0, omitted_count: 0, truncated: false, error: "symbolic_link" };
|
|
216
|
-
if (!metadata.isDirectory())
|
|
217
|
-
return { files: [], total_count: 0, omitted_count: 0, truncated: false, error: "ENOTDIR" };
|
|
218
|
-
}
|
|
219
|
-
catch (error) {
|
|
220
|
-
if (error.code === "ENOENT")
|
|
221
|
-
return { files: [], total_count: 0, omitted_count: 0, truncated: false };
|
|
222
|
-
return { files: [], total_count: 0, omitted_count: 0, truncated: false, error: diagnosticError(error) };
|
|
223
|
-
}
|
|
224
|
-
let names;
|
|
225
|
-
try {
|
|
226
|
-
names = (await readdir(pendingDir, { withFileTypes: true }))
|
|
227
|
-
.filter(entry => (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".json"))
|
|
228
|
-
.map(entry => entry.name).sort(compareCodeUnits);
|
|
229
|
-
}
|
|
230
|
-
catch (error) {
|
|
231
|
-
return { files: [], total_count: 0, omitted_count: 0, truncated: false, error: diagnosticError(error) };
|
|
232
|
-
}
|
|
233
|
-
const selected = names.slice(0, MEMORY_PRUNE_PENDING_MANIFEST_LIMIT);
|
|
234
|
-
const files = [];
|
|
235
|
-
for (const name of selected) {
|
|
236
|
-
if (signal?.aborted)
|
|
237
|
-
break;
|
|
238
|
-
const path = join(pendingDir, name);
|
|
239
|
-
try {
|
|
240
|
-
if ((await lstat(path)).isSymbolicLink()) {
|
|
241
|
-
files.push({ relative_path: `.crew/memory/pending/${name}`, exists: false, error: "symbolic_link" });
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
catch (error) {
|
|
246
|
-
files.push({ relative_path: `.crew/memory/pending/${name}`, exists: false, error: diagnosticError(error) });
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
files.push({ relative_path: `.crew/memory/pending/${name}`, ...await inspectFile(path, false, undefined, signal) });
|
|
250
|
-
}
|
|
251
|
-
return { files, total_count: names.length, omitted_count: Math.max(0, names.length - selected.length),
|
|
252
|
-
truncated: names.length > selected.length, ...(signal?.aborted ? { error: "ABORT_ERR" } : {}) };
|
|
253
|
-
}
|
|
254
195
|
async function inspectMemoryPruneFixedFiles(homeDir, runDir, workLogPath, signal) {
|
|
255
196
|
const cwdIsHome = runDir === homeDir;
|
|
256
197
|
const [memory, lessons, cwdMemory, cwdLessons, workLog] = await Promise.all([
|
|
@@ -269,17 +210,15 @@ async function inspectMemoryPruneFixedFiles(homeDir, runDir, workLogPath, signal
|
|
|
269
210
|
};
|
|
270
211
|
}
|
|
271
212
|
export async function inspectMemoryPruneFiles(homeDir, runDir, workLogPath, signal) {
|
|
272
|
-
const [fixedFiles, notesManifest
|
|
213
|
+
const [fixedFiles, notesManifest] = await Promise.all([
|
|
273
214
|
inspectMemoryPruneFixedFiles(homeDir, runDir, workLogPath, signal),
|
|
274
215
|
inspectMemoryPruneNotes(homeDir, signal),
|
|
275
|
-
inspectMemoryPrunePending(homeDir, signal),
|
|
276
216
|
]);
|
|
277
|
-
return { ...fixedFiles, notes_manifest: notesManifest
|
|
217
|
+
return { ...fixedFiles, notes_manifest: notesManifest };
|
|
278
218
|
}
|
|
279
219
|
export async function inspectMemoryPruneFilesWithinDeadline(homeDir, runDir, workLogPath, timeoutMs) {
|
|
280
220
|
const fixedFiles = await withinMemoryPruneDiagnosticsDeadline((signal) => inspectMemoryPruneFixedFiles(homeDir, runDir, workLogPath, signal), timeoutMs);
|
|
281
221
|
let notesManifest;
|
|
282
|
-
let pendingManifest;
|
|
283
222
|
try {
|
|
284
223
|
notesManifest = await withinMemoryPruneDiagnosticsDeadline((signal) => inspectMemoryPruneNotes(homeDir, signal), timeoutMs);
|
|
285
224
|
}
|
|
@@ -292,13 +231,7 @@ export async function inspectMemoryPruneFilesWithinDeadline(homeDir, runDir, wor
|
|
|
292
231
|
error: diagnosticError(error),
|
|
293
232
|
};
|
|
294
233
|
}
|
|
295
|
-
|
|
296
|
-
pendingManifest = await withinMemoryPruneDiagnosticsDeadline(signal => inspectMemoryPrunePending(homeDir, signal), timeoutMs);
|
|
297
|
-
}
|
|
298
|
-
catch (error) {
|
|
299
|
-
pendingManifest = { files: [], total_count: 0, omitted_count: 0, truncated: false, error: diagnosticError(error) };
|
|
300
|
-
}
|
|
301
|
-
return { ...fixedFiles, notes_manifest: notesManifest, pending_manifest: pendingManifest };
|
|
234
|
+
return { ...fixedFiles, notes_manifest: notesManifest };
|
|
302
235
|
}
|
|
303
236
|
export async function withinMemoryPruneDiagnosticsDeadline(operation, timeoutMs) {
|
|
304
237
|
const controller = new AbortController();
|
|
@@ -428,7 +361,6 @@ export function evaluateMemoryPrunePostcondition(before, after, execution) {
|
|
|
428
361
|
&& (after.cwd_memory.lessons_reference_count ?? 0) > 0,
|
|
429
362
|
cwd_lessons_changed: !after.cwd_lessons.same_as_shared_path
|
|
430
363
|
&& fileChanged(before.cwd_lessons, after.cwd_lessons),
|
|
431
|
-
pending_changed: diffMemoryPruneNotes(before.pending_manifest ?? { files: [], total_count: 0, omitted_count: 0, truncated: false }, after.pending_manifest ?? { files: [], total_count: 0, omitted_count: 0, truncated: false }).change_count > 0,
|
|
432
364
|
};
|
|
433
365
|
let outcome;
|
|
434
366
|
if (!execution.executorCompleted)
|
|
@@ -456,8 +388,6 @@ export function evaluateMemoryPrunePostcondition(before, after, execution) {
|
|
|
456
388
|
outcome = "promoted_without_index_change";
|
|
457
389
|
else if (facts.shared_memory_changed)
|
|
458
390
|
outcome = "shared_memory_updated";
|
|
459
|
-
else if (facts.pending_changed)
|
|
460
|
-
outcome = "pending_updated";
|
|
461
391
|
else
|
|
462
392
|
outcome = "no_files_changed";
|
|
463
393
|
return { outcome, ...facts };
|
|
@@ -478,7 +408,7 @@ function memoryPruneFileFactFields(fileFacts) {
|
|
|
478
408
|
}
|
|
479
409
|
return fields;
|
|
480
410
|
}
|
|
481
|
-
export function memoryPruneSnapshotFields({ notes_manifest: _notesManifest,
|
|
411
|
+
export function memoryPruneSnapshotFields({ notes_manifest: _notesManifest, ...fileFacts }) {
|
|
482
412
|
return memoryPruneFileFactFields(fileFacts);
|
|
483
413
|
}
|
|
484
414
|
export function logMemoryPruneDiagnosticsFailure(input, traceId, phase, error) {
|
|
@@ -9,7 +9,6 @@ import { z } from "zod";
|
|
|
9
9
|
import { isInternalEnvironmentPath } from "./workspace-fs.js";
|
|
10
10
|
import { opaqueKey, safeKey } from "./workspace.js";
|
|
11
11
|
import { AgentHandleSchema } from "./execution-protocol.js";
|
|
12
|
-
import { withMemoryAccess } from "./local-memory/migration-access.js";
|
|
13
12
|
const MAX_BYTES = 256 * 1024;
|
|
14
13
|
const MAX_BINARY_BYTES = 8 * 1024 * 1024;
|
|
15
14
|
const MAX_CONCURRENT_READS = 4;
|
|
@@ -232,16 +231,7 @@ export async function readSharedMessageFile(agentsRoot, rawRequest, registry) {
|
|
|
232
231
|
throw new SharedFileError("file_preview_busy");
|
|
233
232
|
activeReads++;
|
|
234
233
|
try {
|
|
235
|
-
|
|
236
|
-
const agentRoot = await realpath(resolve(agentsRoot, parsed.data.handle)).catch((error) => {
|
|
237
|
-
if (error.code === "ENOENT")
|
|
238
|
-
return undefined;
|
|
239
|
-
throw error;
|
|
240
|
-
});
|
|
241
|
-
// 记忆保护只用于该Agent根内资料,独立项目文件仍按原执行授权读取。
|
|
242
|
-
return await (agentRoot && inside(agentRoot, candidate.path)
|
|
243
|
-
? withMemoryAccess(agentRoot, () => readCandidate(candidate))
|
|
244
|
-
: readCandidate(candidate));
|
|
234
|
+
return await readCandidate(await resolveCandidate(agentsRoot, parsed.data, registry));
|
|
245
235
|
}
|
|
246
236
|
catch (error) {
|
|
247
237
|
if (error instanceof SharedFileError)
|
package/dist/prompt.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* claim-before-work、freshness/draft、"做完所有事再停"、inbox notice 语义、
|
|
6
6
|
* 分层记忆与压缩安全、协作礼仪。措辞为本项目原创。
|
|
7
7
|
*/
|
|
8
|
-
import { detectLocalMemoryStorageVersion, selectLegacyMemoryPreview, selectLocalMemoryPreview, } from "./local-memory/storage-format.js";
|
|
9
8
|
/** work-log 注入上限(字符):防冷启动注入随文件膨胀。超限保头(目标/结论)+尾(最近进展),
|
|
10
9
|
* 截中段并提示读全文——全文仍在盘上,只限"默认推送量"不限"可获取量"。纯函数。 */
|
|
11
10
|
export const WORKLOG_INJECT_CAP = 8000;
|
|
@@ -68,14 +67,23 @@ export function capWorkLogForInject(workLog, cap = WORKLOG_INJECT_CAP) {
|
|
|
68
67
|
const tail = workLog.slice(-WORKLOG_TAIL);
|
|
69
68
|
return `${head}\n…(work-log 过长已截去中段,全文用 Read 读 $CREW_TASK_LOG)\n${tail}`;
|
|
70
69
|
}
|
|
71
|
-
export function capMemoryForInject(memory, cap = MEMORY_INJECT_CAP, maxBytes = Number.POSITIVE_INFINITY
|
|
72
|
-
if (
|
|
73
|
-
return
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
export function capMemoryForInject(memory, cap = MEMORY_INJECT_CAP, maxBytes = Number.POSITIVE_INFINITY) {
|
|
71
|
+
if (memory.length <= cap && Buffer.byteLength(memory, "utf8") <= maxBytes)
|
|
72
|
+
return memory;
|
|
73
|
+
const notice = "[MEMORY.md preview truncated; use crew-memory search/read for more.]\n";
|
|
74
|
+
const noticeBytes = Buffer.byteLength(notice, "utf8");
|
|
75
|
+
if (notice.length > cap || noticeBytes > maxBytes)
|
|
76
|
+
return "";
|
|
77
|
+
let end = 0;
|
|
78
|
+
let bytes = noticeBytes;
|
|
79
|
+
for (const character of memory) {
|
|
80
|
+
const size = Buffer.byteLength(character, "utf8");
|
|
81
|
+
if (notice.length + end + character.length > cap || bytes + size > maxBytes)
|
|
82
|
+
break;
|
|
83
|
+
end += character.length;
|
|
84
|
+
bytes += size;
|
|
77
85
|
}
|
|
78
|
-
return memory;
|
|
86
|
+
return notice + memory.slice(0, end);
|
|
79
87
|
}
|
|
80
88
|
export function buildSystemPrompt(ctx) {
|
|
81
89
|
const product = ctx.productName ?? "nowwork";
|
|
@@ -334,7 +342,7 @@ ${interactiveTaskRules}${freshnessRule}${externalReplyRule}${collaborationEtique
|
|
|
334
342
|
|
|
335
343
|
## Workspace 与分层记忆(CRITICAL — 索引+按需,配合上面的并行规则)
|
|
336
344
|
你的持久记忆在 \`$CREW_HOME\`(跨你所有任务共享),按“常驻索引 + 功能明细 + 当前工作”分层:
|
|
337
|
-
1. **MEMORY.md = 常驻索引 +
|
|
345
|
+
1. **MEMORY.md = 常驻索引 + 角色**:提示词末尾可能只有它的有界预览,预算不足时也可能省略。预览未命中不等于没有记忆。索引条目包含 \`file/type/scope/summary/triggers/priority/updated\`,其中 file 是相对于 \`$CREW_HOME\` 的路径(例如 \`notes/testing.md\`);需要历史约束、经验或偏好时先检索完整索引,再按需分段读取。
|
|
338
346
|
2. **notes/<topic>.md = 功能明细**:按用户偏好、项目规则、测试、调试、Git、事故复盘等主题拆分。优先读取与当前任务匹配的文件;\`notes/lessons.md\` 只保留未分类经验和历史兼容内容,不是所有新经验的默认目的地。普通任务默认只读不写,但当前用户明确要求时可以按最小范围更新;任务结束后的专门收尾执行仍按收尾指令复核。
|
|
339
347
|
- 对旧版 MEMORY.md,如果条目没有完整结构化字段但明确指向 \`notes/<file>.md\`,把它视为 legacy 索引;仍需结合条目标题/链接和当前请求判断相关性,不要因为缺少 summary/triggers 就假定正文已注入。
|
|
340
348
|
3. **本任务 work-log = \`$CREW_TASK_LOG\`**:每任务一份(别的运行不会碰),记当前阶段/下一步/卡点/关键结论。系统已把它的当前内容注入末尾让你续上;有进展就更新它——这是你**唯一**该在任务中持续写的记忆文件。**work-log 是现状快照,不是流水账**:保持 ≤100 行,更新时重写全文替换过时内容,不要无限追加(过长会被注入截断)。
|
package/dist/runner.js
CHANGED
|
@@ -80,7 +80,7 @@ async function runAgentWithMachineEnvironment(config, input, onActivity = defaul
|
|
|
80
80
|
? { scheduledExternalNotificationPolicy: input.scheduled.externalNotificationPolicy }
|
|
81
81
|
: {}),
|
|
82
82
|
...(input.wakeOrigin ? { wakeOrigin: input.wakeOrigin } : {}),
|
|
83
|
-
memory: capMemoryForInject(workspace.memory
|
|
83
|
+
memory: capMemoryForInject(workspace.memory),
|
|
84
84
|
...(resuming ? {} : { workLog: capWorkLogForInject(workspace.workLog) }),
|
|
85
85
|
collaborationToolsEnabled: config.codexCollaborationTools && runtime === "codex",
|
|
86
86
|
localMemoryV2Enabled: workspace.localMemoryV2Enabled ?? localMemoryV2Enabled,
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { RuntimeToolResponseSchema } from "./runtime-tool-update-protocol.js";
|
|
7
|
+
export function runtimeToolEnvironment(env) {
|
|
8
|
+
// Keep only machine identity and lookup settings. Never pass runtime/control-plane credentials or Node injection flags.
|
|
9
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => /^(PATH|HOME|USER|LOGNAME|SHELL|TMPDIR|TMP|TEMP|LANG|LC_[A-Z_]+|XDG_CONFIG_HOME|XDG_CACHE_HOME)$/u.test(key)));
|
|
10
|
+
}
|
|
11
|
+
async function readReport(dir) {
|
|
12
|
+
const file = join(dir, "report.json");
|
|
13
|
+
if ((await stat(file)).size > 256_000)
|
|
14
|
+
throw new Error("invalid_report");
|
|
15
|
+
return RuntimeToolResponseSchema.parse(JSON.parse(await readFile(file, "utf8")));
|
|
16
|
+
}
|
|
17
|
+
async function stopGroup(pid) {
|
|
18
|
+
if (!pid)
|
|
19
|
+
return true;
|
|
20
|
+
try {
|
|
21
|
+
process.kill(-pid, "SIGKILL");
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return error.code === "ESRCH";
|
|
25
|
+
}
|
|
26
|
+
const deadline = Date.now() + 3000;
|
|
27
|
+
while (Date.now() < deadline) {
|
|
28
|
+
try {
|
|
29
|
+
process.kill(-pid, 0);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return error.code === "ESRCH";
|
|
33
|
+
}
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
export function createRuntimeToolProgressPoller(read, progress, intervalMs = 250) {
|
|
39
|
+
let active = true;
|
|
40
|
+
let pending = null;
|
|
41
|
+
const interval = setInterval(() => {
|
|
42
|
+
if (!active || pending || !progress)
|
|
43
|
+
return;
|
|
44
|
+
pending = read().then(async (data) => { if (active)
|
|
45
|
+
await progress(data); })
|
|
46
|
+
.catch(() => { }).finally(() => { pending = null; });
|
|
47
|
+
}, intervalMs);
|
|
48
|
+
return { async stop() { active = false; clearInterval(interval); await pending; } };
|
|
49
|
+
}
|
|
50
|
+
async function supervise(dir, env, start, progress, protectProcessGroup) {
|
|
51
|
+
const child = spawn(process.execPath, [join(dir, "runner.mjs"), "--managed-worker"], { cwd: dir, env, detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"] });
|
|
52
|
+
const closed = new Promise(resolve => { child.once("error", () => resolve(null)); child.once("close", resolve); });
|
|
53
|
+
const polling = createRuntimeToolProgressPoller(() => readReport(dir), progress);
|
|
54
|
+
let expired = false;
|
|
55
|
+
const timer = setTimeout(() => { expired = true; void stopGroup(child.pid); }, start ? 12 * 60_000 : 120_000);
|
|
56
|
+
let launchFailed = false;
|
|
57
|
+
try {
|
|
58
|
+
if (start) {
|
|
59
|
+
if (!protectProcessGroup || !child.pid)
|
|
60
|
+
throw new Error("lease_protection_unavailable");
|
|
61
|
+
await protectProcessGroup(child.pid);
|
|
62
|
+
}
|
|
63
|
+
await new Promise((resolve, reject) => child.send({ type: "runtime-tools:go" }, error => error ? reject(error) : resolve()));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
launchFailed = true;
|
|
67
|
+
await stopGroup(child.pid);
|
|
68
|
+
}
|
|
69
|
+
const code = await closed;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
await polling.stop();
|
|
72
|
+
// A successful parent exit does not prove its package-manager descendants stopped.
|
|
73
|
+
const stopped = await stopGroup(child.pid);
|
|
74
|
+
return { stopped, success: stopped && !expired && !launchFailed && code === 0 };
|
|
75
|
+
}
|
|
76
|
+
export async function runRuntimeToolBridge(config, request, acquireEnv, progress, deps = {}) {
|
|
77
|
+
let dir;
|
|
78
|
+
let started = false;
|
|
79
|
+
let processStopped = true;
|
|
80
|
+
const failure = (errorCode) => ({ processStopped, data: request.action === "start" ? { operation: {
|
|
81
|
+
operationId: request.operationId, state: started ? "unknown" : "failed", currentIndex: 0, targets: request.targets,
|
|
82
|
+
items: request.targets.map(t => ({ runtime: t.installation.runtime, state: started ? "unknown" : "failed", errorCode })),
|
|
83
|
+
updatedAt: new Date().toISOString(), errorCode,
|
|
84
|
+
} } : {} });
|
|
85
|
+
try {
|
|
86
|
+
const response = await (deps.fetch ?? fetch)(`${config.serverUrl.replace(/\/+$/, "")}/machine/runtime-tool-updates/runner/${request.runner.releaseId}`, {
|
|
87
|
+
headers: { authorization: `Bearer ${config.machineToken}` }, redirect: "error", signal: AbortSignal.timeout(15_000),
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok || !response.body)
|
|
90
|
+
return failure("runner_unavailable");
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let bytes = 0;
|
|
93
|
+
for await (const chunk of response.body) {
|
|
94
|
+
bytes += chunk.length;
|
|
95
|
+
if (bytes > 512_000)
|
|
96
|
+
return failure("runner_unavailable");
|
|
97
|
+
chunks.push(chunk);
|
|
98
|
+
}
|
|
99
|
+
const content = Buffer.concat(chunks);
|
|
100
|
+
if (createHash("sha256").update(content).digest("hex") !== request.runner.sha256)
|
|
101
|
+
return failure("runner_unavailable");
|
|
102
|
+
const env = runtimeToolEnvironment(await acquireEnv());
|
|
103
|
+
dir = await mkdtemp(join(tmpdir(), "nowwork-runtime-update-"));
|
|
104
|
+
await writeFile(join(dir, "runner.mjs"), content, { mode: 0o600 });
|
|
105
|
+
await writeFile(join(dir, "request.json"), JSON.stringify(request), { mode: 0o600 });
|
|
106
|
+
started = true;
|
|
107
|
+
processStopped = false;
|
|
108
|
+
const outcome = await supervise(dir, env, request.action === "start", progress, deps.protectProcessGroup);
|
|
109
|
+
processStopped = outcome.stopped;
|
|
110
|
+
if (!outcome.success)
|
|
111
|
+
return failure("operation_unknown");
|
|
112
|
+
const data = await readReport(dir);
|
|
113
|
+
if (request.action === "start" && data.operation?.operationId !== request.operationId)
|
|
114
|
+
return failure("operation_unknown");
|
|
115
|
+
if (request.action === "inspect" && !data.snapshot)
|
|
116
|
+
return failure("inspection_failed");
|
|
117
|
+
return { data, processStopped };
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return failure(started ? "operation_unknown" : "runner_unavailable");
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
if (dir && processStopped)
|
|
124
|
+
await rm(dir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { RuntimeToolRequestSchema, RuntimeToolResponseSchema } from "./runtime-tool-update-protocol.js";
|
|
2
|
+
const finalStates = new Set(["completed", "failed", "unknown", "cancelled"]);
|
|
3
|
+
const phaseRank = (state) => ({ queued: 0, preflight: 1, installing: 2, verifying: 3, completed: 4, failed: 4, unknown: 4, cancelled: 4 })[state];
|
|
4
|
+
function advancesProgress(previous, next) {
|
|
5
|
+
if (finalStates.has(previous.state) || finalStates.has(next.state)
|
|
6
|
+
|| next.operationId !== previous.operationId || JSON.stringify(next.targets) !== JSON.stringify(previous.targets)
|
|
7
|
+
|| next.currentIndex < previous.currentIndex || next.currentIndex >= previous.targets.length
|
|
8
|
+
|| (next.currentIndex === previous.currentIndex && phaseRank(next.state) < phaseRank(previous.state)))
|
|
9
|
+
return false;
|
|
10
|
+
return previous.items.every((item, index) => {
|
|
11
|
+
const candidate = next.items[index];
|
|
12
|
+
if (!candidate || candidate.runtime !== item.runtime)
|
|
13
|
+
return false;
|
|
14
|
+
return finalStates.has(item.state) ? candidate.state === item.state && candidate.errorCode === item.errorCode
|
|
15
|
+
: phaseRank(candidate.state) >= phaseRank(item.state);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function createRuntimeToolUpdateController(deps) {
|
|
19
|
+
const entries = new Map();
|
|
20
|
+
let current = null;
|
|
21
|
+
let stopped = false;
|
|
22
|
+
let orphaned = false;
|
|
23
|
+
const now = deps.now ?? Date.now;
|
|
24
|
+
const date = () => new Date(now()).toISOString();
|
|
25
|
+
const wait = deps.wait ?? (() => new Promise(resolve => setTimeout(resolve, 500)));
|
|
26
|
+
const fail = (error) => ({ ok: false, error });
|
|
27
|
+
const result = (data) => ({ ok: true, data });
|
|
28
|
+
const set = (entry, patch) => { entry.operation = { ...entry.operation, ...patch, updatedAt: date() }; };
|
|
29
|
+
async function execute(entry) {
|
|
30
|
+
const deadline = now() + 10 * 60_000;
|
|
31
|
+
let acceptingProgress = false;
|
|
32
|
+
let lease = null;
|
|
33
|
+
try {
|
|
34
|
+
while (!stopped && entry.operation.state === "queued" && now() < deadline && !lease) {
|
|
35
|
+
lease = await deps.acquire();
|
|
36
|
+
if (!lease)
|
|
37
|
+
await wait();
|
|
38
|
+
}
|
|
39
|
+
if (entry.operation.state === "cancelled")
|
|
40
|
+
return;
|
|
41
|
+
if (!lease || stopped || now() >= deadline) {
|
|
42
|
+
set(entry, { state: "failed", errorCode: "runtime_busy" });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
set(entry, { state: "preflight" });
|
|
46
|
+
// Mark unsafe before spawning: an exception or lost response cannot authorize another writer.
|
|
47
|
+
entry.processStopped = false;
|
|
48
|
+
acceptingProgress = true;
|
|
49
|
+
const output = await deps.run(entry.request, raw => {
|
|
50
|
+
if (!acceptingProgress)
|
|
51
|
+
return;
|
|
52
|
+
const parsed = RuntimeToolResponseSchema.safeParse(raw);
|
|
53
|
+
if (parsed.success && parsed.data.operation && advancesProgress(entry.operation, parsed.data.operation))
|
|
54
|
+
entry.operation = parsed.data.operation;
|
|
55
|
+
}, lease.protectProcessGroup);
|
|
56
|
+
acceptingProgress = false;
|
|
57
|
+
const data = RuntimeToolResponseSchema.parse(output.data);
|
|
58
|
+
entry.processStopped = output.processStopped;
|
|
59
|
+
if (!data.operation || data.operation.operationId !== entry.operation.operationId
|
|
60
|
+
|| JSON.stringify(data.operation.targets) !== JSON.stringify(entry.request.targets)
|
|
61
|
+
|| data.operation.currentIndex < entry.operation.currentIndex || !finalStates.has(data.operation.state))
|
|
62
|
+
throw new Error("operation_mismatch");
|
|
63
|
+
entry.operation = data.operation;
|
|
64
|
+
if (!entry.processStopped)
|
|
65
|
+
set(entry, { state: "unknown", errorCode: "operation_unknown" });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
acceptingProgress = false;
|
|
69
|
+
set(entry, { state: "unknown", errorCode: "operation_unknown" });
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
acceptingProgress = false;
|
|
73
|
+
// Retain the barrier if process-group termination could not be proved.
|
|
74
|
+
if (entry.processStopped) {
|
|
75
|
+
try {
|
|
76
|
+
await lease?.release();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
entry.processStopped = false;
|
|
80
|
+
set(entry, { state: "unknown", errorCode: "operation_unknown" });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
async handle(raw) {
|
|
87
|
+
const parsed = RuntimeToolRequestSchema.safeParse(raw);
|
|
88
|
+
if (!parsed.success || stopped)
|
|
89
|
+
return fail("invalid_request");
|
|
90
|
+
const request = parsed.data;
|
|
91
|
+
if (request.action === "inspect") {
|
|
92
|
+
try {
|
|
93
|
+
const data = RuntimeToolResponseSchema.parse((await deps.run(request)).data);
|
|
94
|
+
return data.snapshot ? result(data) : fail("inspection_failed");
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return fail("inspection_failed");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const entry = entries.get(request.operationId);
|
|
101
|
+
if (request.action === "start") {
|
|
102
|
+
if (entry)
|
|
103
|
+
return result({ operation: entry.operation });
|
|
104
|
+
if (orphaned || [...entries.values()].some(x => x.operation.state === "unknown"))
|
|
105
|
+
return fail("operation_unknown");
|
|
106
|
+
if (current)
|
|
107
|
+
return fail("runtime_busy");
|
|
108
|
+
if (entries.size >= 100)
|
|
109
|
+
return fail("operation_limit");
|
|
110
|
+
if (new Set(request.targets.map(t => t.installation.runtime)).size !== request.targets.length)
|
|
111
|
+
return fail("invalid_request");
|
|
112
|
+
const operation = { operationId: request.operationId, state: "queued", currentIndex: 0, targets: request.targets,
|
|
113
|
+
items: request.targets.map(t => ({ runtime: t.installation.runtime, state: "queued", errorCode: null })), updatedAt: date(), errorCode: null };
|
|
114
|
+
const next = { operation, request, processStopped: true };
|
|
115
|
+
entries.set(request.operationId, next);
|
|
116
|
+
current = execute(next).finally(() => { current = null; });
|
|
117
|
+
return result({ operation });
|
|
118
|
+
}
|
|
119
|
+
if (!entry) {
|
|
120
|
+
orphaned = true;
|
|
121
|
+
return fail("operation_unknown");
|
|
122
|
+
}
|
|
123
|
+
if (request.action === "status")
|
|
124
|
+
return result({ operation: entry.operation });
|
|
125
|
+
if (request.action === "cancel") {
|
|
126
|
+
if (entry.operation.state !== "queued")
|
|
127
|
+
return fail("already_started");
|
|
128
|
+
set(entry, { state: "cancelled", items: entry.operation.items.map(x => ({ ...x, state: "cancelled" })) });
|
|
129
|
+
return result({ operation: entry.operation });
|
|
130
|
+
}
|
|
131
|
+
if (!entry.processStopped || current)
|
|
132
|
+
return fail("operation_unknown");
|
|
133
|
+
try {
|
|
134
|
+
const output = await deps.run({ action: "inspect", runner: entry.request.runner });
|
|
135
|
+
const snapshot = RuntimeToolResponseSchema.parse(output.data).snapshot;
|
|
136
|
+
if (!snapshot || !output.processStopped)
|
|
137
|
+
return fail("operation_unknown");
|
|
138
|
+
const matches = entry.operation.targets.every(target => snapshot.tools.some(tool => tool.runtime === target.installation.runtime
|
|
139
|
+
&& tool.version === target.targetVersion && tool.path === target.installation.path && tool.source === target.installation.source
|
|
140
|
+
&& tool.prefix === target.installation.prefix && tool.brewPath === target.installation.brewPath && tool.automatic));
|
|
141
|
+
set(entry, { state: matches ? "completed" : "failed", errorCode: matches ? null : "verification_failed", snapshot,
|
|
142
|
+
items: entry.operation.items.map(x => ({ ...x, state: matches ? "completed" : "failed", errorCode: matches ? null : "verification_failed" })) });
|
|
143
|
+
return result({ operation: entry.operation, snapshot });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return fail("inspection_failed");
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
async stop() { stopped = true; await current; },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const RUNTIME_TOOL_UPDATES_CAPABILITY = "runtime_tool_updates_v1";
|
|
3
|
+
const boundedPath = z.string().min(1).max(4096);
|
|
4
|
+
export const RuntimeToolRunnerSchema = z.object({ releaseId: z.string().regex(/^[a-f0-9]{64}$/), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
5
|
+
export const RuntimeToolInstallationSchema = z.object({
|
|
6
|
+
runtime: z.enum(["codex", "claude"]), path: boundedPath, realPath: boundedPath,
|
|
7
|
+
version: z.string().max(100).nullable(), source: z.enum(["npm", "homebrew", "native", "unknown"]),
|
|
8
|
+
nodeVersion: z.string().max(100).nullable(), nodePath: boundedPath.nullable(), npmPath: boundedPath.nullable(), prefix: boundedPath.nullable(),
|
|
9
|
+
brewPath: boundedPath.nullable(), brewKind: z.enum(["formula", "cask"]).nullable(), brewName: z.enum(["codex", "claude-code"]).nullable(),
|
|
10
|
+
fingerprint: z.string().regex(/^[a-f0-9]{64}$/), alternatives: z.array(boundedPath).max(32), automatic: z.boolean(), reason: z.string().max(100).nullable(),
|
|
11
|
+
}).strict();
|
|
12
|
+
export const RuntimeToolSnapshotSchema = z.object({ checkedAt: z.string().datetime(), tools: z.array(RuntimeToolInstallationSchema).max(2) }).strict();
|
|
13
|
+
export const RuntimeToolTargetSchema = z.object({ installation: RuntimeToolInstallationSchema, targetVersion: z.string().regex(/^\d+\.\d+\.\d+$/).max(100) }).strict();
|
|
14
|
+
export const RuntimeToolStateSchema = z.enum(["queued", "preflight", "installing", "verifying", "completed", "failed", "unknown", "cancelled"]);
|
|
15
|
+
export const RuntimeToolOperationSchema = z.object({ operationId: z.string().uuid(), state: RuntimeToolStateSchema, currentIndex: z.number().int().min(0).max(2),
|
|
16
|
+
targets: z.array(RuntimeToolTargetSchema).min(1).max(2), items: z.array(z.object({ runtime: z.enum(["codex", "claude"]), state: RuntimeToolStateSchema, errorCode: z.string().max(100).nullable() }).strict()).max(2),
|
|
17
|
+
updatedAt: z.string().datetime(), errorCode: z.string().max(100).nullable(), snapshot: RuntimeToolSnapshotSchema.optional(),
|
|
18
|
+
}).strict();
|
|
19
|
+
export const RuntimeToolRequestSchema = z.discriminatedUnion("action", [
|
|
20
|
+
z.object({ action: z.literal("inspect"), runner: RuntimeToolRunnerSchema }).strict(),
|
|
21
|
+
z.object({ action: z.literal("start"), operationId: z.string().uuid(), targets: z.array(RuntimeToolTargetSchema).min(1).max(2), runner: RuntimeToolRunnerSchema }).strict(),
|
|
22
|
+
...["status", "cancel", "verify"].map(action => z.object({ action: z.literal(action), operationId: z.string().uuid() }).strict()),
|
|
23
|
+
]);
|
|
24
|
+
export const RuntimeToolResponseSchema = z.object({ snapshot: RuntimeToolSnapshotSchema.optional(), operation: RuntimeToolOperationSchema.optional() }).strict();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { prepareDaemonRestart } from "./daemon-updater.js";
|
|
2
|
+
import { createRuntimeToolUpdateController } from "./runtime-tool-update-controller.js";
|
|
3
|
+
import { augmentedPath } from "./runtime-path.js";
|
|
4
|
+
import { runRuntimeToolBridge } from "./runtime-tool-update-bridge.js";
|
|
5
|
+
export async function acquireRuntimeToolLease(localSlots, host) {
|
|
6
|
+
const local = await prepareDaemonRestart({ localSlots });
|
|
7
|
+
if (!local.ok)
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
const shared = await host.tryAcquireExclusiveExecution();
|
|
11
|
+
if (!shared) {
|
|
12
|
+
await local.release();
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
let released = false;
|
|
16
|
+
return { protectProcessGroup: async (pid) => {
|
|
17
|
+
if (!shared.protectProcessGroup)
|
|
18
|
+
throw new Error("lease protection unavailable");
|
|
19
|
+
await shared.protectProcessGroup(pid);
|
|
20
|
+
}, async release() { if (released)
|
|
21
|
+
return; await shared.release(); await local.release(); released = true; } };
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
await local.release();
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function createServeRuntimeToolUpdates(config, localSlots, host, acquireEnv, refresh) {
|
|
29
|
+
const controller = createRuntimeToolUpdateController({ acquire: () => acquireRuntimeToolLease(localSlots, host),
|
|
30
|
+
run: async (request, progress, protectProcessGroup) => {
|
|
31
|
+
const result = await runRuntimeToolBridge(config, request, async () => {
|
|
32
|
+
const env = await acquireEnv();
|
|
33
|
+
return { ...env, PATH: augmentedPath(env, {}, process.platform, "append") };
|
|
34
|
+
}, progress, { ...(protectProcessGroup ? { protectProcessGroup } : {}) });
|
|
35
|
+
if (request.action === "start" && result.processStopped)
|
|
36
|
+
refresh();
|
|
37
|
+
return result;
|
|
38
|
+
} });
|
|
39
|
+
return {
|
|
40
|
+
handle(raw, send) {
|
|
41
|
+
if (!raw || typeof raw !== "object" || !("type" in raw) || raw.type !== "runtime-tools:operation")
|
|
42
|
+
return false;
|
|
43
|
+
const { reqId, type: _type, ...request } = raw;
|
|
44
|
+
if (typeof reqId === "string") {
|
|
45
|
+
if (process.platform !== "linux" && process.platform !== "darwin")
|
|
46
|
+
send({ type: "fs:result", reqId, ok: false, error: "unsupported_platform" });
|
|
47
|
+
else
|
|
48
|
+
void controller.handle(request).then(result => send({ type: "fs:result", reqId, ...result })).catch(() => send({ type: "fs:result", reqId, ok: false, error: "operation_unknown" }));
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
},
|
|
52
|
+
stop: controller.stop,
|
|
53
|
+
};
|
|
54
|
+
}
|