ushman-ledger 1.2.0 → 1.2.2

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 (63) hide show
  1. package/ARCHITECTURE.md +79 -0
  2. package/README.md +144 -5
  3. package/TROUBLESHOOTING.md +170 -0
  4. package/dist/blobs.d.ts +3 -0
  5. package/dist/blobs.d.ts.map +1 -1
  6. package/dist/blobs.js +41 -15
  7. package/dist/builders.d.ts +1 -2
  8. package/dist/builders.d.ts.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +231 -70
  11. package/dist/coverage.d.ts.map +1 -1
  12. package/dist/coverage.js +3 -2
  13. package/dist/doctor.d.ts +17 -4
  14. package/dist/doctor.d.ts.map +1 -1
  15. package/dist/doctor.js +225 -58
  16. package/dist/handle.d.ts +27 -7
  17. package/dist/handle.d.ts.map +1 -1
  18. package/dist/handle.js +96 -20
  19. package/dist/helpers.d.ts +1 -0
  20. package/dist/helpers.d.ts.map +1 -1
  21. package/dist/helpers.js +23 -0
  22. package/dist/index.d.ts +6 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +4 -1
  25. package/dist/list.d.ts +3 -2
  26. package/dist/list.d.ts.map +1 -1
  27. package/dist/list.js +24 -12
  28. package/dist/note.d.ts +7 -0
  29. package/dist/note.d.ts.map +1 -1
  30. package/dist/note.js +6 -0
  31. package/dist/patch-resolver.d.ts +12 -0
  32. package/dist/patch-resolver.d.ts.map +1 -1
  33. package/dist/patch-resolver.js +205 -53
  34. package/dist/read-index.d.ts.map +1 -1
  35. package/dist/read-index.js +6 -5
  36. package/dist/record.d.ts.map +1 -1
  37. package/dist/record.js +3 -3
  38. package/dist/render/migration-log.d.ts +8 -1
  39. package/dist/render/migration-log.d.ts.map +1 -1
  40. package/dist/render/migration-log.js +40 -33
  41. package/dist/render/retro.d.ts.map +1 -1
  42. package/dist/render/retro.js +1 -7
  43. package/dist/render/workspace-narrative.d.ts +7 -1
  44. package/dist/render/workspace-narrative.d.ts.map +1 -1
  45. package/dist/render/workspace-narrative.js +114 -46
  46. package/dist/runtime-config.d.ts +12 -0
  47. package/dist/runtime-config.d.ts.map +1 -0
  48. package/dist/runtime-config.js +83 -0
  49. package/dist/schema/entry-read.d.ts.map +1 -1
  50. package/dist/schema/entry-read.js +1 -1
  51. package/dist/schema/entry-write.d.ts.map +1 -1
  52. package/dist/schema/entry-write.js +1 -1
  53. package/dist/schema/entry.d.ts.map +1 -1
  54. package/dist/storage/filesystem.d.ts +8 -0
  55. package/dist/storage/filesystem.d.ts.map +1 -1
  56. package/dist/storage/filesystem.js +110 -5
  57. package/dist/text-lines.d.ts +8 -0
  58. package/dist/text-lines.d.ts.map +1 -0
  59. package/dist/text-lines.js +20 -0
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.d.ts.map +1 -1
  62. package/dist/version.js +2 -1
  63. package/package.json +5 -3
package/dist/doctor.js CHANGED
@@ -5,71 +5,202 @@ import { sha256File } from "./json.js";
5
5
  import { getOrderedEntryLocations, readManifestEntryBatch } from "./list.js";
6
6
  import { isReadIndexCurrent, readReadIndex } from "./read-index.js";
7
7
  import { loadLedgerState } from "./recovery.js";
8
+ import { getLedgerRuntimeConfig } from "./runtime-config.js";
8
9
  import { readManifest } from "./storage/filesystem.js";
9
- const BLOB_HASH_CONCURRENCY = 16;
10
10
  const CHECKPOINT_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
11
- const ENTRY_READ_BATCH_SIZE = 32;
12
11
  const OPEN_ISSUE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
