gentle-pi 3.7.0 → 4.0.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 +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +92 -28
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +112 -206
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +429 -262
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +96 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +49 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +49 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +9 -15
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +378 -89
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1047 -252
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const sourcePath = fileURLToPath(
|
|
7
|
+
new URL("../extensions/history/index.ts", import.meta.url),
|
|
8
|
+
);
|
|
9
|
+
const source = fs.readFileSync(sourcePath, "utf8");
|
|
10
|
+
|
|
11
|
+
test("preview rows are bottom-padded so the panel shrinks from the bottom", () => {
|
|
12
|
+
const rebuildStart = source.indexOf(
|
|
13
|
+
"private rebuildPreviewWithWidth(width: number): void {",
|
|
14
|
+
);
|
|
15
|
+
assert.notStrictEqual(
|
|
16
|
+
rebuildStart,
|
|
17
|
+
-1,
|
|
18
|
+
"rebuildPreviewWithWidth() should exist",
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const rebuildEnd = source.indexOf(
|
|
22
|
+
"\n // -- Selection actions",
|
|
23
|
+
rebuildStart,
|
|
24
|
+
);
|
|
25
|
+
assert.notStrictEqual(
|
|
26
|
+
rebuildEnd,
|
|
27
|
+
-1,
|
|
28
|
+
"rebuildPreview section boundary should exist",
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const rebuildPreviewSource = source.slice(rebuildStart, rebuildEnd);
|
|
32
|
+
|
|
33
|
+
const rowLoopIndex = rebuildPreviewSource.indexOf(
|
|
34
|
+
"for (let i = 0; i < PREVIEW_ROWS; i++)",
|
|
35
|
+
);
|
|
36
|
+
assert.ok(
|
|
37
|
+
rowLoopIndex >= 0,
|
|
38
|
+
"fixed-height PREVIEW_ROWS row loop should exist",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const emptyRowPadIndex = rebuildPreviewSource.indexOf(
|
|
42
|
+
"this.previewContainer.addChild(new FixedRowText());",
|
|
43
|
+
rowLoopIndex,
|
|
44
|
+
);
|
|
45
|
+
assert.ok(
|
|
46
|
+
emptyRowPadIndex >= 0,
|
|
47
|
+
"rows past the wrapped content should be added as empty bottom padding",
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
assert.ok(
|
|
51
|
+
!rebuildPreviewSource.includes(
|
|
52
|
+
"const topPadding = PREVIEW_ROWS - visible.length;",
|
|
53
|
+
),
|
|
54
|
+
"preview should not compute top padding",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("row padding measures visible width, stripping SGR escapes", () => {
|
|
59
|
+
// Colored list rows carry SGR escape sequences that occupy no terminal
|
|
60
|
+
// cells; padding must use the VISIBLE width or the row falls short of
|
|
61
|
+
// the overlay width and leaves ghost characters on dismiss.
|
|
62
|
+
const renderStart = source.indexOf(" render(width: number): string[] {");
|
|
63
|
+
assert.notStrictEqual(renderStart, -1, "FixedRowText.render should exist");
|
|
64
|
+
|
|
65
|
+
const renderSource = source.slice(renderStart, renderStart + 2200);
|
|
66
|
+
const padLine = renderSource
|
|
67
|
+
.split("\n")
|
|
68
|
+
.find((l) => l.includes('" ".repeat(Math.max(0, width -'));
|
|
69
|
+
assert.ok(padLine !== undefined, "final full-width pad should exist");
|
|
70
|
+
assert.ok(
|
|
71
|
+
padLine.includes("visible"),
|
|
72
|
+
"pad must measure the SGR-stripped visible width, not rendered.length",
|
|
73
|
+
);
|
|
74
|
+
assert.ok(
|
|
75
|
+
/visible = rendered\.replace\(/.test(renderSource),
|
|
76
|
+
"visible width must be derived by stripping escape sequences",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("sanitizeForDisplay appends the full astral code point, not a lone surrogate", () => {
|
|
81
|
+
const fnStart = source.indexOf("function sanitizeForDisplay(");
|
|
82
|
+
assert.notStrictEqual(fnStart, -1, "sanitizeForDisplay should exist");
|
|
83
|
+
|
|
84
|
+
const fnSource = source.slice(fnStart, fnStart + 1200);
|
|
85
|
+
assert.ok(
|
|
86
|
+
fnSource.includes("String.fromCodePoint(cp)"),
|
|
87
|
+
"astral code points must be re-appended whole (emoji survive)",
|
|
88
|
+
);
|
|
89
|
+
assert.ok(
|
|
90
|
+
fnSource.includes("if (cp > 0xffff) i++"),
|
|
91
|
+
"the low surrogate of the pair must still be skipped",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
ensureRegistryEntry,
|
|
9
|
+
projectHash,
|
|
10
|
+
registryPath,
|
|
11
|
+
} from "../extensions/history/store.ts";
|
|
12
|
+
|
|
13
|
+
// Slice-1 port note: the dev suite asserted lookups through the dead
|
|
14
|
+
// `lookupCwd` export, which slice 1 drops. Every lookup assertion is
|
|
15
|
+
// re-expressed against the persisted registry.json content.
|
|
16
|
+
|
|
17
|
+
function makeRoot(): string {
|
|
18
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-registry-"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readRegistryFile(root: string): Record<string, string> {
|
|
22
|
+
return JSON.parse(
|
|
23
|
+
fs.readFileSync(registryPath(root), "utf8"),
|
|
24
|
+
) as Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("creates the registry with the first entry (idempotent)", () => {
|
|
28
|
+
const root = makeRoot();
|
|
29
|
+
const result = ensureRegistryEntry(root, "/pi-history-test/project-a");
|
|
30
|
+
assert.deepEqual(result, { hash: "4be15ec687e9df85", created: true });
|
|
31
|
+
ensureRegistryEntry(root, "/pi-history-test/project-a");
|
|
32
|
+
assert.deepEqual(readRegistryFile(root), {
|
|
33
|
+
"4be15ec687e9df85": "/pi-history-test/project-a",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("second project appends without touching the first", () => {
|
|
38
|
+
const root = makeRoot();
|
|
39
|
+
ensureRegistryEntry(root, "/pi-history-test/project-a");
|
|
40
|
+
const b = ensureRegistryEntry(root, "/pi-history-test/project-b");
|
|
41
|
+
assert.equal(b.created, true);
|
|
42
|
+
const raw = readRegistryFile(root);
|
|
43
|
+
assert.equal(Object.keys(raw).length, 2);
|
|
44
|
+
assert.equal(raw[b.hash], "/pi-history-test/project-b");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("registry.json maps known hashes and omits unknown ones", () => {
|
|
48
|
+
const root = makeRoot();
|
|
49
|
+
const { hash } = ensureRegistryEntry(root, "/pi-history-test/project-a");
|
|
50
|
+
const raw = readRegistryFile(root);
|
|
51
|
+
assert.equal(raw[hash], "/pi-history-test/project-a");
|
|
52
|
+
assert.equal(raw["0000000000000000"], undefined);
|
|
53
|
+
// A fresh root has no registry file until its first entry lands.
|
|
54
|
+
assert.equal(fs.existsSync(registryPath(makeRoot())), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("corrupt registry json is treated as empty and rebuilt on next entry", () => {
|
|
58
|
+
const root = makeRoot();
|
|
59
|
+
fs.writeFileSync(registryPath(root), "{not-json", "utf8");
|
|
60
|
+
const result = ensureRegistryEntry(root, "/pi-history-test/project-a");
|
|
61
|
+
assert.equal(result.created, true);
|
|
62
|
+
// The corrupt content was discarded (fail-open to empty), so the rebuilt
|
|
63
|
+
// registry contains exactly the new entry and nothing else.
|
|
64
|
+
assert.deepEqual(readRegistryFile(root), {
|
|
65
|
+
"4be15ec687e9df85": "/pi-history-test/project-a",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("no leftover tmp files after writes", () => {
|
|
70
|
+
const root = makeRoot();
|
|
71
|
+
ensureRegistryEntry(root, "/a");
|
|
72
|
+
ensureRegistryEntry(root, "/b");
|
|
73
|
+
const leftovers = fs.readdirSync(root).filter((f) => f.includes(".tmp-"));
|
|
74
|
+
assert.deepEqual(leftovers, []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("hash collision re-keys the existing occupant; the new cwd keeps the short hash", () => {
|
|
78
|
+
const root = makeRoot();
|
|
79
|
+
const cwd = "/pi-history-test/project-a";
|
|
80
|
+
const hash = projectHash(cwd);
|
|
81
|
+
// Simulate a collision: the short hash is pre-mapped to a different cwd.
|
|
82
|
+
fs.mkdirSync(root, { recursive: true });
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
registryPath(root),
|
|
85
|
+
JSON.stringify({ [hash]: "/some/other/project" }),
|
|
86
|
+
"utf8",
|
|
87
|
+
);
|
|
88
|
+
const result = ensureRegistryEntry(root, cwd);
|
|
89
|
+
assert.deepEqual(result, { hash, created: true });
|
|
90
|
+
const raw = readRegistryFile(root);
|
|
91
|
+
assert.equal(raw[hash], cwd);
|
|
92
|
+
const longKeys = Object.keys(raw).filter((k) => k.length === 24);
|
|
93
|
+
assert.equal(longKeys.length, 1);
|
|
94
|
+
assert.equal(raw[longKeys[0]], "/some/other/project");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a re-keyed cwd keeps its long key on later calls (stable collision mappings)", () => {
|
|
98
|
+
// projectHashLong is private: derive the documented 24-char key here —
|
|
99
|
+
// the literals never exist, so canonicalization falls back to the raw
|
|
100
|
+
// string on every platform.
|
|
101
|
+
const longKey = (cwd: string) =>
|
|
102
|
+
createHash("sha256").update(cwd).digest("hex").slice(0, 24);
|
|
103
|
+
const root = makeRoot();
|
|
104
|
+
const a = "/pi-history-test/registry-collide-a";
|
|
105
|
+
const b = "/pi-history-test/registry-collide-b";
|
|
106
|
+
// Simulate the collision: b's short hash is pre-mapped to a different
|
|
107
|
+
// cwd, so entering b re-keys that occupant to a 24-char key.
|
|
108
|
+
const shortHash = projectHash(b);
|
|
109
|
+
fs.mkdirSync(root, { recursive: true });
|
|
110
|
+
fs.writeFileSync(
|
|
111
|
+
registryPath(root),
|
|
112
|
+
JSON.stringify({ [shortHash]: a }),
|
|
113
|
+
"utf8",
|
|
114
|
+
);
|
|
115
|
+
ensureRegistryEntry(root, b); // collision: a re-keyed to 24 chars
|
|
116
|
+
const before = readRegistryFile(root);
|
|
117
|
+
// Re-entering the re-keyed cwd must return its EXISTING long key and
|
|
118
|
+
// leave the other occupant's short-hash mapping untouched.
|
|
119
|
+
const again = ensureRegistryEntry(root, a);
|
|
120
|
+
assert.equal(again.created, false);
|
|
121
|
+
assert.equal(again.hash, longKey(a));
|
|
122
|
+
const after = readRegistryFile(root);
|
|
123
|
+
assert.deepEqual(after, before);
|
|
124
|
+
// And re-entering the short-hash holder keeps the short key.
|
|
125
|
+
const holder = ensureRegistryEntry(root, b);
|
|
126
|
+
assert.equal(holder.hash, projectHash(b));
|
|
127
|
+
assert.deepEqual(readRegistryFile(root), before);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("wrong-shaped registry (array / scalar / null) fails open and is rebuilt on the next entry", () => {
|
|
131
|
+
// Valid JSON, wrong shape: the readRegistry shape guard treats each as an
|
|
132
|
+
// empty registry, and the next entry rebuilds a valid object-mapped
|
|
133
|
+
// registry around itself.
|
|
134
|
+
const shapes: unknown[] = [["an", "array"], "scalar-string", null];
|
|
135
|
+
const cwd = "/pi-history-test/project-a";
|
|
136
|
+
for (const shape of shapes) {
|
|
137
|
+
const root = makeRoot();
|
|
138
|
+
fs.writeFileSync(registryPath(root), JSON.stringify(shape), "utf8");
|
|
139
|
+
const result = ensureRegistryEntry(root, cwd);
|
|
140
|
+
assert.deepEqual(result, { hash: projectHash(cwd), created: true });
|
|
141
|
+
assert.deepEqual(readRegistryFile(root), { [projectHash(cwd)]: cwd });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
appendSessionCapture,
|
|
8
|
+
deleteFromGlobal,
|
|
9
|
+
deleteFromProject,
|
|
10
|
+
drainGlobal,
|
|
11
|
+
drainProject,
|
|
12
|
+
globalSeedPath,
|
|
13
|
+
openSessionWriter,
|
|
14
|
+
parseStoreLine,
|
|
15
|
+
projectHash,
|
|
16
|
+
} from "../extensions/history/store.ts";
|
|
17
|
+
import { hidePrompt } from "../extensions/history/hide-prompts.ts";
|
|
18
|
+
|
|
19
|
+
// Scope delete (design v2): sweepFiles' atomic per-file rewrite semantics
|
|
20
|
+
// plus the project/global delete entry points. Synthetic project cwds —
|
|
21
|
+
// never real directories on any machine (identity only feeds projectHash;
|
|
22
|
+
// the fixtures live in tmpdirs and never touch the user's real ~/.pi).
|
|
23
|
+
const PROJECT_A = "/fixtures/pi-history/project-a";
|
|
24
|
+
const PROJECT_B = "/fixtures/pi-history/project-b";
|
|
25
|
+
|
|
26
|
+
function makeRoot(): string {
|
|
27
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-del-"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeLines(file: string, texts: string[]): void {
|
|
31
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
32
|
+
fs.writeFileSync(
|
|
33
|
+
file,
|
|
34
|
+
`${texts.map((t) => JSON.stringify({ v: 1, text: t })).join("\n")}\n`,
|
|
35
|
+
"utf8",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fileTexts(file: string): string[] {
|
|
40
|
+
return fs
|
|
41
|
+
.readFileSync(file, "utf8")
|
|
42
|
+
.trim()
|
|
43
|
+
.split("\n")
|
|
44
|
+
.filter((l) => l.length > 0)
|
|
45
|
+
.map((l) => (JSON.parse(l) as { text: string }).text);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("project delete removes every copy across the project's files", () => {
|
|
49
|
+
const root = makeRoot();
|
|
50
|
+
const dir = path.join(root, "projects", projectHash(PROJECT_A));
|
|
51
|
+
writeLines(path.join(dir, "s1.jsonl"), ["keep", "victim"]);
|
|
52
|
+
writeLines(path.join(dir, "s2.jsonl"), ["VICTIM ", "also-keep"]);
|
|
53
|
+
const result = deleteFromProject(root, PROJECT_A, "victim");
|
|
54
|
+
assert.deepEqual(result, { filesAffected: 2, removed: 2, failed: 0 });
|
|
55
|
+
assert.deepEqual(fileTexts(path.join(dir, "s1.jsonl")), ["keep"]);
|
|
56
|
+
assert.deepEqual(fileTexts(path.join(dir, "s2.jsonl")), ["also-keep"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("project delete leaves other projects untouched", () => {
|
|
60
|
+
const root = makeRoot();
|
|
61
|
+
const dirA = path.join(root, "projects", projectHash(PROJECT_A));
|
|
62
|
+
const dirB = path.join(root, "projects", projectHash(PROJECT_B));
|
|
63
|
+
writeLines(path.join(dirA, "s.jsonl"), ["victim"]);
|
|
64
|
+
writeLines(path.join(dirB, "s.jsonl"), ["victim", "b-keep"]);
|
|
65
|
+
deleteFromProject(root, PROJECT_A, "victim");
|
|
66
|
+
assert.deepEqual(fileTexts(path.join(dirB, "s.jsonl")), ["victim", "b-keep"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("project delete of unknown prompt is a no-op", () => {
|
|
70
|
+
const root = makeRoot();
|
|
71
|
+
const dir = path.join(root, "projects", projectHash(PROJECT_A));
|
|
72
|
+
writeLines(path.join(dir, "s.jsonl"), ["a"]);
|
|
73
|
+
const result = deleteFromProject(root, PROJECT_A, "missing");
|
|
74
|
+
assert.deepEqual(result, { filesAffected: 0, removed: 0, failed: 0 });
|
|
75
|
+
assert.deepEqual(fileTexts(path.join(dir, "s.jsonl")), ["a"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("project delete on a missing dir is a no-op", () => {
|
|
79
|
+
const root = makeRoot();
|
|
80
|
+
const result = deleteFromProject(root, PROJECT_A, "x");
|
|
81
|
+
assert.deepEqual(result, { filesAffected: 0, removed: 0, failed: 0 });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("global delete on a root without a projects dir is a zero-delete no-op", () => {
|
|
85
|
+
const root = makeRoot();
|
|
86
|
+
const result = deleteFromGlobal(root, "x");
|
|
87
|
+
assert.deepEqual(result, { filesAffected: 0, removed: 0, failed: 0 });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("global delete sweeps every project dir plus the legacy seed", () => {
|
|
91
|
+
const root = makeRoot();
|
|
92
|
+
const dirA = path.join(root, "projects", projectHash(PROJECT_A));
|
|
93
|
+
const dirB = path.join(root, "projects", projectHash(PROJECT_B));
|
|
94
|
+
writeLines(path.join(dirA, "s.jsonl"), ["victim", "a-keep"]);
|
|
95
|
+
writeLines(path.join(dirB, "s.jsonl"), ["victim"]);
|
|
96
|
+
writeLines(globalSeedPath(root), ["victim", "legacy-keep"]);
|
|
97
|
+
const result = deleteFromGlobal(root, "victim");
|
|
98
|
+
assert.deepEqual(result, { filesAffected: 3, removed: 3, failed: 0 });
|
|
99
|
+
assert.deepEqual(fileTexts(path.join(dirA, "s.jsonl")), ["a-keep"]);
|
|
100
|
+
assert.deepEqual(fileTexts(path.join(dirB, "s.jsonl")), []);
|
|
101
|
+
assert.deepEqual(fileTexts(globalSeedPath(root)), ["legacy-keep"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("delete leaves no tmp files behind", () => {
|
|
105
|
+
const root = makeRoot();
|
|
106
|
+
const dir = path.join(root, "projects", projectHash(PROJECT_A));
|
|
107
|
+
writeLines(path.join(dir, "s.jsonl"), ["victim"]);
|
|
108
|
+
deleteFromProject(root, PROJECT_A, "victim");
|
|
109
|
+
const leftovers = fs.readdirSync(dir).filter((f) => f.includes(".tmp-"));
|
|
110
|
+
assert.deepEqual(leftovers, []);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// node:test has no test.skipIf (Bun-ism): root skips via the options object.
|
|
114
|
+
test(
|
|
115
|
+
"an unreadable store file (chmod 000) is counted as failed; readable copies still swept",
|
|
116
|
+
{ skip: process.getuid?.() === 0 ? "requires non-root" : false },
|
|
117
|
+
() => {
|
|
118
|
+
const root = makeRoot();
|
|
119
|
+
const dir = path.join(root, "projects", projectHash(PROJECT_A));
|
|
120
|
+
const readable = path.join(dir, "readable.jsonl");
|
|
121
|
+
const sealed = path.join(dir, "sealed.jsonl");
|
|
122
|
+
writeLines(readable, ["victim", "keep"]);
|
|
123
|
+
writeLines(sealed, ["victim"]);
|
|
124
|
+
fs.chmodSync(sealed, 0o000);
|
|
125
|
+
try {
|
|
126
|
+
const result = deleteFromProject(root, PROJECT_A, "victim");
|
|
127
|
+
// The unreadable file may still hold a copy: it is counted as a
|
|
128
|
+
// failure (never reported as a clean delete); the readable copy is
|
|
129
|
+
// removed and the sweep is never fatal.
|
|
130
|
+
assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 1 });
|
|
131
|
+
assert.deepEqual(fileTexts(readable), ["keep"]);
|
|
132
|
+
assert.equal(fs.existsSync(sealed), true);
|
|
133
|
+
} finally {
|
|
134
|
+
fs.chmodSync(sealed, 0o644); // restore before cleanup
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
test("a file whose every line is deleted becomes empty (kept, not removed)", () => {
|
|
140
|
+
const root = makeRoot();
|
|
141
|
+
const dir = path.join(root, "projects", projectHash(PROJECT_A));
|
|
142
|
+
const file = path.join(dir, "s.jsonl");
|
|
143
|
+
writeLines(file, ["only-victim"]);
|
|
144
|
+
deleteFromProject(root, PROJECT_A, "only-victim");
|
|
145
|
+
assert.equal(fs.existsSync(file), true);
|
|
146
|
+
assert.equal(fs.readFileSync(file, "utf8"), "");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Active-writer safety (design v2): the sweep rewrites the writer's own
|
|
150
|
+
// file IN PLACE (tmp + rename, never a removal — emptied files are kept),
|
|
151
|
+
// so a concurrently live writer keeps working by path: its next capture
|
|
152
|
+
// appends into the swept file, and the surviving + new lines parse fine.
|
|
153
|
+
test("a sweep with a concurrent live writer keeps the writer's file functional", () => {
|
|
154
|
+
const root = makeRoot();
|
|
155
|
+
const state = openSessionWriter(root, PROJECT_A, "instance-1");
|
|
156
|
+
appendSessionCapture(state, "victim");
|
|
157
|
+
appendSessionCapture(state, "keeper");
|
|
158
|
+
|
|
159
|
+
const result = deleteFromProject(root, PROJECT_A, "victim");
|
|
160
|
+
assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 0 });
|
|
161
|
+
|
|
162
|
+
// The same writer state keeps appending after the sweep — the file was
|
|
163
|
+
// rewritten under the writer's feet, not removed.
|
|
164
|
+
appendSessionCapture(state, "after-delete");
|
|
165
|
+
assert.equal(state.lineCount, 3);
|
|
166
|
+
assert.equal(fs.existsSync(state.filePath), true);
|
|
167
|
+
assert.deepEqual(fileTexts(state.filePath), ["keeper", "after-delete"]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Concurrent appends (PR #1393 adaptation): another pi instance appends to
|
|
171
|
+
// its own file by path at any time. A line that lands between the sweep's
|
|
172
|
+
// read and its rename must survive the rewrite instead of vanishing with
|
|
173
|
+
// the replaced inode. The hook simulates that instance: it appends right
|
|
174
|
+
// before the sweep's rename of the swept file.
|
|
175
|
+
function withAppendBeforeRename(
|
|
176
|
+
target: string,
|
|
177
|
+
appended: string,
|
|
178
|
+
run: () => void,
|
|
179
|
+
): void {
|
|
180
|
+
const realRename = fs.renameSync;
|
|
181
|
+
let fired = false;
|
|
182
|
+
fs.renameSync = ((from: fs.PathLike, to: fs.PathLike) => {
|
|
183
|
+
if (!fired && String(to) === target) {
|
|
184
|
+
fired = true;
|
|
185
|
+
fs.appendFileSync(target, appended, "utf8");
|
|
186
|
+
}
|
|
187
|
+
return realRename(from, to);
|
|
188
|
+
}) as typeof fs.renameSync;
|
|
189
|
+
try {
|
|
190
|
+
run();
|
|
191
|
+
} finally {
|
|
192
|
+
fs.renameSync = realRename;
|
|
193
|
+
}
|
|
194
|
+
assert.ok(fired, "the simulated concurrent append must fire");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
test("a line appended between the sweep's read and rename survives", () => {
|
|
198
|
+
const root = makeRoot();
|
|
199
|
+
const state = openSessionWriter(root, PROJECT_A, "other-instance");
|
|
200
|
+
appendSessionCapture(state, "victim");
|
|
201
|
+
appendSessionCapture(state, "keeper");
|
|
202
|
+
withAppendBeforeRename(
|
|
203
|
+
state.filePath,
|
|
204
|
+
`${JSON.stringify({ v: 1, text: "raced in" })}\n`,
|
|
205
|
+
() => {
|
|
206
|
+
const result = deleteFromProject(root, PROJECT_A, "victim");
|
|
207
|
+
assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 0 });
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
assert.deepEqual(fileTexts(state.filePath), ["keeper", "raced in"]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("a torn last line completed during the sweep is preserved whole", () => {
|
|
214
|
+
const root = makeRoot();
|
|
215
|
+
const dir = path.join(root, "projects", projectHash(PROJECT_A));
|
|
216
|
+
const file = path.join(dir, "other.jsonl");
|
|
217
|
+
const torn = JSON.stringify({ v: 1, text: "in flight" });
|
|
218
|
+
writeLines(file, ["victim", "keeper"]);
|
|
219
|
+
fs.appendFileSync(file, torn.slice(0, 10), "utf8");
|
|
220
|
+
withAppendBeforeRename(file, `${torn.slice(10)}\n`, () => {
|
|
221
|
+
deleteFromProject(root, PROJECT_A, "victim");
|
|
222
|
+
});
|
|
223
|
+
assert.deepEqual(fileTexts(file), ["keeper", "in flight"]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("a failed rewrite is counted, keeps the original, and leaves no tmp file", () => {
|
|
227
|
+
const root = makeRoot();
|
|
228
|
+
const dir = path.join(root, "projects", projectHash(PROJECT_A));
|
|
229
|
+
const broken = path.join(dir, "broken.jsonl");
|
|
230
|
+
const healthy = path.join(dir, "healthy.jsonl");
|
|
231
|
+
writeLines(broken, ["victim", "broken-keep"]);
|
|
232
|
+
writeLines(healthy, ["victim", "healthy-keep"]);
|
|
233
|
+
const realRename = fs.renameSync;
|
|
234
|
+
fs.renameSync = ((from: fs.PathLike, to: fs.PathLike) => {
|
|
235
|
+
if (String(to) === broken) {
|
|
236
|
+
throw Object.assign(new Error("simulated EIO"), { code: "EIO" });
|
|
237
|
+
}
|
|
238
|
+
return realRename(from, to);
|
|
239
|
+
}) as typeof fs.renameSync;
|
|
240
|
+
let result;
|
|
241
|
+
try {
|
|
242
|
+
result = deleteFromProject(root, PROJECT_A, "victim");
|
|
243
|
+
} finally {
|
|
244
|
+
fs.renameSync = realRename;
|
|
245
|
+
}
|
|
246
|
+
assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 1 });
|
|
247
|
+
assert.deepEqual(fileTexts(broken), ["victim", "broken-keep"]);
|
|
248
|
+
assert.deepEqual(fileTexts(healthy), ["healthy-keep"]);
|
|
249
|
+
const leftovers = fs.readdirSync(dir).filter((f) => f.includes(".tmp-"));
|
|
250
|
+
assert.deepEqual(leftovers, []);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// A failed carry-over append (#1393 advisory): the rewrite already replaced
|
|
254
|
+
// the file, so the raced lines exist only in the kept descriptor. They must
|
|
255
|
+
// land in a sibling store file instead of vanishing with the old inode.
|
|
256
|
+
test("raced lines survive a failed carry-over append in a sibling store file", () => {
|
|
257
|
+
const root = makeRoot();
|
|
258
|
+
const state = openSessionWriter(root, PROJECT_A, "other-instance");
|
|
259
|
+
appendSessionCapture(state, "victim");
|
|
260
|
+
appendSessionCapture(state, "keeper");
|
|
261
|
+
const realAppend = fs.appendFileSync;
|
|
262
|
+
let carryFailed = false;
|
|
263
|
+
fs.appendFileSync = ((
|
|
264
|
+
file: fs.PathOrFileDescriptor,
|
|
265
|
+
data: string | Uint8Array,
|
|
266
|
+
options?: fs.WriteFileOptions,
|
|
267
|
+
) => {
|
|
268
|
+
// The sweep carries a Buffer; the simulated instance appends a string.
|
|
269
|
+
if (String(file) === state.filePath && Buffer.isBuffer(data)) {
|
|
270
|
+
carryFailed = true;
|
|
271
|
+
throw Object.assign(new Error("simulated EIO"), { code: "EIO" });
|
|
272
|
+
}
|
|
273
|
+
return realAppend(file, data, options);
|
|
274
|
+
}) as typeof fs.appendFileSync;
|
|
275
|
+
let result;
|
|
276
|
+
try {
|
|
277
|
+
withAppendBeforeRename(
|
|
278
|
+
state.filePath,
|
|
279
|
+
`${JSON.stringify({ v: 1, text: "raced in" })}\n`,
|
|
280
|
+
() => {
|
|
281
|
+
result = deleteFromProject(root, PROJECT_A, "victim");
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
} finally {
|
|
285
|
+
fs.appendFileSync = realAppend;
|
|
286
|
+
}
|
|
287
|
+
assert.equal(carryFailed, true);
|
|
288
|
+
assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 0 });
|
|
289
|
+
assert.deepEqual(fileTexts(state.filePath), ["keeper"]);
|
|
290
|
+
const drained = drainProject(root, PROJECT_A);
|
|
291
|
+
assert.equal(drained.status, "ok");
|
|
292
|
+
if (drained.status === "ok") {
|
|
293
|
+
assert.deepEqual([...drained.prompts].sort(), ["keeper", "raced in"]);
|
|
294
|
+
}
|
|
295
|
+
const dir = path.dirname(state.filePath);
|
|
296
|
+
assert.deepEqual(
|
|
297
|
+
fs.readdirSync(dir).filter((f) => f.includes(".tmp-")),
|
|
298
|
+
[],
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
/** Fail the sweep's Buffer carry-over append to `target`; `partial` bytes land first. */
|
|
303
|
+
function withFailedCarry(target: string, partial: number, run: () => void): void {
|
|
304
|
+
const realAppend = fs.appendFileSync;
|
|
305
|
+
let failed = false;
|
|
306
|
+
fs.appendFileSync = ((
|
|
307
|
+
file: fs.PathOrFileDescriptor,
|
|
308
|
+
data: string | Uint8Array,
|
|
309
|
+
options?: fs.WriteFileOptions,
|
|
310
|
+
) => {
|
|
311
|
+
if (String(file) === target && Buffer.isBuffer(data)) {
|
|
312
|
+
failed = true;
|
|
313
|
+
if (partial > 0) realAppend(file, data.subarray(0, partial));
|
|
314
|
+
throw Object.assign(new Error("simulated ENOSPC"), { code: "ENOSPC" });
|
|
315
|
+
}
|
|
316
|
+
return realAppend(file, data, options);
|
|
317
|
+
}) as typeof fs.appendFileSync;
|
|
318
|
+
try {
|
|
319
|
+
run();
|
|
320
|
+
} finally {
|
|
321
|
+
fs.appendFileSync = realAppend;
|
|
322
|
+
}
|
|
323
|
+
assert.ok(failed, "the simulated carry-over failure must fire");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Review advisory A1 (#1477): a failed carry-over on the GLOBAL seed writes
|
|
327
|
+
// `history-global.jsonl.carry-*.jsonl` next to it, outside every project
|
|
328
|
+
// dir. The global drain and delete must reach it like any store file.
|
|
329
|
+
test("the global scope drains and deletes the global seed's carry sibling", () => {
|
|
330
|
+
const root = makeRoot();
|
|
331
|
+
const stateDir = makeRoot();
|
|
332
|
+
const seed = globalSeedPath(root);
|
|
333
|
+
writeLines(seed, ["victim", "legacy-keep"]);
|
|
334
|
+
const dir = path.join(root, "projects", projectHash(PROJECT_A));
|
|
335
|
+
writeLines(path.join(dir, "s.jsonl"), ["project-newest"]);
|
|
336
|
+
withFailedCarry(seed, 0, () => {
|
|
337
|
+
withAppendBeforeRename(
|
|
338
|
+
seed,
|
|
339
|
+
`${JSON.stringify({ v: 1, text: "hidden raced" })}\n` +
|
|
340
|
+
`${JSON.stringify({ v: 1, text: "raced in" })}\n`,
|
|
341
|
+
() => {
|
|
342
|
+
const result = deleteFromGlobal(root, "victim");
|
|
343
|
+
assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 0 });
|
|
344
|
+
},
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
const carries = fs
|
|
348
|
+
.readdirSync(root)
|
|
349
|
+
.filter((f) => f.startsWith("history-global.jsonl.carry-"));
|
|
350
|
+
assert.equal(carries.length, 1, "the raced lines live in one carry sibling");
|
|
351
|
+
assert.equal(hidePrompt(stateDir, "hidden raced").status, "written");
|
|
352
|
+
|
|
353
|
+
// Tombstones apply; the carry holds bytes newer than the seed, so it
|
|
354
|
+
// drains right before it, after every project file (legacy last).
|
|
355
|
+
const drained = drainGlobal(root, 1000, stateDir);
|
|
356
|
+
assert.equal(drained.status, "ok");
|
|
357
|
+
if (drained.status === "ok") {
|
|
358
|
+
assert.deepEqual(drained.prompts, [
|
|
359
|
+
"project-newest",
|
|
360
|
+
"raced in",
|
|
361
|
+
"legacy-keep",
|
|
362
|
+
]);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const result = deleteFromGlobal(root, "raced in");
|
|
366
|
+
assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 0 });
|
|
367
|
+
const after = drainGlobal(root, 1000, stateDir);
|
|
368
|
+
assert.equal(after.status, "ok");
|
|
369
|
+
if (after.status === "ok") {
|
|
370
|
+
assert.deepEqual(after.prompts, ["project-newest", "legacy-keep"]);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Review advisory A3 (#1477): a carry-over append that fails after writing
|
|
375
|
+
// part of its bytes must not leave a newline-less fragment in the rewritten
|
|
376
|
+
// file, where the owner's next append would merge into one corrupt line.
|
|
377
|
+
test("a partially written carry-over leaves no fragment for the next append", () => {
|
|
378
|
+
const root = makeRoot();
|
|
379
|
+
const state = openSessionWriter(root, PROJECT_A, "other-instance");
|
|
380
|
+
appendSessionCapture(state, "victim");
|
|
381
|
+
appendSessionCapture(state, "keeper");
|
|
382
|
+
withFailedCarry(state.filePath, 7, () => {
|
|
383
|
+
withAppendBeforeRename(
|
|
384
|
+
state.filePath,
|
|
385
|
+
`${JSON.stringify({ v: 1, text: "raced in" })}\n`,
|
|
386
|
+
() => {
|
|
387
|
+
const result = deleteFromProject(root, PROJECT_A, "victim");
|
|
388
|
+
assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 0 });
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
appendSessionCapture(state, "after-carry");
|
|
393
|
+
const lines = fs
|
|
394
|
+
.readFileSync(state.filePath, "utf8")
|
|
395
|
+
.split("\n")
|
|
396
|
+
.filter((l) => l.length > 0);
|
|
397
|
+
assert.deepEqual(
|
|
398
|
+
lines.map((l) => parseStoreLine(l)?.text ?? null),
|
|
399
|
+
["keeper", "after-carry"],
|
|
400
|
+
"every line of the rewritten file parses",
|
|
401
|
+
);
|
|
402
|
+
const drained = drainProject(root, PROJECT_A);
|
|
403
|
+
assert.equal(drained.status, "ok");
|
|
404
|
+
if (drained.status === "ok") {
|
|
405
|
+
assert.deepEqual([...drained.prompts].sort(), [
|
|
406
|
+
"after-carry",
|
|
407
|
+
"keeper",
|
|
408
|
+
"raced in",
|
|
409
|
+
]);
|
|
410
|
+
}
|
|
411
|
+
});
|