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,832 @@
|
|
|
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 { hidePrompt } from "../extensions/history/hide-prompts.ts";
|
|
7
|
+
import {
|
|
8
|
+
drainProject,
|
|
9
|
+
gcProjectDir,
|
|
10
|
+
globalSeedPath,
|
|
11
|
+
projectHash,
|
|
12
|
+
seedFilePath,
|
|
13
|
+
} from "../extensions/history/store.ts";
|
|
14
|
+
|
|
15
|
+
// GC/compaction (slice 6): threshold no-op below the limits, keep-newest
|
|
16
|
+
// semantics, and the failure paths — the compact file lands atomically
|
|
17
|
+
// before any original is removed, cleanup failures are tolerated, unreadable
|
|
18
|
+
// files are skipped, and an append landing mid-compaction is never lost.
|
|
19
|
+
// All fixtures live under os.tmpdir(): the user's real ~/.pi store root is
|
|
20
|
+
// never touched. (Ported from the dev repo's test/history/gc.test.ts;
|
|
21
|
+
// gcProjectDir with explicit thresholds is the wired and tested entry
|
|
22
|
+
// point.)
|
|
23
|
+
|
|
24
|
+
const CWD = "/pi-history-fixtures/project-gc";
|
|
25
|
+
|
|
26
|
+
function makeRoot(): string {
|
|
27
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-gc-"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function projectRoot(root: string): string {
|
|
31
|
+
return path.join(root, "projects", projectHash(CWD));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeFile(
|
|
35
|
+
dir: string,
|
|
36
|
+
name: string,
|
|
37
|
+
count: number,
|
|
38
|
+
mtimeMs: number,
|
|
39
|
+
): string {
|
|
40
|
+
const file = path.join(dir, name);
|
|
41
|
+
fs.writeFileSync(
|
|
42
|
+
file,
|
|
43
|
+
`${Array.from({ length: count }, (_, i) =>
|
|
44
|
+
JSON.stringify({ v: 1, text: `${name}-${i}` }),
|
|
45
|
+
).join("\n")}\n`,
|
|
46
|
+
"utf8",
|
|
47
|
+
);
|
|
48
|
+
fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs));
|
|
49
|
+
return file;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function totalLines(dir: string): number {
|
|
53
|
+
let total = 0;
|
|
54
|
+
for (const f of fs.readdirSync(dir)) {
|
|
55
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
56
|
+
total += fs
|
|
57
|
+
.readFileSync(path.join(dir, f), "utf8")
|
|
58
|
+
.split("\n")
|
|
59
|
+
.filter((l) => l.trim().length > 0).length;
|
|
60
|
+
}
|
|
61
|
+
return total;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Line texts of the single compact-*.jsonl file in dir (must exist). */
|
|
65
|
+
function compactTexts(dir: string): string[] {
|
|
66
|
+
const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-"));
|
|
67
|
+
assert.ok(compact, "a compact-*.jsonl file must exist");
|
|
68
|
+
return fs
|
|
69
|
+
.readFileSync(path.join(dir, compact), "utf8")
|
|
70
|
+
.trim()
|
|
71
|
+
.split("\n")
|
|
72
|
+
.map((l) => (JSON.parse(l) as { text: string }).text);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Write explicit entries (text + optional ts) with a fixed mtime. */
|
|
76
|
+
function writeEntries(
|
|
77
|
+
dir: string,
|
|
78
|
+
name: string,
|
|
79
|
+
entries: Array<{ text: string; ts?: number }>,
|
|
80
|
+
mtimeMs: number,
|
|
81
|
+
): string {
|
|
82
|
+
const file = path.join(dir, name);
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
file,
|
|
85
|
+
`${entries.map((e) => JSON.stringify({ v: 1, ...e })).join("\n")}\n`,
|
|
86
|
+
"utf8",
|
|
87
|
+
);
|
|
88
|
+
fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs));
|
|
89
|
+
return file;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Every entry text across the dir's .jsonl files (order unspecified). */
|
|
93
|
+
function dirTexts(dir: string): string[] {
|
|
94
|
+
const out: string[] = [];
|
|
95
|
+
for (const f of fs.readdirSync(dir)) {
|
|
96
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
97
|
+
for (const line of fs.readFileSync(path.join(dir, f), "utf8").split("\n")) {
|
|
98
|
+
if (line.trim().length === 0) continue;
|
|
99
|
+
out.push((JSON.parse(line) as { text: string }).text);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Replace fs.rmSync (the shared CJS exports object store.ts resolves at
|
|
107
|
+
* call time) for the duration of fn; the original is always restored.
|
|
108
|
+
* `rmSync` inside the replacement is the captured original, so replacements
|
|
109
|
+
* can observe-or-fail and then call through.
|
|
110
|
+
*/
|
|
111
|
+
function withRmSyncPatched(
|
|
112
|
+
replacement: (file: string, rmSync: (file: string) => void) => void,
|
|
113
|
+
fn: () => void,
|
|
114
|
+
): void {
|
|
115
|
+
type RmSync = (file: string) => void;
|
|
116
|
+
const realRmSync = fs.rmSync.bind(fs) as RmSync;
|
|
117
|
+
const target = fs as unknown as { rmSync: RmSync };
|
|
118
|
+
target.rmSync = (file: string) => {
|
|
119
|
+
replacement(file, realRmSync);
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
fn();
|
|
123
|
+
} finally {
|
|
124
|
+
target.rmSync = realRmSync;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test("under both thresholds: GC is a no-op", () => {
|
|
129
|
+
const root = makeRoot();
|
|
130
|
+
const dir = projectRoot(root);
|
|
131
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
132
|
+
writeFile(dir, "a.jsonl", 10, 1000);
|
|
133
|
+
writeFile(dir, "b.jsonl", 10, 2000);
|
|
134
|
+
const result = gcProjectDir(root, CWD, {
|
|
135
|
+
fileThreshold: 10,
|
|
136
|
+
lineThreshold: 10000,
|
|
137
|
+
keepNewest: 1,
|
|
138
|
+
});
|
|
139
|
+
assert.deepEqual(result, { compacted: false, merged: 0 });
|
|
140
|
+
assert.equal(fs.readdirSync(dir).length, 2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("file-count threshold merges the oldest files into one compact file", () => {
|
|
144
|
+
const root = makeRoot();
|
|
145
|
+
const dir = projectRoot(root);
|
|
146
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
147
|
+
// 12 files (threshold 10) x 10 lines each.
|
|
148
|
+
for (let i = 1; i <= 12; i++) {
|
|
149
|
+
writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000);
|
|
150
|
+
}
|
|
151
|
+
const result = gcProjectDir(root, CWD, {
|
|
152
|
+
fileThreshold: 10,
|
|
153
|
+
lineThreshold: 10000,
|
|
154
|
+
keepNewest: 1,
|
|
155
|
+
});
|
|
156
|
+
assert.deepEqual(result, { compacted: true, merged: 11 });
|
|
157
|
+
// 12 files -> newest 1 kept + 1 compact file = 2 files; all lines kept.
|
|
158
|
+
assert.equal(fs.readdirSync(dir).length, 2);
|
|
159
|
+
assert.equal(totalLines(dir), 120);
|
|
160
|
+
// The compact file is the renamed final artifact, not a staging leftover.
|
|
161
|
+
assert.match(
|
|
162
|
+
fs.readdirSync(dir).find((f) => f.startsWith("compact-")) ?? "",
|
|
163
|
+
/^compact-\d+-\d+\.jsonl$/,
|
|
164
|
+
);
|
|
165
|
+
assert.deepEqual(
|
|
166
|
+
fs.readdirSync(dir).filter((f) => f.includes(".tmp-")),
|
|
167
|
+
[],
|
|
168
|
+
);
|
|
169
|
+
// The newest original file survives untouched by name.
|
|
170
|
+
assert.equal(fs.readdirSync(dir).includes("f12.jsonl"), true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("line-count threshold triggers compaction too", () => {
|
|
174
|
+
const root = makeRoot();
|
|
175
|
+
const dir = projectRoot(root);
|
|
176
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
177
|
+
// 3 files x 4000 lines = 12000 > 10000 threshold.
|
|
178
|
+
for (let i = 1; i <= 3; i++) {
|
|
179
|
+
writeFile(dir, `g${i}.jsonl`, 4000, i * 1000);
|
|
180
|
+
}
|
|
181
|
+
const result = gcProjectDir(root, CWD, {
|
|
182
|
+
fileThreshold: 10,
|
|
183
|
+
lineThreshold: 10000,
|
|
184
|
+
keepNewest: 1,
|
|
185
|
+
});
|
|
186
|
+
assert.equal(result.compacted, true);
|
|
187
|
+
assert.equal(totalLines(dir), 12000);
|
|
188
|
+
assert.equal(fs.readdirSync(dir).includes("g3.jsonl"), true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("GC on a missing project dir is a no-op", () => {
|
|
192
|
+
const root = makeRoot();
|
|
193
|
+
const result = gcProjectDir(root, "/does/not/exist");
|
|
194
|
+
assert.deepEqual(result, { compacted: false, merged: 0 });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("compaction keeps the newest 10 files, merges the rest", () => {
|
|
198
|
+
const root = makeRoot();
|
|
199
|
+
const dir = projectRoot(root);
|
|
200
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
201
|
+
for (let i = 1; i <= 15; i++) {
|
|
202
|
+
writeFile(dir, `h${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000);
|
|
203
|
+
}
|
|
204
|
+
const result = gcProjectDir(root, CWD, {
|
|
205
|
+
fileThreshold: 10,
|
|
206
|
+
lineThreshold: 10000,
|
|
207
|
+
keepNewest: 10,
|
|
208
|
+
});
|
|
209
|
+
assert.deepEqual(result, { compacted: true, merged: 5 });
|
|
210
|
+
const names = fs.readdirSync(dir).sort();
|
|
211
|
+
// 10 newest originals + 1 compact file.
|
|
212
|
+
assert.equal(names.length, 11);
|
|
213
|
+
assert.equal(names[0].startsWith("compact-"), true);
|
|
214
|
+
assert.equal(names.includes("h15.jsonl"), true);
|
|
215
|
+
assert.equal(names.includes("h05.jsonl"), false);
|
|
216
|
+
assert.equal(names.includes("h06.jsonl"), true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// node:test has no test.skipIf (Bun-ism): root skips via the options object.
|
|
220
|
+
test(
|
|
221
|
+
"an unreadable file (chmod 000) is left in place; GC still compacts the readable tail",
|
|
222
|
+
{ skip: process.getuid?.() === 0 ? "requires non-root" : false },
|
|
223
|
+
() => {
|
|
224
|
+
const root = makeRoot();
|
|
225
|
+
const dir = projectRoot(root);
|
|
226
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
227
|
+
// 3 files, keepNewest 1 -> the two oldest are merge candidates; the
|
|
228
|
+
// sealed one cannot be read, so it must be neither merged nor removed:
|
|
229
|
+
// removing bytes that were never copied would lose them.
|
|
230
|
+
writeFile(dir, "readable-old.jsonl", 5, 1000);
|
|
231
|
+
const sealed = writeFile(dir, "sealed-old.jsonl", 5, 2000);
|
|
232
|
+
writeFile(dir, "newest.jsonl", 5, 3000);
|
|
233
|
+
fs.chmodSync(sealed, 0o000);
|
|
234
|
+
try {
|
|
235
|
+
const result = gcProjectDir(root, CWD, {
|
|
236
|
+
fileThreshold: 2,
|
|
237
|
+
lineThreshold: 100000,
|
|
238
|
+
keepNewest: 1,
|
|
239
|
+
});
|
|
240
|
+
assert.deepEqual(result, { compacted: true, merged: 1 });
|
|
241
|
+
assert.deepEqual(compactTexts(dir), [
|
|
242
|
+
"readable-old.jsonl-0",
|
|
243
|
+
"readable-old.jsonl-1",
|
|
244
|
+
"readable-old.jsonl-2",
|
|
245
|
+
"readable-old.jsonl-3",
|
|
246
|
+
"readable-old.jsonl-4",
|
|
247
|
+
]);
|
|
248
|
+
assert.equal(fs.existsSync(sealed), true);
|
|
249
|
+
assert.equal(fs.readdirSync(dir).includes("newest.jsonl"), true);
|
|
250
|
+
} finally {
|
|
251
|
+
fs.chmodSync(sealed, 0o644);
|
|
252
|
+
}
|
|
253
|
+
assert.equal(
|
|
254
|
+
fs.readFileSync(sealed, "utf8").trim().split("\n").length,
|
|
255
|
+
5,
|
|
256
|
+
);
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
test("the compact file lands complete before any original is removed", () => {
|
|
261
|
+
const root = makeRoot();
|
|
262
|
+
const dir = projectRoot(root);
|
|
263
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
264
|
+
for (let i = 1; i <= 12; i++) {
|
|
265
|
+
writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000);
|
|
266
|
+
}
|
|
267
|
+
// Observe, do not replace: at the FIRST cleanup unlink the compact file
|
|
268
|
+
// must already exist on disk with the full merged content (110 lines).
|
|
269
|
+
// That is the crash-safe ordering contract: readers never see the tail
|
|
270
|
+
// gone with no compact file in its place.
|
|
271
|
+
let compactCompleteAtFirstRm: boolean | null = null;
|
|
272
|
+
withRmSyncPatched(
|
|
273
|
+
(file, rmSync) => {
|
|
274
|
+
if (compactCompleteAtFirstRm === null) {
|
|
275
|
+
const parent = path.dirname(file);
|
|
276
|
+
const compact = fs
|
|
277
|
+
.readdirSync(parent)
|
|
278
|
+
.find((f) => f.startsWith("compact-"));
|
|
279
|
+
compactCompleteAtFirstRm =
|
|
280
|
+
compact !== undefined &&
|
|
281
|
+
fs
|
|
282
|
+
.readFileSync(path.join(parent, compact), "utf8")
|
|
283
|
+
.trim()
|
|
284
|
+
.split("\n")
|
|
285
|
+
.filter((l) => l.trim().length > 0).length === 110;
|
|
286
|
+
}
|
|
287
|
+
rmSync(file);
|
|
288
|
+
},
|
|
289
|
+
() => {
|
|
290
|
+
const result = gcProjectDir(root, CWD, {
|
|
291
|
+
fileThreshold: 10,
|
|
292
|
+
lineThreshold: 10000,
|
|
293
|
+
keepNewest: 1,
|
|
294
|
+
});
|
|
295
|
+
assert.deepEqual(result, { compacted: true, merged: 11 });
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
assert.equal(compactCompleteAtFirstRm, true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("rm failure is tolerated: originals survive, GC still reports success", () => {
|
|
302
|
+
const root = makeRoot();
|
|
303
|
+
const dir = projectRoot(root);
|
|
304
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
305
|
+
for (let i = 1; i <= 12; i++) {
|
|
306
|
+
writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000);
|
|
307
|
+
}
|
|
308
|
+
// Simulate every cleanup unlink failing (e.g. originals held by another
|
|
309
|
+
// process): the compact file already landed, so a surviving original is
|
|
310
|
+
// harmless — readers dedupe by identity.
|
|
311
|
+
withRmSyncPatched(
|
|
312
|
+
() => {
|
|
313
|
+
throw new Error("simulated EBUSY: original still held");
|
|
314
|
+
},
|
|
315
|
+
() => {
|
|
316
|
+
const result = gcProjectDir(root, CWD, {
|
|
317
|
+
fileThreshold: 10,
|
|
318
|
+
lineThreshold: 10000,
|
|
319
|
+
keepNewest: 1,
|
|
320
|
+
});
|
|
321
|
+
// The success shape is unchanged even though cleanup failed.
|
|
322
|
+
assert.deepEqual(result, { compacted: true, merged: 11 });
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
// The compact file is complete on disk...
|
|
326
|
+
assert.equal(compactTexts(dir).length, 110);
|
|
327
|
+
// ...and every original survived the failed cleanup (12 + 1 compact).
|
|
328
|
+
assert.equal(fs.readdirSync(dir).length, 13);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("an append landing during compaction is never lost (active writer)", () => {
|
|
332
|
+
const root = makeRoot();
|
|
333
|
+
const dir = projectRoot(root);
|
|
334
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
335
|
+
// 12 old files (merge-tail candidates) + one active writer file with the
|
|
336
|
+
// newest mtime. The freshness rule keeps the active file out of the merge
|
|
337
|
+
// tail — that is what makes concurrent appends safe during GC.
|
|
338
|
+
for (let i = 1; i <= 12; i++) {
|
|
339
|
+
writeFile(dir, `t${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000);
|
|
340
|
+
}
|
|
341
|
+
const active = writeFile(dir, "active.jsonl", 5, 99_000);
|
|
342
|
+
// Mid-compaction (first cleanup unlink), the active writer appends a line.
|
|
343
|
+
let appended = false;
|
|
344
|
+
withRmSyncPatched(
|
|
345
|
+
(file, rmSync) => {
|
|
346
|
+
if (!appended) {
|
|
347
|
+
appended = true;
|
|
348
|
+
fs.appendFileSync(
|
|
349
|
+
active,
|
|
350
|
+
`${JSON.stringify({ v: 1, text: "during-gc" })}\n`,
|
|
351
|
+
"utf8",
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
rmSync(file);
|
|
355
|
+
},
|
|
356
|
+
() => {
|
|
357
|
+
const result = gcProjectDir(root, CWD, {
|
|
358
|
+
fileThreshold: 10,
|
|
359
|
+
lineThreshold: 100000,
|
|
360
|
+
keepNewest: 10,
|
|
361
|
+
});
|
|
362
|
+
// 13 files > threshold 10; tail = 3 oldest; active writer untouched.
|
|
363
|
+
assert.deepEqual(result, { compacted: true, merged: 3 });
|
|
364
|
+
},
|
|
365
|
+
);
|
|
366
|
+
// The active file survived by name with every line: the pre-GC lines and
|
|
367
|
+
// the line appended mid-compaction.
|
|
368
|
+
const activeTexts = fs
|
|
369
|
+
.readFileSync(active, "utf8")
|
|
370
|
+
.trim()
|
|
371
|
+
.split("\n")
|
|
372
|
+
.map((l) => (JSON.parse(l) as { text: string }).text);
|
|
373
|
+
assert.deepEqual(activeTexts, [
|
|
374
|
+
"active.jsonl-0",
|
|
375
|
+
"active.jsonl-1",
|
|
376
|
+
"active.jsonl-2",
|
|
377
|
+
"active.jsonl-3",
|
|
378
|
+
"active.jsonl-4",
|
|
379
|
+
"during-gc",
|
|
380
|
+
]);
|
|
381
|
+
// The tail's 15 lines all compacted; nothing from kept files was merged.
|
|
382
|
+
const mergedTexts = compactTexts(dir);
|
|
383
|
+
assert.equal(mergedTexts.length, 15);
|
|
384
|
+
assert.ok(mergedTexts.includes("t01.jsonl-0"));
|
|
385
|
+
assert.ok(mergedTexts.includes("t03.jsonl-4"));
|
|
386
|
+
assert.ok(!mergedTexts.some((t) => t.startsWith("active.")));
|
|
387
|
+
assert.ok(!mergedTexts.some((t) => t.startsWith("t04.")));
|
|
388
|
+
// Whole-dir accounting: 13 x 5 original lines + 1 mid-GC append.
|
|
389
|
+
assert.equal(totalLines(dir), 66);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Adaptation hardening (PR #1394 on main): seed gate, live writers,
|
|
394
|
+
// chronology, tombstones, and failure tolerance.
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
test("compaction never selects seed.jsonl nor touches the global seed", () => {
|
|
398
|
+
const root = makeRoot();
|
|
399
|
+
const dir = projectRoot(root);
|
|
400
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
401
|
+
// The seed is the OLDEST file, so a plain oldest-tail pick would merge it
|
|
402
|
+
// and drop the bootstrap gate (an existing seed is never regenerated).
|
|
403
|
+
const seed = seedFilePath(root, CWD);
|
|
404
|
+
writeEntries(dir, "seed.jsonl", [{ text: "seeded", ts: 10 }], 500);
|
|
405
|
+
const seedBytes = fs.readFileSync(seed, "utf8");
|
|
406
|
+
const globalSeed = globalSeedPath(root);
|
|
407
|
+
fs.writeFileSync(globalSeed, `${JSON.stringify({ v: 1, text: "g" })}\n`);
|
|
408
|
+
const globalBytes = fs.readFileSync(globalSeed, "utf8");
|
|
409
|
+
for (let i = 1; i <= 4; i++) {
|
|
410
|
+
writeFile(dir, `s${i}.jsonl`, 2, i * 1000);
|
|
411
|
+
}
|
|
412
|
+
const result = gcProjectDir(root, CWD, {
|
|
413
|
+
fileThreshold: 2,
|
|
414
|
+
lineThreshold: 100000,
|
|
415
|
+
keepNewest: 1,
|
|
416
|
+
});
|
|
417
|
+
assert.deepEqual(result, { compacted: true, merged: 3 });
|
|
418
|
+
assert.equal(fs.readFileSync(seed, "utf8"), seedBytes);
|
|
419
|
+
assert.equal(fs.readFileSync(globalSeed, "utf8"), globalBytes);
|
|
420
|
+
assert.ok(!compactTexts(dir).includes("seeded"));
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("the current instance's own session file is never merged", () => {
|
|
424
|
+
const root = makeRoot();
|
|
425
|
+
const dir = projectRoot(root);
|
|
426
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
427
|
+
// A long-lived instance whose file is the oldest in the dir.
|
|
428
|
+
const own = writeFile(dir, "own-instance.jsonl", 3, 500);
|
|
429
|
+
for (let i = 1; i <= 4; i++) {
|
|
430
|
+
writeFile(dir, `o${i}.jsonl`, 2, i * 1000);
|
|
431
|
+
}
|
|
432
|
+
const result = gcProjectDir(root, CWD, {
|
|
433
|
+
fileThreshold: 2,
|
|
434
|
+
lineThreshold: 100000,
|
|
435
|
+
keepNewest: 1,
|
|
436
|
+
keepFiles: [own],
|
|
437
|
+
});
|
|
438
|
+
assert.deepEqual(result, { compacted: true, merged: 3 });
|
|
439
|
+
assert.equal(fs.existsSync(own), true);
|
|
440
|
+
assert.ok(!compactTexts(dir).some((t) => t.startsWith("own-instance")));
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("an append by path to a merged file between read and removal is kept", () => {
|
|
444
|
+
const root = makeRoot();
|
|
445
|
+
const dir = projectRoot(root);
|
|
446
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
447
|
+
// An idle-but-live instance: old mtime, so its file is in the merge tail.
|
|
448
|
+
const idle = writeFile(dir, "idle.jsonl", 2, 1000);
|
|
449
|
+
for (let i = 2; i <= 4; i++) {
|
|
450
|
+
writeFile(dir, `p${i}.jsonl`, 2, i * 1000);
|
|
451
|
+
}
|
|
452
|
+
let appended = false;
|
|
453
|
+
withRmSyncPatched(
|
|
454
|
+
(file, rmSync) => {
|
|
455
|
+
if (!appended) {
|
|
456
|
+
appended = true;
|
|
457
|
+
// The live writer appends by path, exactly as appendSessionCapture.
|
|
458
|
+
fs.appendFileSync(
|
|
459
|
+
idle,
|
|
460
|
+
`${JSON.stringify({ v: 1, text: "late-by-path" })}\n`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
rmSync(file);
|
|
464
|
+
},
|
|
465
|
+
() => {
|
|
466
|
+
const result = gcProjectDir(root, CWD, {
|
|
467
|
+
fileThreshold: 2,
|
|
468
|
+
lineThreshold: 100000,
|
|
469
|
+
keepNewest: 1,
|
|
470
|
+
});
|
|
471
|
+
assert.equal(result.compacted, true);
|
|
472
|
+
},
|
|
473
|
+
);
|
|
474
|
+
assert.equal(appended, true);
|
|
475
|
+
const texts = dirTexts(dir);
|
|
476
|
+
assert.ok(texts.includes("late-by-path"), "the late append survived");
|
|
477
|
+
assert.ok(texts.includes("idle.jsonl-0"));
|
|
478
|
+
assert.ok(texts.includes("idle.jsonl-1"));
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("a write through a descriptor opened before the claim is carried over", () => {
|
|
482
|
+
const root = makeRoot();
|
|
483
|
+
const dir = projectRoot(root);
|
|
484
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
485
|
+
const idle = writeFile(dir, "idle.jsonl", 2, 1000);
|
|
486
|
+
for (let i = 2; i <= 4; i++) {
|
|
487
|
+
writeFile(dir, `q${i}.jsonl`, 2, i * 1000);
|
|
488
|
+
}
|
|
489
|
+
// A writer that opened the file before GC started writes after GC read it.
|
|
490
|
+
const fd = fs.openSync(idle, "a");
|
|
491
|
+
let written = false;
|
|
492
|
+
try {
|
|
493
|
+
withRmSyncPatched(
|
|
494
|
+
(file, rmSync) => {
|
|
495
|
+
if (!written) {
|
|
496
|
+
written = true;
|
|
497
|
+
fs.writeSync(
|
|
498
|
+
fd,
|
|
499
|
+
`${JSON.stringify({ v: 1, text: "late-by-fd" })}\n`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
rmSync(file);
|
|
503
|
+
},
|
|
504
|
+
() => {
|
|
505
|
+
gcProjectDir(root, CWD, {
|
|
506
|
+
fileThreshold: 2,
|
|
507
|
+
lineThreshold: 100000,
|
|
508
|
+
keepNewest: 1,
|
|
509
|
+
});
|
|
510
|
+
},
|
|
511
|
+
);
|
|
512
|
+
} finally {
|
|
513
|
+
fs.closeSync(fd);
|
|
514
|
+
}
|
|
515
|
+
assert.equal(written, true);
|
|
516
|
+
const texts = dirTexts(dir);
|
|
517
|
+
assert.ok(texts.includes("late-by-fd"), "the in-flight write survived");
|
|
518
|
+
assert.equal(texts.filter((t) => t.startsWith("idle.jsonl-")).length, 2);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("merged output is chronological by entry ts, not by mutable mtime", () => {
|
|
522
|
+
const root = makeRoot();
|
|
523
|
+
const dir = projectRoot(root);
|
|
524
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
525
|
+
// "old" holds the oldest prompts but was rewritten recently (a delete
|
|
526
|
+
// sweep bumps mtime); "mid" is genuinely newer by ts.
|
|
527
|
+
writeEntries(
|
|
528
|
+
dir,
|
|
529
|
+
"old.jsonl",
|
|
530
|
+
[
|
|
531
|
+
{ text: "old-1", ts: 1000 },
|
|
532
|
+
{ text: "old-2", ts: 1001 },
|
|
533
|
+
],
|
|
534
|
+
90_000,
|
|
535
|
+
);
|
|
536
|
+
writeEntries(
|
|
537
|
+
dir,
|
|
538
|
+
"mid.jsonl",
|
|
539
|
+
[
|
|
540
|
+
{ text: "mid-1", ts: 2000 },
|
|
541
|
+
{ text: "mid-2", ts: 2001 },
|
|
542
|
+
],
|
|
543
|
+
2000,
|
|
544
|
+
);
|
|
545
|
+
writeEntries(dir, "new.jsonl", [{ text: "new-1", ts: 3000 }], 3000);
|
|
546
|
+
const result = gcProjectDir(root, CWD, {
|
|
547
|
+
fileThreshold: 2,
|
|
548
|
+
lineThreshold: 100000,
|
|
549
|
+
keepNewest: 1,
|
|
550
|
+
});
|
|
551
|
+
assert.deepEqual(result, { compacted: true, merged: 2 });
|
|
552
|
+
assert.deepEqual(compactTexts(dir), ["old-1", "old-2", "mid-1", "mid-2"]);
|
|
553
|
+
assert.equal(fs.readdirSync(dir).includes("new.jsonl"), true);
|
|
554
|
+
// The drain still reads newest-first after compaction.
|
|
555
|
+
const drained = drainProject(root, CWD, 1000, root);
|
|
556
|
+
assert.equal(drained.status, "ok");
|
|
557
|
+
if (drained.status === "ok") {
|
|
558
|
+
assert.deepEqual(drained.prompts, [
|
|
559
|
+
"new-1",
|
|
560
|
+
"mid-2",
|
|
561
|
+
"mid-1",
|
|
562
|
+
"old-2",
|
|
563
|
+
"old-1",
|
|
564
|
+
]);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("compaction drops tombstoned prompts instead of copying them", () => {
|
|
569
|
+
const root = makeRoot();
|
|
570
|
+
const dir = projectRoot(root);
|
|
571
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
572
|
+
writeEntries(
|
|
573
|
+
dir,
|
|
574
|
+
"a.jsonl",
|
|
575
|
+
[{ text: "keep me" }, { text: "secret token" }],
|
|
576
|
+
1000,
|
|
577
|
+
);
|
|
578
|
+
writeEntries(dir, "b.jsonl", [{ text: "legacy hidden" }], 2000);
|
|
579
|
+
writeEntries(dir, "c.jsonl", [{ text: "newest" }], 3000);
|
|
580
|
+
assert.equal(hidePrompt(root, "secret token").status, "written");
|
|
581
|
+
// A legacy plaintext (prefix-format) tombstone stays honored too.
|
|
582
|
+
const hidden = JSON.parse(
|
|
583
|
+
fs.readFileSync(path.join(root, "hidden.json"), "utf8"),
|
|
584
|
+
) as string[];
|
|
585
|
+
fs.writeFileSync(
|
|
586
|
+
path.join(root, "hidden.json"),
|
|
587
|
+
JSON.stringify([...hidden, "legacy hidden"]),
|
|
588
|
+
);
|
|
589
|
+
const result = gcProjectDir(root, CWD, {
|
|
590
|
+
fileThreshold: 2,
|
|
591
|
+
lineThreshold: 100000,
|
|
592
|
+
keepNewest: 1,
|
|
593
|
+
});
|
|
594
|
+
assert.equal(result.compacted, true);
|
|
595
|
+
const texts = dirTexts(dir);
|
|
596
|
+
assert.ok(texts.includes("keep me"));
|
|
597
|
+
assert.ok(!texts.includes("secret token"));
|
|
598
|
+
assert.ok(!texts.includes("legacy hidden"));
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("an untrusted hidden.json blocks compaction (fail closed)", () => {
|
|
602
|
+
const root = makeRoot();
|
|
603
|
+
const dir = projectRoot(root);
|
|
604
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
605
|
+
for (let i = 1; i <= 4; i++) {
|
|
606
|
+
writeFile(dir, `u${i}.jsonl`, 2, i * 1000);
|
|
607
|
+
}
|
|
608
|
+
fs.writeFileSync(path.join(root, "hidden.json"), "{corrupt", "utf8");
|
|
609
|
+
const before = fs.readdirSync(dir).sort();
|
|
610
|
+
const result = gcProjectDir(root, CWD, {
|
|
611
|
+
fileThreshold: 2,
|
|
612
|
+
lineThreshold: 100000,
|
|
613
|
+
keepNewest: 1,
|
|
614
|
+
});
|
|
615
|
+
assert.deepEqual(result, { compacted: false, merged: 0 });
|
|
616
|
+
assert.deepEqual(fs.readdirSync(dir).sort(), before);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("a failed compact write is tolerated and loses nothing", () => {
|
|
620
|
+
const root = makeRoot();
|
|
621
|
+
const dir = projectRoot(root);
|
|
622
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
623
|
+
for (let i = 1; i <= 4; i++) {
|
|
624
|
+
writeFile(dir, `w${i}.jsonl`, 3, i * 1000);
|
|
625
|
+
}
|
|
626
|
+
type RenameSync = (from: string, to: string) => void;
|
|
627
|
+
const target = fs as unknown as { renameSync: RenameSync };
|
|
628
|
+
const realRename = fs.renameSync.bind(fs) as RenameSync;
|
|
629
|
+
target.renameSync = (from: string, to: string) => {
|
|
630
|
+
if (path.basename(to).startsWith("compact-")) {
|
|
631
|
+
throw new Error("simulated ENOSPC");
|
|
632
|
+
}
|
|
633
|
+
realRename(from, to);
|
|
634
|
+
};
|
|
635
|
+
let result: ReturnType<typeof gcProjectDir>;
|
|
636
|
+
try {
|
|
637
|
+
result = gcProjectDir(root, CWD, {
|
|
638
|
+
fileThreshold: 2,
|
|
639
|
+
lineThreshold: 100000,
|
|
640
|
+
keepNewest: 1,
|
|
641
|
+
});
|
|
642
|
+
} finally {
|
|
643
|
+
target.renameSync = realRename;
|
|
644
|
+
}
|
|
645
|
+
assert.deepEqual(result, { compacted: false, merged: 0 });
|
|
646
|
+
assert.equal(dirTexts(dir).length, 12);
|
|
647
|
+
assert.deepEqual(
|
|
648
|
+
fs.readdirSync(dir).filter((f) => f.includes(".tmp-")),
|
|
649
|
+
[],
|
|
650
|
+
);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("a torn last line in a merged file is carried over, not dropped", () => {
|
|
654
|
+
const root = makeRoot();
|
|
655
|
+
const dir = projectRoot(root);
|
|
656
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
657
|
+
// A writer died mid-append: the final line has no newline yet.
|
|
658
|
+
const torn = path.join(dir, "torn.jsonl");
|
|
659
|
+
fs.writeFileSync(
|
|
660
|
+
torn,
|
|
661
|
+
`${JSON.stringify({ v: 1, text: "whole" })}\n${JSON.stringify({ v: 1, text: "torn" })}`,
|
|
662
|
+
);
|
|
663
|
+
fs.utimesSync(torn, new Date(1000), new Date(1000));
|
|
664
|
+
writeFile(dir, "r2.jsonl", 2, 2000);
|
|
665
|
+
writeFile(dir, "r3.jsonl", 2, 3000);
|
|
666
|
+
const result = gcProjectDir(root, CWD, {
|
|
667
|
+
fileThreshold: 2,
|
|
668
|
+
lineThreshold: 100000,
|
|
669
|
+
keepNewest: 1,
|
|
670
|
+
});
|
|
671
|
+
assert.deepEqual(result, { compacted: true, merged: 2 });
|
|
672
|
+
assert.equal(fs.existsSync(torn), false);
|
|
673
|
+
// Every compact line parses, and the torn entry survived.
|
|
674
|
+
const texts = compactTexts(dir);
|
|
675
|
+
assert.ok(texts.includes("whole"));
|
|
676
|
+
assert.ok(texts.includes("torn"));
|
|
677
|
+
assert.ok(texts.includes("r2.jsonl-1"));
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// Carry-over ordering (#1394 advisory "carryover-after-unlink"): bytes that
|
|
681
|
+
// reach a claim after its merge read are copied into the compact file
|
|
682
|
+
// BEFORE the claim is removed, so a failed copy leaves the claim — and every
|
|
683
|
+
// byte in it — on disk instead of depending on a post-removal fallback.
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Replace fs.renameSync for the duration of fn; `after` runs right after a
|
|
687
|
+
* rename whose destination is a compact-*.jsonl file lands.
|
|
688
|
+
*/
|
|
689
|
+
function withAfterCompactRename(after: () => void, fn: () => void): void {
|
|
690
|
+
type RenameSync = (from: string, to: string) => void;
|
|
691
|
+
const target = fs as unknown as { renameSync: RenameSync };
|
|
692
|
+
const realRename = fs.renameSync.bind(fs) as RenameSync;
|
|
693
|
+
target.renameSync = (from: string, to: string) => {
|
|
694
|
+
realRename(from, to);
|
|
695
|
+
if (path.basename(to).startsWith("compact-")) after();
|
|
696
|
+
};
|
|
697
|
+
try {
|
|
698
|
+
fn();
|
|
699
|
+
} finally {
|
|
700
|
+
target.renameSync = realRename;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
test("late bytes reach the compact file before their claim is removed", () => {
|
|
705
|
+
const root = makeRoot();
|
|
706
|
+
const dir = projectRoot(root);
|
|
707
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
708
|
+
const idle = writeFile(dir, "idle.jsonl", 2, 1000);
|
|
709
|
+
for (let i = 2; i <= 4; i++) {
|
|
710
|
+
writeFile(dir, `k${i}.jsonl`, 2, i * 1000);
|
|
711
|
+
}
|
|
712
|
+
// A writer that opened the file before the claim writes after the merge.
|
|
713
|
+
const fd = fs.openSync(idle, "a");
|
|
714
|
+
let lateInCompactAtRemoval: boolean | null = null;
|
|
715
|
+
try {
|
|
716
|
+
withAfterCompactRename(
|
|
717
|
+
() => {
|
|
718
|
+
fs.writeSync(fd, `${JSON.stringify({ v: 1, text: "late-by-fd" })}\n`);
|
|
719
|
+
},
|
|
720
|
+
() => {
|
|
721
|
+
withRmSyncPatched(
|
|
722
|
+
(file, rmSync) => {
|
|
723
|
+
if (path.basename(file).startsWith("idle.jsonl.gc-")) {
|
|
724
|
+
lateInCompactAtRemoval = compactTexts(dir).includes("late-by-fd");
|
|
725
|
+
}
|
|
726
|
+
rmSync(file);
|
|
727
|
+
},
|
|
728
|
+
() => {
|
|
729
|
+
const result = gcProjectDir(root, CWD, {
|
|
730
|
+
fileThreshold: 2,
|
|
731
|
+
lineThreshold: 100000,
|
|
732
|
+
keepNewest: 1,
|
|
733
|
+
});
|
|
734
|
+
assert.deepEqual(result, { compacted: true, merged: 3 });
|
|
735
|
+
},
|
|
736
|
+
);
|
|
737
|
+
},
|
|
738
|
+
);
|
|
739
|
+
} finally {
|
|
740
|
+
fs.closeSync(fd);
|
|
741
|
+
}
|
|
742
|
+
assert.equal(lateInCompactAtRemoval, true);
|
|
743
|
+
assert.equal(dirTexts(dir).filter((t) => t === "late-by-fd").length, 1);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test("a failed carry-over keeps the whole claim on disk", () => {
|
|
747
|
+
const root = makeRoot();
|
|
748
|
+
const dir = projectRoot(root);
|
|
749
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
750
|
+
const idle = writeFile(dir, "idle.jsonl", 2, 1000);
|
|
751
|
+
for (let i = 2; i <= 4; i++) {
|
|
752
|
+
writeFile(dir, `m${i}.jsonl`, 2, i * 1000);
|
|
753
|
+
}
|
|
754
|
+
const fd = fs.openSync(idle, "a");
|
|
755
|
+
type AppendFileSync = (file: string, data: string | Buffer) => void;
|
|
756
|
+
const target = fs as unknown as { appendFileSync: AppendFileSync };
|
|
757
|
+
const realAppend = fs.appendFileSync.bind(fs) as AppendFileSync;
|
|
758
|
+
target.appendFileSync = (file: string, data: string | Buffer) => {
|
|
759
|
+
if (path.basename(String(file)).startsWith("compact-")) {
|
|
760
|
+
throw Object.assign(new Error("simulated ENOSPC"), { code: "ENOSPC" });
|
|
761
|
+
}
|
|
762
|
+
realAppend(file, data);
|
|
763
|
+
};
|
|
764
|
+
try {
|
|
765
|
+
withAfterCompactRename(
|
|
766
|
+
() => {
|
|
767
|
+
fs.writeSync(fd, `${JSON.stringify({ v: 1, text: "late-by-fd" })}\n`);
|
|
768
|
+
},
|
|
769
|
+
() => {
|
|
770
|
+
gcProjectDir(root, CWD, {
|
|
771
|
+
fileThreshold: 2,
|
|
772
|
+
lineThreshold: 100000,
|
|
773
|
+
keepNewest: 1,
|
|
774
|
+
});
|
|
775
|
+
},
|
|
776
|
+
);
|
|
777
|
+
} finally {
|
|
778
|
+
target.appendFileSync = realAppend;
|
|
779
|
+
fs.closeSync(fd);
|
|
780
|
+
}
|
|
781
|
+
// The claim survives intact: its merged lines AND the late line.
|
|
782
|
+
const claim = fs
|
|
783
|
+
.readdirSync(dir)
|
|
784
|
+
.find((f) => f.startsWith("idle.jsonl.gc-"));
|
|
785
|
+
assert.ok(claim, "the claim must stay on disk when its carry-over fails");
|
|
786
|
+
const claimTexts = fs
|
|
787
|
+
.readFileSync(path.join(dir, claim), "utf8")
|
|
788
|
+
.trim()
|
|
789
|
+
.split("\n")
|
|
790
|
+
.map((l) => (JSON.parse(l) as { text: string }).text);
|
|
791
|
+
assert.deepEqual(claimTexts, ["idle.jsonl-0", "idle.jsonl-1", "late-by-fd"]);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// Idempotence (#1394 advisory "line-threshold-perpetual"): merging a single
|
|
795
|
+
// file cannot reduce the file count, so GC must not rewrite it — otherwise
|
|
796
|
+
// every shutdown above a threshold rewrites the compact file again.
|
|
797
|
+
|
|
798
|
+
test("a second GC over an already compacted dir rewrites nothing", () => {
|
|
799
|
+
const root = makeRoot();
|
|
800
|
+
const dir = projectRoot(root);
|
|
801
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
802
|
+
for (let i = 1; i <= 12; i++) {
|
|
803
|
+
writeFile(dir, `n${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000);
|
|
804
|
+
}
|
|
805
|
+
const opts = { fileThreshold: 10, lineThreshold: 100000, keepNewest: 10 };
|
|
806
|
+
assert.deepEqual(gcProjectDir(root, CWD, opts), { compacted: true, merged: 2 });
|
|
807
|
+
// 10 newest + 1 compact = 11 files: still above the file threshold.
|
|
808
|
+
const before = fs.readdirSync(dir).sort();
|
|
809
|
+
assert.equal(before.length, 11);
|
|
810
|
+
const compactBefore = compactTexts(dir);
|
|
811
|
+
assert.deepEqual(gcProjectDir(root, CWD, opts), { compacted: false, merged: 0 });
|
|
812
|
+
assert.deepEqual(fs.readdirSync(dir).sort(), before);
|
|
813
|
+
assert.deepEqual(compactTexts(dir), compactBefore);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test("the line threshold with a single merge candidate rewrites nothing", () => {
|
|
817
|
+
const root = makeRoot();
|
|
818
|
+
const dir = projectRoot(root);
|
|
819
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
820
|
+
// 3 files x 4000 lines = 12000 > 10000, but keepNewest 2 leaves one file.
|
|
821
|
+
for (let i = 1; i <= 3; i++) {
|
|
822
|
+
writeFile(dir, `l${i}.jsonl`, 4000, i * 1000);
|
|
823
|
+
}
|
|
824
|
+
const before = fs.readdirSync(dir).sort();
|
|
825
|
+
const result = gcProjectDir(root, CWD, {
|
|
826
|
+
fileThreshold: 10,
|
|
827
|
+
lineThreshold: 10000,
|
|
828
|
+
keepNewest: 2,
|
|
829
|
+
});
|
|
830
|
+
assert.deepEqual(result, { compacted: false, merged: 0 });
|
|
831
|
+
assert.deepEqual(fs.readdirSync(dir).sort(), before);
|
|
832
|
+
});
|