13
- const checkPrevChain = (entry, previousByPhase, issues) => {
12
+ export const DOCTOR_FINDING_CODES = [
13
+ 'blob-corrupt',
14
+ 'blob-missing',
15
+ 'blob-unreadable',
16
+ 'change-log-rollback-missing-target',
17
+ 'change-log-smoke-failure-missing-rollback-plan',
18
+ 'manifest-entry-count-mismatch',
19
+ 'manifest-entry-location-missing',
20
+ 'manifest-entry-missing-on-disk',
21
+ 'manifest-last-sequence-mismatch',
22
+ 'manifest-per-phase-latest-mismatch',
23
+ 'manifest-phase-mismatch',
24
+ 'manifest-sequence-mismatch',
25
+ 'open-issue-stale',
26
+ 'phase-prev-entry-mismatch',
27
+ 'pre-change-checkpoint-stale',
28
+ 'read-failure',
29
+ ];
30
+ const createFinding = ({ code, message, metadata, remediation, }) => ({
31
+ code,
32
+ message,
33
+ metadata,
34
+ remediation,
35
+ });
36
+ const buildDoctorReport = (findings) => ({
37
+ checkedAt: new Date().toISOString(),
38
+ findings: [...findings],
39
+ issueCount: findings.length,
40
+ issues: findings.map((finding) => finding.message),
41
+ ok: findings.length === 0,
42
+ });
43
+ const pushFinding = (findings, finding) => {
44
+ findings.push(finding);
45
+ };
46
+ const isMissingPathError = (error) => {
47
+ const code = error.code;
48
+ return code === 'ENOENT' || code === 'ENOTDIR';
49
+ };
50
+ const buildReadFailure = (error) => buildDoctorReport([
51
+ createFinding({
52
+ code: 'read-failure',
53
+ message: `Failed to read ledger state: ${error instanceof Error ? error.message ?? error.name : String(error)}.`,
54
+ remediation: 'Re-open the ledger or rerun the command first so recovery can reconcile pending state. If the error persists, repair or restore the invalid manifest/read-index JSON before archiving.',
55
+ }),
56
+ ]);
57
+ const isChangeLogEntry = (entry) => entry.kind === 'change-log';
58
+ const isOpenIssueNote = (entry) => entry.kind === 'note' && entry.subkind === 'open-issue';
59
+ const checkPrevChain = (entry, findings, previousByPhase) => {
14
60
  const expectedPrev = previousByPhase.get(entry.phase) ?? null;
15
61
  if (entry.prevEntryId !== expectedPrev) {
16
- issues.push(`Phase ${entry.phase} has broken prevEntryId chain at ${entry.id}: expected ${expectedPrev ?? 'null'}, found ${entry.prevEntryId ?? 'null'}.`);
62
+ pushFinding(findings, createFinding({
63
+ code: 'phase-prev-entry-mismatch',
64
+ message: `Phase ${entry.phase} has broken prevEntryId chain at ${entry.id}: expected ${expectedPrev ?? 'null'}, found ${entry.prevEntryId ?? 'null'}.`,
65
+ metadata: {
66
+ entryId: entry.id,
67
+ expectedPrevEntryId: expectedPrev,
68
+ foundPrevEntryId: entry.prevEntryId,
69
+ phase: entry.phase,
70
+ },
71
+ remediation: 'Restore the edited entry or repair the phase chain so prevEntryId matches append order. Use a correction entry for content fixes instead of hand-editing ledger history.',
72
+ }));
17
73
  }
18
74
  previousByPhase.set(entry.phase, entry.id);
19
75
  };
20
76
  const checkBlobPresence = async (workspaceRoot, blobChecks) => {
21
- const results = await mapWithConcurrencyLimit(blobChecks, BLOB_HASH_CONCURRENCY, async ({ blobHash, entryId }) => {
77
+ const { blobHashConcurrency } = getLedgerRuntimeConfig();
78
+ const groupedBlobChecks = new Map();
79
+ for (const { blobHash, entryId } of blobChecks) {
80
+ const entryIds = groupedBlobChecks.get(blobHash) ?? [];
81
+ entryIds.push(entryId);
82
+ groupedBlobChecks.set(blobHash, entryIds);
83
+ }
84
+ const results = await mapWithConcurrencyLimit([...groupedBlobChecks.entries()], blobHashConcurrency, async ([blobHash, entryIds]) => {
85
+ const blobPath = resolveBlobPath(workspaceRoot, blobHash);
22
86
  try {
23
- const blobPath = resolveBlobPath(workspaceRoot, blobHash);
24
87
  await stat(blobPath);
88
+ }
89
+ catch (error) {
90
+ if (!isMissingPathError(error)) {
91
+ throw error;
92
+ }
93
+ return entryIds.map((entryId) => createFinding({
94
+ code: 'blob-missing',
95
+ message: `Missing blob ${blobHash} for ${entryId}.`,
96
+ metadata: {
97
+ blobSha256: blobHash,
98
+ entryId,
99
+ },
100
+ remediation: 'Restore the missing blob file under .lab/ledger/blobs/ or recreate the patch entry from the original diff before archiving.',
101
+ }));
102
+ }
103
+ try {
25
104
  const actualHash = await sha256File(blobPath);
26
105
  if (actualHash !== blobHash) {
27
- return `Blob ${blobHash} for ${entryId} is corrupted (found ${actualHash}).`;
106
+ return entryIds.map((entryId) => createFinding({
107
+ code: 'blob-corrupt',
108
+ message: `Blob ${blobHash} for ${entryId} is corrupted (found ${actualHash}).`,
109
+ metadata: {
110
+ actualSha256: actualHash,
111
+ blobSha256: blobHash,
112
+ entryId,
113
+ },
114
+ remediation: 'Restore the original blob content under .lab/ledger/blobs/ or recreate the patch entry from the original diff before archiving.',
115
+ }));
28
116
  }
29
- return null;
117
+ return [];
30
118
  }
31
- catch {
32
- return `Missing blob ${blobHash} for ${entryId}.`;
119
+ catch (error) {
120
+ return entryIds.map((entryId) => createFinding({
121
+ code: 'blob-unreadable',
122
+ message: `Blob ${blobHash} for ${entryId} could not be read: ${error instanceof Error ? error.message : String(error)}.`,
123
+ metadata: {
124
+ blobSha256: blobHash,
125
+ entryId,
126
+ },
127
+ remediation: 'Fix the filesystem permission or read error first, then rerun doctor so blob integrity can be verified before archiving.',
128
+ }));
33
129
  }
34
130
  });
35
- return results.filter((issue) => issue !== null);
131
+ return results.flat();
36
132
  };
37
- const checkManifestCounts = (manifest, entryCount) => {
38
- const issues = [];
133
+ const checkManifestCounts = (findings, manifest, entryCount) => {
39
134
  if (manifest.entryCount !== entryCount) {
40
- issues.push(`Manifest entryCount ${manifest.entryCount} does not match disk entries ${entryCount}.`);
135
+ pushFinding(findings, createFinding({
136
+ code: 'manifest-entry-count-mismatch',
137
+ message: `Manifest entryCount ${manifest.entryCount} does not match disk entries ${entryCount}.`,
138
+ metadata: {
139
+ diskEntryCount: entryCount,
140
+ manifestEntryCount: manifest.entryCount,
141
+ },
142
+ remediation: 'Rerun the command or reopen the ledger so recovery can replay pending commits. If the mismatch persists, compare manifest.json with the on-disk phase entries and repair the missing location or file.',
143
+ }));
41
144
  }
42
145
  if (manifest.lastSequence !== entryCount) {
43
- issues.push(`Manifest lastSequence ${manifest.lastSequence} does not match disk entries ${entryCount}.`);
146
+ pushFinding(findings, createFinding({
147
+ code: 'manifest-last-sequence-mismatch',
148
+ message: `Manifest lastSequence ${manifest.lastSequence} does not match disk entries ${entryCount}.`,
149
+ metadata: {
150
+ diskEntryCount: entryCount,
151
+ manifestLastSequence: manifest.lastSequence,
152
+ },
153
+ remediation: 'Repair manifest.json so lastSequence matches the highest durable append sequence after recovery completes.',
154
+ }));
44
155
  }
45
- return issues;
46
156
  };
47
- const finalizeManifestChecks = ({ entryCount, issues, latestByPhase, manifest, unseenManifestEntryIds, }) => {
48
- issues.push(...checkManifestCounts(manifest, entryCount));
157
+ const finalizeManifestChecks = ({ entryCount, findings, latestByPhase, manifest, unseenManifestEntryIds, }) => {
158
+ checkManifestCounts(findings, manifest, entryCount);
49
159
  for (const entryId of unseenManifestEntryIds) {
50
- issues.push(`Manifest entry location points to missing disk entry ${entryId}.`);
160
+ pushFinding(findings, createFinding({
161
+ code: 'manifest-entry-missing-on-disk',
162
+ message: `Manifest entry location points to missing disk entry ${entryId}.`,
163
+ metadata: { entryId },
164
+ remediation: 'Restore the missing entry file or repair manifest.entryLocations so every referenced entry id exists on disk before archiving.',
165
+ }));
51
166
  }
52
- for (const [phase, latest] of latestByPhase.entries()) {
53
- if (manifest.perPhaseLatest[phase] !== latest.entryId) {
54
- issues.push(`Manifest perPhaseLatest mismatch for ${phase}: expected ${latest.entryId}, found ${manifest.perPhaseLatest[phase] ?? 'missing'}.`);
167
+ const phasesToCheck = new Set([...Object.keys(manifest.perPhaseLatest), ...latestByPhase.keys()]);
168
+ for (const phase of phasesToCheck) {
169
+ const latest = latestByPhase.get(phase);
170
+ const expectedEntryId = latest?.entryId ?? null;
171
+ const foundEntryId = manifest.perPhaseLatest[phase] ?? null;
172
+ if (foundEntryId !== expectedEntryId) {
173
+ pushFinding(findings, createFinding({
174
+ code: 'manifest-per-phase-latest-mismatch',
175
+ message: `Manifest perPhaseLatest mismatch for ${phase}: expected ${expectedEntryId ?? 'missing'}, found ${foundEntryId ?? 'missing'}.`,
176
+ metadata: {
177
+ expectedEntryId,
178
+ foundEntryId,
179
+ phase,
180
+ },
181
+ remediation: 'Repair perPhaseLatest so each phase points at the newest durable entry in that phase.',
182
+ }));
55
183
  }
56
184
  }
57
185
  };
58
- const checkManifestSequenceOrder = (entryLocations, issues) => {
186
+ const checkManifestSequenceOrder = (entryLocations, findings) => {
59
187
  for (let index = 0; index < entryLocations.length; index += 1) {
60
188
  const [entryId, location] = entryLocations[index];
61
189
  const expectedSequence = index + 1;
62
190
  if (location.sequence !== expectedSequence) {
63
- issues.push(`Manifest sequence mismatch for ${entryId}: expected ${expectedSequence}, found ${location.sequence}.`);
191
+ pushFinding(findings, createFinding({
192
+ code: 'manifest-sequence-mismatch',
193
+ message: `Manifest sequence mismatch for ${entryId}: expected ${expectedSequence}, found ${location.sequence}.`,
194
+ metadata: {
195
+ entryId,
196
+ expectedSequence,
197
+ foundSequence: location.sequence,
198
+ },
199
+ remediation: 'Repair manifest.entryLocations so sequences remain contiguous and match append order.',
200
+ }));
64
201
  }
65
202
  }
66
203
  };
67
- const buildReadFailure = (error) => ({
68
- issues: [`Failed to read ledger state: ${error instanceof Error ? (error.message ?? error.name) : String(error)}.`],
69
- ok: false,
70
- });
71
- const isChangeLogEntry = (entry) => entry.kind === 'change-log';
72
- const isOpenIssueNote = (entry) => entry.kind === 'note' && entry.subkind === 'open-issue';
73
204
  const trackResolutionLinks = (entry, resolvedLedgerIds) => {
74
205
  if (entry.links.correctsLedgerId) {
75
206
  resolvedLedgerIds.add(entry.links.correctsLedgerId);
@@ -87,7 +218,7 @@ const trackIdempotencyEntry = (entry, entriesByIdempotencyKey) => {
87
218
  existingEntries.push({ id: entry.id, ts: entry.ts });
88
219
  entriesByIdempotencyKey.set(idempotencyKey, existingEntries);
89
220
  };
90
- const checkChangeLogWarnings = ({ checkpointEntries, entriesByIdempotencyKey, issues, nowMs, openIssueEntries, resolvedLedgerIds, }) => {
221
+ const checkChangeLogWarnings = ({ checkpointEntries, entriesByIdempotencyKey, findings, nowMs, openIssueEntries, resolvedLedgerIds, }) => {
91
222
  for (const checkpointEntry of checkpointEntries) {
92
223
  const ageMs = nowMs - Date.parse(checkpointEntry.ts);
93
224
  if (ageMs <= CHECKPOINT_MAX_AGE_MS) {
@@ -97,7 +228,15 @@ const checkChangeLogWarnings = ({ checkpointEntries, entriesByIdempotencyKey, is
97
228
  const hasFollowUp = typeof idempotencyKey === 'string' &&
98
229
  (entriesByIdempotencyKey.get(idempotencyKey) ?? []).some((candidate) => candidate.id !== checkpointEntry.id && candidate.ts >= checkpointEntry.ts);
99
230
  if (!hasFollowUp) {
100
- issues.push(`Pre-change checkpoint ${checkpointEntry.id} is older than 24h and has no follow-up entry with matching idempotencyKey.`);
231
+ pushFinding(findings, createFinding({
232
+ code: 'pre-change-checkpoint-stale',
233
+ message: `Pre-change checkpoint ${checkpointEntry.id} is older than 24h and has no follow-up entry with matching idempotencyKey.`,
234
+ metadata: {
235
+ entryId: checkpointEntry.id,
236
+ idempotencyKey: idempotencyKey ?? null,
237
+ },
238
+ remediation: 'Append a follow-up change-log entry with the same idempotency key, or close the stale checkpoint with a correction entry explaining the abandoned work.',
239
+ }));
101
240
  }
102
241
  }
103
242
  for (const openIssueEntry of openIssueEntries) {
@@ -105,17 +244,36 @@ const checkChangeLogWarnings = ({ checkpointEntries, entriesByIdempotencyKey, is
105
244
  if (ageMs <= OPEN_ISSUE_MAX_AGE_MS || resolvedLedgerIds.has(openIssueEntry.id)) {
106
245
  continue;
107
246
  }
108
- issues.push(`Open issue note ${openIssueEntry.id} is older than 30 days and has no resolution link.`);
247
+ pushFinding(findings, createFinding({
248
+ code: 'open-issue-stale',
249
+ message: `Open issue note ${openIssueEntry.id} is older than 30 days and has no resolution link.`,
250
+ metadata: { entryId: openIssueEntry.id },
251
+ remediation: 'Append a correction or superseding note that links back to the open issue once the follow-up is complete.',
252
+ }));
109
253
  }
110
254
  };
111
- const inspectManifestLocation = ({ entry, issues, latestByPhase, manifest, }) => {
255
+ const inspectManifestLocation = ({ entry, findings, latestByPhase, manifest, }) => {
112
256
  const manifestLocation = manifest.entryLocations[entry.id];
113
257
  if (!manifestLocation) {
114
- issues.push(`Manifest is missing entry location for ${entry.id}.`);
258
+ pushFinding(findings, createFinding({
259
+ code: 'manifest-entry-location-missing',
260
+ message: `Manifest is missing entry location for ${entry.id}.`,
261
+ metadata: { entryId: entry.id },
262
+ remediation: 'Repair manifest.entryLocations so every durable entry has a phase/sequence location before archiving.',
263
+ }));
115
264
  return null;
116
265
  }
117
266
  if (manifestLocation.phase !== entry.phase) {
118
- issues.push(`Manifest phase mismatch for ${entry.id}: expected ${entry.phase}, found ${manifestLocation.phase}.`);
267
+ pushFinding(findings, createFinding({
268
+ code: 'manifest-phase-mismatch',
269
+ message: `Manifest phase mismatch for ${entry.id}: expected ${entry.phase}, found ${manifestLocation.phase}.`,
270
+ metadata: {
271
+ entryId: entry.id,
272
+ expectedPhase: entry.phase,
273
+ foundPhase: manifestLocation.phase,
274
+ },
275
+ remediation: 'Move the entry back to the correct phase directory or repair manifest.entryLocations so the recorded phase matches the stored entry.',
276
+ }));
119
277
  }
120
278
  const currentLatest = latestByPhase.get(entry.phase);
121
279
  if (!currentLatest || manifestLocation.sequence > currentLatest.sequence) {
@@ -123,16 +281,26 @@ const inspectManifestLocation = ({ entry, issues, latestByPhase, manifest, }) =>
123
281
  }
124
282
  return manifestLocation;
125
283
  };
126
- const inspectNarrativeEntry = ({ checkpointEntries, entry, issues, openIssueEntries, }) => {
284
+ const inspectNarrativeEntry = ({ checkpointEntries, entry, findings, openIssueEntries, }) => {
127
285
  if (isChangeLogEntry(entry)) {
128
286
  if (entry.subkind === 'pre-change-checkpoint') {
129
287
  checkpointEntries.push(entry);
130
288
  }
131
289
  if (entry.smokeResult === 'fail' && !entry.rollbackPlan) {
132
- issues.push(`Change-log entry ${entry.id} has smokeResult=fail but no rollbackPlan.`);
290
+ pushFinding(findings, createFinding({
291
+ code: 'change-log-smoke-failure-missing-rollback-plan',
292
+ message: `Change-log entry ${entry.id} has smokeResult=fail but no rollbackPlan.`,
293
+ metadata: { entryId: entry.id },
294
+ remediation: 'Append a correction or follow-up change-log entry documenting the rollback plan before treating the failure as closed.',
295
+ }));
133
296
  }
134
297
  if (entry.subkind === 'rollback' && !entry.rollsBack) {
135
- issues.push(`Change-log rollback entry ${entry.id} is missing rollsBack.`);
298
+ pushFinding(findings, createFinding({
299
+ code: 'change-log-rollback-missing-target',
300
+ message: `Change-log rollback entry ${entry.id} is missing rollsBack.`,
301
+ metadata: { entryId: entry.id },
302
+ remediation: 'Append a correction or replacement rollback entry with rollsBack pointing at the reverted ledger entry id.',
303
+ }));
136
304
  }
137
305
  }
138
306
  if (isOpenIssueNote(entry)) {
@@ -147,14 +315,14 @@ const inspectPatchEntry = ({ blobChecks, entry, }) => {
147
315
  blobChecks.push({ blobHash, entryId: entry.id });
148
316
  }
149
317
  };
150
- const inspectDoctorEntry = ({ blobChecks, checkpointEntries, entry, entriesByIdempotencyKey, issues, latestByPhase, manifest, openIssueEntries, previousByPhase, resolvedLedgerIds, unseenManifestEntryIds, }) => {
318
+ const inspectDoctorEntry = ({ blobChecks, checkpointEntries, entriesByIdempotencyKey, entry, findings, latestByPhase, manifest, openIssueEntries, previousByPhase, resolvedLedgerIds, unseenManifestEntryIds, }) => {
151
319
  unseenManifestEntryIds.delete(entry.id);
152
- checkPrevChain(entry, previousByPhase, issues);
320
+ checkPrevChain(entry, findings, previousByPhase);
153
321
  trackIdempotencyEntry(entry, entriesByIdempotencyKey);
154
322
  trackResolutionLinks(entry, resolvedLedgerIds);
155
323
  if (!inspectManifestLocation({
156
324
  entry,
157
- issues,
325
+ findings,
158
326
  latestByPhase,
159
327
  manifest,
160
328
  })) {
@@ -163,7 +331,7 @@ const inspectDoctorEntry = ({ blobChecks, checkpointEntries, entry, entriesByIde
163
331
  inspectNarrativeEntry({
164
332
  checkpointEntries,
165
333
  entry,
166
- issues,
334
+ findings,
167
335
  openIssueEntries,
168
336
  });
169
337
  inspectPatchEntry({
@@ -172,7 +340,7 @@ const inspectDoctorEntry = ({ blobChecks, checkpointEntries, entry, entriesByIde
172
340
  });
173
341
  };
174
342
  const collectDoctorState = async (workspaceRoot, manifest, readIndex) => {
175
- const issues = [];
343
+ const findings = [];
176
344
  const previousByPhase = new Map();
177
345
  const latestByPhase = new Map();
178
346
  const unseenManifestEntryIds = new Set(Object.keys(manifest.entryLocations));
@@ -183,13 +351,15 @@ const collectDoctorState = async (workspaceRoot, manifest, readIndex) => {
183
351
  const resolvedLedgerIds = new Set();
184
352
  let entryCount = 0;
185
353
  const nowMs = Date.now();
354
+ const { scanBatchSize, scanConcurrency } = getLedgerRuntimeConfig();
186
355
  const orderedEntries = getOrderedEntryLocations(manifest, readIndex, {});
187
- checkManifestSequenceOrder(orderedEntries, issues);
188
- for (let index = 0; index < orderedEntries.length; index += ENTRY_READ_BATCH_SIZE) {
189
- const batch = orderedEntries.slice(index, index + ENTRY_READ_BATCH_SIZE);
356
+ checkManifestSequenceOrder(orderedEntries, findings);
357
+ for (let index = 0; index < orderedEntries.length; index += scanBatchSize) {
358
+ const batch = orderedEntries.slice(index, index + scanBatchSize);
190
359
  const resolvedEntries = await readManifestEntryBatch({
191
360
  allowMissing: true,
192
361
  entryLocations: batch,
362
+ entryReadConcurrency: scanConcurrency,
193
363
  workspaceRoot,
194
364
  });
195
365
  for (const resolvedEntry of resolvedEntries) {
@@ -200,9 +370,9 @@ const collectDoctorState = async (workspaceRoot, manifest, readIndex) => {
200
370
  inspectDoctorEntry({
201
371
  blobChecks,
202
372
  checkpointEntries,
203
- entry: resolvedEntry.entry,
204
373
  entriesByIdempotencyKey,
205
- issues,
374
+ entry: resolvedEntry.entry,
375
+ findings,
206
376
  latestByPhase,
207
377
  manifest,
208
378
  openIssueEntries,
@@ -215,7 +385,7 @@ const collectDoctorState = async (workspaceRoot, manifest, readIndex) => {
215
385
  checkChangeLogWarnings({
216
386
  checkpointEntries,
217
387
  entriesByIdempotencyKey,
218
- issues,
388
+ findings,
219
389
  nowMs,
220
390
  openIssueEntries,
221
391
  resolvedLedgerIds,
@@ -223,7 +393,7 @@ const collectDoctorState = async (workspaceRoot, manifest, readIndex) => {
223
393
  return {
224
394
  blobChecks,
225
395
  entryCount,
226
- issues,
396
+ findings,
227
397
  latestByPhase,
228
398
  unseenManifestEntryIds,
229
399
  };
@@ -256,17 +426,14 @@ export const runLedgerDoctor = async (workspaceRoot, options = {}) => {
256
426
  catch (error) {
257
427
  return buildReadFailure(error);
258
428
  }
259
- const { blobChecks, entryCount, issues, latestByPhase, unseenManifestEntryIds } = doctorState;
260
- issues.push(...(await checkBlobPresence(workspaceRoot, blobChecks)));
429
+ const { blobChecks, entryCount, findings, latestByPhase, unseenManifestEntryIds } = doctorState;
430
+ findings.push(...(await checkBlobPresence(workspaceRoot, blobChecks)));
261
431
  finalizeManifestChecks({
262
432
  entryCount,
263
- issues,
433
+ findings,
264
434
  latestByPhase,
265
435
  manifest: preparedState.manifest,
266
436
  unseenManifestEntryIds,
267
437
  });
268
- return {
269
- issues,
270
- ok: issues.length === 0,
271
- };
438
+ return buildDoctorReport(findings);
272
439
  };
package/dist/handle.d.ts CHANGED
@@ -4,6 +4,30 @@ import { type LedgerFilter } from './list.ts';
4
4
  import { appendNote, type NoteBody } from './note.ts';
5
5
  import type { LedgerEntry, LedgerPhase } from './schema/entry.ts';
6
6
  export type RenderTarget = 'dependency-graph' | 'jsonl' | 'migration-log-md' | 'retro' | 'timeline-html' | 'workspace-narrative-md';
7
+ /**
8
+ * Chunk writer used by `renderTo()` for bounded render emission.
9
+ *
10
+ * Writers may complete synchronously or asynchronously. Throwing rejects the render and causes any in-progress
11
+ * atomic output file to be aborted.
12
+ */
13
+ export type RenderWriter = (chunk: string) => Promise<void> | void;
14
+ /** Options for `render()`, which returns the rendered text and optionally mirrors it to disk. */
15
+ export type LedgerRenderOptions = {
16
+ readonly limit?: number;
17
+ readonly out?: string;
18
+ readonly phase?: LedgerPhase;
19
+ readonly since?: string;
20
+ readonly to: RenderTarget;
21
+ };
22
+ /** Options for `renderTo()`, which writes to a callback and/or canonical render file without buffering the full output. */
23
+ export type LedgerRenderToOptions = {
24
+ readonly limit?: number;
25
+ readonly out?: string;
26
+ readonly phase?: LedgerPhase;
27
+ readonly since?: string;
28
+ readonly to: RenderTarget;
29
+ readonly write?: RenderWriter;
30
+ };
7
31
  export type LedgerHandle = {
8
32
  readonly archive: (outPath: string) => Promise<{
9
33
  integrityHash: string;
@@ -17,14 +41,10 @@ export type LedgerHandle = {
17
41
  readonly record: (entry: unknown) => Promise<{
18
42
  id: string;
19
43
  }>;
20
- readonly render: (options: {
21
- out?: string;
22
- limit?: number;
23
- phase?: LedgerPhase;
24
- since?: string;
25
- to: RenderTarget;
26
- }) => Promise<string>;
44
+ readonly render: (options: LedgerRenderOptions) => Promise<string>;
45
+ readonly renderTo: (options: LedgerRenderToOptions) => Promise<void>;
27
46
  readonly show: (entryId: string) => Promise<LedgerEntry | null>;
28
47
  };
48
+ /** Open a workspace ledger handle after reconciling any pending crash-recovery state. */
29
49
  export declare const openLedger: (workspaceRoot: string) => Promise<LedgerHandle>;
30
50
  //# sourceMappingURL=handle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"handle.d.ts","sourceRoot":"","sources":["../src/handle.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAA8B,KAAK,YAAY,EAAe,MAAM,WAAW,CAAC;AACvF,OAAO,EAAE,UAAU,EAAE,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAC;AAStD,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGlE,MAAM,MAAM,YAAY,GAClB,kBAAkB,GAClB,OAAO,GACP,kBAAkB,GAClB,OAAO,GACP,eAAe,GACf,wBAAwB,CAAC;AAkD/B,MAAM,MAAM,YAAY,GAAG;IACvB,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1E,QAAQ,CAAC,eAAe,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC,CAAC,CAAC;IACrF,QAAQ,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC,CAAC,CAAC;IAC5E,QAAQ,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,YAAY,KAAK,aAAa,CAAC,WAAW,CAAC,CAAC;IACrE,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtG,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7D,QAAQ,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE;QACvB,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,WAAW,CAAC;QACpB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,EAAE,EAAE,YAAY,CAAC;KACpB,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;CACnE,CAAC;AAEF,eAAO,MAAM,UAAU,GAAU,eAAe,MAAM,KAAG,OAAO,CAAC,YAAY,CAyC5E,CAAC"}
1
+ {"version":3,"file":"handle.d.ts","sourceRoot":"","sources":["../src/handle.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAA8B,KAAK,YAAY,EAAe,MAAM,WAAW,CAAC;AACvF,OAAO,EAAE,UAAU,EAAE,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAC;AAStD,OAAO,KAAK,EAAE,WAAW,EAAc,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAG9E,MAAM,MAAM,YAAY,GAClB,kBAAkB,GAClB,OAAO,GACP,kBAAkB,GAClB,OAAO,GACP,eAAe,GACf,wBAAwB,CAAC;AAE/B;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;AAEnE,iGAAiG;AACjG,MAAM,MAAM,mBAAmB,GAAG;IAC9B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,EAAE,YAAY,CAAC;CAC7B,CAAC;AAEF,2HAA2H;AAC3H,MAAM,MAAM,qBAAqB,GAAG;IAChC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,EAAE,YAAY,CAAC;IAC1B,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;CACjC,CAAC;AA4GF,MAAM,MAAM,YAAY,GAAG;IACvB,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1E,QAAQ,CAAC,eAAe,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC,CAAC,CAAC;IACrF,QAAQ,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC,CAAC,CAAC;IAC5E,QAAQ,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,YAAY,KAAK,aAAa,CAAC,WAAW,CAAC,CAAC;IACrE,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtG,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7D,QAAQ,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACnE,QAAQ,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,qBAAqB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrE,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;CACnE,CAAC;AAEF,yFAAyF;AACzF,eAAO,MAAM,UAAU,GAAU,eAAe,MAAM,KAAG,OAAO,CAAC,YAAY,CAyF5E,CAAC"}
package/dist/handle.js CHANGED
@@ -13,28 +13,58 @@ import { renderMigrationLogMarkdown } from "./render/migration-log.js";
13
13
  import { renderRetroMarkdown } from "./render/retro.js";
14
14
  import { renderTimelineHtml } from "./render/timeline-html.js";
15
15
  import { renderWorkspaceNarrativeMarkdown } from "./render/workspace-narrative.js";
16
- import { resolveLedgerPaths, writeAtomicTextFile } from "./storage/filesystem.js";
17
- const renderEntries = async ({ filter, target, workspaceRoot, }) => {
18
- const { manifest, readIndex } = await loadLedgerState(workspaceRoot);
16
+ import { createAtomicTextFileWriter, resolveLedgerPaths, writeAtomicTextFile } from "./storage/filesystem.js";
17
+ const createEntryIteratorFactory = ({ filter, state, workspaceRoot, }) => {
18
+ return (entryOptions = {}) => iterateEntriesFromManifest(workspaceRoot, state.manifest, state.readIndex, {
19
+ kind: entryOptions.kind,
20
+ limit: filter.limit,
21
+ phase: filter.phase,
22
+ since: filter.since,
23
+ }, entryOptions.direction);
24
+ };
25
+ const collectRenderedText = async (emit) => {
26
+ const chunks = [];
27
+ await emit((chunk) => {
28
+ chunks.push(chunk);
29
+ });
30
+ return chunks.join('');
31
+ };
32
+ const emitRenderedTarget = async ({ createEntries, manifest, target, write, workspaceName, }) => {
19
33
  switch (target) {
20
34
  case 'dependency-graph':
21
- return renderDependencyGraph(iterateEntriesFromManifest(workspaceRoot, manifest, readIndex, filter));
35
+ await write(await renderDependencyGraph(createEntries()));
36
+ return;
22
37
  case 'jsonl':
23
- return renderJsonl(iterateEntriesFromManifest(workspaceRoot, manifest, readIndex, filter));
38
+ await write(await renderJsonl(createEntries()));
39
+ return;
24
40
  case 'migration-log-md':
25
- return renderMigrationLogMarkdown(iterateEntriesFromManifest(workspaceRoot, manifest, readIndex, filter));
41
+ await renderMigrationLogMarkdown({
42
+ entries: createEntries({
43
+ direction: 'desc',
44
+ kind: 'change-log',
45
+ }),
46
+ write,
47
+ });
48
+ return;
26
49
  case 'timeline-html':
27
- return renderTimelineHtml(iterateEntriesFromManifest(workspaceRoot, manifest, readIndex, filter));
50
+ await write(await renderTimelineHtml(createEntries()));
51
+ return;
28
52
  case 'retro':
29
- return renderRetroMarkdown({
30
- entries: iterateEntriesFromManifest(workspaceRoot, manifest, readIndex, filter),
53
+ await write(await renderRetroMarkdown({
54
+ entries: createEntries(),
31
55
  manifest,
32
- });
56
+ }));
57
+ return;
33
58
  case 'workspace-narrative-md':
34
- return renderWorkspaceNarrativeMarkdown({
35
- entries: iterateEntriesFromManifest(workspaceRoot, manifest, readIndex, filter),
36
- workspaceName: path.basename(workspaceRoot),
59
+ await renderWorkspaceNarrativeMarkdown({
60
+ entries: createEntries({
61
+ direction: 'desc',
62
+ kind: 'note',
63
+ }),
64
+ workspaceName,
65
+ write,
37
66
  });
67
+ return;
38
68
  }
39
69
  };
40
70
  const resolveRenderOutputPath = (workspaceRoot, target) => {
@@ -52,6 +82,7 @@ const resolveRenderOutputPath = (workspaceRoot, target) => {
52
82
  return null;
53
83
  }
54
84
  };
85
+ /** Open a workspace ledger handle after reconciling any pending crash-recovery state. */
55
86
  export const openLedger = async (workspaceRoot) => {
56
87
  await readLabManifestMin(workspaceRoot);
57
88
  await prepareLedgerState(workspaceRoot);
@@ -69,21 +100,66 @@ export const openLedger = async (workspaceRoot) => {
69
100
  return { id: result.id };
70
101
  },
71
102
  render: async (options) => {
72
- const content = await renderEntries({
73
- filter: {
74
- limit: options.limit,
75
- phase: options.phase,
76
- since: options.since,
77
- },
78
- target: options.to,
103
+ const state = await loadLedgerState(workspaceRoot);
104
+ const createEntries = createEntryIteratorFactory({
105
+ filter: options,
106
+ state,
79
107
  workspaceRoot,
80
108
  });
109
+ const content = await collectRenderedText((write) => emitRenderedTarget({
110
+ createEntries,
111
+ manifest: state.manifest,
112
+ target: options.to,
113
+ workspaceName: path.basename(workspaceRoot),
114
+ write,
115
+ }));
81
116
  const outputPath = options.out ?? resolveRenderOutputPath(workspaceRoot, options.to);
82
117
  if (outputPath) {
83
118
  await writeAtomicTextFile(outputPath, `${content}${content.endsWith('\n') ? '' : '\n'}`);
84
119
  }
85
120
  return content;
86
121
  },
122
+ renderTo: async (options) => {
123
+ const state = await loadLedgerState(workspaceRoot);
124
+ const createEntries = createEntryIteratorFactory({
125
+ filter: options,
126
+ state,
127
+ workspaceRoot,
128
+ });
129
+ const outputPath = options.out ?? resolveRenderOutputPath(workspaceRoot, options.to);
130
+ const atomicWriter = outputPath ? await createAtomicTextFileWriter(outputPath) : null;
131
+ const writer = options.write;
132
+ let hasWrittenContent = false;
133
+ let endsWithNewline = false;
134
+ if (!atomicWriter && !writer) {
135
+ throw new Error(`Render target ${options.to} requires either a writer callback or an output path.`);
136
+ }
137
+ try {
138
+ await emitRenderedTarget({
139
+ createEntries,
140
+ manifest: state.manifest,
141
+ target: options.to,
142
+ workspaceName: path.basename(workspaceRoot),
143
+ write: async (chunk) => {
144
+ if (chunk.length > 0) {
145
+ hasWrittenContent = true;
146
+ endsWithNewline = chunk.endsWith('\n');
147
+ }
148
+ await writer?.(chunk);
149
+ await atomicWriter?.write(chunk);
150
+ },
151
+ });
152
+ if (!hasWrittenContent || !endsWithNewline) {
153
+ await writer?.('\n');
154
+ await atomicWriter?.write('\n');
155
+ }
156
+ await atomicWriter?.close();
157
+ }
158
+ catch (error) {
159
+ await atomicWriter?.abort();
160
+ throw error;
161
+ }
162
+ },
87
163
  show: async (entryId) => {
88
164
  const { manifest } = await loadLedgerState(workspaceRoot);
89
165
  if (!manifest.entryLocations[entryId]) {
package/dist/helpers.d.ts CHANGED
@@ -4,4 +4,5 @@ export declare const createWorkspaceFixture: () => Promise<{
4
4
  }>;
5
5
  export declare const ageFile: (filePath: string, ageMs?: number) => Promise<void>;
6
6
  export declare const waitForFile: (filePath: string, timeoutMs?: number) => Promise<void>;
7
+ export declare const withEnvOverrides: <Value>(overrides: Record<string, string | undefined>, action: () => Promise<Value>) => Promise<Value>;
7
8
  //# sourceMappingURL=helpers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../src/helpers.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB;;;EAuBlC,CAAC;AAEF,eAAO,MAAM,OAAO,GAAU,UAAU,MAAM,EAAE,cAAc,kBAG7D,CAAC;AAEF,eAAO,MAAM,WAAW,GAAU,UAAU,MAAM,EAAE,kBAAiB,kBAcpE,CAAC"}
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../src/helpers.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB;;;EAuBlC,CAAC;AAEF,eAAO,MAAM,OAAO,GAAU,UAAU,MAAM,EAAE,cAAc,kBAG7D,CAAC;AAEF,eAAO,MAAM,WAAW,GAAU,UAAU,MAAM,EAAE,kBAAiB,kBAcpE,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAU,KAAK,EACxC,WAAW,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EAC7C,QAAQ,MAAM,OAAO,CAAC,KAAK,CAAC,mBAuB/B,CAAC"}