@visual-z/xeno 0.7.2 → 0.8.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 +43 -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 +38 -6
- 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.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 +2 -1
- 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/provider-request.ts +2 -1
- 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/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/status.ts +6 -2
- 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/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);
|
package/src/store/sessions.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* and silently truncate a log.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import { cp, lstat, mkdir, mkdtemp, open, readdir, rename, rm, stat } from "node:fs/promises";
|
|
25
|
+
import { cp, lstat, mkdir, mkdtemp, open, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
26
26
|
import type { Dirent } from "node:fs";
|
|
27
27
|
import { tmpdir } from "node:os";
|
|
28
28
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -112,10 +112,6 @@ export function sessionsDir(workspace: string, override?: string): string {
|
|
|
112
112
|
return join(workspace, ".xeno", "sessions");
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
export function legacyLogDir(workspace: string): string {
|
|
116
|
-
return join(workspace, ".xeno", "log");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
115
|
/**
|
|
120
116
|
* `<base36 ms>-<4 digit base36 random>`.
|
|
121
117
|
*
|
|
@@ -141,6 +137,8 @@ export async function createSession(
|
|
|
141
137
|
): Promise<{ id: string; path: string }> {
|
|
142
138
|
const dir = sessionsDir(workspace, sessionDir);
|
|
143
139
|
await mkdir(dir, { recursive: true });
|
|
140
|
+
// The first session makes the state directory exist; keep it out of git from that moment.
|
|
141
|
+
await ignoreStateDir(dirname(dir));
|
|
144
142
|
for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt++) {
|
|
145
143
|
const id = formatSessionId(clock.nowMs(), clock.random());
|
|
146
144
|
const path = join(dir, id);
|
|
@@ -193,21 +191,6 @@ export async function listSessions(workspace: string, sessionDir?: string, limit
|
|
|
193
191
|
return { sessions, truncated };
|
|
194
192
|
}
|
|
195
193
|
|
|
196
|
-
/**
|
|
197
|
-
* A leftover `.xeno/log` is not migrated. Moving someone else's data cannot
|
|
198
|
-
* be a startup side effect; the person keeps using it by pointing `--log`
|
|
199
|
-
* at it. Once `.xeno/sessions/` exists they are already in the new layout
|
|
200
|
-
* and the leftover is just leftover, so the notice stays quiet.
|
|
201
|
-
*/
|
|
202
|
-
export async function legacyLogNotice(workspace: string): Promise<string | null> {
|
|
203
|
-
if (!(await isDirectory(legacyLogDir(workspace)))) return null;
|
|
204
|
-
if (await isDirectory(sessionsDir(workspace))) return null;
|
|
205
|
-
return (
|
|
206
|
-
"This workspace still has a log at .xeno/log from before named sessions existed. " +
|
|
207
|
-
"It has not been moved. Continue it with --log .xeno/log"
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
194
|
export type SessionPrefixMatch =
|
|
212
195
|
| { ok: true; session: Extract<ListedSession, { ok: true }> }
|
|
213
196
|
| { ok: false; reason: string; candidates: string[] };
|
|
@@ -352,46 +335,44 @@ export interface ResolveLogOpts extends SessionEntryFlags {
|
|
|
352
335
|
export async function resolveLogDir(
|
|
353
336
|
workspace: string,
|
|
354
337
|
opts: ResolveLogOpts = {},
|
|
355
|
-
): Promise<{ logDir: string; created: boolean
|
|
338
|
+
): Promise<{ logDir: string; created: boolean }> {
|
|
356
339
|
const entry = planSessionEntry(opts);
|
|
357
|
-
// Computed first: createSession will mkdir `.xeno/sessions/`, after which
|
|
358
|
-
// the leftover-log condition is no longer true.
|
|
359
|
-
const legacyNotice = await legacyLogNotice(workspace);
|
|
360
340
|
switch (entry.kind) {
|
|
361
341
|
case "named": {
|
|
362
342
|
const root = sessionsDir(workspace, opts.sessionDir), logDir = join(root, entry.id);
|
|
363
343
|
await mkdir(root, { recursive: true });
|
|
364
|
-
|
|
344
|
+
await ignoreStateDir(dirname(root));
|
|
345
|
+
try { await mkdir(logDir); return { logDir, created: true }; }
|
|
365
346
|
catch (error) {
|
|
366
347
|
if ((error as { code?: string }).code !== "EEXIST" || !(await lstat(logDir)).isDirectory()) throw error;
|
|
367
|
-
return { logDir, created: false
|
|
348
|
+
return { logDir, created: false };
|
|
368
349
|
}
|
|
369
350
|
}
|
|
370
351
|
case "ephemeral": {
|
|
371
352
|
const logDir = await mkdtemp(join(tmpdir(), "xeno-nosession-"));
|
|
372
|
-
return { logDir, created: true
|
|
353
|
+
return { logDir, created: true };
|
|
373
354
|
}
|
|
374
355
|
case "open": {
|
|
375
|
-
if (entry.explicit) return { logDir: entry.target, created: false
|
|
356
|
+
if (entry.explicit) return { logDir: entry.target, created: false };
|
|
376
357
|
const archive = resolve(workspace, entry.target);
|
|
377
358
|
if (await stat(archive).then(info => info.isFile(), () => false)) {
|
|
378
|
-
return { logDir: await importSessionArchive(archive, sessionsDir(workspace, opts.sessionDir), workspace), created: true
|
|
359
|
+
return { logDir: await importSessionArchive(archive, sessionsDir(workspace, opts.sessionDir), workspace), created: true };
|
|
379
360
|
}
|
|
380
361
|
const found = await findSessionByPrefix(workspace, entry.target, opts.sessionDir);
|
|
381
362
|
if (!found.ok) throw new Error(found.reason);
|
|
382
|
-
return { logDir: found.session.path, created: false
|
|
363
|
+
return { logDir: found.session.path, created: false };
|
|
383
364
|
}
|
|
384
365
|
case "fork": {
|
|
385
366
|
const found = await findSessionByPrefix(workspace, entry.target, opts.sessionDir);
|
|
386
367
|
if (!found.ok) throw new Error(found.reason);
|
|
387
368
|
const created = await createSession(workspace, systemIdClock, opts.sessionDir);
|
|
388
369
|
await copyAncestryTo(found.session.path, created.path, new Date().toISOString());
|
|
389
|
-
return { logDir: created.path, created: true
|
|
370
|
+
return { logDir: created.path, created: true };
|
|
390
371
|
}
|
|
391
372
|
case "continue": {
|
|
392
373
|
const { sessions } = await listSessions(workspace, opts.sessionDir, Infinity);
|
|
393
374
|
const newest = sessions.find((s) => s.ok);
|
|
394
|
-
if (newest !== undefined) return { logDir: newest.path, created: false
|
|
375
|
+
if (newest !== undefined) return { logDir: newest.path, created: false };
|
|
395
376
|
break;
|
|
396
377
|
}
|
|
397
378
|
case "select":
|
|
@@ -400,7 +381,7 @@ export async function resolveLogDir(
|
|
|
400
381
|
default: return assertNever(entry, "resolveLogDir");
|
|
401
382
|
}
|
|
402
383
|
const created = await createSession(workspace, systemIdClock, opts.sessionDir);
|
|
403
|
-
return { logDir: created.path, created: true
|
|
384
|
+
return { logDir: created.path, created: true };
|
|
404
385
|
}
|
|
405
386
|
|
|
406
387
|
async function existingDir(workspace: string, prefix: string): Promise<string | null> {
|
|
@@ -482,3 +463,20 @@ async function isDirectory(path: string): Promise<boolean> {
|
|
|
482
463
|
throw err;
|
|
483
464
|
}
|
|
484
465
|
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Keeps a state directory out of `git status` the way pytest, ruff and mypy
|
|
469
|
+
* keep their caches out: a `.gitignore` holding `*` inside the directory
|
|
470
|
+
* itself. Checks people and models write ("only plan.md changed") read git,
|
|
471
|
+
* and a visible session directory made such a check fail forever
|
|
472
|
+
* (fix-state-dir-self-ignore). The user's own ignore files are never edited,
|
|
473
|
+
* and .git/info/exclude is avoided: worktrees do not read it, and a sandboxed
|
|
474
|
+
* agent could use it to hide files. An existing file is left alone; failure
|
|
475
|
+
* to write one never blocks a session.
|
|
476
|
+
*/
|
|
477
|
+
export async function ignoreStateDir(stateRoot: string): Promise<void> {
|
|
478
|
+
try {
|
|
479
|
+
await mkdir(stateRoot, { recursive: true });
|
|
480
|
+
await writeFile(join(stateRoot, ".gitignore"), "# Created by xeno: session state is not project source.\n*\n", { flag: "wx" });
|
|
481
|
+
} catch { /* present already, or not writable: either way nothing to do */ }
|
|
482
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A disposable copy of a workspace, for running a check somewhere it cannot
|
|
3
|
+
* touch the real files (feat-counter-check).
|
|
4
|
+
*
|
|
5
|
+
* Copy-on-write where the filesystem offers it -- APFS clonefile through
|
|
6
|
+
* `cp -c`, reflinks through `cp --reflink=auto` -- so a workspace with large
|
|
7
|
+
* dependency trees costs metadata, not bytes. Where it does not, cp falls back
|
|
8
|
+
* to a real copy, bounded by the deadline below.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Assumed, not measured: a clone is metadata-only on APFS and reflink
|
|
16
|
+
* filesystems; a full copy that takes longer than this is better reported as
|
|
17
|
+
* unavailable than waited on.
|
|
18
|
+
*/
|
|
19
|
+
export const CLONE_TIMEOUT_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
export type WorkspaceClone = { ok: true; path: string; dispose: () => Promise<void> } | { ok: false; reason: string };
|
|
22
|
+
|
|
23
|
+
export async function cloneWorkspace(root: string, signal?: AbortSignal): Promise<WorkspaceClone> {
|
|
24
|
+
const parent = await mkdtemp(join(tmpdir(), "xeno-counter-check-")), path = join(parent, "workspace");
|
|
25
|
+
const dispose = () => rm(parent, { recursive: true, force: true });
|
|
26
|
+
const attempts = process.platform === "darwin" ? [["cp", "-cR", root, path], ["cp", "-R", root, path]] : [["cp", "-a", "--reflink=auto", root, path]];
|
|
27
|
+
let reason = "the workspace could not be copied";
|
|
28
|
+
for (const argv of attempts) {
|
|
29
|
+
if (signal?.aborted) { await dispose(); return { ok: false, reason: "cancelled" }; }
|
|
30
|
+
await rm(path, { recursive: true, force: true });
|
|
31
|
+
const child = Bun.spawn(argv, { stdout: "ignore", stderr: "pipe" });
|
|
32
|
+
const timer = setTimeout(() => child.kill(), CLONE_TIMEOUT_MS);
|
|
33
|
+
const abort = () => child.kill();
|
|
34
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
35
|
+
const [exit, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
36
|
+
clearTimeout(timer); signal?.removeEventListener("abort", abort);
|
|
37
|
+
if (exit === 0) return { ok: true, path, dispose };
|
|
38
|
+
reason = child.signalCode ? `copying the workspace exceeded ${CLONE_TIMEOUT_MS / 1000}s` : `the workspace could not be copied: ${stderr.trim().split("\n").at(-1) ?? `exit ${exit}`}`;
|
|
39
|
+
}
|
|
40
|
+
await dispose();
|
|
41
|
+
return { ok: false, reason };
|
|
42
|
+
}
|