ushman-ledger 0.3.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.
Files changed (101) hide show
  1. package/AGENTS.md +41 -0
  2. package/CHANGELOG.md +23 -0
  3. package/LICENSE.md +21 -0
  4. package/README.md +233 -0
  5. package/dist/archive-journal.d.ts +63 -0
  6. package/dist/archive-journal.d.ts.map +1 -0
  7. package/dist/archive-journal.js +220 -0
  8. package/dist/archive.d.ts +30 -0
  9. package/dist/archive.d.ts.map +1 -0
  10. package/dist/archive.js +117 -0
  11. package/dist/async.d.ts +2 -0
  12. package/dist/async.d.ts.map +1 -0
  13. package/dist/async.js +20 -0
  14. package/dist/blobs.d.ts +10 -0
  15. package/dist/blobs.d.ts.map +1 -0
  16. package/dist/blobs.js +58 -0
  17. package/dist/builders.d.ts +465 -0
  18. package/dist/builders.d.ts.map +1 -0
  19. package/dist/builders.js +73 -0
  20. package/dist/candidate-paths.d.ts +3 -0
  21. package/dist/candidate-paths.d.ts.map +1 -0
  22. package/dist/candidate-paths.js +11 -0
  23. package/dist/cli.d.ts +15 -0
  24. package/dist/cli.d.ts.map +1 -0
  25. package/dist/cli.js +562 -0
  26. package/dist/coverage.d.ts +8 -0
  27. package/dist/coverage.d.ts.map +1 -0
  28. package/dist/coverage.js +128 -0
  29. package/dist/doctor.d.ts +9 -0
  30. package/dist/doctor.d.ts.map +1 -0
  31. package/dist/doctor.js +172 -0
  32. package/dist/handle.d.ts +28 -0
  33. package/dist/handle.d.ts.map +1 -0
  34. package/dist/handle.js +90 -0
  35. package/dist/index.d.ts +11 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +9 -0
  38. package/dist/json.d.ts +4 -0
  39. package/dist/json.d.ts.map +1 -0
  40. package/dist/json.js +25 -0
  41. package/dist/lab-min.d.ts +9 -0
  42. package/dist/lab-min.d.ts.map +1 -0
  43. package/dist/lab-min.js +23 -0
  44. package/dist/list.d.ts +582 -0
  45. package/dist/list.d.ts.map +1 -0
  46. package/dist/list.js +139 -0
  47. package/dist/manifest-update.d.ts +13 -0
  48. package/dist/manifest-update.d.ts.map +1 -0
  49. package/dist/manifest-update.js +43 -0
  50. package/dist/note.d.ts +13 -0
  51. package/dist/note.d.ts.map +1 -0
  52. package/dist/note.js +15 -0
  53. package/dist/patch-metadata.d.ts +37 -0
  54. package/dist/patch-metadata.d.ts.map +1 -0
  55. package/dist/patch-metadata.js +300 -0
  56. package/dist/read-index.d.ts +114 -0
  57. package/dist/read-index.d.ts.map +1 -0
  58. package/dist/read-index.js +210 -0
  59. package/dist/record.d.ts +25 -0
  60. package/dist/record.d.ts.map +1 -0
  61. package/dist/record.js +268 -0
  62. package/dist/recovery.d.ts +39 -0
  63. package/dist/recovery.d.ts.map +1 -0
  64. package/dist/recovery.js +189 -0
  65. package/dist/render/analytics-summary.d.ts +58 -0
  66. package/dist/render/analytics-summary.d.ts.map +1 -0
  67. package/dist/render/analytics-summary.js +151 -0
  68. package/dist/render/dependency-graph.d.ts +3 -0
  69. package/dist/render/dependency-graph.d.ts.map +1 -0
  70. package/dist/render/dependency-graph.js +18 -0
  71. package/dist/render/jsonl.d.ts +3 -0
  72. package/dist/render/jsonl.d.ts.map +1 -0
  73. package/dist/render/jsonl.js +8 -0
  74. package/dist/render/retro.d.ts +6 -0
  75. package/dist/render/retro.d.ts.map +1 -0
  76. package/dist/render/retro.js +124 -0
  77. package/dist/render/timeline-html.d.ts +3 -0
  78. package/dist/render/timeline-html.d.ts.map +1 -0
  79. package/dist/render/timeline-html.js +37 -0
  80. package/dist/schema/entry.d.ts +3298 -0
  81. package/dist/schema/entry.d.ts.map +1 -0
  82. package/dist/schema/entry.js +619 -0
  83. package/dist/schema/manifest.d.ts +42 -0
  84. package/dist/schema/manifest.d.ts.map +1 -0
  85. package/dist/schema/manifest.js +27 -0
  86. package/dist/schema/note.d.ts +10 -0
  87. package/dist/schema/note.d.ts.map +1 -0
  88. package/dist/schema/note.js +2 -0
  89. package/dist/storage/filesystem.d.ts +35 -0
  90. package/dist/storage/filesystem.d.ts.map +1 -0
  91. package/dist/storage/filesystem.js +258 -0
  92. package/dist/storage/lock.d.ts +18 -0
  93. package/dist/storage/lock.d.ts.map +1 -0
  94. package/dist/storage/lock.js +224 -0
  95. package/dist/uuid.d.ts +7 -0
  96. package/dist/uuid.d.ts.map +1 -0
  97. package/dist/uuid.js +25 -0
  98. package/dist/version.d.ts +2 -0
  99. package/dist/version.d.ts.map +1 -0
  100. package/dist/version.js +1 -0
  101. package/package.json +73 -0
