@visual-z/xeno 0.2.1 → 0.4.0
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/CHANGELOG.md +18 -0
- package/README.md +126 -34
- package/docs/app-server.md +84 -0
- package/docs/install.md +51 -13
- package/docs/providers.md +24 -3
- package/package.json +7 -5
- package/src/apps/app-server.ts +28 -0
- package/src/apps/approve.ts +3 -3
- package/src/apps/cli.ts +25 -10
- package/src/apps/commands.ts +45 -8
- package/src/apps/completions.ts +78 -22
- package/src/apps/control-transport.ts +89 -0
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/model-catalog.ts +2 -1
- package/src/apps/project-trust.ts +2 -42
- package/src/apps/provider-connect.ts +346 -0
- package/src/apps/render.ts +22 -47
- package/src/apps/resources.ts +5 -2
- package/src/apps/shell.ts +333 -1542
- package/src/apps/startup-input.ts +45 -9
- package/src/apps/startup.ts +149 -94
- package/src/apps/ui/app.ts +1553 -0
- package/src/apps/ui/control-model.ts +26 -0
- package/src/apps/ui/extension-host.ts +194 -0
- package/src/apps/ui/image-cache.ts +78 -0
- package/src/apps/ui/palette.ts +88 -0
- package/src/apps/ui/project.ts +127 -0
- package/src/apps/ui/standalone.ts +146 -0
- package/src/apps/ui/status-model.ts +68 -0
- package/src/apps/ui/surface.ts +86 -0
- package/src/apps/ui/theme.ts +86 -0
- package/src/core/acceptance.ts +107 -0
- package/src/core/action-defaults.ts +47 -295
- package/src/core/benchmark.ts +2 -2
- package/src/core/branch-receipt.ts +24 -0
- package/src/core/checkpoints.ts +64 -0
- package/src/core/churn.ts +2 -0
- package/src/core/command-artifacts.ts +94 -0
- package/src/core/command-contract.ts +35 -0
- package/src/core/completion-action.ts +20 -0
- package/src/core/completion-review.ts +37 -21
- package/src/core/context-detail.ts +37 -0
- package/src/core/context-policy.ts +74 -1
- package/src/core/continuation.ts +17 -2
- package/src/core/control-protocol.ts +62 -0
- package/src/core/control-view.ts +42 -0
- package/src/core/derive.ts +29 -3
- package/src/core/draft-images.ts +56 -0
- package/src/core/environment.ts +145 -0
- package/src/core/evaluation-coverage.ts +74 -0
- package/src/core/event-validation.ts +81 -6
- package/src/core/events.ts +99 -3
- package/src/core/evidence-links.ts +7 -0
- package/src/core/evidence.ts +13 -3
- package/src/core/execution.ts +8 -2
- package/src/core/experiment.ts +133 -0
- package/src/core/export.ts +9 -0
- package/src/core/failure-recovery.ts +173 -0
- package/src/core/file-precondition.ts +72 -0
- package/src/core/handoff.ts +65 -4
- package/src/core/harness-diagnostics.ts +206 -0
- package/src/core/history-query.ts +38 -2
- package/src/core/history.ts +38 -1
- package/src/core/integration-health.ts +34 -0
- package/src/core/keymap.ts +8 -20
- package/src/core/model-picker.ts +64 -0
- package/src/core/native-eval.ts +25 -0
- package/src/core/note-guidance.ts +34 -0
- package/src/core/oauth-fields.ts +44 -0
- package/src/core/observation-view.ts +49 -8
- package/src/core/permissions.ts +24 -2
- package/src/core/process-receipt.ts +105 -0
- package/src/core/process-state.ts +37 -0
- package/src/core/program-status.ts +31 -0
- package/src/core/project.ts +70 -19
- package/src/core/prompt-policy.ts +37 -0
- package/src/core/query-eval-contract.ts +14 -0
- package/src/core/read-observation.ts +82 -0
- package/src/core/recovery-corpus.ts +17 -0
- package/src/core/recovery-evaluation.ts +60 -0
- package/src/core/recovery-sources.ts +74 -0
- package/src/core/request-progress.ts +87 -0
- package/src/core/request-state.ts +114 -0
- package/src/core/runtime-target.ts +94 -0
- package/src/core/search-observation.ts +97 -0
- package/src/core/session-tree.ts +147 -0
- package/src/core/settings.ts +33 -10
- package/src/core/skills.ts +44 -0
- package/src/core/sql-query.ts +211 -0
- package/src/core/theme.ts +36 -58
- package/src/core/tool-contract.ts +51 -2
- package/src/core/tool-discovery.ts +87 -0
- package/src/core/tool-failure.ts +60 -0
- package/src/core/tool-input.ts +6 -0
- package/src/core/tool-mode-protocol.ts +25 -0
- package/src/core/transcript.ts +19 -4
- package/src/core/trial-lifecycle.ts +14 -0
- package/src/core/tui-mode.ts +29 -0
- package/src/core/update-check.ts +97 -0
- package/src/core/version.ts +55 -0
- package/src/core/work-state.ts +49 -3
- package/src/core/working-set.ts +91 -0
- package/src/runtime/auth.ts +11 -4
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/catalog.ts +13 -3
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/control-connection.ts +104 -0
- package/src/runtime/environment-tools.ts +60 -0
- package/src/runtime/evidence-notes.ts +4 -2
- package/src/runtime/exec-sessions.ts +206 -61
- package/src/runtime/execution-control.ts +1 -0
- package/src/runtime/extension-ui.ts +22 -30
- package/src/runtime/extensions.ts +95 -16
- package/src/runtime/harness-comparison.ts +27 -7
- package/src/runtime/harness-eval.ts +462 -0
- package/src/runtime/history-cache.ts +57 -0
- package/src/runtime/history-tools.ts +62 -12
- package/src/runtime/integrations.ts +210 -0
- package/src/runtime/loop.ts +1088 -282
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/model.ts +9 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +146 -0
- package/src/runtime/oauth.ts +80 -3
- package/src/runtime/pi-provider.ts +169 -12
- package/src/runtime/program-status.ts +38 -0
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/provider-request.ts +13 -2
- package/src/runtime/provider.ts +23 -9
- package/src/runtime/query-eval-verifier.ts +28 -0
- package/src/runtime/recovery-coverage.ts +71 -0
- package/src/runtime/recovery-evidence.ts +76 -0
- package/src/runtime/recovery-fixtures.ts +116 -0
- package/src/runtime/runtime-target.ts +149 -0
- package/src/runtime/session-control.ts +198 -0
- package/src/runtime/tool-broker.ts +137 -0
- package/src/runtime/tool-discovery.ts +57 -0
- package/src/runtime/tool-mode-eval.ts +146 -0
- package/src/runtime/tool-queue.ts +77 -0
- package/src/runtime/tools.ts +628 -183
- package/src/runtime/trial-control.ts +45 -0
- package/src/runtime/update-check.ts +47 -0
- package/src/runtime/window-trial.ts +2 -1
- package/src/runtime/working-notes.ts +33 -3
- package/src/runtime/workspace-lifecycle.ts +97 -0
- package/src/store/acceptance.ts +15 -0
- package/src/store/checkpoint-history.ts +43 -0
- package/src/store/command-artifacts.ts +96 -0
- package/src/store/command.ts +65 -37
- package/src/store/file-checkpoints.ts +104 -0
- package/src/store/file-index.ts +13 -1
- package/src/store/git-checkpoints.ts +67 -0
- package/src/store/observations.ts +61 -20
- package/src/store/process-receipts.ts +34 -0
- package/src/store/query-source.ts +13 -8
- package/src/store/recovery.ts +2 -3
- package/src/store/replay.ts +16 -4
- package/src/store/runtime-target.ts +64 -0
- package/src/store/search.ts +171 -132
- package/src/store/session-archive.ts +4 -0
- package/src/store/source-cancellation.ts +6 -0
- package/src/store/update-cache.ts +36 -0
- package/src/store/workspace-observation.ts +113 -0
- package/src/tui/ansi-spans.ts +126 -0
- package/src/tui/style.ts +19 -16
- package/src/tui/ui/atomic-draft.ts +121 -0
- package/src/tui/ui/canvas.ts +96 -0
- package/src/tui/ui/colors.ts +70 -0
- package/src/tui/ui/composer.ts +200 -0
- package/src/tui/ui/control-panel.ts +23 -0
- package/src/tui/ui/dialog.ts +379 -0
- package/src/tui/ui/draft.ts +23 -0
- package/src/tui/ui/edge.ts +186 -0
- package/src/tui/ui/image-size.ts +40 -0
- package/src/tui/ui/ink.ts +210 -0
- package/src/tui/ui/items.ts +40 -0
- package/src/tui/ui/keys.ts +40 -0
- package/src/tui/ui/layout.ts +74 -0
- package/src/tui/ui/list-model.ts +86 -0
- package/src/tui/ui/list-view.ts +172 -0
- package/src/tui/ui/mascot.ts +67 -0
- package/src/tui/ui/messages.ts +518 -0
- package/src/tui/ui/motion.ts +28 -0
- package/src/tui/ui/paths.ts +10 -0
- package/src/tui/ui/pictures.ts +78 -0
- package/src/tui/ui/screen.ts +421 -0
- package/src/tui/ui/status.ts +279 -0
- package/src/tui/ui/styled.ts +85 -0
- package/src/tui/ui/testing.ts +5 -0
- package/src/tui/ui/tool-card.ts +150 -0
- package/src/apps/extension-ui.ts +0 -330
- package/src/apps/resource-browser.ts +0 -75
- package/src/apps/session-browser.ts +0 -113
- package/src/apps/session-picker.ts +0 -55
- package/src/tui/ansi.ts +0 -67
- package/src/tui/autocomplete.ts +0 -29
- package/src/tui/chat-viewport.ts +0 -47
- package/src/tui/complete.ts +0 -87
- package/src/tui/editor.ts +0 -940
- package/src/tui/host.ts +0 -264
- package/src/tui/input.ts +0 -75
- package/src/tui/keys.ts +0 -378
- package/src/tui/markdown.ts +0 -17
- package/src/tui/render.ts +0 -153
- package/src/tui/select.ts +0 -258
- package/src/tui/settings.ts +0 -44
- package/src/tui/statusline.ts +0 -162
- package/src/tui/system-theme.ts +0 -700
- package/src/tui/terminal.ts +0 -30
- package/src/tui/text.ts +0 -52
- package/src/tui/toolview.ts +0 -386
- package/src/tui/transcript.ts +0 -123
- package/src/tui/tree.ts +0 -471
- package/src/vendor/pi-tui/LICENSE.upstream +0 -21
- package/src/vendor/pi-tui/UPSTREAM.json +0 -50
- package/src/vendor/pi-tui/VENDOR.md +0 -25
- package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
- package/src/vendor/pi-tui/autocomplete.ts +0 -860
- package/src/vendor/pi-tui/colors.ts +0 -367
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
- package/src/vendor/pi-tui/components/box.ts +0 -166
- package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
- package/src/vendor/pi-tui/components/editor.ts +0 -2472
- package/src/vendor/pi-tui/components/h-stack.ts +0 -44
- package/src/vendor/pi-tui/components/image.ts +0 -127
- package/src/vendor/pi-tui/components/input.ts +0 -494
- package/src/vendor/pi-tui/components/loader.ts +0 -101
- package/src/vendor/pi-tui/components/markdown.ts +0 -1021
- package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
- package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
- package/src/vendor/pi-tui/components/select-list.ts +0 -273
- package/src/vendor/pi-tui/components/settings-list.ts +0 -328
- package/src/vendor/pi-tui/components/spacer.ts +0 -28
- package/src/vendor/pi-tui/components/stack.ts +0 -154
- package/src/vendor/pi-tui/components/text.ts +0 -107
- package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
- package/src/vendor/pi-tui/components/v-stack.ts +0 -33
- package/src/vendor/pi-tui/editor-component.ts +0 -74
- package/src/vendor/pi-tui/fuzzy.ts +0 -138
- package/src/vendor/pi-tui/index.ts +0 -186
- package/src/vendor/pi-tui/keybindings.ts +0 -320
- package/src/vendor/pi-tui/keys.ts +0 -1401
- package/src/vendor/pi-tui/kill-ring.ts +0 -46
- package/src/vendor/pi-tui/latex.ts +0 -1506
- package/src/vendor/pi-tui/layout-node.ts +0 -51
- package/src/vendor/pi-tui/layout.ts +0 -449
- package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
- package/src/vendor/pi-tui/native/clipboard.h +0 -208
- package/src/vendor/pi-tui/native/darwin/README.md +0 -22
- package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
- package/src/vendor/pi-tui/native/linux/README.md +0 -23
- package/src/vendor/pi-tui/native/linux/build.sh +0 -65
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
- package/src/vendor/pi-tui/native/napi.h +0 -91
- package/src/vendor/pi-tui/native/win32/README.md +0 -31
- package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
- package/src/vendor/pi-tui/native-modifiers.ts +0 -13
- package/src/vendor/pi-tui/native-module-path.ts +0 -31
- package/src/vendor/pi-tui/native-platform.ts +0 -65
- package/src/vendor/pi-tui/oklab.ts +0 -233
- package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
- package/src/vendor/pi-tui/terminal-colors.ts +0 -91
- package/src/vendor/pi-tui/terminal-image.ts +0 -731
- package/src/vendor/pi-tui/terminal.ts +0 -554
- package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
- package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
- package/src/vendor/pi-tui/tui.ts +0 -1493
- package/src/vendor/pi-tui/undo-stack.ts +0 -28
- package/src/vendor/pi-tui/utils.ts +0 -1385
- package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
- package/src/vendor/pi-tui/word-navigation.ts +0 -117
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** P.3: bounded path operations with durable before-images and conflict checks. */
|
|
2
|
+
import { lstat, readFile, mkdir, open, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { resolve, relative, dirname, join, sep } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { blobAddress, putBlob, getBlobBytes, verifyBlob } from "./blobs.ts";
|
|
6
|
+
import { sameVersion, changedPaths, type FileVersion, type FileChanges } from "../core/checkpoints.ts";
|
|
7
|
+
import { ToolFault, NOT_STARTED, READ_ATTEMPTED, failureFromError } from "../core/tool-failure.ts";
|
|
8
|
+
|
|
9
|
+
export async function editablePath(root: string, path: string): Promise<string> {
|
|
10
|
+
const base = resolve(root), absolute = resolve(base, path), rel = relative(base, absolute);
|
|
11
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || resolve(rel) === rel ||
|
|
12
|
+
rel.split(sep).some(part => part === ".git" || part === ".xeno")) throw new ToolFault("permission_denied", `unsafe file checkpoint path: ${path}`, NOT_STARTED);
|
|
13
|
+
let current = base;
|
|
14
|
+
for (const part of rel.split(sep)) {
|
|
15
|
+
current = join(current, part);
|
|
16
|
+
try {
|
|
17
|
+
const info = await lstat(current);
|
|
18
|
+
if (info.isSymbolicLink() || current !== absolute && !info.isDirectory() || current === absolute && (!info.isFile() || info.nlink > 1))
|
|
19
|
+
throw new ToolFault("permission_denied", `unsupported file checkpoint path: ${path}`, NOT_STARTED);
|
|
20
|
+
} catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; }
|
|
21
|
+
}
|
|
22
|
+
return absolute;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readEditableFile(root: string, path: string): Promise<{ bytes: Uint8Array | null; version: FileVersion }> {
|
|
26
|
+
const absolute = await editablePath(root, path);
|
|
27
|
+
try {
|
|
28
|
+
const info = await lstat(absolute);
|
|
29
|
+
const bytes = await readFile(absolute);
|
|
30
|
+
return { bytes, version: { sha256: blobAddress(bytes), mode: info.mode & 0o777 } };
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if ((error as { code?: string }).code === "ENOENT") return { bytes: null, version: null };
|
|
33
|
+
const diagnostic = failureFromError(error, READ_ATTEMPTED);
|
|
34
|
+
throw new ToolFault(diagnostic.code, diagnostic.message, READ_ATTEMPTED);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The prepared record is durable before rename; cancellation and conflicts leave bytes untouched. */
|
|
39
|
+
export async function replaceFile(root: string, path: string, expected: FileVersion, bytes: Uint8Array | null,
|
|
40
|
+
mode: number, signal?: AbortSignal): Promise<void> {
|
|
41
|
+
const absolute = await editablePath(root, path);
|
|
42
|
+
if (signal?.aborted) throw new ToolFault("cancelled", "file operation cancelled", NOT_STARTED);
|
|
43
|
+
if (!sameVersion((await readEditableFile(root, path)).version, expected)) throw new ToolFault("version_mismatch", `file conflict: ${path}; read it again before editing`, READ_ATTEMPTED, { recover: { tool: "read", input: { path } } });
|
|
44
|
+
if (bytes === null) {
|
|
45
|
+
if (expected === null) return;
|
|
46
|
+
await rm(absolute);
|
|
47
|
+
const directory = await open(dirname(absolute), "r");
|
|
48
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
52
|
+
const temporary = join(dirname(absolute), `.xeno-edit-${randomUUID()}`);
|
|
53
|
+
try {
|
|
54
|
+
const file = await open(temporary, "wx", mode);
|
|
55
|
+
try { await file.writeFile(bytes); await file.chmod(mode); await file.sync(); } finally { await file.close(); }
|
|
56
|
+
await editablePath(root, path);
|
|
57
|
+
if (signal?.aborted) throw new ToolFault("cancelled", "file operation cancelled", READ_ATTEMPTED);
|
|
58
|
+
if (!sameVersion((await readEditableFile(root, path)).version, expected)) throw new ToolFault("version_mismatch", `file conflict: ${path}; read it again before editing`, READ_ATTEMPTED, { recover: { tool: "read", input: { path } } });
|
|
59
|
+
await rename(temporary, absolute);
|
|
60
|
+
const directory = await open(dirname(absolute), "r");
|
|
61
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
62
|
+
} finally { await rm(temporary, { force: true }); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function archiveEdit(blobs: string, before: Uint8Array | null, after: Uint8Array): Promise<void> {
|
|
66
|
+
if (before !== null) await putBlob(blobs, before);
|
|
67
|
+
await putBlob(blobs, after);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function preflightChanges(root: string, blobs: string, changes: FileChanges): Promise<void> {
|
|
71
|
+
for (const path of changedPaths(changes)) {
|
|
72
|
+
const change = changes[path]!;
|
|
73
|
+
if (!sameVersion((await readEditableFile(root, path)).version, change.before)) throw new Error(`file conflict: ${path}; external changes were preserved`);
|
|
74
|
+
if (change.after && !await verifyBlob(blobs, change.after.sha256)) throw new Error(`missing checkpoint object: ${path}`);
|
|
75
|
+
if (change.before && !await verifyBlob(blobs, change.before.sha256)) throw new Error(`missing recovery object: ${path}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Preflight the complete plan, then compensate partial writes if an individual replacement fails. */
|
|
80
|
+
export async function applyFileChanges(root: string, blobs: string, changes: FileChanges): Promise<void> {
|
|
81
|
+
await preflightChanges(root, blobs, changes);
|
|
82
|
+
const applied: string[] = [];
|
|
83
|
+
try {
|
|
84
|
+
for (const path of changedPaths(changes)) {
|
|
85
|
+
const { before, after } = changes[path]!;
|
|
86
|
+
const bytes = after === null ? null : await getBlobBytes(blobs, after.sha256);
|
|
87
|
+
if (after && bytes === null) throw new Error(`missing checkpoint object: ${path}`);
|
|
88
|
+
await replaceFile(root, path, before, bytes, after?.mode ?? 0o644);
|
|
89
|
+
applied.push(path);
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const failures: string[] = [];
|
|
93
|
+
for (const path of applied.reverse()) {
|
|
94
|
+
const { before, after } = changes[path]!;
|
|
95
|
+
try {
|
|
96
|
+
const bytes = before === null ? null : await getBlobBytes(blobs, before.sha256);
|
|
97
|
+
if (before && bytes === null) throw new Error("missing recovery object");
|
|
98
|
+
await replaceFile(root, path, after, bytes, before?.mode ?? 0o644);
|
|
99
|
+
} catch { failures.push(path); }
|
|
100
|
+
}
|
|
101
|
+
if (failures.length) throw new Error(`restore interrupted; inspect partially applied paths: ${failures.join(", ")}; ${String(error)}`);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/store/file-index.ts
CHANGED
|
@@ -23,11 +23,14 @@ export const MAX_INDEX_ENTRIES = 20_000;
|
|
|
23
23
|
export interface FileIndex {
|
|
24
24
|
readonly paths: readonly string[];
|
|
25
25
|
readonly truncated: boolean;
|
|
26
|
+
readonly issues?: readonly { code: string; path?: string }[];
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export interface IndexOptions {
|
|
29
30
|
readonly ignore?: readonly string[];
|
|
30
31
|
readonly maxEntries?: number;
|
|
32
|
+
readonly deadline?: number;
|
|
33
|
+
readonly signal?: AbortSignal;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
function isIgnored(rel: string, ignore: readonly string[]): boolean {
|
|
@@ -40,17 +43,25 @@ export async function buildFileIndex(root: string, opts: IndexOptions = {}): Pro
|
|
|
40
43
|
const max = opts.maxEntries ?? MAX_INDEX_ENTRIES;
|
|
41
44
|
const paths: string[] = [];
|
|
42
45
|
let truncated = false;
|
|
46
|
+
const issues: { code: string; path?: string }[] = [];
|
|
43
47
|
|
|
44
48
|
async function walk(dir: string): Promise<void> {
|
|
45
49
|
if (truncated) return;
|
|
50
|
+
if (opts.signal?.aborted || opts.deadline !== undefined && performance.now() >= opts.deadline) {
|
|
51
|
+
truncated = true; issues.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); return;
|
|
52
|
+
}
|
|
46
53
|
let entries: Dirent[];
|
|
47
54
|
try {
|
|
48
55
|
entries = await readdir(dir, { withFileTypes: true });
|
|
49
56
|
} catch {
|
|
57
|
+
issues.push({ code: "unreadable_directory", path: relative(root, dir) || "." });
|
|
50
58
|
return;
|
|
51
59
|
}
|
|
52
60
|
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
53
61
|
if (truncated) return;
|
|
62
|
+
if (opts.signal?.aborted || opts.deadline !== undefined && performance.now() >= opts.deadline) {
|
|
63
|
+
truncated = true; issues.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); return;
|
|
64
|
+
}
|
|
54
65
|
// Do not follow symlinks: a link to / walks the disk, and
|
|
55
66
|
// node_modules/.bin is nothing but links. Same judgment as
|
|
56
67
|
// feat-workspace-snapshots, which also skips them via isFile().
|
|
@@ -65,6 +76,7 @@ export async function buildFileIndex(root: string, opts: IndexOptions = {}): Pro
|
|
|
65
76
|
if (!entry.isFile()) continue;
|
|
66
77
|
if (paths.length >= max) {
|
|
67
78
|
truncated = true;
|
|
79
|
+
issues.push({ code: "file_index_limit" });
|
|
68
80
|
return;
|
|
69
81
|
}
|
|
70
82
|
paths.push(rel.split(sep).join("/"));
|
|
@@ -72,7 +84,7 @@ export async function buildFileIndex(root: string, opts: IndexOptions = {}): Pro
|
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
await walk(root);
|
|
75
|
-
return { paths, truncated };
|
|
87
|
+
return { paths, truncated, ...(issues.length ? { issues } : {}) };
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
export function indexTruncationNote(index: FileIndex): string | null {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** P.3/OpenCode v2: retained private index, scoped capture, path-only CAS export. */
|
|
2
|
+
import { mkdir, lstat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { blobAddress, putBlob } from "./blobs.ts";
|
|
5
|
+
import { gitBytes, sourceInventory } from "./workspace-observation.ts";
|
|
6
|
+
import type { FileChanges, FileVersion } from "../core/checkpoints.ts";
|
|
7
|
+
import { atomicWrite } from "./atomic.ts";
|
|
8
|
+
|
|
9
|
+
const text = (bytes: Uint8Array) => new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
10
|
+
export interface GitCheckpoint { tree: string; modes: ReadonlyMap<string, number>; }
|
|
11
|
+
export class GitCheckpoints {
|
|
12
|
+
readonly root: string;
|
|
13
|
+
private readonly ownedPaths: readonly string[];
|
|
14
|
+
private initialized = false;
|
|
15
|
+
readonly directory: string;
|
|
16
|
+
constructor(root: string, storage: string, ownedPaths: readonly string[] = []) {
|
|
17
|
+
this.root = root; this.directory = join(storage, "git-checkpoints", blobAddress(root)); this.ownedPaths = ownedPaths;
|
|
18
|
+
}
|
|
19
|
+
private git(args: readonly string[], input?: Uint8Array, signal?: AbortSignal) {
|
|
20
|
+
return gitBytes(this.root, ["--literal-pathspecs", "-c", "core.autocrlf=false", "-c", "core.safecrlf=false", "-c", "core.attributesFile=/dev/null", "-c", "core.hooksPath=/dev/null", "-c", "gc.auto=0", ...args], input, signal,
|
|
21
|
+
{ GIT_DIR: this.directory, GIT_WORK_TREE: this.root, GIT_INDEX_FILE: join(this.directory, "index"), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_ATTR_NOSYSTEM: "1" });
|
|
22
|
+
}
|
|
23
|
+
async capture(signal?: AbortSignal): Promise<GitCheckpoint> {
|
|
24
|
+
const inventory = await sourceInventory(this.root, signal, true, this.ownedPaths);
|
|
25
|
+
if (!this.initialized) {
|
|
26
|
+
await mkdir(this.directory, { recursive: true });
|
|
27
|
+
await gitBytes(this.root, ["init", "--bare", "--quiet", this.directory], undefined, signal);
|
|
28
|
+
// Git's highest-precedence attributes keep checkpoint bytes exact, even if the project uses LFS, encoding or EOL conversion.
|
|
29
|
+
await atomicWrite(join(this.directory, "info", "attributes"), "* -text -crlf -ident -filter -working-tree-encoding -eol\n");
|
|
30
|
+
this.initialized = true;
|
|
31
|
+
}
|
|
32
|
+
// Git trees retain only the executable bit; recovery needs the observed POSIX permissions as well.
|
|
33
|
+
const modes = new Map<string, number>();
|
|
34
|
+
for (const path of inventory.paths) {
|
|
35
|
+
const info = await lstat(join(this.root, path));
|
|
36
|
+
if (!info.isFile() || info.nlink > 1) throw new Error(`unsupported Git checkpoint path: ${path}`);
|
|
37
|
+
modes.set(path, info.mode & 0o777);
|
|
38
|
+
}
|
|
39
|
+
const previous = text(await this.git(["ls-files", "-z"], undefined, signal)).split("\0").filter(Boolean);
|
|
40
|
+
const present = new Set(inventory.paths);
|
|
41
|
+
const removed = previous.filter(path => !present.has(path));
|
|
42
|
+
if (removed.length) await this.git(["update-index", "--force-remove", "-z", "--stdin"], Buffer.from(removed.join("\0") + "\0"), signal);
|
|
43
|
+
// Feeding explicit paths avoids scanning ignored descendants and preserves names with newlines or leading dashes.
|
|
44
|
+
if (inventory.paths.length) await this.git(["add", "--force", "--pathspec-from-file=-", "--pathspec-file-nul"], Buffer.from(inventory.paths.join("\0") + "\0"), signal);
|
|
45
|
+
const tree = text(await this.git(["write-tree"], undefined, signal)).trim();
|
|
46
|
+
// Durable reachability is owned by Xeno, independent of the user's Git GC/index/HEAD.
|
|
47
|
+
await this.git(["update-ref", `refs/checkpoints/${tree}`, tree], undefined, signal);
|
|
48
|
+
return { tree, modes };
|
|
49
|
+
}
|
|
50
|
+
async changes(before: GitCheckpoint, after: GitCheckpoint, blobs: string, signal?: AbortSignal): Promise<FileChanges> {
|
|
51
|
+
const paths = new Set(before.tree === after.tree ? [] : text(await this.git(["diff-tree", "--no-renames", "--name-only", "-r", "-z", before.tree, after.tree], undefined, signal)).split("\0").filter(Boolean));
|
|
52
|
+
for (const [path, mode] of before.modes) if (mode !== after.modes.get(path)) paths.add(path);
|
|
53
|
+
for (const path of after.modes.keys()) if (!before.modes.has(path)) paths.add(path);
|
|
54
|
+
const version = async (snapshot: GitCheckpoint, path: string): Promise<FileVersion> => {
|
|
55
|
+
const entry = text(await this.git(["ls-tree", "-z", snapshot.tree, "--", path], undefined, signal));
|
|
56
|
+
if (!entry) return null;
|
|
57
|
+
const match = /^(100644|100755) blob ([a-f0-9]+)\t/.exec(entry);
|
|
58
|
+
const mode = snapshot.modes.get(path);
|
|
59
|
+
if (!match || mode === undefined) throw new Error(`unsupported Git checkpoint entry: ${path}`);
|
|
60
|
+
const bytes = await this.git(["cat-file", "blob", match[2]!], undefined, signal);
|
|
61
|
+
return { sha256: await putBlob(blobs, bytes), mode };
|
|
62
|
+
};
|
|
63
|
+
const changes: FileChanges = Object.create(null) as FileChanges;
|
|
64
|
+
for (const path of paths) changes[path] = { before: await version(before, path), after: await version(after, path) };
|
|
65
|
+
return changes;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -53,41 +53,72 @@ const EXIT_DRAIN_MS = 1_000;
|
|
|
53
53
|
async function spoolMerged(
|
|
54
54
|
dir: string, streams: readonly ReadableStream<Uint8Array>[], exited: Promise<number>,
|
|
55
55
|
onOutput?: (chunk: Uint8Array) => void,
|
|
56
|
-
|
|
56
|
+
separate = false,
|
|
57
|
+
limitBytes?: number,
|
|
58
|
+
): Promise<{ path: string; channels: string[]; complete: boolean }> {
|
|
57
59
|
await mkdir(dir, { recursive: true });
|
|
58
60
|
const path = join(dir, `.${randomUUID()}.stream`);
|
|
59
61
|
const file = await open(path, "wx", 0o600);
|
|
62
|
+
const channels: string[] = [];
|
|
63
|
+
const channelFiles: Awaited<ReturnType<typeof open>>[] = [];
|
|
64
|
+
try {
|
|
65
|
+
if (separate) for (let i = 0; i < streams.length; i++) {
|
|
66
|
+
const channel = `${path}.${i}`;
|
|
67
|
+
channelFiles.push(await open(channel, "wx", 0o600)); channels.push(channel);
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
await Promise.allSettled([file.close(), ...channelFiles.map(item => item.close())]);
|
|
71
|
+
await Promise.all([path, ...channels].map(item => rm(item, { force: true })));
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
60
74
|
const readers = streams.map((stream) => stream.getReader());
|
|
61
75
|
let queue: Promise<unknown> = Promise.resolve();
|
|
62
76
|
let exitedAt: number | undefined;
|
|
63
77
|
let idle: ReturnType<typeof setTimeout> | undefined;
|
|
64
78
|
let drain: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
-
|
|
79
|
+
let finished = false, complete = true, capturedBytes = 0;
|
|
80
|
+
const stop = () => { if (finished) return; complete = false; for (const reader of readers) reader.cancel().catch(() => {}); };
|
|
66
81
|
const armIdle = () => {
|
|
67
|
-
if (exitedAt === undefined) return;
|
|
82
|
+
if (finished || exitedAt === undefined) return;
|
|
68
83
|
clearTimeout(idle);
|
|
69
84
|
idle = setTimeout(stop, EXIT_IDLE_MS);
|
|
70
85
|
};
|
|
71
|
-
void exited.then(() => { exitedAt = performance.now(); armIdle(); drain = setTimeout(stop, EXIT_DRAIN_MS); }, () => {});
|
|
86
|
+
void exited.then(() => { if (finished) return; exitedAt = performance.now(); armIdle(); drain = setTimeout(stop, EXIT_DRAIN_MS); }, () => {});
|
|
72
87
|
try {
|
|
73
|
-
const drained = await Promise.allSettled(readers.map(async (reader) => {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
const drained = await Promise.allSettled(readers.map(async (reader, index) => {
|
|
89
|
+
try {
|
|
90
|
+
for (;;) {
|
|
91
|
+
const result = await reader.read();
|
|
92
|
+
if (result.done) return;
|
|
93
|
+
armIdle();
|
|
94
|
+
queue = queue.then(async () => {
|
|
95
|
+
await file.writeFile(result.value);
|
|
96
|
+
await channelFiles[index]?.writeFile(result.value);
|
|
97
|
+
onOutput?.(result.value);
|
|
98
|
+
capturedBytes += result.value.byteLength;
|
|
99
|
+
// Retain every delivered chunk, then cancel both readers. At most
|
|
100
|
+
// the in-flight crossing chunks exceed the bound; SIGTERM grace
|
|
101
|
+
// cannot turn an output limit into unlimited spool growth (R6).
|
|
102
|
+
if (limitBytes !== undefined && capturedBytes > limitBytes) stop();
|
|
103
|
+
});
|
|
104
|
+
await queue;
|
|
105
|
+
}
|
|
106
|
+
} catch (error) { stop(); throw error; }
|
|
81
107
|
}));
|
|
82
108
|
await queue;
|
|
83
109
|
for (const result of drained) if (result.status === "rejected") throw result.reason;
|
|
84
|
-
} catch (error) {
|
|
110
|
+
} catch (error) {
|
|
111
|
+
await Promise.allSettled([file.close(), ...channelFiles.map(item => item.close())]);
|
|
112
|
+
await Promise.all([path, ...channels].map(item => rm(item, { force: true })));
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
85
115
|
finally {
|
|
116
|
+
finished = true;
|
|
86
117
|
clearTimeout(idle); clearTimeout(drain);
|
|
87
118
|
for (const reader of readers) reader.releaseLock();
|
|
88
119
|
}
|
|
89
|
-
await file.close();
|
|
90
|
-
return path;
|
|
120
|
+
await Promise.all([file.close(), ...channelFiles.map(item => item.close())]);
|
|
121
|
+
return { path, channels, complete };
|
|
91
122
|
}
|
|
92
123
|
|
|
93
124
|
/** Last `limit` bytes of a stream, in O(limit) memory however long it runs. */
|
|
@@ -167,17 +198,27 @@ export async function captureCommandObservation(
|
|
|
167
198
|
dir: string, stdout: ReadableStream<Uint8Array>, stderr: ReadableStream<Uint8Array>,
|
|
168
199
|
exited: Promise<number>, previewBytes: number,
|
|
169
200
|
onOutput?: (chunk: Uint8Array) => void,
|
|
170
|
-
|
|
171
|
-
|
|
201
|
+
separate = false,
|
|
202
|
+
limitBytes?: number,
|
|
203
|
+
): Promise<ArchivedObservation & { exit: number; prefixBytes: number; channels: ArtifactRef[]; capture: "complete" | "partial" }> {
|
|
204
|
+
const merged = await spoolMerged(dir, [stdout, stderr], exited, onOutput, separate, limitBytes);
|
|
172
205
|
try {
|
|
173
206
|
const exit = await exited;
|
|
174
207
|
const prefix = new TextEncoder().encode(`exit ${exit}\n`);
|
|
175
208
|
async function* combined(): AsyncGenerator<Uint8Array> {
|
|
176
209
|
yield prefix;
|
|
177
|
-
for await (const chunk of createReadStream(merged)) yield chunk as Uint8Array;
|
|
210
|
+
for await (const chunk of createReadStream(merged.path)) yield chunk as Uint8Array;
|
|
211
|
+
}
|
|
212
|
+
async function* channel(path: string): AsyncGenerator<Uint8Array> {
|
|
213
|
+
for await (const chunk of createReadStream(path)) yield chunk as Uint8Array;
|
|
178
214
|
}
|
|
179
|
-
|
|
215
|
+
// Drain all archive attempts before removing their source files, including failures.
|
|
216
|
+
const attempts = await Promise.allSettled([archiveObservation(dir, combined(), previewBytes),
|
|
217
|
+
...merged.channels.map(path => archiveObservation(dir, channel(path), 0))]);
|
|
218
|
+
const stored = attempts.map(result => { if (result.status === "rejected") throw result.reason; return result.value; });
|
|
219
|
+
return { ...stored[0]!, exit, prefixBytes: prefix.byteLength, channels: stored.slice(1).map(item => item.ref),
|
|
220
|
+
capture: merged.complete ? "complete" : "partial" };
|
|
180
221
|
} finally {
|
|
181
|
-
await
|
|
222
|
+
await Promise.all([merged.path, ...merged.channels].map(path => rm(path, { force: true })));
|
|
182
223
|
}
|
|
183
224
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** R7/R8: data_ref is the original receipt, not permission to substitute current process/file state. */
|
|
2
|
+
import type { LogEvent } from "../core/events.ts";
|
|
3
|
+
import { isLogEvent } from "../core/event-validation.ts";
|
|
4
|
+
import { readObservation } from "./observations.ts";
|
|
5
|
+
|
|
6
|
+
// Assumed per-query metadata budget. Exhaustion is explicit unknown, never fallback to older success.
|
|
7
|
+
export const PROCESS_RECEIPT_READ_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
export async function hydrateProcessReceipts(nodes: readonly { node: number; events: readonly LogEvent[] }[], archive: string):
|
|
9
|
+
Promise<{ node: number; events: LogEvent[] }[]> {
|
|
10
|
+
const calls = new Map<string, string>();
|
|
11
|
+
for (const part of nodes) for (const event of part.events) {
|
|
12
|
+
if (event.t === "tool_invocation") calls.set(event.call_id, event.name);
|
|
13
|
+
if (event.t === "assistant") for (const block of event.blocks) if (block.type === "tool_use") calls.set(block.call_id, block.name);
|
|
14
|
+
}
|
|
15
|
+
let remaining = PROCESS_RECEIPT_READ_BYTES;
|
|
16
|
+
const result: { node: number; events: LogEvent[] }[] = [];
|
|
17
|
+
for (const part of nodes) {
|
|
18
|
+
const events: LogEvent[] = [];
|
|
19
|
+
for (const event of part.events) {
|
|
20
|
+
if (event.t !== "tool_result" || !event.data_ref || !["exec_command", "write_stdin", "target"].includes(calls.get(event.call_id) ?? "")) { events.push(event); continue; }
|
|
21
|
+
const ref = event.data_ref;
|
|
22
|
+
if (ref.bytes > remaining) throw new Error("process receipt metadata exceeds observation budget; state remains unknown");
|
|
23
|
+
remaining -= ref.bytes;
|
|
24
|
+
const page = await readObservation(archive, ref.sha256, 0, Math.max(1, ref.bytes));
|
|
25
|
+
if (page.bytes !== ref.bytes || page.next !== null) throw new Error("process receipt metadata length mismatch");
|
|
26
|
+
const { data_ref: _ref, ...original } = event;
|
|
27
|
+
const restored: unknown = { ...original, data: JSON.parse(page.text) };
|
|
28
|
+
if (!isLogEvent(restored)) throw new Error("archived process receipt contradicts its original event");
|
|
29
|
+
events.push(restored);
|
|
30
|
+
}
|
|
31
|
+
result.push({ node: part.node, events });
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
@@ -3,38 +3,43 @@ import { lstat, open } from "node:fs/promises";
|
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
4
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
+
import { ToolFault, NOT_STARTED, READ_ATTEMPTED } from "../core/tool-failure.ts";
|
|
7
|
+
import { checkSourceCancellation } from "./source-cancellation.ts";
|
|
6
8
|
|
|
7
9
|
// Assumed working-memory safety budget for the synchronous pure query engine.
|
|
8
10
|
// Exceeding it is an explicit refusal, never a truncated calculation.
|
|
9
11
|
export const MAX_QUERY_SOURCE_BYTES = 32 * 1024 * 1024;
|
|
10
|
-
export async function readQuerySources(root: string, paths: unknown): Promise<{
|
|
12
|
+
export async function readQuerySources(root: string, paths: unknown, signal?: AbortSignal): Promise<{
|
|
11
13
|
text: string; sources: { path: string; sha256: string; bytes: number }[];
|
|
12
14
|
}> {
|
|
13
|
-
if (!Array.isArray(paths) || paths.length === 0 || paths.length > 1024 || paths.some((p) => typeof p !== "string" || !p)) throw new
|
|
15
|
+
if (!Array.isArray(paths) || paths.length === 0 || paths.length > 1024 || paths.some((p) => typeof p !== "string" || !p)) throw new ToolFault("invalid_input", "query paths must contain 1..1024 file paths in concatenation order", NOT_STARTED);
|
|
14
16
|
const sources: { path: string; sha256: string; bytes: number }[] = [];
|
|
15
17
|
const chunks: Uint8Array[] = [];
|
|
16
18
|
let bytes = 0;
|
|
17
19
|
for (const path of paths as string[]) {
|
|
20
|
+
checkSourceCancellation(signal);
|
|
18
21
|
const absolute = resolve(root, path), rel = relative(root, absolute);
|
|
19
|
-
if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) throw new
|
|
22
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) throw new ToolFault("permission_denied", "query path escapes workspace", READ_ATTEMPTED);
|
|
20
23
|
let current = resolve(root);
|
|
21
24
|
for (const part of rel.split(sep)) {
|
|
22
25
|
current = resolve(current, part);
|
|
23
|
-
if ((await lstat(current)).isSymbolicLink()) throw new
|
|
26
|
+
if ((await lstat(current)).isSymbolicLink()) throw new ToolFault("permission_denied", "query does not follow symbolic links", READ_ATTEMPTED);
|
|
24
27
|
}
|
|
25
|
-
const file = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
28
|
+
const file = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
26
29
|
try {
|
|
27
30
|
const info = await file.stat();
|
|
28
|
-
if (!info.isFile()) throw new
|
|
29
|
-
if (bytes + info.size > MAX_QUERY_SOURCE_BYTES) throw new
|
|
31
|
+
if (!info.isFile()) throw new ToolFault("invalid_input", "query source must be a regular file", READ_ATTEMPTED);
|
|
32
|
+
if (bytes + info.size > MAX_QUERY_SOURCE_BYTES) throw new ToolFault("invalid_input", "query source exceeds 32 MiB resource budget; use a smaller input", READ_ATTEMPTED);
|
|
30
33
|
const hash = createHash("sha256");
|
|
31
34
|
let sourceBytes = 0;
|
|
32
35
|
for (;;) {
|
|
36
|
+
checkSourceCancellation(signal);
|
|
33
37
|
const buffer = Buffer.alloc(Math.min(65536, MAX_QUERY_SOURCE_BYTES - bytes + 1));
|
|
34
38
|
const result = await file.read(buffer, 0, buffer.length, null);
|
|
39
|
+
checkSourceCancellation(signal);
|
|
35
40
|
if (result.bytesRead === 0) break;
|
|
36
41
|
bytes += result.bytesRead; sourceBytes += result.bytesRead;
|
|
37
|
-
if (bytes > MAX_QUERY_SOURCE_BYTES) throw new
|
|
42
|
+
if (bytes > MAX_QUERY_SOURCE_BYTES) throw new ToolFault("invalid_input", "query source grew past resource budget", READ_ATTEMPTED);
|
|
38
43
|
const data = buffer.subarray(0, result.bytesRead); hash.update(data); chunks.push(data);
|
|
39
44
|
}
|
|
40
45
|
sources.push({ path: rel.split(sep).join("/"), sha256: hash.digest("hex"), bytes: sourceBytes });
|
package/src/store/recovery.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/** Complete an explicitly logged cut; never infer a user's chosen branch. */
|
|
2
2
|
import type { Cursor } from "./log.ts";
|
|
3
3
|
import { closeNode, digestNode, initNode, listNodes, nodePath, readNode, readManifest, writeCursor } from "./log.ts";
|
|
4
|
-
import { replayContext } from "./replay.ts";
|
|
4
|
+
import { replayContext, carriedHandoff } from "./replay.ts";
|
|
5
5
|
import { deriveHandoff } from "../core/derive.ts";
|
|
6
|
-
import { boundHandoff } from "../core/history.ts";
|
|
7
6
|
|
|
8
7
|
export async function recoverCut(dir: string, cursor: Cursor): Promise<Cursor> {
|
|
9
8
|
const source = await readNode(dir, cursor.node);
|
|
@@ -18,7 +17,7 @@ export async function recoverCut(dir: string, cursor: Cursor): Promise<Cursor> {
|
|
|
18
17
|
self: { node: cursor.node, log_ref: nodePath(dir, cursor.node), sha256: await digestNode(dir, cursor.node) },
|
|
19
18
|
});
|
|
20
19
|
await closeNode(dir, cursor.node,
|
|
21
|
-
|
|
20
|
+
await carriedHandoff(dir, cursor.node, handoff, cut, manifest), source.events.length, cut.at);
|
|
22
21
|
}
|
|
23
22
|
const successor = await readNode(dir, cut.next_node);
|
|
24
23
|
if (successor.header === null) {
|
package/src/store/replay.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { deriveHandoff } from "../core/derive.ts";
|
|
8
8
|
import type { Handoff } from "../core/handoff.ts";
|
|
9
|
-
import type { LogEvent } from "../core/events.ts";
|
|
10
|
-
import { boundHandoff } from "../core/history.ts";
|
|
9
|
+
import type { CutEvent, LogEvent } from "../core/events.ts";
|
|
10
|
+
import { boundHandoff, boundWorkingHandoff, boundRecoveryHandoff } from "../core/history.ts";
|
|
11
11
|
import { retainedContext } from "../core/context-window.ts";
|
|
12
12
|
import { digestNode, nodePath, readAncestry, readManifest, type Manifest } from "./log.ts";
|
|
13
|
+
import { readNodeHistory } from "./restore.ts";
|
|
13
14
|
|
|
14
15
|
export interface ReplayedContext {
|
|
15
16
|
/** Events not already included in fullHandoff; use these for derivation. */
|
|
@@ -22,6 +23,18 @@ export interface ReplayedContext {
|
|
|
22
23
|
depth: number;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** A live cut, cold replay and interrupted-cut repair must select the same version. */
|
|
27
|
+
export async function carriedHandoff(dir: string, node: number, full: Handoff,
|
|
28
|
+
policy: Pick<CutEvent, "handoff_tokens" | "working_set_version" | "guidance_tools">, manifest?: Manifest): Promise<Handoff> {
|
|
29
|
+
if (policy.handoff_tokens === undefined) return full;
|
|
30
|
+
if (policy.working_set_version === 2) {
|
|
31
|
+
const history = await readNodeHistory(dir, node, manifest, "context");
|
|
32
|
+
if (!history.ok) throw new Error(history.error);
|
|
33
|
+
return boundRecoveryHandoff(full, history.nodes, policy.handoff_tokens, policy.guidance_tools);
|
|
34
|
+
}
|
|
35
|
+
return (policy.working_set_version === 1 ? boundWorkingHandoff : boundHandoff)(full, policy.handoff_tokens);
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
export async function replayContext(
|
|
26
39
|
dir: string, node: number, manifest?: Manifest,
|
|
27
40
|
): Promise<ReplayedContext> {
|
|
@@ -54,8 +67,7 @@ export async function replayContext(
|
|
|
54
67
|
self: { node: part.node, log_ref: nodePath(dir, part.node), sha256: await digestNode(dir, part.node) },
|
|
55
68
|
});
|
|
56
69
|
const tail = part.events.at(-1);
|
|
57
|
-
carried = tail?.t === "cut"
|
|
58
|
-
? boundHandoff(handoff, tail.handoff_tokens) : handoff;
|
|
70
|
+
carried = tail?.t === "cut" ? await carriedHandoff(dir, part.node, handoff, tail, index) : handoff;
|
|
59
71
|
retained = tail?.t === "cut" && tail.retained_from !== undefined
|
|
60
72
|
? retainedContext(contextEvents, tail.retained_from) : [];
|
|
61
73
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** R8: OS inspection is confined to the exact owned PID; never discover a target by its name or port. */
|
|
2
|
+
import { realpath, readlink, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, basename, resolve, relative, sep } from "node:path";
|
|
4
|
+
import { connect } from "node:net";
|
|
5
|
+
import { observeArgvCommand } from "./command.ts";
|
|
6
|
+
import { readObservation } from "./observations.ts";
|
|
7
|
+
import { observeArtifactFiles, archiveCommandArtifacts } from "./command-artifacts.ts";
|
|
8
|
+
import { lsofOwnsImage, lsofOwnsPort } from "../core/runtime-target.ts";
|
|
9
|
+
import type { ArtifactRef } from "../core/events.ts";
|
|
10
|
+
|
|
11
|
+
// Assumed probe limits. Output beyond the budget and unavailable OS fields remain unknown.
|
|
12
|
+
const PROBE_BYTES = 64 * 1024;
|
|
13
|
+
const PROBE_MS = 2_000;
|
|
14
|
+
export async function targetDirectory(root: string, path: string): Promise<string> {
|
|
15
|
+
const base = await realpath(root), cwd = await realpath(resolve(base, path)), rel = relative(base, cwd);
|
|
16
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || rel.startsWith(sep) || !(await stat(cwd)).isDirectory()) throw Error("target workdir escapes the workspace or is not a directory");
|
|
17
|
+
return cwd;
|
|
18
|
+
}
|
|
19
|
+
export async function targetProgram(path: string, signal?: AbortSignal) {
|
|
20
|
+
const absolute = await realpath(path);
|
|
21
|
+
const [file] = await observeArtifactFiles(dirname(absolute), [basename(absolute)], signal);
|
|
22
|
+
if (file?.state !== "observed") throw Error("target executable cannot be observed as a stable ordinary file");
|
|
23
|
+
return { path: absolute, sha256: file.sha256, device: file.identity.device, inode: file.identity.inode };
|
|
24
|
+
}
|
|
25
|
+
export async function observeRuntimeIdentity(pid: number, program: { path: string; device: string; inode: string }, port: number | undefined,
|
|
26
|
+
outputStore: string, signal?: AbortSignal): Promise<{ image: boolean; port: boolean | null; known: boolean; raw: ArtifactRef[]; reason: string }> {
|
|
27
|
+
const raw: ArtifactRef[] = [];
|
|
28
|
+
const lsof = process.platform === "darwin" ? "/usr/sbin/lsof" : "/usr/bin/lsof";
|
|
29
|
+
const probe = async (args: string[]) => {
|
|
30
|
+
const result = await observeArgvCommand({ argv: [lsof, ...args], cwd: "/", outputStore, maxOutputBytes: PROBE_BYTES,
|
|
31
|
+
timeoutMs: PROBE_MS, outputLimitBytes: PROBE_BYTES, ...(signal ? { signal } : {}) });
|
|
32
|
+
raw.push(result.stdout, result.stderr);
|
|
33
|
+
raw.push(await archiveCommandArtifacts(outputStore, { argv: [lsof, ...args], cwd: "/", exit: result.exit, capture: result.capture,
|
|
34
|
+
stdout: result.stdout, stderr: result.stderr, stopped: result.stopped ?? null, cleanup: result.cleanup ?? null }));
|
|
35
|
+
if (result.stopped || result.cleanup || result.capture !== "complete" || result.stdout.bytes > PROBE_BYTES || ![0, 1].includes(result.exit)) throw Error("OS target observation was incomplete");
|
|
36
|
+
return (await readObservation(outputStore, result.stdout.sha256, 0, PROBE_BYTES)).text;
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
let image: boolean;
|
|
40
|
+
if (process.platform === "darwin") image = lsofOwnsImage(await probe(["-a", "-p", String(pid), "-d", "txt", "-F0pftDin"]), pid, program.device, program.inode);
|
|
41
|
+
else if (process.platform === "linux") {
|
|
42
|
+
const path = `/proc/${pid}/exe`, observed = await stat(path, { bigint: true });
|
|
43
|
+
const link = await readlink(path), device = String(observed.dev), inode = String(observed.ino);
|
|
44
|
+
raw.push(await archiveCommandArtifacts(outputStore, { path, link, device, inode }));
|
|
45
|
+
image = link === program.path && device === program.device && inode === program.inode;
|
|
46
|
+
} else return { image: false, port: port === undefined ? null : false, known: false, raw, reason: "OS executable identity is unsupported on this platform" };
|
|
47
|
+
const ownsPort = port === undefined ? null : lsofOwnsPort(await probe(["-nP", "-a", "-p", String(pid), `-iTCP:${port}`, "-sTCP:LISTEN", "-F0pftPnT"]), pid, port);
|
|
48
|
+
const reachable = port === undefined ? null : ownsPort && await loopbackReady(port, signal);
|
|
49
|
+
return { image, port: reachable, known: true, raw, reason: !image ? "owned process executable does not match the launch observation" : reachable === false ? "the owned PID does not serve the declared IPv4 loopback port" : "owned executable and declared endpoint observed" };
|
|
50
|
+
} catch { return { image: false, port: port === undefined ? null : false, known: false, raw, reason: "OS target identity could not be established" }; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function loopbackReady(port: number, signal?: AbortSignal): Promise<boolean> {
|
|
54
|
+
if (signal?.aborted) return false;
|
|
55
|
+
return new Promise(resolve => {
|
|
56
|
+
const socket = connect({ host: "127.0.0.1", port });
|
|
57
|
+
const done = (ok: boolean) => { signal?.removeEventListener("abort", abort); socket.destroy(); resolve(ok); };
|
|
58
|
+
const abort = () => done(false);
|
|
59
|
+
socket.setTimeout(PROBE_MS, () => done(false));
|
|
60
|
+
socket.once("connect", () => done(true)); socket.once("error", () => done(false));
|
|
61
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
62
|
+
if (signal?.aborted) abort();
|
|
63
|
+
});
|
|
64
|
+
}
|