gentle-pi 4.3.0 → 4.5.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/README.md +10 -7
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +138 -155
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +141 -57
- package/lib/gentle-shell-launcher.ts +11 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +227 -27
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +104 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +67 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/vim-editor-adapter.test.ts +2 -2
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Run only in the parent's isolated snapshot with supported existing Pi deps.
|
|
2
|
+
// Public API fixtures, never providers or production flush/rollback workarounds.
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import {
|
|
5
|
+
appendFileSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { test, type TestContext } from "node:test";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { readSessionProfileDisk } from "../lib/session-profile-disk-reader.ts";
|
|
19
|
+
import { SESSION_PROFILE_CUSTOM_TYPE as customType } from "../lib/session-profile-persistence.ts";
|
|
20
|
+
|
|
21
|
+
const packagePath = join(
|
|
22
|
+
dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))),
|
|
23
|
+
"..",
|
|
24
|
+
"package.json",
|
|
25
|
+
);
|
|
26
|
+
const version: string = JSON.parse(readFileSync(packagePath, "utf8")).version;
|
|
27
|
+
// Fail closed rather than silently skipping tests against stale feature deps.
|
|
28
|
+
assert.match(version, /^\d+\.\d+\.\d+$/);
|
|
29
|
+
assert.ok(
|
|
30
|
+
Number(version.split(".")[0]) >= 1,
|
|
31
|
+
`Pi >=1.0.0 required, installed ${version}`,
|
|
32
|
+
);
|
|
33
|
+
console.info(
|
|
34
|
+
`Public SessionManager fixture API: ${version}; minimum tested baseline 1.0.0`,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
function fixture(t: TestContext) {
|
|
38
|
+
const root = mkdtempSync(join(tmpdir(), "profile-reader-pi-"));
|
|
39
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
40
|
+
const sessions = join(root, "sessions");
|
|
41
|
+
const manager = SessionManager.create(root, sessions);
|
|
42
|
+
return { manager, sessions };
|
|
43
|
+
}
|
|
44
|
+
function bind(name: string) {
|
|
45
|
+
return {
|
|
46
|
+
kind: "bind",
|
|
47
|
+
origin: "user",
|
|
48
|
+
name,
|
|
49
|
+
modelProfiles: { worker: { model: `offline/${name}` } },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function flushFixture(manager: SessionManager) {
|
|
53
|
+
// An ordinary test-owned conversation message triggers Pi's normal persistence.
|
|
54
|
+
manager.appendMessage({
|
|
55
|
+
role: "user",
|
|
56
|
+
content: "isolated reader fixture",
|
|
57
|
+
timestamp: Date.now(),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function expectBound(manager: SessionManager, name: string) {
|
|
61
|
+
const result = readSessionProfileDisk(manager);
|
|
62
|
+
assert.equal(result.status, "bound");
|
|
63
|
+
if (result.status !== "bound")
|
|
64
|
+
throw new Error("Expected corroborated binding");
|
|
65
|
+
assert.equal(result.binding.name, name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test("custom-only public session cannot restore memory; ordinary fixture flush corroborates", (t) => {
|
|
69
|
+
const { manager } = fixture(t);
|
|
70
|
+
manager.appendCustomEntry(customType, bind("initial"));
|
|
71
|
+
const path = manager.getSessionFile();
|
|
72
|
+
assert.ok(path);
|
|
73
|
+
assert.equal(existsSync(path), false);
|
|
74
|
+
assert.equal(readSessionProfileDisk(manager).status, "indeterminate");
|
|
75
|
+
flushFixture(manager);
|
|
76
|
+
expectBound(manager, "initial");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("malformed line Pi skips on open does not block corroboration", (t) => {
|
|
80
|
+
const { manager, sessions } = fixture(t);
|
|
81
|
+
manager.appendCustomEntry(customType, bind("before"));
|
|
82
|
+
flushFixture(manager);
|
|
83
|
+
const path = manager.getSessionFile();
|
|
84
|
+
assert.ok(path);
|
|
85
|
+
appendFileSync(path, '{"type":"custom","id":"torn\n');
|
|
86
|
+
const reopened = SessionManager.open(path, sessions);
|
|
87
|
+
expectBound(reopened, "before");
|
|
88
|
+
reopened.appendCustomEntry(customType, bind("after"));
|
|
89
|
+
flushFixture(reopened);
|
|
90
|
+
expectBound(reopened, "after");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("public selected branch, sibling and extracted session stay separated", (t) => {
|
|
94
|
+
const { manager, sessions } = fixture(t);
|
|
95
|
+
manager.appendCustomEntry(customType, bind("base"));
|
|
96
|
+
flushFixture(manager);
|
|
97
|
+
const base = manager.getLeafId();
|
|
98
|
+
assert.ok(base);
|
|
99
|
+
manager.appendCustomEntry(customType, bind("left"));
|
|
100
|
+
const left = manager.getLeafId();
|
|
101
|
+
assert.ok(left);
|
|
102
|
+
manager.branch(base);
|
|
103
|
+
manager.appendCustomEntry(customType, bind("right"));
|
|
104
|
+
expectBound(manager, "right");
|
|
105
|
+
manager.branch(left);
|
|
106
|
+
expectBound(manager, "left");
|
|
107
|
+
const originalFile = manager.getSessionFile();
|
|
108
|
+
const extracted = manager.createBranchedSession(left);
|
|
109
|
+
assert.ok(extracted);
|
|
110
|
+
assert.notEqual(extracted, originalFile);
|
|
111
|
+
const reopened = SessionManager.open(extracted, sessions);
|
|
112
|
+
expectBound(reopened, "left");
|
|
113
|
+
assert.equal(
|
|
114
|
+
reopened
|
|
115
|
+
.getBranch()
|
|
116
|
+
.some(
|
|
117
|
+
(row) =>
|
|
118
|
+
row.type === "custom" && (row.data as { name?: string })?.name === "right",
|
|
119
|
+
),
|
|
120
|
+
false,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
for (const recovery of ["bind", "clear"] as const) {
|
|
125
|
+
test(`real EISDIR ghost quarantined; new ${recovery} corroborates missing parent even after reopen`, (t) => {
|
|
126
|
+
const { manager, sessions } = fixture(t);
|
|
127
|
+
manager.appendCustomEntry(customType, bind("good"));
|
|
128
|
+
flushFixture(manager);
|
|
129
|
+
const file = manager.getSessionFile();
|
|
130
|
+
assert.ok(file);
|
|
131
|
+
const saved = `${file}.saved`;
|
|
132
|
+
renameSync(file, saved);
|
|
133
|
+
mkdirSync(file);
|
|
134
|
+
assert.throws(
|
|
135
|
+
() => manager.appendCustomEntry(customType, bind("failed")),
|
|
136
|
+
(error: unknown) => {
|
|
137
|
+
assert.equal((error as NodeJS.ErrnoException).code, "EISDIR");
|
|
138
|
+
return true;
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
const failedId = manager.getLeafId();
|
|
142
|
+
assert.ok(failedId);
|
|
143
|
+
assert.equal(readSessionProfileDisk(manager).status, "indeterminate");
|
|
144
|
+
renameSync(file, `${file}.fault-directory`);
|
|
145
|
+
renameSync(saved, file);
|
|
146
|
+
assert.equal(readSessionProfileDisk(manager).status, "indeterminate");
|
|
147
|
+
const knownFailedEntryIds = new Set([failedId]);
|
|
148
|
+
const old = readSessionProfileDisk(manager, { knownFailedEntryIds });
|
|
149
|
+
assert.equal(old.status, "bound");
|
|
150
|
+
if (old.status === "bound") assert.equal(old.binding.name, "good");
|
|
151
|
+
manager.appendCustomEntry(
|
|
152
|
+
customType,
|
|
153
|
+
recovery === "bind" ? bind("recovered") : { kind: "clear" },
|
|
154
|
+
);
|
|
155
|
+
const latest = manager.getBranch().at(-1);
|
|
156
|
+
assert.ok(latest);
|
|
157
|
+
assert.equal(latest.parentId, failedId);
|
|
158
|
+
const disk = readFileSync(file, "utf8")
|
|
159
|
+
.trim()
|
|
160
|
+
.split("\n")
|
|
161
|
+
.map((line) => JSON.parse(line));
|
|
162
|
+
assert.equal(
|
|
163
|
+
disk.some((row) => row.id === failedId),
|
|
164
|
+
false,
|
|
165
|
+
);
|
|
166
|
+
assert.deepEqual(
|
|
167
|
+
disk.find((row) => row.id === latest.id),
|
|
168
|
+
JSON.parse(JSON.stringify(latest)),
|
|
169
|
+
);
|
|
170
|
+
assert.equal(
|
|
171
|
+
readSessionProfileDisk(manager, { knownFailedEntryIds }).status,
|
|
172
|
+
recovery === "bind" ? "bound" : "cleared",
|
|
173
|
+
);
|
|
174
|
+
const reopened = SessionManager.open(file, sessions);
|
|
175
|
+
assert.equal(reopened.getBranch().length, 1);
|
|
176
|
+
assert.equal(reopened.getBranch()[0].id, latest.id);
|
|
177
|
+
assert.equal(
|
|
178
|
+
readSessionProfileDisk(reopened).status,
|
|
179
|
+
recovery === "bind" ? "bound" : "cleared",
|
|
180
|
+
);
|
|
181
|
+
if (recovery === "bind") expectBound(reopened, "recovered");
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { test, type TestContext } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
type DiskProfileIndeterminateReason,
|
|
8
|
+
readSessionProfileDisk,
|
|
9
|
+
} from "../lib/session-profile-disk-reader.ts";
|
|
10
|
+
import { SESSION_PROFILE_CUSTOM_TYPE as customType } from "../lib/session-profile-persistence.ts";
|
|
11
|
+
|
|
12
|
+
const header = { type: "session", id: "session", version: 3 };
|
|
13
|
+
function entry(id = "selected", data: unknown = { kind: "clear" }) {
|
|
14
|
+
return {
|
|
15
|
+
type: "custom",
|
|
16
|
+
id,
|
|
17
|
+
parentId: null,
|
|
18
|
+
timestamp: "2026-10-05T00:00:00Z",
|
|
19
|
+
customType,
|
|
20
|
+
data,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function fixture(
|
|
24
|
+
t: TestContext,
|
|
25
|
+
branch: unknown[] = [],
|
|
26
|
+
rows: unknown[] = [header, ...branch],
|
|
27
|
+
) {
|
|
28
|
+
const root = mkdtempSync(join(tmpdir(), "profile-reader-"));
|
|
29
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
30
|
+
const path = join(root, "session.jsonl");
|
|
31
|
+
writeFileSync(
|
|
32
|
+
path,
|
|
33
|
+
rows
|
|
34
|
+
.map((row) => (typeof row === "string" ? row : JSON.stringify(row)))
|
|
35
|
+
.join("\n") + "\n",
|
|
36
|
+
);
|
|
37
|
+
const source = {
|
|
38
|
+
getSessionId: () => "session",
|
|
39
|
+
getSessionFile: (): string | undefined => path,
|
|
40
|
+
getBranch: () => branch,
|
|
41
|
+
};
|
|
42
|
+
return { path, source, read: () => readSessionProfileDisk(source) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("header only establishes absence", (t) => {
|
|
46
|
+
assert.deepEqual(fixture(t).read(), { status: "absent" });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("ordinary branch without family remains absent despite disk sibling", (t) => {
|
|
50
|
+
const message = {
|
|
51
|
+
type: "message",
|
|
52
|
+
id: "message",
|
|
53
|
+
parentId: null,
|
|
54
|
+
timestamp: "fixture",
|
|
55
|
+
};
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
fixture(t, [message], [header, message, entry("sibling")]).read(),
|
|
58
|
+
{ status: "absent" },
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("omitted data is terminal invalid or unsupported, never older resurrection", (t) => {
|
|
63
|
+
for (const identifier of [customType, "gentle-pi.session-profile/v9"]) {
|
|
64
|
+
const selected = { ...entry(), customType: identifier, data: undefined };
|
|
65
|
+
const result = fixture(t, [entry("older"), selected]).read();
|
|
66
|
+
assert.equal(
|
|
67
|
+
result.status,
|
|
68
|
+
identifier === customType ? "invalid" : "unsupported",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
for (const [label, data, status] of [
|
|
74
|
+
["clear", { kind: "clear" }, "cleared"],
|
|
75
|
+
[
|
|
76
|
+
"empty",
|
|
77
|
+
{ kind: "bind", origin: "user", name: "chosen", modelProfiles: {} },
|
|
78
|
+
"bound",
|
|
79
|
+
],
|
|
80
|
+
["invalid", { kind: "broken" }, "invalid"],
|
|
81
|
+
] as const) {
|
|
82
|
+
test(`corroborated ${label} retains semantic state and original positions`, (t) => {
|
|
83
|
+
const selected = entry("selected", data);
|
|
84
|
+
const message = {
|
|
85
|
+
type: "message",
|
|
86
|
+
id: "message",
|
|
87
|
+
parentId: null,
|
|
88
|
+
timestamp: "fixture",
|
|
89
|
+
};
|
|
90
|
+
const f = fixture(t, [message, selected], [header, "", message, selected]);
|
|
91
|
+
const result = f.read();
|
|
92
|
+
assert.equal(result.status, status);
|
|
93
|
+
assert.equal(result.entryIndex, 1);
|
|
94
|
+
assert.equal(result.lineNumber, 4);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
test("future newest is terminal; sibling records never win", (t) => {
|
|
99
|
+
const older = entry("older");
|
|
100
|
+
const future = {
|
|
101
|
+
...entry("future"),
|
|
102
|
+
customType: "gentle-pi.session-profile/v2",
|
|
103
|
+
};
|
|
104
|
+
const f = fixture(
|
|
105
|
+
t,
|
|
106
|
+
[older, future],
|
|
107
|
+
[header, older, future, entry("sibling")],
|
|
108
|
+
);
|
|
109
|
+
assert.equal(f.read().status, "unsupported");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("unknown unwritten newest never falls back; trusted failed evidence excludes only its ID", (t) => {
|
|
113
|
+
const older = entry("older");
|
|
114
|
+
const ghost = entry("ghost");
|
|
115
|
+
const f = fixture(t, [older, ghost], [header, older]);
|
|
116
|
+
assert.equal(f.read().status, "indeterminate");
|
|
117
|
+
const options = { knownFailedEntryIds: new Set(["ghost"]) };
|
|
118
|
+
assert.equal(readSessionProfileDisk(f.source, options).status, "cleared");
|
|
119
|
+
writeFileSync(
|
|
120
|
+
f.path,
|
|
121
|
+
[header, older, ghost].map((row) => JSON.stringify(row)).join("\n"),
|
|
122
|
+
);
|
|
123
|
+
assert.equal(readSessionProfileDisk(f.source, options).entryIndex, 0);
|
|
124
|
+
assert.equal(
|
|
125
|
+
readSessionProfileDisk(f.source, {
|
|
126
|
+
knownFailedEntryIds: new Set(["unrelated"]),
|
|
127
|
+
}).entryIndex,
|
|
128
|
+
1,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
for (const [label, rows] of [
|
|
133
|
+
["wrong session", [{ ...header, id: "other" }, entry()]],
|
|
134
|
+
["wrong header", [{ ...header, type: "message" }, entry()]],
|
|
135
|
+
["duplicate ID", [header, entry(), entry()]],
|
|
136
|
+
["malformed", [header, "{secret"]],
|
|
137
|
+
["truncated", [header, '{"type":']],
|
|
138
|
+
["data mismatch", [header, entry("selected", { kind: "broken" })]],
|
|
139
|
+
["parent mismatch", [header, { ...entry(), parentId: "other" }]],
|
|
140
|
+
["metadata mismatch", [header, { ...entry(), timestamp: "other" }]],
|
|
141
|
+
["custom type mismatch", [header, { ...entry(), customType: "other" }]],
|
|
142
|
+
["extra metadata mismatch", [header, { ...entry(), extra: "changed" }]],
|
|
143
|
+
["nonobject record", [header, "null"]],
|
|
144
|
+
["empty file", []],
|
|
145
|
+
[
|
|
146
|
+
"missing metadata",
|
|
147
|
+
[
|
|
148
|
+
header,
|
|
149
|
+
{ type: "custom", id: "selected", customType, data: { kind: "clear" } },
|
|
150
|
+
],
|
|
151
|
+
],
|
|
152
|
+
] as const) {
|
|
153
|
+
test(`${label} is explicitly indeterminate`, (t) => {
|
|
154
|
+
const result = fixture(t, [entry()], [...rows]).read();
|
|
155
|
+
assert.equal(result.status, "indeterminate");
|
|
156
|
+
assert.ok(!JSON.stringify(result).includes("secret"));
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
test("malformed lines are skipped like Pi's session parser, keeping physical line numbers", (t) => {
|
|
161
|
+
const selected = entry();
|
|
162
|
+
for (const rows of [
|
|
163
|
+
[header, "{bad private contents", selected],
|
|
164
|
+
["{bad private contents", header, selected],
|
|
165
|
+
[header, selected, '{"type":"custom","id":"torn'],
|
|
166
|
+
]) {
|
|
167
|
+
const result = fixture(t, [selected], rows).read();
|
|
168
|
+
assert.equal(result.status, "cleared");
|
|
169
|
+
assert.equal(result.lineNumber, rows.indexOf(selected) + 1);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("malformed selected record is missing, never admitted", (t) => {
|
|
174
|
+
const result = fixture(t, [entry()], [header, "{secret"]).read();
|
|
175
|
+
assert.deepEqual(result, {
|
|
176
|
+
status: "indeterminate",
|
|
177
|
+
reason: "selected-record-missing",
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("file with only blank or malformed lines has no header", (t) => {
|
|
182
|
+
for (const rows of [[], ["", "{bad"]]) {
|
|
183
|
+
assert.deepEqual(fixture(t, [entry()], rows).read(), {
|
|
184
|
+
status: "indeterminate",
|
|
185
|
+
reason: "missing-header",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("non-string customType is ordinary, non-string kind and empty agent name are invalid", (t) => {
|
|
191
|
+
const ordinary = { ...entry(), customType: 7 };
|
|
192
|
+
assert.deepEqual(fixture(t, [ordinary]).read(), { status: "absent" });
|
|
193
|
+
for (const data of [
|
|
194
|
+
{ kind: 1 },
|
|
195
|
+
{ kind: ["clear"] },
|
|
196
|
+
{
|
|
197
|
+
kind: "bind",
|
|
198
|
+
origin: "user",
|
|
199
|
+
name: "chosen",
|
|
200
|
+
modelProfiles: { "": "provider/model" },
|
|
201
|
+
},
|
|
202
|
+
]) {
|
|
203
|
+
const selected = entry("selected", data);
|
|
204
|
+
assert.equal(fixture(t, [selected]).read().status, "invalid");
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("every indeterminate reason is a named literal", () => {
|
|
209
|
+
const reasons = {
|
|
210
|
+
"missing-source": true,
|
|
211
|
+
"invalid-candidate-metadata": true,
|
|
212
|
+
"unreadable-file": true,
|
|
213
|
+
"source-changed": true,
|
|
214
|
+
"invalid-record": true,
|
|
215
|
+
"session-header-mismatch": true,
|
|
216
|
+
"duplicate-id": true,
|
|
217
|
+
"missing-header": true,
|
|
218
|
+
"selected-record-missing": true,
|
|
219
|
+
"selected-record-mismatch": true,
|
|
220
|
+
"unserializable-source": true,
|
|
221
|
+
} satisfies Record<DiskProfileIndeterminateReason, true>;
|
|
222
|
+
assert.equal(Object.keys(reasons).length, 11);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("missing, undefined and unreadable paths are conservative", (t) => {
|
|
226
|
+
const f = fixture(t);
|
|
227
|
+
rmSync(f.path);
|
|
228
|
+
assert.equal(f.read().status, "indeterminate");
|
|
229
|
+
f.source.getSessionFile = () => undefined;
|
|
230
|
+
assert.equal(f.read().status, "indeterminate");
|
|
231
|
+
f.source.getSessionFile = () => f.path;
|
|
232
|
+
assert.equal(
|
|
233
|
+
readSessionProfileDisk(f.source, {
|
|
234
|
+
readFile: () => {
|
|
235
|
+
throw new Error("private");
|
|
236
|
+
},
|
|
237
|
+
}).status,
|
|
238
|
+
"indeterminate",
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
for (const change of ["id", "path", "branch"] as const) {
|
|
243
|
+
test(`source ${change} change during read invalidates authority`, (t) => {
|
|
244
|
+
const f = fixture(t, [entry()]);
|
|
245
|
+
const result = readSessionProfileDisk(f.source, {
|
|
246
|
+
readFile: (path) => {
|
|
247
|
+
const text = readFileSync(path, "utf8");
|
|
248
|
+
if (change === "id") f.source.getSessionId = () => "changed";
|
|
249
|
+
if (change === "path") f.source.getSessionFile = () => "changed";
|
|
250
|
+
if (change === "branch") f.source.getBranch().push(entry("changed"));
|
|
251
|
+
return text;
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
assert.equal(result.status, "indeterminate");
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
test("JSON key order and omitted undefined follow serialization; orphan parent is allowed", (t) => {
|
|
259
|
+
const selected = { ...entry(), parentId: "missing-failed", extra: undefined };
|
|
260
|
+
const reversed = Object.fromEntries(Object.entries(selected).reverse());
|
|
261
|
+
assert.equal(
|
|
262
|
+
fixture(t, [selected], [header, reversed]).read().status,
|
|
263
|
+
"cleared",
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("unserializable memory and malformed own candidate metadata fail closed", (t) => {
|
|
268
|
+
const circular: any = entry();
|
|
269
|
+
circular.extra = circular;
|
|
270
|
+
assert.equal(
|
|
271
|
+
fixture(t, [circular], [header, entry()]).read().status,
|
|
272
|
+
"indeterminate",
|
|
273
|
+
);
|
|
274
|
+
for (const field of ["id", "parentId", "timestamp"]) {
|
|
275
|
+
const candidate: any = entry();
|
|
276
|
+
delete candidate[field];
|
|
277
|
+
assert.equal(
|
|
278
|
+
fixture(t, [candidate], [header, candidate]).read().status,
|
|
279
|
+
"indeterminate",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("all origins, legacy routes and orchestrator are detached and preserved", (t) => {
|
|
285
|
+
for (const origin of ["user", "local", "repo", "global"]) {
|
|
286
|
+
const selected = entry("selected", {
|
|
287
|
+
kind: "bind",
|
|
288
|
+
origin,
|
|
289
|
+
name: "chosen",
|
|
290
|
+
modelProfiles: {
|
|
291
|
+
worker: "provider/model",
|
|
292
|
+
orchestrator: { effort: "high" },
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
const f = fixture(t, [selected]);
|
|
296
|
+
const result = f.read();
|
|
297
|
+
assert.equal(result.status, "bound");
|
|
298
|
+
if (result.status !== "bound") throw new Error("expected binding");
|
|
299
|
+
assert.equal(result.binding.origin, origin);
|
|
300
|
+
assert.deepEqual(result.binding.modelProfiles.orchestrator, {
|
|
301
|
+
thinking: "high",
|
|
302
|
+
});
|
|
303
|
+
result.binding.modelProfiles.worker = { model: "changed" };
|
|
304
|
+
const next = f.read();
|
|
305
|
+
assert.equal(next.status, "bound");
|
|
306
|
+
if (next.status === "bound")
|
|
307
|
+
assert.deepEqual(next.binding.modelProfiles.worker, {
|
|
308
|
+
model: "provider/model",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
});
|
|
@@ -282,14 +282,14 @@ test("drift notice: shown once per distinct change of the defaults", () => {
|
|
|
282
282
|
f.pinLocal("local");
|
|
283
283
|
assert.equal(
|
|
284
284
|
inheritedProfileDriftNotice(opts),
|
|
285
|
-
'el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local).
|
|
285
|
+
'el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Open /gentle:profiles and choose Use in this session to adopt one.',
|
|
286
286
|
);
|
|
287
287
|
assert.equal(inheritedProfileDriftNotice(opts), undefined, "the same change is not repeated");
|
|
288
288
|
f.pinLocal(undefined);
|
|
289
289
|
f.setActive("team");
|
|
290
290
|
assert.equal(
|
|
291
291
|
inheritedProfileDriftNotice(opts),
|
|
292
|
-
'el Gentleman: the default profile changed to "team" (global), this session keeps "frontier" (local).
|
|
292
|
+
'el Gentleman: the default profile changed to "team" (global), this session keeps "frontier" (local). Open /gentle:profiles and choose Use in this session to adopt one.',
|
|
293
293
|
);
|
|
294
294
|
assert.equal(inheritedProfileDriftNotice(opts), undefined);
|
|
295
295
|
assert.equal(readFrozenInheritedProfile("session-a")?.profile?.name, "frontier", "the notice never adopts the new default");
|
|
@@ -305,6 +305,8 @@ test("drift notice: an explicit binding learns its profile definition changed in
|
|
|
305
305
|
const notice = inheritedProfileDriftNotice(opts);
|
|
306
306
|
assert.match(notice ?? "", /the profile definition changed to "team"/);
|
|
307
307
|
assert.match(notice ?? "", /this session keeps the binding from selection/);
|
|
308
|
+
assert.match(notice ?? "", /Open \/gentle:profiles and choose Use in this session to adopt one\.$/, "the notice names the current adoption action");
|
|
309
|
+
assert.doesNotMatch(notice ?? "", /Press Enter/, "the retired Enter instruction never comes back");
|
|
308
310
|
assert.equal(inheritedProfileDriftNotice(opts), undefined, "the same change is not repeated");
|
|
309
311
|
|
|
310
312
|
f.editProfile("team", { worker: { model: "openai/older" } });
|
|
@@ -344,14 +346,14 @@ test("drift notice covers a session frozen without a profile and a default that
|
|
|
344
346
|
f.pinLocal("frontier");
|
|
345
347
|
assert.equal(
|
|
346
348
|
inheritedProfileDriftNotice(none),
|
|
347
|
-
'el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile.
|
|
349
|
+
'el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Open /gentle:profiles and choose Use in this session to adopt one.',
|
|
348
350
|
);
|
|
349
351
|
const kept = options(f, "session-kept");
|
|
350
352
|
freezeSessionProfileAtStartup(kept);
|
|
351
353
|
f.pinLocal(undefined);
|
|
352
354
|
assert.equal(
|
|
353
355
|
inheritedProfileDriftNotice(kept),
|
|
354
|
-
'el Gentleman: the default profile changed to no profile, this session keeps "frontier" (local).
|
|
356
|
+
'el Gentleman: the default profile changed to no profile, this session keeps "frontier" (local). Open /gentle:profiles and choose Use in this session to adopt one.',
|
|
355
357
|
);
|
|
356
358
|
});
|
|
357
359
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { basename, dirname, join } from "node:path";
|
|
6
6
|
import test, { type TestContext } from "node:test";
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { createSessionScratchExtension, SCRATCH_COMMAND_USAGE } from "../extensions/session-scratch.ts";
|
|
9
9
|
import * as scratchLib from "../lib/session-scratch.ts";
|
|
10
|
-
import { gitArtifactTargets, LEDGER_VERSION, type ScratchLedger } from "../lib/session-scratch.ts";
|
|
10
|
+
import { gitArtifactTargets, LEDGER_ARTIFACTS_CAP, LEDGER_VERSION, type ScratchLedger } from "../lib/session-scratch.ts";
|
|
11
11
|
|
|
12
12
|
// Session scratch and artifact ledger (odd/tasks/session-scratch-ledger.md):
|
|
13
13
|
// the runtime owns the parent session's TMPDIR and the clones/worktrees it
|
|
@@ -943,3 +943,69 @@ test("advisories S7: the reference documents the advisory rules", () => {
|
|
|
943
943
|
"changed in the last minute", "recorded `inherited` values", "not a `gpXXXXXX` directory",
|
|
944
944
|
]) assert.ok(section.includes(term), `scratch reference missing ${term}`);
|
|
945
945
|
});
|
|
946
|
+
|
|
947
|
+
// Perf (long-session audit): the ledger file I/O must not grow with the number
|
|
948
|
+
// of retained ledgers or artifacts. `listLedgers` and `writeLedger` use only
|
|
949
|
+
// the async fs seam, and `capture` keeps at most `LEDGER_ARTIFACTS_CAP`
|
|
950
|
+
// artifacts, evicting the oldest first. Old pre-cap ledgers still read.
|
|
951
|
+
|
|
952
|
+
test("perf: pushing beyond the cap keeps the ledger bounded and keeps the newest artifacts", async (t) => {
|
|
953
|
+
const fx = fixture(t);
|
|
954
|
+
const s = session(fx);
|
|
955
|
+
await s.start();
|
|
956
|
+
for (let i = 0; i < LEDGER_ARTIFACTS_CAP + 6; i++) {
|
|
957
|
+
await s.bash(`cd ${fx.work} && git clone x d${i}`, () => mkdirSync(join(fx.work, `d${i}`)));
|
|
958
|
+
}
|
|
959
|
+
const ledger = readLedger(fx, "s-1");
|
|
960
|
+
assert.equal(ledger.artifacts.length, LEDGER_ARTIFACTS_CAP, "the ledger file stays bounded at the cap");
|
|
961
|
+
assert.equal(basename(ledger.artifacts[0].path), `d6`, "eviction is oldest-first");
|
|
962
|
+
assert.equal(basename(ledger.artifacts.at(-1)!.path), `d${LEDGER_ARTIFACTS_CAP + 5}`, "the newest artifacts are kept");
|
|
963
|
+
assert.ok(!ledger.artifacts.some((artifact) => basename(artifact.path) === "d0"));
|
|
964
|
+
await s.shutdown();
|
|
965
|
+
// An old ledger written before the cap still reads, whole, by the pre-cap reader.
|
|
966
|
+
writeForeignLedger(fx, { sessionId: "pre-cap", artifacts: Array.from({ length: LEDGER_ARTIFACTS_CAP + 10 }, (_, i) => ({ kind: "clone" as const, path: join(fx.work, `old${i}`), command: "git clone", recordedAt: new Date(0).toISOString() })) });
|
|
967
|
+
const preCap = scratchLib.readLedger(ledgerFile(fx, "pre-cap"));
|
|
968
|
+
assert.equal(preCap?.artifacts.length, LEDGER_ARTIFACTS_CAP + 10, "a pre-cap ledger is read without pruning");
|
|
969
|
+
const stored = await scratchLib.listLedgers(fx.configHome);
|
|
970
|
+
assert.equal(stored.find(({ ledger: found }) => found.sessionId === "pre-cap")?.ledger.artifacts.length, LEDGER_ARTIFACTS_CAP + 10);
|
|
971
|
+
const out = await s.run("list");
|
|
972
|
+
assert.match(out, /old0 \(session pre-cap, ended\)/);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test("perf: listLedgers and writeLedger run on the async fs seam only", async (t) => {
|
|
976
|
+
const fx = fixture(t);
|
|
977
|
+
assert.equal(scratchLib.writeLedger.constructor.name, "AsyncFunction", "writeLedger is async");
|
|
978
|
+
assert.equal(scratchLib.listLedgers.constructor.name, "AsyncFunction", "listLedgers is async");
|
|
979
|
+
const saved = { ...scratchLib.ledgerFs };
|
|
980
|
+
const calls: Record<string, number> = {};
|
|
981
|
+
t.after(() => Object.assign(scratchLib.ledgerFs, saved));
|
|
982
|
+
for (const name of Object.keys(scratchLib.ledgerFs) as (keyof typeof scratchLib.ledgerFs)[]) {
|
|
983
|
+
(scratchLib.ledgerFs as Record<string, unknown>)[name] = async (...args: unknown[]) => {
|
|
984
|
+
calls[name] = (calls[name] ?? 0) + 1;
|
|
985
|
+
return await (saved[name] as (...rest: unknown[]) => unknown)(...args);
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
const ledger: ScratchLedger = { version: LEDGER_VERSION, sessionId: "async", pid: 1, processStartedAt: new Date(0).toISOString(), cwd: fx.cwd, tempRoot: null, inherited: {}, scratchDir: null, artifacts: [] };
|
|
989
|
+
await scratchLib.writeLedger(ledgerFile(fx, "async"), ledger);
|
|
990
|
+
for (const name of ["mkdir", "writeFile", "rename"]) assert.ok(calls[name], `writeLedger goes through the seam: ${name}`);
|
|
991
|
+
const stored = await scratchLib.listLedgers(fx.configHome);
|
|
992
|
+
assert.equal(stored.length, 1);
|
|
993
|
+
assert.equal(stored[0].ledger.sessionId, "async");
|
|
994
|
+
for (const name of ["readdir", "readFile", "stat"]) assert.ok(calls[name], `listLedgers goes through the seam: ${name}`);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
test("perf: a failed ledger write leaves the previous ledger file intact", async (t) => {
|
|
998
|
+
if (process.getuid?.() === 0) { t.skip("a write never fails for root"); return; }
|
|
999
|
+
const fx = fixture(t);
|
|
1000
|
+
const path = ledgerFile(fx, "intact");
|
|
1001
|
+
const ledger: ScratchLedger = { version: LEDGER_VERSION, sessionId: "intact", pid: 1, processStartedAt: new Date(0).toISOString(), cwd: fx.cwd, tempRoot: null, inherited: {}, scratchDir: null, artifacts: [{ kind: "clone", path: join(fx.work, "kept"), command: "git clone", recordedAt: new Date(0).toISOString() }] };
|
|
1002
|
+
await scratchLib.writeLedger(path, ledger);
|
|
1003
|
+
chmodSync(dirname(path), 0o500);
|
|
1004
|
+
try {
|
|
1005
|
+
await assert.rejects(() => scratchLib.writeLedger(path, { ...ledger, sessionId: "lost" }));
|
|
1006
|
+
assert.equal(scratchLib.readLedger(path)?.sessionId, "intact", "the previous ledger file survives the failed write");
|
|
1007
|
+
assert.deepEqual(readdirSync(dirname(path)).filter((name) => name.endsWith(".tmp")), [], "no temp file is left behind");
|
|
1008
|
+
} finally {
|
|
1009
|
+
chmodSync(dirname(path), 0o700);
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { SessionWorktreeRegistry, clearSessionWorktreeMemo, memoizeWorktreeResolver, resolveSessionWorktree, resolveSessionWorktreeWithGit, SESSION_WORKTREE_ENTRY, toolWorktreePath, worktreeGitEnvironment } from "../lib/session-worktree-registry.ts";
|
|
9
|
+
import { SessionWorktreeRegistry, clearSessionWorktreeMemo, memoizeWorktreeResolver, resolveSessionWorktree, resolveSessionWorktreeWithGit, SESSION_WORKTREE_ENTRY, nativeRootSpelling, toolWorktreePath, worktreeGitEnvironment } from "../lib/session-worktree-registry.ts";
|
|
10
10
|
import { sessionRepositoryAuthority } from "../lib/bounded-writer-admission.ts";
|
|
11
11
|
|
|
12
12
|
// All Git and session writes belong to unique fixtures, never the live clone.
|
|
@@ -306,3 +306,15 @@ test("memoized identity still observes removal, nested repositories and expiry",
|
|
|
306
306
|
counted(join(f.dir, "empty"), f.dir);
|
|
307
307
|
assert.equal(calls, 4, "a missing repository is never memoized, so bootstrap is observed at once");
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
test("Windows root spelling accepts forward slashes as the native separator only (gentle-shell#2314)", () => {
|
|
311
|
+
// Git prints C:/repo while realpath returns C:\\repo; both name the same root.
|
|
312
|
+
assert.equal(nativeRootSpelling("C:/path/to/sibling-repo", "win32"), "C:\\path\\to\\sibling-repo");
|
|
313
|
+
assert.equal(nativeRootSpelling("C:\\path\\to\\sibling-repo", "win32"), "C:\\path\\to\\sibling-repo");
|
|
314
|
+
// Only separators change: dot segments and trailing separators stay non-canonical.
|
|
315
|
+
assert.equal(nativeRootSpelling("C:/repo/.", "win32"), "C:\\repo\\.");
|
|
316
|
+
assert.equal(nativeRootSpelling("C:/repo/", "win32"), "C:\\repo\\");
|
|
317
|
+
// POSIX spelling is untouched; a backslash is an ordinary filename character there.
|
|
318
|
+
assert.equal(nativeRootSpelling("/srv/repo", "linux"), "/srv/repo");
|
|
319
|
+
assert.equal(nativeRootSpelling("/srv/re\\po", "darwin"), "/srv/re\\po");
|
|
320
|
+
});
|