package/AGENTS.md ADDED
@@ -0,0 +1,41 @@
1
+ # AGENTS.md — ushman-ledger
2
+
3
+ Read this before changing code in this package.
4
+
5
+ ## What this package is
6
+
7
+ An append-only ledger library and CLI for ushman v4 workspaces. It owns ledger schemas, storage, rendering, archive integrity, and doctor/coverage helpers.
8
+
9
+ ## What this package is NOT
10
+
11
+ - Not a pipeline orchestrator.
12
+ - Not a markdown source of truth.
13
+ - Not a general database abstraction.
14
+
15
+ ## Working rules
16
+
17
+ - Keep runtime code pure Node and offline.
18
+ - Exception: the CLI `--diff-from-git` convenience flag shells out to `git`; document and test any behavior that depends on it.
19
+ - Preserve append-only semantics.
20
+ - Every storage mutation must be atomic and recoverable after crashes.
21
+ - Every new behavior ships with tests.
22
+
23
+ ## Read order
24
+
25
+ 1. `README.md`
26
+ 2. `src/schema/entry.ts`
27
+ 3. `src/storage/filesystem.ts`
28
+ 4. `src/record.ts`
29
+ 5. `src/handle.ts`
30
+ 6. `src/coverage.ts`
31
+ 7. `src/doctor.ts`
32
+ 8. `src/cli.ts`
33
+
34
+ ## Commands
35
+
36
+ ```bash
37
+ bun test
38
+ bun run lint
39
+ bun run typecheck
40
+ bun run build
41
+ ```
package/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ ## [1.0.0] - 2026-05-14
4
+
5
+ - Added structured patch payloads with touched paths, diff hashes, line hunks, and before/after file hashes.
6
+ - Added UUIDv7-backed `validator-result` payload ids, structured `stage-transition` and `operator-decision` payloads, and the new first-class ledger kinds required by ushman v4.1 analytics.
7
+ - Added cached `render --to=analytics-summary` output, legacy-entry read migration markers, and write-side builder helpers for the structured record kinds.
8
+
9
+ ### Breaking changes
10
+
11
+ - New `operator-decision` writes must use the structured `payload.action` and `payload.rationale` fields.
12
+ - Stored `validator-result` entries now persist `payload.id`; helper-side synthesis is only a temporary migration convenience.
13
+ - Structured patch writes now enforce the machine-readable payload fields instead of accepting prose-only patch metadata.
14
+
15
+ ## [0.2.0] - 2026-05-14
16
+
17
+ - Added durable `read-index.json` sidecar rebuilds and persisted coverage indexing for large-ledger scans.
18
+ - Made archive creation crash-recoverable with pending archive journals and post-create tar verification.
19
+ - Added archive recovery tests, read-index rebuild tests, and a manual scale benchmark harness.
20
+
21
+ ## [0.1.0] - 2026-05-12
22
+
23
+ - Initial implementation of `ushman-ledger`.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ragaeeb Haq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,233 @@
1
+ # ushman-ledger
2
+
3
+ Append-only workspace ledger library and CLI for ushman v4 workspaces.
4
+
5
+ ## What it owns
6
+
7
+ - Ledger entry schemas
8
+ - Structured patch / validator / stage payloads for analytics consumers
9
+ - Serialized manifest-safe entry writes
10
+ - Content-hash idempotency
11
+ - Patch blob storage
12
+ - Retro / JSONL / timeline rendering
13
+ - Archive integrity output
14
+ - Doctor and coverage helpers
15
+
16
+ ## Install for local workspace development
17
+
18
+ ```bash
19
+ bun install
20
+ bun run build
21
+ bun link
22
+
23
+ cd ~/workspace/ushman
24
+ bun link ushman-ledger
25
+ ```
26
+
27
+ ## Library API
28
+
29
+ ```ts
30
+ import { buildValidatorResultRecord, openLedger } from 'ushman-ledger';
31
+
32
+ const ledger = await openLedger('/path/to/workspace');
33
+
34
+ await ledger.record({
35
+ emitter: { tool: 'ushman-cli', version: '1.0.0' },
36
+ kind: 'tool-invocation',
37
+ phase: 'capture',
38
+ summary: 'capture started',
39
+ });
40
+
41
+ await ledger.record({
42
+ agent: { name: 'codex-cli' },
43
+ diffPath: '/tmp/change.patch',
44
+ emitter: { tool: 'ushman-cli', version: '1.0.0' },
45
+ kind: 'agent-patch',
46
+ phase: 'cleanup',
47
+ rationale: 'Track a manual patch.',
48
+ summary: 'Apply websocket shim',
49
+ });
50
+
51
+ await ledger.record(
52
+ buildValidatorResultRecord({
53
+ emitter: { tool: 'ushman-doctor', version: '1.0.0' },
54
+ phase: 'cleanup',
55
+ summary: 'coverage gate passed',
56
+ validator: 'doctor',
57
+ verdict: 'green',
58
+ }),
59
+ );
60
+
61
+ await ledger.render({ to: 'retro' });
62
+ await ledger.render({ to: 'analytics-summary' });
63
+ await ledger.archive('/tmp/ledger.tgz');
64
+ ```
65
+
66
+ ## CLI
67
+
68
+ ```bash
69
+ ushman-ledger record --workspace=<ws> --kind=tool-invocation --phase=capture --summary="capture started"
70
+ ushman-ledger record --workspace=<ws> --kind=agent-patch --phase=cleanup --summary="capture git diff" --rationale="track working tree change" --diff-from-git=HEAD
71
+ ushman-ledger record --workspace=<ws> --kind=operator-decision --phase=cleanup --summary="manual override" --action=ledger-hand-edit --check-id=manual-review --rationale="operator edited the ledger after audit"
72
+ ushman-ledger note regression --workspace=<ws> --phase=cleanup --summary="runtime drift" --body=/tmp/note.md
73
+ ushman-ledger list --workspace=<ws> --json
74
+ ushman-ledger render --workspace=<ws> --to=retro
75
+ ushman-ledger render --workspace=<ws> --to=jsonl --out=/tmp/ledger.jsonl
76
+ ushman-ledger render --workspace=<ws> --to=dependency-graph --out=/tmp/ledger.mmd
77
+ ushman-ledger render --workspace=<ws> --to=analytics-summary
78
+ ushman-ledger render --workspace=<ws> --to=analytics-summary --fresh
79
+ ushman-ledger render --workspace=<ws> --to=analytics-summary --json
80
+ ushman-ledger archive --workspace=<ws> --out=/tmp/ledger.tgz
81
+ ushman-ledger doctor --workspace=<ws>
82
+ ```
83
+
84
+ Valid record kinds: `tool-invocation`, `agent-patch`, `operator-patch`, `stage-transition`, `operator-decision`, `validator-result`, `runtime-event`, `note`, `correction`, `strip-decision-reverted`, `descope-brief`, `merge-return`, `merge-return-rejected`, `revert`, `rollback`, `rework.test_retired`
85
+
86
+ Valid phases: `capture`, `intake`, `seed`, `vendor-extract`, `cleanup`, `parity`, `characterize`, `equiv`, `analyze`, `recover`, `ship`, `migration`
87
+
88
+ Valid note subkinds: `regression`, `automation`, `retro`, `operator`, `tooling-gap`
89
+
90
+ Valid render targets: `retro`, `jsonl`, `timeline-html`, `dependency-graph`, `analytics-summary`
91
+
92
+ ## Workspace prerequisite
93
+
94
+ `openLedger()` and the CLI expect a valid ushman v4 workspace with `.lab/lab.json` already present.
95
+
96
+ ## Idempotency
97
+
98
+ - Without `idempotencyKey`, records dedupe by their logical content hash.
99
+ - With `idempotencyKey`, the key is authoritative within a phase. Reusing the same key with different content in the same phase returns the first recorded entry unchanged.
100
+ - Stored entries copy the idempotency key into `links.idempotencyKey` for auditability.
101
+
102
+ ## Structured payloads
103
+
104
+ - Patch entries (`agent-patch`, `operator-patch`) store `payload.touchedPaths`, `payload.fileSha256Before`, `payload.fileSha256After`, `payload.hunks`, `payload.diffSha256`, and `payload.diff`.
105
+ - Stored `validator-result` entries require a UUIDv7 `payload.id`. Callers should provide it in normal operation; the write helpers only synthesize one during the migration window when legacy callers omit it.
106
+ - `stage-transition` entries capture `{ stage, startedAt, endedAt, exitCode, doctorBaselineId? }` explicitly in the ledger.
107
+ - `operator-decision` entries capture `{ action, checkId?, rationale }` in `payload`.
108
+ - `rework.test_retired` is the structured way to retire a seed-emitted test from the pipeline corpus.
109
+ - Legacy on-disk entries still read successfully. The library normalizes them into the current shape and marks them with `_legacy: true`.
110
+
111
+ Example payloads:
112
+
113
+ ```json
114
+ {
115
+ "kind": "stage-transition",
116
+ "payload": {
117
+ "stage": "cleanup",
118
+ "startedAt": "2026-05-14T12:00:00.000Z",
119
+ "endedAt": "2026-05-14T12:00:05.000Z",
120
+ "exitCode": 0
121
+ }
122
+ }
123
+ ```
124
+
125
+ ```json
126
+ {
127
+ "kind": "rework.test_retired",
128
+ "payload": {
129
+ "removedTestPath": "tests/pure/app.test.ts",
130
+ "removedTestSymbols": ["renderApp"],
131
+ "removedBy": "descope",
132
+ "sourceSymbolDeletedFrom": "src/app.ts"
133
+ }
134
+ }
135
+ ```
136
+
137
+ ## Migration guide
138
+
139
+ - Historical entries are not rewritten. Old on-disk entries continue to load through read-side normalization.
140
+ - Normalized legacy entries are marked with `_legacy: true` so analytics can distinguish migrated reads from fully structured writes.
141
+ - New writes are expected to use structured payloads. Temporary compatibility logic only remains for omitted `validator-result.payload.id` values.
142
+
143
+ ## Analytics summary
144
+
145
+ - `render --to=analytics-summary` emits a compact JSON envelope for downstream analytics consumers.
146
+ - The summary is cached at `.lab/ledger/analytics-summary.json` and invalidates automatically when the ledger tip changes.
147
+ - `render --to=analytics-summary --fresh` bypasses the cache and recomputes the envelope for one-off verification.
148
+ - `render --to=analytics-summary --json` remains as a compatibility alias for `--fresh`.
149
+
150
+ ## Links and coverage
151
+
152
+ - `links.affectedFiles` should contain normalized workspace-relative paths for files changed by an `agent-patch` or `operator-patch`.
153
+ - Use forward slashes, do not prefix paths with `./`, and do not include trailing slashes or `..` segments.
154
+ - Coverage only considers candidate workspace files modified after workspace initialization.
155
+ - A modified file is considered covered when any patch entry lists it in `links.affectedFiles`.
156
+ - Coverage is backed by a durable read index so repeated coverage runs do not rescan every patch entry.
157
+
158
+ ## Append chain and concurrency
159
+
160
+ - Each phase is an append-only linked list through `prevEntryId`.
161
+ - Manifest updates are serialized through a global ledger lock.
162
+ - Appends use a pending-commit journal so entry files, the manifest, and the durable read index can be replayed after crashes.
163
+ - Open, read, and append paths reconcile unfinished commits before serving ledger data.
164
+ - Corrupt or stale manifest locks are reclaimed automatically with compare-and-swap style quarantine semantics.
165
+
166
+ ## Crash recovery
167
+
168
+ - Pending commit journals live under `.lab/ledger/pending/`.
169
+ - Pending archive journals live under `.lab/ledger/pending-archives/`.
170
+ - Startup reconciliation replays journaled appends in manifest sequence order.
171
+ - Startup reconciliation also rebuilds a missing or stale `read-index.json` from manifest sequence order.
172
+ - Verified pending archives are adopted into the manifest on startup; corrupt or partial pending archives are deleted.
173
+ - Stale temp files are cleaned for phase entries, the manifest, blobs, the read index, and render outputs.
174
+
175
+ ## Recovery & Troubleshooting
176
+
177
+ - `.lab/ledger/pending/` contains append journals that let the ledger recover incomplete writes after crashes. Do not edit these files by hand.
178
+ - If startup or `doctor` reports a pending commit mismatch, re-open the ledger or rerun the command first so reconciliation can replay or discard the journal safely.
179
+ - If `analytics-summary.json` is invalid or stale, rerun `ushman-ledger render --to=analytics-summary --fresh` to recompute it from the ledger tip.
180
+ - If `doctor` reports manifest or blob corruption, fix the underlying entry/blob mismatch first and rerun `doctor` before attempting `archive`.
181
+
182
+ ## Scan behavior
183
+
184
+ - `list`, render, coverage, and doctor iterate entries in manifest sequence order.
185
+ - Read paths use a durable `read-index.json` sidecar instead of resorting `manifest.entryLocations` on every scan.
186
+ - `list --limit=<n>` keeps bounded memory and returns the last `n` matching entries in append order.
187
+ - Limited reads can filter by `kind` and `since` from the durable read index before reading entry files.
188
+ - Coverage file stats and doctor blob hashing use bounded concurrency instead of unbounded `Promise.all`.
189
+
190
+ ## Archive integrity
191
+
192
+ - Archives are created as `.tgz` files using pure Node code.
193
+ - Each archive includes `archive-manifest.json` with per-file SHA-256 hashes and an overall `integrityHash`.
194
+ - `archive` writes a pending archive journal before tar creation, verifies the final tarball after creation, and only then appends archive metadata to the manifest.
195
+ - `archive` refuses to run when `doctor` reports an unhealthy ledger.
196
+
197
+ ## Git diff capture
198
+
199
+ - `--diff-from-git=<ref>` runs `git diff <ref>` against the workspace working tree.
200
+ - The CLI times out `git diff` after 30 seconds and reports a usage error instead of hanging indefinitely.
201
+ - This is the only CLI feature that depends on an external binary. `git` must be available in `PATH`.
202
+
203
+ ## Storage shape
204
+
205
+ ```text
206
+ <ws>/.lab/ledger/
207
+ .manifest.lock
208
+ analytics-summary.json
209
+ manifest.json
210
+ read-index.json
211
+ pending/
212
+ pending-archives/
213
+ blobs/
214
+ render.md
215
+ render.timeline.html
216
+ <phase>/
217
+ <timestamp>-<sequence>-<hash>.json
218
+ ```
219
+
220
+ ## Validation
221
+
222
+ ```bash
223
+ bun run lint
224
+ bun run typecheck
225
+ bun test
226
+ bun run build
227
+ bun run bench:scale
228
+ ```
229
+
230
+ ## Scale benchmark
231
+
232
+ - `bun run bench:scale` creates a temporary workspace and benchmarks large-ledger paths for population, limited reads, repeated coverage, and repeated doctor runs.
233
+ - Override the defaults with environment variables such as `LEDGER_BENCH_ENTRY_COUNT=100000` and `LEDGER_BENCH_CANDIDATE_FILE_COUNT=10000`.
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod';
2
+ import type { LedgerManifest } from './schema/manifest.ts';
3
+ export declare const ArchiveManifestSchema: z.ZodObject<{
4
+ fileHashes: z.ZodArray<z.ZodObject<{
5
+ path: z.ZodString;
6
+ sha256: z.ZodString;
7
+ }, z.core.$strip>>;
8
+ integrityHash: z.ZodString;
9
+ schemaVersion: z.ZodLiteral<"ushman-ledger-archive-manifest/v1">;
10
+ }, z.core.$strip>;
11
+ export type ArchiveManifest = z.infer<typeof ArchiveManifestSchema>;
12
+ export declare const collectFileHashes: (root: string, prefix?: string) => Promise<Array<{
13
+ path: string;
14
+ sha256: string;
15
+ }>>;
16
+ export declare const createArchiveManifest: (ledgerRoot: string) => Promise<{
17
+ fileHashes: {
18
+ path: string;
19
+ sha256: string;
20
+ }[];
21
+ integrityHash: string;
22
+ schemaVersion: "ushman-ledger-archive-manifest/v1";
23
+ }>;
24
+ export declare const verifyArchiveFile: ({ archivePath, expectedArchiveManifest, }: {
25
+ readonly archivePath: string;
26
+ readonly expectedArchiveManifest: ArchiveManifest;
27
+ }) => Promise<void>;
28
+ export declare const writeArchiveManifestFile: ({ archiveManifest, stagingRoot, }: {
29
+ readonly archiveManifest: ArchiveManifest;
30
+ readonly stagingRoot: string;
31
+ }) => Promise<void>;
32
+ export declare const writePendingArchive: ({ archiveManifest, createdAt, outPath, workspaceRoot, }: {
33
+ readonly archiveManifest: ArchiveManifest;
34
+ readonly createdAt: string;
35
+ readonly outPath: string;
36
+ readonly workspaceRoot: string;
37
+ }) => Promise<string>;
38
+ export declare const removePendingArchive: (filePath: string) => Promise<void>;
39
+ export declare const reconcilePendingArchivesUnderLock: ({ manifest, workspaceRoot, }: {
40
+ readonly manifest: LedgerManifest;
41
+ readonly workspaceRoot: string;
42
+ }) => Promise<{
43
+ [x: string]: unknown;
44
+ archives: {
45
+ createdAt: string;
46
+ integrityHash: string;
47
+ outPath: string;
48
+ }[];
49
+ createdAt: string;
50
+ entryCount: number;
51
+ entryLocations: Record<string, {
52
+ phase: "seed" | "capture" | "intake" | "vendor-extract" | "cleanup" | "parity" | "characterize" | "equiv" | "analyze" | "recover" | "ship" | "migration";
53
+ sequence: number;
54
+ }>;
55
+ idempotencyIndex: Record<string, Record<string, string>>;
56
+ lastSequence: number;
57
+ perPhaseCounts: Record<string, number>;
58
+ perPhaseLatest: Record<string, string>;
59
+ schemaVersion: "ushman-ledger-manifest/v1";
60
+ updatedAt: string;
61
+ workspaceId: string;
62
+ }>;
63
+ //# sourceMappingURL=archive-journal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"archive-journal.d.ts","sourceRoot":"","sources":["../src/archive-journal.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAW3D,eAAO,MAAM,qBAAqB;;;;;;;iBAIhC,CAAC;AASH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAyCpE,eAAO,MAAM,iBAAiB,GAC1B,MAAM,MAAM,EACZ,eAAW,KACZ,OAAO,CAAC,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAcjD,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAAU,YAAY,MAAM;;;;;;;EAO7D,CAAC;AA+DF,eAAO,MAAM,iBAAiB,GAAU,2CAGrC;IACC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,uBAAuB,EAAE,eAAe,CAAC;CACrD,kBA2BA,CAAC;AAEF,eAAO,MAAM,wBAAwB,GAAU,mCAG5C;IACC,QAAQ,CAAC,eAAe,EAAE,eAAe,CAAC;IAC1C,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAChC,kBAMA,CAAC;AA4CF,eAAO,MAAM,mBAAmB,GAAU,yDAKvC;IACC,QAAQ,CAAC,eAAe,EAAE,eAAe,CAAC;IAC1C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAClC,oBAgBA,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAU,UAAU,MAAM,kBAE1D,CAAC;AAEF,eAAO,MAAM,iCAAiC,GAAU,8BAGrD;IACC,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAClC;;;;;;;;;;;;;;;;;;;;EA+CA,CAAC"}
@@ -0,0 +1,220 @@
1
+ import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { extract as extractTar } from 'tar';
5
+ import { z } from 'zod';
6
+ import { sha256File, sha256Hex, stableStringify } from "./json.js";
7
+ import { updateManifestForArchive } from "./manifest-update.js";
8
+ import { resolveLedgerPaths, saveManifest, writeAtomicJsonFile } from "./storage/filesystem.js";
9
+ const ARCHIVE_MANIFEST_SCHEMA_VERSION = 'ushman-ledger-archive-manifest/v1';
10
+ const PENDING_ARCHIVE_SCHEMA_VERSION = 'ushman-ledger-pending-archive/v1';
11
+ const ArchiveManifestFileHashSchema = z.object({
12
+ path: z.string().min(1),
13
+ sha256: z.string().length(64),
14
+ });
15
+ export const ArchiveManifestSchema = z.object({
16
+ fileHashes: z.array(ArchiveManifestFileHashSchema),
17
+ integrityHash: z.string().length(64),
18
+ schemaVersion: z.literal(ARCHIVE_MANIFEST_SCHEMA_VERSION),
19
+ });
20
+ const PendingArchiveSchema = z.object({
21
+ archiveManifest: ArchiveManifestSchema,
22
+ createdAt: z.string().datetime({ offset: true }),
23
+ outPath: z.string().min(1),
24
+ schemaVersion: z.literal(PENDING_ARCHIVE_SCHEMA_VERSION),
25
+ });
26
+ const formatPendingArchiveId = (createdAt, outPath) => `${createdAt.replaceAll(':', '-').replaceAll('.', '-')}-${sha256Hex(outPath).slice(0, 12)}`;
27
+ const buildPendingArchivePath = ({ createdAt, outPath, workspaceRoot, }) => path.join(resolveLedgerPaths(workspaceRoot).pendingArchivesDir, `${formatPendingArchiveId(createdAt, outPath)}.json`);
28
+ const parsePendingArchiveText = (filePath, text) => {
29
+ try {
30
+ return PendingArchiveSchema.parse(JSON.parse(text));
31
+ }
32
+ catch (error) {
33
+ throw new Error(`Invalid pending archive at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
34
+ }
35
+ };
36
+ const parseArchiveManifestText = (archivePath, text) => {
37
+ try {
38
+ return ArchiveManifestSchema.parse(JSON.parse(text));
39
+ }
40
+ catch (error) {
41
+ throw new Error(`Invalid archive manifest inside ${archivePath}: ${error instanceof Error ? error.message : String(error)}`);
42
+ }
43
+ };
44
+ export const collectFileHashes = async (root, prefix = '') => {
45
+ const entries = await readdir(root, { withFileTypes: true });
46
+ const hashes = [];
47
+ for (const entry of entries) {
48
+ const relPath = prefix ? path.join(prefix, entry.name) : entry.name;
49
+ const fullPath = path.join(root, entry.name);
50
+ if (entry.isDirectory()) {
51
+ hashes.push(...(await collectFileHashes(fullPath, relPath)));
52
+ continue;
53
+ }
54
+ hashes.push({ path: relPath.replaceAll('\\', '/'), sha256: await sha256File(fullPath) });
55
+ }
56
+ hashes.sort((left, right) => left.path.localeCompare(right.path));
57
+ return hashes;
58
+ };
59
+ export const createArchiveManifest = async (ledgerRoot) => {
60
+ const fileHashes = await collectFileHashes(ledgerRoot);
61
+ return ArchiveManifestSchema.parse({
62
+ fileHashes,
63
+ integrityHash: sha256Hex(stableStringify(fileHashes)),
64
+ schemaVersion: ARCHIVE_MANIFEST_SCHEMA_VERSION,
65
+ });
66
+ };
67
+ const verifyArchiveManifest = ({ actualArchiveManifest, archivePath, expectedArchiveManifest, }) => {
68
+ if (stableStringify(actualArchiveManifest) !== stableStringify(expectedArchiveManifest)) {
69
+ throw new Error(`Archive manifest mismatch for ${archivePath}.`);
70
+ }
71
+ };
72
+ const verifyExtractedLedgerHashes = async ({ archivePath, expectedArchiveManifest, extractedRoot, }) => {
73
+ const actualLedgerRoot = path.join(extractedRoot, 'ledger');
74
+ const actualFileHashes = await collectFileHashes(actualLedgerRoot);
75
+ const actualIntegrityHash = sha256Hex(stableStringify(actualFileHashes));
76
+ if (stableStringify(actualFileHashes) !== stableStringify(expectedArchiveManifest.fileHashes)) {
77
+ throw new Error(`Archive payload mismatch for ${archivePath}.`);
78
+ }
79
+ if (actualIntegrityHash !== expectedArchiveManifest.integrityHash) {
80
+ throw new Error(`Archive integrity hash mismatch for ${archivePath}: expected ${expectedArchiveManifest.integrityHash}, found ${actualIntegrityHash}.`);
81
+ }
82
+ };
83
+ const verifyArchiveStructure = async ({ archivePath, extractedRoot, }) => {
84
+ const rootEntries = (await readdir(extractedRoot, { withFileTypes: true }))
85
+ .map((entry) => ({
86
+ isDirectory: entry.isDirectory(),
87
+ name: entry.name,
88
+ }))
89
+ .sort((left, right) => left.name.localeCompare(right.name));
90
+ const expectedEntries = [
91
+ { isDirectory: false, name: 'archive-manifest.json' },
92
+ { isDirectory: true, name: 'ledger' },
93
+ ];
94
+ if (stableStringify(rootEntries) !== stableStringify(expectedEntries)) {
95
+ throw new Error(`Archive structure mismatch for ${archivePath}.`);
96
+ }
97
+ };
98
+ export const verifyArchiveFile = async ({ archivePath, expectedArchiveManifest, }) => {
99
+ const extractedRoot = await mkdtemp(path.join(os.tmpdir(), 'ushman-ledger-archive-verify-'));
100
+ try {
101
+ await extractTar({
102
+ cwd: extractedRoot,
103
+ file: archivePath,
104
+ gzip: true,
105
+ });
106
+ await verifyArchiveStructure({
107
+ archivePath,
108
+ extractedRoot,
109
+ });
110
+ const manifestText = await readFile(path.join(extractedRoot, 'archive-manifest.json'), 'utf8');
111
+ const actualArchiveManifest = parseArchiveManifestText(archivePath, manifestText);
112
+ verifyArchiveManifest({
113
+ actualArchiveManifest,
114
+ archivePath,
115
+ expectedArchiveManifest,
116
+ });
117
+ await verifyExtractedLedgerHashes({
118
+ archivePath,
119
+ expectedArchiveManifest,
120
+ extractedRoot,
121
+ });
122
+ }
123
+ finally {
124
+ await rm(extractedRoot, { force: true, recursive: true });
125
+ }
126
+ };
127
+ export const writeArchiveManifestFile = async ({ archiveManifest, stagingRoot, }) => {
128
+ await writeFile(path.join(stagingRoot, 'archive-manifest.json'), `${stableStringify(ArchiveManifestSchema.parse(archiveManifest), true)}\n`, 'utf8');
129
+ };
130
+ const readPendingArchive = async (filePath) => parsePendingArchiveText(filePath, await readFile(filePath, 'utf8'));
131
+ const readPendingArchives = async (workspaceRoot) => {
132
+ const pendingArchivesDir = resolveLedgerPaths(workspaceRoot).pendingArchivesDir;
133
+ const pendingArchiveFiles = (await readdir(pendingArchivesDir, { withFileTypes: true }))
134
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
135
+ .map((entry) => path.join(pendingArchivesDir, entry.name))
136
+ .sort((left, right) => left.localeCompare(right));
137
+ return Promise.all(pendingArchiveFiles.map(async (filePath) => ({
138
+ filePath,
139
+ pendingArchive: await readPendingArchive(filePath),
140
+ })));
141
+ };
142
+ const hasTrackedArchive = (manifest, pendingArchive) => manifest.archives.some((archive) => archive.createdAt === pendingArchive.createdAt &&
143
+ archive.integrityHash === pendingArchive.archiveManifest.integrityHash &&
144
+ archive.outPath === pendingArchive.outPath);
145
+ const archiveOutputExists = async (archivePath) => {
146
+ try {
147
+ await stat(archivePath);
148
+ return true;
149
+ }
150
+ catch (error) {
151
+ if (error.code === 'ENOENT') {
152
+ return false;
153
+ }
154
+ throw error;
155
+ }
156
+ };
157
+ const removePendingArchiveOutput = async (archivePath) => {
158
+ await rm(archivePath, { force: true });
159
+ };
160
+ export const writePendingArchive = async ({ archiveManifest, createdAt, outPath, workspaceRoot, }) => {
161
+ const filePath = buildPendingArchivePath({
162
+ createdAt,
163
+ outPath,
164
+ workspaceRoot,
165
+ });
166
+ await writeAtomicJsonFile(filePath, PendingArchiveSchema.parse({
167
+ archiveManifest,
168
+ createdAt,
169
+ outPath,
170
+ schemaVersion: PENDING_ARCHIVE_SCHEMA_VERSION,
171
+ }));
172
+ return filePath;
173
+ };
174
+ export const removePendingArchive = async (filePath) => {
175
+ await rm(filePath, { force: true });
176
+ };
177
+ export const reconcilePendingArchivesUnderLock = async ({ manifest, workspaceRoot, }) => {
178
+ let nextManifest = manifest;
179
+ let manifestChanged = false;
180
+ const pendingArchives = await readPendingArchives(workspaceRoot);
181
+ const processedArchivePaths = [];
182
+ for (const { filePath, pendingArchive } of pendingArchives) {
183
+ if (hasTrackedArchive(nextManifest, pendingArchive)) {
184
+ processedArchivePaths.push(filePath);
185
+ continue;
186
+ }
187
+ if (!(await archiveOutputExists(pendingArchive.outPath))) {
188
+ processedArchivePaths.push(filePath);
189
+ continue;
190
+ }
191
+ try {
192
+ await verifyArchiveFile({
193
+ archivePath: pendingArchive.outPath,
194
+ expectedArchiveManifest: pendingArchive.archiveManifest,
195
+ });
196
+ nextManifest = updateManifestForArchive({
197
+ archive: {
198
+ createdAt: pendingArchive.createdAt,
199
+ integrityHash: pendingArchive.archiveManifest.integrityHash,
200
+ outPath: pendingArchive.outPath,
201
+ },
202
+ manifest: nextManifest,
203
+ });
204
+ manifestChanged = true;
205
+ }
206
+ catch (error) {
207
+ if (process.env.NODE_ENV !== 'test') {
208
+ const details = error instanceof Error ? error.message : String(error);
209
+ console.debug(`[ushman-ledger] removing invalid pending archive ${pendingArchive.outPath}: ${details}`);
210
+ }
211
+ await removePendingArchiveOutput(pendingArchive.outPath);
212
+ }
213
+ processedArchivePaths.push(filePath);
214
+ }
215
+ if (manifestChanged) {
216
+ await saveManifest(workspaceRoot, nextManifest);
217
+ }
218
+ await Promise.all(processedArchivePaths.map(async (filePath) => removePendingArchive(filePath)));
219
+ return nextManifest;
220
+ };
@@ -0,0 +1,30 @@
1
+ import { type ArchiveManifest } from './archive-journal.ts';
2
+ type ArchiveTestHooks = {
3
+ readonly afterArchiveVerify?: (context: {
4
+ archiveManifest: ArchiveManifest;
5
+ outPath: string;
6
+ }) => Promise<void>;
7
+ readonly afterManifestSave?: (context: {
8
+ archiveManifest: ArchiveManifest;
9
+ outPath: string;
10
+ }) => Promise<void>;
11
+ readonly afterPendingArchiveWrite?: (context: {
12
+ archiveManifest: ArchiveManifest;
13
+ outPath: string;
14
+ pendingArchivePath: string;
15
+ }) => Promise<void>;
16
+ readonly afterTarCreate?: (context: {
17
+ archiveManifest: ArchiveManifest;
18
+ outPath: string;
19
+ }) => Promise<void>;
20
+ readonly createTar?: (context: {
21
+ outPath: string;
22
+ stagingRoot: string;
23
+ }) => Promise<void>;
24
+ };
25
+ export declare const setArchiveTestHooks: (workspaceRoot: string, hooks: ArchiveTestHooks | null) => void;
26
+ export declare const archiveLedger: (workspaceRoot: string, outPath: string) => Promise<{
27
+ integrityHash: string;
28
+ }>;
29
+ export {};
30
+ //# sourceMappingURL=archive.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"AAIA,OAAO,EACH,KAAK,eAAe,EAKvB,MAAM,sBAAsB,CAAC;AAO9B,KAAK,gBAAgB,GAAG;IACpB,QAAQ,CAAC,kBAAkB,CAAC,EAAE,CAAC,OAAO,EAAE;QAAE,eAAe,EAAE,eAAe,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,OAAO,EAAE;QAAE,eAAe,EAAE,eAAe,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/G,QAAQ,CAAC,wBAAwB,CAAC,EAAE,CAAC,OAAO,EAAE;QAC1C,eAAe,EAAE,eAAe,CAAC;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,kBAAkB,EAAE,MAAM,CAAC;KAC9B,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpB,QAAQ,CAAC,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE;QAAE,eAAe,EAAE,eAAe,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5G,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7F,CAAC;AAOF,eAAO,MAAM,mBAAmB,GAAI,eAAe,MAAM,EAAE,OAAO,gBAAgB,GAAG,IAAI,SAOxF,CAAC;AA8BF,eAAO,MAAM,aAAa,GAAU,eAAe,MAAM,EAAE,SAAS,MAAM;;EAyFzE,CAAC"}