@visual-z/xeno 0.7.2 → 0.8.1
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 +60 -0
- package/README.md +36 -9
- package/docs/foundations.md +1 -1
- package/package.json +3 -1
- package/src/apps/cli.ts +4 -3
- package/src/apps/commands.ts +8 -0
- package/src/apps/goal-flow.ts +70 -0
- package/src/apps/render.ts +0 -2
- package/src/apps/session-hub.ts +6 -5
- package/src/apps/session-lifecycle.ts +64 -0
- package/src/apps/shell.ts +89 -34
- package/src/apps/startup.ts +32 -31
- package/src/apps/ui/app.ts +105 -13
- package/src/apps/ui/control-model.ts +18 -1
- package/src/apps/ui/surface.ts +2 -0
- package/src/apps/update-check.ts +1 -1
- package/src/core/acceptance.ts +41 -9
- package/src/core/blind-checks.ts +72 -0
- package/src/core/branch-receipt.ts +3 -1
- package/src/core/checkpoints.ts +21 -0
- package/src/core/churn.ts +2 -2
- package/src/core/completion-review.ts +18 -11
- package/src/core/continuation.ts +11 -10
- package/src/core/control-view.ts +5 -8
- package/src/core/deadline.ts +2 -1
- package/src/core/derive.ts +0 -7
- package/src/core/environment.ts +2 -2
- package/src/core/event-validation.ts +19 -6
- package/src/core/events.ts +41 -32
- package/src/core/evidence-ladder.ts +62 -0
- package/src/core/evidence-links.ts +0 -10
- package/src/core/evidence.ts +1 -2
- package/src/core/export.ts +6 -11
- package/src/core/goal-view.ts +64 -0
- package/src/core/goal.ts +200 -0
- package/src/core/history.ts +1 -11
- package/src/core/json-event.ts +0 -6
- package/src/core/outcome.ts +0 -1
- package/src/core/program-status.ts +11 -2
- package/src/core/progress.ts +14 -8
- package/src/core/project.ts +6 -16
- package/src/core/read-bounds.ts +11 -2
- package/src/core/read-observation.ts +24 -0
- package/src/core/request-budget.ts +13 -3
- package/src/core/request-state.ts +178 -19
- package/src/core/response-recovery.ts +15 -2
- package/src/core/session-entry.ts +8 -3
- package/src/core/session-exit.ts +19 -3
- package/src/core/session-format.ts +169 -0
- package/src/core/settings.ts +7 -27
- package/src/core/stall.ts +30 -7
- package/src/core/stop-report.ts +4 -3
- package/src/core/transcript.ts +22 -2
- package/src/runtime/blind-check.ts +36 -0
- package/src/runtime/counter-check.ts +74 -0
- package/src/runtime/exec-sessions.ts +11 -0
- package/src/runtime/foundation.ts +46 -8
- package/src/runtime/loop.ts +239 -430
- package/src/runtime/oauth.ts +1 -3
- package/src/runtime/openai.ts +2 -2
- package/src/runtime/pi-provider.ts +3 -3
- package/src/runtime/provider-request.ts +2 -1
- package/src/runtime/provider.ts +33 -3
- package/src/runtime/retry.ts +9 -2
- package/src/runtime/runtime-target.ts +2 -2
- package/src/runtime/session-control.ts +4 -4
- package/src/runtime/tools.ts +44 -42
- package/src/runtime/usage.ts +28 -0
- package/src/runtime/version.ts +12 -0
- package/src/runtime/workspace-lifecycle.ts +31 -0
- package/src/store/blind-checks.ts +32 -0
- package/src/store/checkpoint-history.ts +1 -12
- package/src/store/file-index.ts +88 -7
- package/src/store/log.ts +25 -27
- package/src/store/recovery.ts +1 -3
- package/src/store/replay.ts +2 -2
- package/src/store/restore.ts +10 -202
- package/src/store/search.ts +7 -5
- package/src/store/session-archive.ts +0 -1
- package/src/store/sessions.ts +31 -33
- package/src/store/workspace-clone.ts +42 -0
- package/src/store/workspace.ts +10 -157
- package/src/tui/ui/duration.ts +16 -0
- package/src/tui/ui/layout.ts +4 -4
- package/src/tui/ui/screen.ts +13 -7
- package/src/tui/ui/status.ts +99 -17
- package/src/core/benchmark.ts +0 -195
- package/src/core/evaluation-coverage.ts +0 -74
- package/src/core/experiment.ts +0 -173
- package/src/core/harness-comparison.ts +0 -90
- package/src/core/harness-diagnostics.ts +0 -206
- package/src/core/native-eval.ts +0 -25
- package/src/core/policy-evaluation.ts +0 -55
- package/src/core/query-eval-contract.ts +0 -14
- package/src/core/recall-analysis.ts +0 -90
- package/src/core/recall-trial.ts +0 -153
- package/src/core/recovery-corpus.ts +0 -17
- package/src/core/recovery-evaluation.ts +0 -60
- package/src/core/tool-mode-protocol.ts +0 -25
- package/src/core/trial-lifecycle.ts +0 -14
- package/src/core/window-trial.ts +0 -65
- package/src/index.ts +0 -10
- package/src/runtime/benchmark.ts +0 -187
- package/src/runtime/harness-comparison.ts +0 -376
- package/src/runtime/harness-eval.ts +0 -462
- package/src/runtime/native-eval-fixtures.ts +0 -33
- package/src/runtime/native-eval.ts +0 -146
- package/src/runtime/query-eval-verifier.ts +0 -28
- package/src/runtime/recall-trial.ts +0 -226
- package/src/runtime/recovery-coverage.ts +0 -71
- package/src/runtime/recovery-evidence.ts +0 -76
- package/src/runtime/recovery-fixtures.ts +0 -116
- package/src/runtime/tool-mode-eval.ts +0 -146
- package/src/runtime/trial-control.ts +0 -45
- package/src/runtime/window-trial.ts +0 -141
- package/src/store/git-branch.ts +0 -35
- package/src/tui/ui/testing.ts +0 -5
package/src/store/file-index.ts
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Candidate paths for @-file expansion.
|
|
2
|
+
* Candidate paths for @-file expansion and the walk search engine.
|
|
3
3
|
*
|
|
4
4
|
* A glob on every keystroke is a stall in a large repo (the card's rejected
|
|
5
5
|
* alternative). Scan once, cache for the session. The snapshot scanner is a
|
|
6
6
|
* different job -- it hashes contents and refuses to truncate -- so this walk
|
|
7
7
|
* is its own function rather than a mode flag on scanWorkspace.
|
|
8
|
+
*
|
|
9
|
+
* Inside a Git work tree the candidates are Git's own listing: tracked plus
|
|
10
|
+
* untracked files that are not ignored (S.6 item 4). Claude Code's Grep,
|
|
11
|
+
* OpenCode (`rg --files`) and Pi (`fd`) follow .gitignore the same way; a
|
|
12
|
+
* walk that ignores it let an ignored worktree's build output fill the whole
|
|
13
|
+
* budget before the product's directories were reached
|
|
14
|
+
* (research-file-index-truncation).
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
|
-
import { readdir } from "node:fs/promises";
|
|
17
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
11
18
|
import type { Dirent } from "node:fs";
|
|
12
|
-
import { join, relative, sep } from "node:path";
|
|
19
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
13
20
|
import { DEFAULT_IGNORE } from "./workspace.ts";
|
|
21
|
+
import { gitBytes } from "./workspace-observation.ts";
|
|
14
22
|
|
|
15
23
|
/**
|
|
16
|
-
* Cap on
|
|
17
|
-
* large
|
|
18
|
-
* one. Hitting it truncates and says so -- a
|
|
19
|
-
* files that exist.
|
|
24
|
+
* Cap on paths from an unfiltered directory walk, outside Git. Assumed, not
|
|
25
|
+
* measured: enough to fuzzy-search a large tree; past this the walk is hiding
|
|
26
|
+
* a missing filter, not serving one. Hitting it truncates and says so -- a
|
|
27
|
+
* silent cut would make @ miss files that exist. A Git listing is already the
|
|
28
|
+
* filtered candidate set and is not capped unless the caller asks.
|
|
20
29
|
*/
|
|
21
30
|
export const MAX_INDEX_ENTRIES = 20_000;
|
|
22
31
|
|
|
@@ -38,7 +47,79 @@ function isIgnored(rel: string, ignore: readonly string[]): boolean {
|
|
|
38
47
|
return ignore.some((i) => parts.includes(i));
|
|
39
48
|
}
|
|
40
49
|
|
|
50
|
+
/** Git judges its own ignore rules; an error or a path outside any work tree is not ignored. */
|
|
51
|
+
export async function gitIgnored(path: string, signal?: AbortSignal): Promise<boolean> {
|
|
52
|
+
try {
|
|
53
|
+
const info = await lstat(path);
|
|
54
|
+
await gitBytes(info.isDirectory() ? path : dirname(path), ["check-ignore", "-q", "--", path], undefined, signal);
|
|
55
|
+
return true;
|
|
56
|
+
} catch { return false; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Git's listing under `dir`, relative to it, or null when `dir` is not a work tree that Git lists. */
|
|
60
|
+
async function gitListing(dir: string, signal?: AbortSignal): Promise<string[] | null> {
|
|
61
|
+
try {
|
|
62
|
+
if (new TextDecoder().decode(await gitBytes(dir, ["rev-parse", "--is-inside-work-tree"], undefined, signal)).trim() !== "true") return null;
|
|
63
|
+
// Naming an ignored directory is an explicit request: walk it instead.
|
|
64
|
+
if (await gitIgnored(dir, signal)) return null;
|
|
65
|
+
const listed = new TextDecoder().decode(await gitBytes(dir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], undefined, signal));
|
|
66
|
+
// An untracked nested repository is listed once, as "name/".
|
|
67
|
+
return [...new Set(listed.split("\0").filter(Boolean).map(path => path.replace(/\/$/, "")))].sort();
|
|
68
|
+
} catch { return null; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Assumed: enough concurrent lstat calls to hide latency without exhausting descriptors. */
|
|
72
|
+
const LSTAT_BATCH = 256;
|
|
73
|
+
|
|
74
|
+
async function indexGitListing(root: string, listed: readonly string[], opts: IndexOptions): Promise<FileIndex> {
|
|
75
|
+
const ignore = opts.ignore ?? DEFAULT_IGNORE;
|
|
76
|
+
const max = opts.maxEntries ?? Infinity;
|
|
77
|
+
const paths: string[] = [], issues: { code: string; path?: string }[] = [];
|
|
78
|
+
// The index can name a path under a directory since replaced by a symlink;
|
|
79
|
+
// following it would read outside the tree, so every ancestor is checked once.
|
|
80
|
+
const directories = new Map<string, Promise<boolean>>([["", Promise.resolve(true)]]);
|
|
81
|
+
const plainDirectory = (rel: string): Promise<boolean> => {
|
|
82
|
+
let known = directories.get(rel);
|
|
83
|
+
if (known === undefined) {
|
|
84
|
+
const parent = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
|
|
85
|
+
known = plainDirectory(parent).then(async ok => ok && await lstat(join(root, rel)).then(info => info.isDirectory() && !info.isSymbolicLink(), () => false));
|
|
86
|
+
directories.set(rel, known);
|
|
87
|
+
}
|
|
88
|
+
return known;
|
|
89
|
+
};
|
|
90
|
+
for (let offset = 0; offset < listed.length; offset += LSTAT_BATCH) {
|
|
91
|
+
if (opts.signal?.aborted || opts.deadline !== undefined && performance.now() >= opts.deadline) {
|
|
92
|
+
issues.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); return { paths, truncated: true, issues };
|
|
93
|
+
}
|
|
94
|
+
const batch = listed.slice(offset, offset + LSTAT_BATCH).filter(rel => !isIgnored(rel.split("/").join(sep), ignore));
|
|
95
|
+
const kinds = await Promise.all(batch.map(async rel => {
|
|
96
|
+
const parent = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
|
|
97
|
+
if (!await plainDirectory(parent)) return "skip";
|
|
98
|
+
// A tracked path deleted from the work tree is not a candidate.
|
|
99
|
+
const info = await lstat(join(root, rel)).catch(() => null);
|
|
100
|
+
return info === null || info.isSymbolicLink() ? "skip" : info.isDirectory() ? "nested" : info.isFile() ? "file" : "skip";
|
|
101
|
+
}));
|
|
102
|
+
for (let i = 0; i < batch.length; i++) {
|
|
103
|
+
const rel = batch[i]!;
|
|
104
|
+
if (kinds[i] === "nested") {
|
|
105
|
+
// A submodule or nested repository: its own listing, or a walk if it has none.
|
|
106
|
+
const nested = await buildFileIndex(join(root, rel), { ...opts, ...(max === Infinity ? {} : { maxEntries: max - paths.length }) });
|
|
107
|
+
paths.push(...nested.paths.map(path => `${rel}/${path}`));
|
|
108
|
+
issues.push(...(nested.issues ?? []).map(issue => issue.path === undefined ? issue : { ...issue, path: `${rel}/${issue.path}` }));
|
|
109
|
+
if (nested.truncated) return { paths, truncated: true, issues };
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (kinds[i] !== "file") continue;
|
|
113
|
+
if (paths.length >= max) { issues.push({ code: "file_index_limit" }); return { paths, truncated: true, issues }; }
|
|
114
|
+
paths.push(rel);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { paths, truncated: false, ...(issues.length ? { issues } : {}) };
|
|
118
|
+
}
|
|
119
|
+
|
|
41
120
|
export async function buildFileIndex(root: string, opts: IndexOptions = {}): Promise<FileIndex> {
|
|
121
|
+
const listed = await gitListing(root, opts.signal);
|
|
122
|
+
if (listed !== null) return indexGitListing(root, listed, opts);
|
|
42
123
|
const ignore = opts.ignore ?? DEFAULT_IGNORE;
|
|
43
124
|
const max = opts.maxEntries ?? MAX_INDEX_ENTRIES;
|
|
44
125
|
const paths: string[] = [];
|
package/src/store/log.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* fine and, unlike a single rollout file, nothing grows without bound.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { mkdir, readdir, readFile, open } from "node:fs/promises";
|
|
29
|
+
import { access, mkdir, readdir, readFile, open } from "node:fs/promises";
|
|
30
30
|
import { createHash } from "node:crypto";
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import type { LogEvent, NodeRef, IsoTime, EventId } from "../core/events.ts";
|
|
@@ -37,6 +37,7 @@ import { emitHandoff } from "../core/handoff.ts";
|
|
|
37
37
|
import { atomicWrite, durableAppend } from "./atomic.ts";
|
|
38
38
|
import { serialized } from "./serial.ts";
|
|
39
39
|
import { stateAtPosition } from "../core/session-position.ts";
|
|
40
|
+
import { SESSION_FORMAT, SessionFormatError } from "../core/session-format.ts";
|
|
40
41
|
|
|
41
42
|
/** Allocation number. Not an ordering over the tree. */
|
|
42
43
|
export type NodeId = number;
|
|
@@ -502,17 +503,8 @@ export async function writeHandoff(
|
|
|
502
503
|
return text;
|
|
503
504
|
}
|
|
504
505
|
|
|
505
|
-
async function readLegacyManifest(dir: string): Promise<Manifest> {
|
|
506
|
-
try {
|
|
507
|
-
return JSON.parse(await readFile(manifestPath(dir), "utf8")) as Manifest;
|
|
508
|
-
} catch (err) {
|
|
509
|
-
if ((err as { code?: string }).code === "ENOENT") return { version: 1, nodes: [] };
|
|
510
|
-
throw err;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
506
|
type MetadataRecord =
|
|
515
|
-
| { kind: "
|
|
507
|
+
| { kind: "format"; version: number }
|
|
516
508
|
| { kind: "relocate"; workspace: string }
|
|
517
509
|
| { kind: "cursor"; cursor: Cursor }
|
|
518
510
|
| { kind: "parent_session"; path: string }
|
|
@@ -521,31 +513,40 @@ type MetadataRecord =
|
|
|
521
513
|
|
|
522
514
|
function metadataPath(dir: string): string { return join(dir, "metadata.jsonl"); }
|
|
523
515
|
|
|
516
|
+
/**
|
|
517
|
+
* S.3: the journal's first record declares the session format. A directory in
|
|
518
|
+
* any other format -- a journal opening with something else, or a manifest
|
|
519
|
+
* with no journal at all -- is refused before anything reads or writes it.
|
|
520
|
+
* Null means a directory no session has written yet.
|
|
521
|
+
*/
|
|
524
522
|
async function readMetadata(dir: string): Promise<MetadataRecord[] | null> {
|
|
525
523
|
let text: string;
|
|
526
524
|
try { text = await readFile(metadataPath(dir), "utf8"); }
|
|
527
525
|
catch (error) {
|
|
528
|
-
if ((error as { code?: string }).code
|
|
529
|
-
throw
|
|
526
|
+
if ((error as { code?: string }).code !== "ENOENT") throw error;
|
|
527
|
+
if (await access(manifestPath(dir)).then(() => true, () => false)) throw new SessionFormatError(dir, "undeclared");
|
|
528
|
+
return null;
|
|
530
529
|
}
|
|
531
530
|
if (text.length > 0 && !text.endsWith("\n")) throw new Error("INTEGRITY: incomplete metadata journal tail; no cursor is guessed");
|
|
532
|
-
|
|
531
|
+
const records = text.split("\n").filter(Boolean).map((line) => {
|
|
533
532
|
const envelope = JSON.parse(line) as { record: MetadataRecord; sha256: string };
|
|
534
533
|
const digest = createHash("sha256").update(JSON.stringify(envelope.record)).digest("hex");
|
|
535
534
|
if (envelope.sha256 !== digest) throw new Error("INTEGRITY: metadata journal digest mismatch");
|
|
536
535
|
return envelope.record;
|
|
537
536
|
});
|
|
537
|
+
const first = records[0];
|
|
538
|
+
if (first?.kind !== "format") throw new SessionFormatError(dir, "undeclared");
|
|
539
|
+
if (first.version !== SESSION_FORMAT) throw new SessionFormatError(dir, first.version);
|
|
540
|
+
return records;
|
|
538
541
|
}
|
|
539
542
|
|
|
540
543
|
/** The metadata journal already owns configuration history; no second state log is needed. */
|
|
541
544
|
export async function readCursorAt(dir: string, node: number, atEvent?: string): Promise<Cursor | null> {
|
|
542
545
|
const records = await readMetadata(dir);
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
let relocated = legacy?.relocated === true;
|
|
546
|
+
const states: Cursor[] = [];
|
|
547
|
+
let relocated = false;
|
|
546
548
|
for (const record of records ?? []) {
|
|
547
549
|
if (record.kind === "cursor") states.push({ ...record.cursor });
|
|
548
|
-
else if (record.kind === "seed") { if (record.manifest.cursor) states.push({ ...record.manifest.cursor }); relocated ||= record.manifest.relocated === true; }
|
|
549
550
|
else if (record.kind === "relocate") { relocated = true; for (const state of states) state.workspace = record.workspace; }
|
|
550
551
|
}
|
|
551
552
|
if (relocated) for (const state of states) state.blob_dir = join(dir, "blobs");
|
|
@@ -560,11 +561,10 @@ export async function readManifest(dir: string): Promise<Manifest> {
|
|
|
560
561
|
|
|
561
562
|
async function readManifestUnlocked(dir: string): Promise<Manifest> {
|
|
562
563
|
const records = await readMetadata(dir);
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
for (const event of records) {
|
|
564
|
+
const manifest: Manifest = { version: 1, nodes: [] };
|
|
565
|
+
for (const event of records ?? []) {
|
|
566
566
|
switch (event.kind) {
|
|
567
|
-
case "
|
|
567
|
+
case "format": break;
|
|
568
568
|
case "cursor": manifest.cursor = event.cursor; break;
|
|
569
569
|
case "relocate":
|
|
570
570
|
manifest.relocated = true;
|
|
@@ -603,11 +603,9 @@ async function appendMetadata(dir: string, event: MetadataRecord): Promise<void>
|
|
|
603
603
|
const encode = (record: MetadataRecord) => JSON.stringify({
|
|
604
604
|
record, sha256: createHash("sha256").update(JSON.stringify(record)).digest("hex"),
|
|
605
605
|
}) + "\n";
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
await durableAppend(metadataPath(dir), encode(seed));
|
|
610
|
-
}
|
|
606
|
+
// The first write declares the format; readMetadata has already refused a
|
|
607
|
+
// directory in any other format, so nothing here appends to an old log.
|
|
608
|
+
if (await readMetadata(dir) === null) await durableAppend(metadataPath(dir), encode({ kind: "format", version: SESSION_FORMAT }));
|
|
611
609
|
await durableAppend(metadataPath(dir), encode(event));
|
|
612
610
|
}
|
|
613
611
|
|
package/src/store/recovery.ts
CHANGED
|
@@ -33,10 +33,8 @@ export async function recoverCut(dir: string, cursor: Cursor): Promise<Cursor> {
|
|
|
33
33
|
next_node: Math.max(cursor.next_node, cut.next_node + 1, ...nodes.map((n) => n + 1)),
|
|
34
34
|
model: cut.next_model ?? cursor.model, updated_at: cut.at,
|
|
35
35
|
};
|
|
36
|
-
//
|
|
37
|
-
// provider onto a different legacy model and manufacture an identity.
|
|
36
|
+
// Cuts of this session format always record the successor's provider (S.3).
|
|
38
37
|
if (cut.next_provider !== undefined) next.provider = cut.next_provider;
|
|
39
|
-
else if (next.model !== cursor.model) delete next.provider;
|
|
40
38
|
if (cut.next_thinking !== undefined) next.thinking = cut.next_thinking;
|
|
41
39
|
if (cut.next_context_ref !== undefined) next.context_ref = cut.next_context_ref;
|
|
42
40
|
delete next.at_event;
|
package/src/store/replay.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { deriveHandoff } from "../core/derive.ts";
|
|
8
8
|
import type { Handoff } from "../core/handoff.ts";
|
|
9
9
|
import type { CutEvent, LogEvent } from "../core/events.ts";
|
|
10
|
-
import { boundHandoff,
|
|
10
|
+
import { boundHandoff, 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
13
|
import { readNodeHistory } from "./restore.ts";
|
|
@@ -32,7 +32,7 @@ export async function carriedHandoff(dir: string, node: number, full: Handoff,
|
|
|
32
32
|
if (!history.ok) throw new Error(history.error);
|
|
33
33
|
return boundRecoveryHandoff(full, history.nodes, policy.handoff_tokens, policy.guidance_tools);
|
|
34
34
|
}
|
|
35
|
-
return
|
|
35
|
+
return boundHandoff(full, policy.handoff_tokens);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export async function replayContext(
|
package/src/store/restore.ts
CHANGED
|
@@ -1,46 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* This is where appendix H's argument becomes executable: reverting is not
|
|
5
|
-
* overwriting the present with the past, it is moving a cursor to another
|
|
6
|
-
* node. The nodes are untouched, so the cursor can move again -- which is
|
|
7
|
-
* why a restore here cannot produce the failure that made Zed's checkpoint
|
|
8
|
-
* restore an unanswered S1, where reverting destroyed both the files and the
|
|
9
|
-
* thread with no way back.
|
|
2
|
+
* Walking a node's ancestry for history: every ancestor's events, bounded by
|
|
3
|
+
* the fork point below each one.
|
|
10
4
|
*
|
|
11
5
|
* WHY THIS DOES NOT REUSE readAncestry. That walk stops at a cut, because a
|
|
12
6
|
* handoff basis means the parent's events do not replay -- correct for
|
|
13
|
-
* context,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* file written before it. File history therefore always walks to the root,
|
|
17
|
-
* whatever each node declared about context.
|
|
7
|
+
* context, wrong for files and turns. File history (checkpoint-history.ts)
|
|
8
|
+
* therefore walks to the root whatever each node declared about context; the
|
|
9
|
+
* "context" scope stops at an explicit new root instead.
|
|
18
10
|
*/
|
|
19
11
|
|
|
20
12
|
import { readNode, readManifest, digestNode, MAX_ANCESTRY_DEPTH, type Manifest } from "./log.ts";
|
|
21
13
|
import type { LogEvent } from "../core/events.ts";
|
|
22
|
-
import {
|
|
23
|
-
planRestore, applyRestore, scanWorkspace, DEFAULT_IGNORE,
|
|
24
|
-
type ScanDelta, type FileSet, type RestoreResult,
|
|
25
|
-
} from "./workspace.ts";
|
|
26
14
|
|
|
27
15
|
/**
|
|
28
|
-
* Where to move the workspace to.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* it, which is what makes an ordinary undo possible: a node accumulates many
|
|
32
|
-
* turns, so without it the earliest reachable state is the end of the node
|
|
33
|
-
* and nothing in the node being worked in can be taken back.
|
|
16
|
+
* Where to move the workspace to. A bare node means the end of that node;
|
|
17
|
+
* `atEvent` names a point inside it, which is what makes an ordinary undo
|
|
18
|
+
* possible within the node being worked in.
|
|
34
19
|
*/
|
|
35
20
|
export type RestoreTarget = number | { node: number; atEvent?: string };
|
|
36
21
|
|
|
37
|
-
/** One snapshot, kept separate from its neighbours so a turn can be addressed. */
|
|
38
|
-
export type FileDelta = ScanDelta & { node: number; event: string };
|
|
39
|
-
|
|
40
|
-
export type FileHistoryResult =
|
|
41
|
-
| { ok: true; deltas: FileDelta[] }
|
|
42
|
-
| { ok: false; error: string };
|
|
43
|
-
|
|
44
22
|
/** One ancestor's contribution: its events, bounded by any fork point below it. */
|
|
45
23
|
export interface NodeEvents {
|
|
46
24
|
node: number;
|
|
@@ -51,28 +29,6 @@ export type NodeHistoryResult =
|
|
|
51
29
|
| { ok: true; nodes: NodeEvents[] }
|
|
52
30
|
| { ok: false; error: string };
|
|
53
31
|
|
|
54
|
-
export type PlanResult =
|
|
55
|
-
| { ok: true; files: FileSet; willWrite: string[]; willDelete: string[] }
|
|
56
|
-
| { ok: false; error: string };
|
|
57
|
-
|
|
58
|
-
export type RestoreToNodeResult =
|
|
59
|
-
| ({ ok: true } & RestoreResult)
|
|
60
|
-
| { ok: false; error: string };
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Every snapshot delta from the root to `node`, in replay order.
|
|
64
|
-
*
|
|
65
|
-
* Two fields on the way up, treated oppositely, because they answer different
|
|
66
|
-
* questions:
|
|
67
|
-
*
|
|
68
|
-
* `basis` says what the MODEL should see, and is ignored here. A cut
|
|
69
|
-
* truncates the context walk; the files it wrote are still on disk.
|
|
70
|
-
*
|
|
71
|
-
* `at_event` says where this node's history BEGINS in its parent, which is
|
|
72
|
-
* a question about history, so it is obeyed. Ignoring it makes a rewind
|
|
73
|
-
* restore the very file it was asked to undo -- the parent's later turns
|
|
74
|
-
* would flow into a node that forked before them.
|
|
75
|
-
*/
|
|
76
32
|
/**
|
|
77
33
|
* Every ancestor's events, root-to-leaf, bounded by the fork points below.
|
|
78
34
|
*
|
|
@@ -125,9 +81,8 @@ export async function readNodeHistory(
|
|
|
125
81
|
};
|
|
126
82
|
}
|
|
127
83
|
|
|
128
|
-
// The fork point is an event id in the node's whole stream,
|
|
129
|
-
//
|
|
130
|
-
// counting snapshots would land on the wrong turn.
|
|
84
|
+
// The fork point is an event id in the node's whole stream, never a
|
|
85
|
+
// count of checkpoints: a turn that changed nothing writes none.
|
|
131
86
|
let contributed = events;
|
|
132
87
|
if (cutoff !== undefined) {
|
|
133
88
|
const at = events.findIndex((e) => e.id === cutoff);
|
|
@@ -153,150 +108,3 @@ export async function readNodeHistory(
|
|
|
153
108
|
chain.reverse();
|
|
154
109
|
return { ok: true, nodes: chain };
|
|
155
110
|
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Every snapshot delta from the root to `node`, in replay order.
|
|
159
|
-
*
|
|
160
|
-
* Two fields on the way up, treated oppositely, because they answer different
|
|
161
|
-
* questions:
|
|
162
|
-
*
|
|
163
|
-
* `basis` says what the MODEL should see, and is ignored here. A cut
|
|
164
|
-
* truncates the context walk; the files it wrote are still on disk.
|
|
165
|
-
*
|
|
166
|
-
* `at_event` says where this node's history BEGINS in its parent, which is
|
|
167
|
-
* a question about history, so it is obeyed. Ignoring it makes a rewind
|
|
168
|
-
* restore the very file it was asked to undo -- the parent's later turns
|
|
169
|
-
* would flow into a node that forked before them.
|
|
170
|
-
*
|
|
171
|
-
* Kept one entry per snapshot rather than merged per node: merging is lossy
|
|
172
|
-
* in exactly the direction that matters, since the turns inside a node are
|
|
173
|
-
* the ones a person is most likely to want back.
|
|
174
|
-
*/
|
|
175
|
-
export async function readFileHistory(
|
|
176
|
-
dir: string,
|
|
177
|
-
node: number,
|
|
178
|
-
manifest?: Manifest,
|
|
179
|
-
): Promise<FileHistoryResult> {
|
|
180
|
-
const history = await readNodeHistory(dir, node, manifest);
|
|
181
|
-
if (!history.ok) return { ok: false, error: history.error };
|
|
182
|
-
|
|
183
|
-
const deltas: FileDelta[] = [];
|
|
184
|
-
for (const n of history.nodes) {
|
|
185
|
-
for (const e of n.events) {
|
|
186
|
-
if (e.t !== "snapshot") continue;
|
|
187
|
-
deltas.push({
|
|
188
|
-
node: n.node, event: e.id,
|
|
189
|
-
added: { ...e.added }, changed: { ...e.changed }, removed: [...e.removed],
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return { ok: true, deltas };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* What a restore would do, without doing it.
|
|
198
|
-
*
|
|
199
|
-
* Separate from the restore because applyRestore deletes whatever is not in
|
|
200
|
-
* the target -- necessary, or the workspace would end up a mixture of two
|
|
201
|
-
* states, but destructive enough that a caller has to be able to look first.
|
|
202
|
-
*/
|
|
203
|
-
export async function planRestoreToNode(
|
|
204
|
-
dir: string,
|
|
205
|
-
target: RestoreTarget,
|
|
206
|
-
manifest?: Manifest,
|
|
207
|
-
root?: string,
|
|
208
|
-
ignore: readonly string[] = DEFAULT_IGNORE,
|
|
209
|
-
): Promise<PlanResult> {
|
|
210
|
-
const { node, atEvent } = typeof target === "number" ? { node: target, atEvent: undefined } : target;
|
|
211
|
-
const history = await readNodeHistory(dir, node, manifest);
|
|
212
|
-
if (!history.ok) return { ok: false, error: history.error };
|
|
213
|
-
|
|
214
|
-
const deltas: FileDelta[] = [];
|
|
215
|
-
for (const n of history.nodes) {
|
|
216
|
-
// The cutoff is an id in the EVENT stream, not in the snapshot stream: a
|
|
217
|
-
// fork point is where a conversation was whole, which is the end of an
|
|
218
|
-
// exchange and usually not a snapshot at all. Cutting the events first
|
|
219
|
-
// and collecting snapshots after is what keeps the files and the
|
|
220
|
-
// conversation landing on the same point.
|
|
221
|
-
let events = n.events;
|
|
222
|
-
if (atEvent !== undefined && n.node === node) {
|
|
223
|
-
const at = events.findIndex((e) => e.id === atEvent);
|
|
224
|
-
if (at === -1) {
|
|
225
|
-
return { ok: false, error: `NO_SUCH_EVENT: node ${node} has no event ${atEvent}` };
|
|
226
|
-
}
|
|
227
|
-
events = events.slice(0, at + 1);
|
|
228
|
-
}
|
|
229
|
-
for (const e of events) {
|
|
230
|
-
if (e.t !== "snapshot") continue;
|
|
231
|
-
deltas.push({
|
|
232
|
-
node: n.node, event: e.id,
|
|
233
|
-
added: { ...e.added }, changed: { ...e.changed }, removed: [...e.removed],
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const files = planRestore(deltas, node);
|
|
239
|
-
const willWrite = Object.keys(files).sort();
|
|
240
|
-
if (!root) return { ok: true, files, willWrite, willDelete: [] };
|
|
241
|
-
|
|
242
|
-
const current = await scanWorkspace(root, ignore);
|
|
243
|
-
if (!current.ok) return { ok: false, error: current.error };
|
|
244
|
-
const willDelete = Object.keys(current.files).filter((p) => !(p in files)).sort();
|
|
245
|
-
return { ok: true, files, willWrite, willDelete };
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Moves the workspace to the state it had at `node`.
|
|
250
|
-
*
|
|
251
|
-
* Writes nothing to the log. The log is the truth and the workspace is a
|
|
252
|
-
* projection of it, so there is nothing to record: the nodes did not change,
|
|
253
|
-
* only which one is being projected. That is also what keeps this safe
|
|
254
|
-
* despite being irreversible in itself -- the previous cursor position is
|
|
255
|
-
* still a node, so the move can be made again in the other direction.
|
|
256
|
-
*/
|
|
257
|
-
export async function restoreToNode(
|
|
258
|
-
dir: string,
|
|
259
|
-
blobDir: string,
|
|
260
|
-
root: string,
|
|
261
|
-
target: RestoreTarget,
|
|
262
|
-
manifest?: Manifest,
|
|
263
|
-
ignore: readonly string[] = DEFAULT_IGNORE,
|
|
264
|
-
): Promise<RestoreToNodeResult> {
|
|
265
|
-
const plan = await planRestoreToNode(dir, target, manifest ?? (await readManifest(dir)), root, ignore);
|
|
266
|
-
if (!plan.ok) return { ok: false, error: plan.error };
|
|
267
|
-
return { ok: true, ...(await applyRestorePlan(root, blobDir, plan.files, ignore, describeTarget(target))) };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Applies a plan that has already been computed.
|
|
272
|
-
*
|
|
273
|
-
* Split out so a caller holding a plan does not have to compute it twice.
|
|
274
|
-
* That is not only wasted work: a caller that re-planned inside the apply
|
|
275
|
-
* could never see the apply fail on its own, which turns its own "did the
|
|
276
|
-
* files move?" guard into unreachable code.
|
|
277
|
-
*/
|
|
278
|
-
export async function applyRestorePlan(
|
|
279
|
-
root: string,
|
|
280
|
-
blobDir: string,
|
|
281
|
-
files: FileSet,
|
|
282
|
-
ignore: readonly string[],
|
|
283
|
-
label: string,
|
|
284
|
-
): Promise<RestoreResult> {
|
|
285
|
-
const result = await applyRestore(root, blobDir, files, ignore);
|
|
286
|
-
if (result.unrecoverable.length > 0) return result;
|
|
287
|
-
return {
|
|
288
|
-
...result,
|
|
289
|
-
note:
|
|
290
|
-
`Workspace moved to ${label}. ` +
|
|
291
|
-
result.note +
|
|
292
|
-
" Other logged snapshots remain available. File versions never " +
|
|
293
|
-
"snapshotted cannot be reconstructed from the log.",
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function describeTarget(target: RestoreTarget): string {
|
|
298
|
-
if (typeof target === "number") return `node ${target}`;
|
|
299
|
-
return target.atEvent === undefined
|
|
300
|
-
? `node ${target.node}`
|
|
301
|
-
: `node ${target.node} as of snapshot ${target.atEvent}`;
|
|
302
|
-
}
|
package/src/store/search.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Workspace search: grep, find, ls.
|
|
3
3
|
*
|
|
4
|
-
* The walk reuses buildFileIndex so ignore rules
|
|
5
|
-
*
|
|
4
|
+
* The walk reuses buildFileIndex so ignore rules (.gitignore inside a Git
|
|
5
|
+
* work tree, S.6 item 4), the walk cap, and the no-symlink rule stay one
|
|
6
|
+
* implementation. Ripgrep / fd follow the same .gitignore rule, and search an
|
|
7
|
+
* ignored target named explicitly, like the walk. They are used only when
|
|
6
8
|
* they are already on PATH -- never downloaded -- because fetching a binary
|
|
7
9
|
* at runtime is a supply-chain path the Isolation section of README.md
|
|
8
10
|
* told the user they would not be taking.
|
|
@@ -16,7 +18,7 @@ import { lstat, readdir, readFile } from "node:fs/promises";
|
|
|
16
18
|
import { isAbsolute, join, relative, sep } from "node:path";
|
|
17
19
|
import { Worker } from "node:worker_threads";
|
|
18
20
|
import { assertNever } from "../core/exhaustive.ts";
|
|
19
|
-
import { buildFileIndex } from "./file-index.ts";
|
|
21
|
+
import { buildFileIndex, gitIgnored } from "./file-index.ts";
|
|
20
22
|
import { DEFAULT_IGNORE } from "./workspace.ts";
|
|
21
23
|
import { blobAddress } from "./blobs.ts";
|
|
22
24
|
import { previewSearchObservation, type SearchObservation } from "../core/search-observation.ts";
|
|
@@ -464,7 +466,7 @@ async function grepRipgrep(opts: GrepOpts, abs: string, limit: number): Promise<
|
|
|
464
466
|
"--color=never",
|
|
465
467
|
"--hidden",
|
|
466
468
|
"--no-follow",
|
|
467
|
-
"--no-ignore",
|
|
469
|
+
...(await gitIgnored(abs, opts.signal) ? ["--no-ignore"] : []),
|
|
468
470
|
"--max-filesize",
|
|
469
471
|
String(MAX_FILE_BYTES),
|
|
470
472
|
...rgIgnoreArgs(),
|
|
@@ -606,7 +608,7 @@ async function findFd(opts: FindOpts, abs: string, limit: number): Promise<Searc
|
|
|
606
608
|
"--glob",
|
|
607
609
|
"--color=never",
|
|
608
610
|
"--hidden",
|
|
609
|
-
"--no-ignore",
|
|
611
|
+
...(await gitIgnored(abs, opts.signal) ? ["--no-ignore"] : []),
|
|
610
612
|
"--absolute-path",
|
|
611
613
|
"--print0",
|
|
612
614
|
...fdExcludeArgs(),
|
|
@@ -39,7 +39,6 @@ async function referencedBlobs(dir: string, fallback?: string): Promise<Map<stri
|
|
|
39
39
|
const roots = [...new Set([historical?.blob_dir, cursor?.blob_dir, fallback, join(dir, "blobs")].filter((p): p is string => !!p))];
|
|
40
40
|
const add = (sha: string) => { refs.set(sha, [...new Set([...(refs.get(sha) ?? []), ...roots])]); };
|
|
41
41
|
for (const event of read.events) {
|
|
42
|
-
if (event.t === "snapshot") for (const sha of [...Object.values(event.added), ...Object.values(event.changed)]) add(sha);
|
|
43
42
|
if (event.t === "checkpoint") for (const change of Object.values(event.changes)) {
|
|
44
43
|
if (change.before) add(change.before.sha256);
|
|
45
44
|
if (change.after) add(change.after.sha256);
|