ushman-ledger 1.2.1 → 1.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 (67) hide show
  1. package/AGENTS.md +7 -5
  2. package/ARCHITECTURE.md +85 -0
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +114 -5
  5. package/TROUBLESHOOTING.md +184 -0
  6. package/dist/blobs.d.ts +3 -0
  7. package/dist/blobs.d.ts.map +1 -1
  8. package/dist/blobs.js +41 -15
  9. package/dist/builders.d.ts +33 -0
  10. package/dist/builders.d.ts.map +1 -1
  11. package/dist/builders.js +10 -1
  12. package/dist/cli.d.ts.map +1 -1
  13. package/dist/cli.js +176 -59
  14. package/dist/coverage.d.ts.map +1 -1
  15. package/dist/coverage.js +3 -2
  16. package/dist/doctor.d.ts +17 -4
  17. package/dist/doctor.d.ts.map +1 -1
  18. package/dist/doctor.js +263 -62
  19. package/dist/handle.d.ts.map +1 -1
  20. package/dist/handle.js +67 -30
  21. package/dist/helpers.d.ts +1 -0
  22. package/dist/helpers.d.ts.map +1 -1
  23. package/dist/helpers.js +23 -0
  24. package/dist/index.d.ts +4 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -2
  27. package/dist/list.d.ts +34 -1
  28. package/dist/list.d.ts.map +1 -1
  29. package/dist/list.js +19 -9
  30. package/dist/patch-resolver.d.ts.map +1 -1
  31. package/dist/patch-resolver.js +193 -53
  32. package/dist/process.d.ts +2 -0
  33. package/dist/process.d.ts.map +1 -0
  34. package/dist/process.js +16 -0
  35. package/dist/read-index.d.ts +7 -7
  36. package/dist/read-index.d.ts.map +1 -1
  37. package/dist/read-index.js +18 -13
  38. package/dist/record.js +2 -2
  39. package/dist/recovery.d.ts +8 -0
  40. package/dist/recovery.d.ts.map +1 -1
  41. package/dist/recovery.js +142 -30
  42. package/dist/render/retro.d.ts.map +1 -1
  43. package/dist/render/retro.js +4 -1
  44. package/dist/runtime-config.d.ts +14 -0
  45. package/dist/runtime-config.d.ts.map +1 -0
  46. package/dist/runtime-config.js +97 -0
  47. package/dist/schema/entry-core.d.ts +5 -2
  48. package/dist/schema/entry-core.d.ts.map +1 -1
  49. package/dist/schema/entry-core.js +3 -0
  50. package/dist/schema/entry-read.d.ts +57 -0
  51. package/dist/schema/entry-read.d.ts.map +1 -1
  52. package/dist/schema/entry-read.js +9 -1
  53. package/dist/schema/entry-write.d.ts +51 -0
  54. package/dist/schema/entry-write.d.ts.map +1 -1
  55. package/dist/schema/entry-write.js +9 -1
  56. package/dist/storage/filesystem.d.ts +15 -2
  57. package/dist/storage/filesystem.d.ts.map +1 -1
  58. package/dist/storage/filesystem.js +234 -37
  59. package/dist/storage/lock.d.ts.map +1 -1
  60. package/dist/storage/lock.js +38 -16
  61. package/dist/text-lines.d.ts +8 -0
  62. package/dist/text-lines.d.ts.map +1 -0
  63. package/dist/text-lines.js +20 -0
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.d.ts.map +1 -1
  66. package/dist/version.js +2 -1
  67. package/package.json +4 -2
package/dist/doctor.js CHANGED
@@ -4,72 +4,232 @@ import { resolveBlobPath } from "./blobs.js";
4
4
  import { sha256File } from "./json.js";
5
5
  import { getOrderedEntryLocations, readManifestEntryBatch } from "./list.js";
6
6
  import { isReadIndexCurrent, readReadIndex } from "./read-index.js";
7
- import { loadLedgerState } from "./recovery.js";
7
+ import { listPendingCommitQuarantines, 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
- const CHECKPOINT_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
11
- const ENTRY_READ_BATCH_SIZE = 32;
12
- const OPEN_ISSUE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
13
- const checkPrevChain = (entry, previousByPhase, issues) => {
10
+ const HOUR_MS = 60 * 60 * 1_000;
11
+ const DAY_MS = 24 * HOUR_MS;
12
+ const MINUTE_MS = 60 * 1_000;
13
+ export const DOCTOR_FINDING_CODES = [
14
+ 'blob-corrupt',
15
+ 'blob-missing',
16
+ 'blob-unreadable',
17
+ 'change-log-rollback-missing-target',
18
+ 'change-log-smoke-failure-missing-rollback-plan',
19
+ 'manifest-entry-count-mismatch',
20
+ 'manifest-entry-location-missing',
21
+ 'manifest-entry-missing-on-disk',
22
+ 'manifest-last-sequence-mismatch',
23
+ 'manifest-per-phase-latest-mismatch',
24
+ 'manifest-phase-mismatch',
25
+ 'manifest-sequence-mismatch',
26
+ 'open-issue-stale',
27
+ 'pending-commit-quarantined',
28
+ 'phase-prev-entry-mismatch',
29
+ 'pre-change-checkpoint-stale',
30
+ 'read-failure',
31
+ ];
32
+ const createFinding = ({ code, message, metadata, remediation }) => ({
33
+ code,
34
+ message,
35
+ metadata,
36
+ remediation,
37
+ });
38
+ const buildDoctorReport = (findings) => ({
39
+ checkedAt: new Date().toISOString(),
40
+ findings: [...findings],
41
+ issueCount: findings.length,
42
+ issues: findings.map((finding) => finding.message),
43
+ ok: findings.length === 0,
44
+ });
45
+ const formatAgeThreshold = (ageMs) => {
46
+ if (ageMs >= DAY_MS && ageMs % DAY_MS === 0) {
47
+ const days = ageMs / DAY_MS;
48
+ return `${days} day${days === 1 ? '' : 's'}`;
49
+ }
50
+ if (ageMs >= HOUR_MS && ageMs % HOUR_MS === 0) {
51
+ return `${ageMs / HOUR_MS}h`;
52
+ }
53
+ if (ageMs >= MINUTE_MS && ageMs % MINUTE_MS === 0) {
54
+ return `${ageMs / MINUTE_MS}m`;
55
+ }
56
+ return `${ageMs}ms`;
57
+ };
58
+ const pushFinding = (findings, finding) => {
59
+ findings.push(finding);
60
+ };
61
+ const isMissingPathError = (error) => {
62
+ const code = error.code;
63
+ return code === 'ENOENT' || code === 'ENOTDIR';
64
+ };
65
+ const buildReadFailure = (error) => buildDoctorReport([
66
+ createFinding({
67
+ code: 'read-failure',
68
+ message: `Failed to read ledger state: ${error instanceof Error ? (error.message ?? error.name) : String(error)}.`,
69
+ 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.',
70
+ }),
71
+ ]);
72
+ const isChangeLogEntry = (entry) => entry.kind === 'change-log';
73
+ const isOpenIssueNote = (entry) => entry.kind === 'note' && entry.subkind === 'open-issue';
74
+ const checkPrevChain = (entry, findings, previousByPhase) => {
14
75
  const expectedPrev = previousByPhase.get(entry.phase) ?? null;
15
76
  if (entry.prevEntryId !== expectedPrev) {
16
- issues.push(`Phase ${entry.phase} has broken prevEntryId chain at ${entry.id}: expected ${expectedPrev ?? 'null'}, found ${entry.prevEntryId ?? 'null'}.`);
77
+ pushFinding(findings, createFinding({
78
+ code: 'phase-prev-entry-mismatch',
79
+ message: `Phase ${entry.phase} has broken prevEntryId chain at ${entry.id}: expected ${expectedPrev ?? 'null'}, found ${entry.prevEntryId ?? 'null'}.`,
80
+ metadata: {
81
+ entryId: entry.id,
82
+ expectedPrevEntryId: expectedPrev,
83
+ foundPrevEntryId: entry.prevEntryId,
84
+ phase: entry.phase,
85
+ },
86
+ 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.',
87
+ }));
17
88
  }
18
89
  previousByPhase.set(entry.phase, entry.id);
19
90
  };
20
91
  const checkBlobPresence = async (workspaceRoot, blobChecks) => {
21
- const results = await mapWithConcurrencyLimit(blobChecks, BLOB_HASH_CONCURRENCY, async ({ blobHash, entryId }) => {
92
+ const { blobHashConcurrency } = getLedgerRuntimeConfig();
93
+ const groupedBlobChecks = new Map();
94
+ for (const { blobHash, entryId } of blobChecks) {
95
+ const entryIds = groupedBlobChecks.get(blobHash) ?? [];
96
+ entryIds.push(entryId);
97
+ groupedBlobChecks.set(blobHash, entryIds);
98
+ }
99
+ const results = await mapWithConcurrencyLimit([...groupedBlobChecks.entries()], blobHashConcurrency, async ([blobHash, entryIds]) => {
100
+ const blobPath = resolveBlobPath(workspaceRoot, blobHash);
22
101
  try {
23
- const blobPath = resolveBlobPath(workspaceRoot, blobHash);
24
102
  await stat(blobPath);
103
+ }
104
+ catch (error) {
105
+ if (!isMissingPathError(error)) {
106
+ throw error;
107
+ }
108
+ return entryIds.map((entryId) => createFinding({
109
+ code: 'blob-missing',
110
+ message: `Missing blob ${blobHash} for ${entryId}.`,
111
+ metadata: {
112
+ blobSha256: blobHash,
113
+ entryId,
114
+ },
115
+ remediation: 'Restore the missing blob file under .lab/ledger/blobs/ or recreate the patch entry from the original diff before archiving.',
116
+ }));
117
+ }
118
+ try {
25
119
  const actualHash = await sha256File(blobPath);
26
120
  if (actualHash !== blobHash) {
27
- return `Blob ${blobHash} for ${entryId} is corrupted (found ${actualHash}).`;
121
+ return entryIds.map((entryId) => createFinding({
122
+ code: 'blob-corrupt',
123
+ message: `Blob ${blobHash} for ${entryId} is corrupted (found ${actualHash}).`,
124
+ metadata: {
125
+ actualSha256: actualHash,
126
+ blobSha256: blobHash,
127
+ entryId,
128
+ },
129
+ remediation: 'Restore the original blob content under .lab/ledger/blobs/ or recreate the patch entry from the original diff before archiving.',
130
+ }));
28
131
  }
29
- return null;
132
+ return [];
30
133
  }
31
- catch {
32
- return `Missing blob ${blobHash} for ${entryId}.`;
134
+ catch (error) {
135
+ return entryIds.map((entryId) => createFinding({
136
+ code: 'blob-unreadable',
137
+ message: `Blob ${blobHash} for ${entryId} could not be read: ${error instanceof Error ? error.message : String(error)}.`,
138
+ metadata: {
139
+ blobSha256: blobHash,
140
+ entryId,
141
+ },
142
+ remediation: 'Fix the filesystem permission or read error first, then rerun doctor so blob integrity can be verified before archiving.',
143
+ }));
33
144
  }
34
145
  });
35
- return results.filter((issue) => issue !== null);
146
+ return results.flat();
147
+ };
148
+ const checkPendingCommitQuarantines = async (workspaceRoot) => {
149
+ const quarantines = await listPendingCommitQuarantines(workspaceRoot);
150
+ return quarantines.map((quarantine) => createFinding({
151
+ code: 'pending-commit-quarantined',
152
+ message: `Pending commit journal ${quarantine.originalFileName} is quarantined: ${quarantine.reason}`,
153
+ metadata: {
154
+ commitPath: quarantine.commitPath,
155
+ metadataPath: quarantine.metadataPath,
156
+ originalFileName: quarantine.originalFileName,
157
+ quarantinedAt: quarantine.quarantinedAt,
158
+ },
159
+ remediation: 'Inspect the quarantined journal under .lab/ledger/pending-quarantine/. Restore it to .lab/ledger/pending/ only if its entry, sequence, and manifest base are known to be safe; otherwise keep or remove it after recording an operator decision.',
160
+ }));
36
161
  };
37
- const checkManifestCounts = (manifest, entryCount) => {
38
- const issues = [];
162
+ const checkManifestCounts = (findings, manifest, entryCount) => {
39
163
  if (manifest.entryCount !== entryCount) {
40
- issues.push(`Manifest entryCount ${manifest.entryCount} does not match disk entries ${entryCount}.`);
164
+ pushFinding(findings, createFinding({
165
+ code: 'manifest-entry-count-mismatch',
166
+ message: `Manifest entryCount ${manifest.entryCount} does not match disk entries ${entryCount}.`,
167
+ metadata: {
168
+ diskEntryCount: entryCount,
169
+ manifestEntryCount: manifest.entryCount,
170
+ },
171
+ 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.',
172
+ }));
41
173
  }
42
174
  if (manifest.lastSequence !== entryCount) {
43
- issues.push(`Manifest lastSequence ${manifest.lastSequence} does not match disk entries ${entryCount}.`);
175
+ pushFinding(findings, createFinding({
176
+ code: 'manifest-last-sequence-mismatch',
177
+ message: `Manifest lastSequence ${manifest.lastSequence} does not match disk entries ${entryCount}.`,
178
+ metadata: {
179
+ diskEntryCount: entryCount,
180
+ manifestLastSequence: manifest.lastSequence,
181
+ },
182
+ remediation: 'Repair manifest.json so lastSequence matches the highest durable append sequence after recovery completes.',
183
+ }));
44
184
  }
45
- return issues;
46
185
  };
47
- const finalizeManifestChecks = ({ entryCount, issues, latestByPhase, manifest, unseenManifestEntryIds, }) => {
48
- issues.push(...checkManifestCounts(manifest, entryCount));
186
+ const finalizeManifestChecks = ({ entryCount, findings, latestByPhase, manifest, unseenManifestEntryIds, }) => {
187
+ checkManifestCounts(findings, manifest, entryCount);
49
188
  for (const entryId of unseenManifestEntryIds) {
50
- issues.push(`Manifest entry location points to missing disk entry ${entryId}.`);
189
+ pushFinding(findings, createFinding({
190
+ code: 'manifest-entry-missing-on-disk',
191
+ message: `Manifest entry location points to missing disk entry ${entryId}.`,
192
+ metadata: { entryId },
193
+ remediation: 'Restore the missing entry file or repair manifest.entryLocations so every referenced entry id exists on disk before archiving.',
194
+ }));
51
195
  }
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'}.`);
196
+ const phasesToCheck = new Set([...Object.keys(manifest.perPhaseLatest), ...latestByPhase.keys()]);
197
+ for (const phase of phasesToCheck) {
198
+ const latest = latestByPhase.get(phase);
199
+ const expectedEntryId = latest?.entryId ?? null;
200
+ const foundEntryId = manifest.perPhaseLatest[phase] ?? null;
201
+ if (foundEntryId !== expectedEntryId) {
202
+ pushFinding(findings, createFinding({
203
+ code: 'manifest-per-phase-latest-mismatch',
204
+ message: `Manifest perPhaseLatest mismatch for ${phase}: expected ${expectedEntryId ?? 'missing'}, found ${foundEntryId ?? 'missing'}.`,
205
+ metadata: {
206
+ expectedEntryId,
207
+ foundEntryId,
208
+ phase,
209
+ },
210
+ remediation: 'Repair perPhaseLatest so each phase points at the newest durable entry in that phase.',
211
+ }));
55
212
  }
56
213
  }
57
214
  };
58
- const checkManifestSequenceOrder = (entryLocations, issues) => {
215
+ const checkManifestSequenceOrder = (entryLocations, findings) => {
59
216
  for (let index = 0; index < entryLocations.length; index += 1) {
60
217
  const [entryId, location] = entryLocations[index];
61
218
  const expectedSequence = index + 1;
62
219
  if (location.sequence !== expectedSequence) {
63
- issues.push(`Manifest sequence mismatch for ${entryId}: expected ${expectedSequence}, found ${location.sequence}.`);
220
+ pushFinding(findings, createFinding({
221
+ code: 'manifest-sequence-mismatch',
222
+ message: `Manifest sequence mismatch for ${entryId}: expected ${expectedSequence}, found ${location.sequence}.`,
223
+ metadata: {
224
+ entryId,
225
+ expectedSequence,
226
+ foundSequence: location.sequence,
227
+ },
228
+ remediation: 'Repair manifest.entryLocations so sequences remain contiguous and match append order.',
229
+ }));
64
230
  }
65
231
  }
66
232
  };
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
233
  const trackResolutionLinks = (entry, resolvedLedgerIds) => {
74
234
  if (entry.links.correctsLedgerId) {
75
235
  resolvedLedgerIds.add(entry.links.correctsLedgerId);
@@ -87,35 +247,64 @@ const trackIdempotencyEntry = (entry, entriesByIdempotencyKey) => {
87
247
  existingEntries.push({ id: entry.id, ts: entry.ts });
88
248
  entriesByIdempotencyKey.set(idempotencyKey, existingEntries);
89
249
  };
90
- const checkChangeLogWarnings = ({ checkpointEntries, entriesByIdempotencyKey, issues, nowMs, openIssueEntries, resolvedLedgerIds, }) => {
250
+ const checkChangeLogWarnings = ({ checkpointEntries, checkpointMaxAgeMs, entriesByIdempotencyKey, findings, nowMs, openIssueMaxAgeMs, openIssueEntries, resolvedLedgerIds, }) => {
251
+ const checkpointMaxAgeLabel = formatAgeThreshold(checkpointMaxAgeMs);
91
252
  for (const checkpointEntry of checkpointEntries) {
92
253
  const ageMs = nowMs - Date.parse(checkpointEntry.ts);
93
- if (ageMs <= CHECKPOINT_MAX_AGE_MS) {
254
+ if (ageMs <= checkpointMaxAgeMs) {
94
255
  continue;
95
256
  }
96
257
  const idempotencyKey = checkpointEntry.links.idempotencyKey;
97
258
  const hasFollowUp = typeof idempotencyKey === 'string' &&
98
259
  (entriesByIdempotencyKey.get(idempotencyKey) ?? []).some((candidate) => candidate.id !== checkpointEntry.id && candidate.ts >= checkpointEntry.ts);
99
260
  if (!hasFollowUp) {
100
- issues.push(`Pre-change checkpoint ${checkpointEntry.id} is older than 24h and has no follow-up entry with matching idempotencyKey.`);
261
+ pushFinding(findings, createFinding({
262
+ code: 'pre-change-checkpoint-stale',
263
+ message: `Pre-change checkpoint ${checkpointEntry.id} is older than ${checkpointMaxAgeLabel} and has no follow-up entry with matching idempotencyKey.`,
264
+ metadata: {
265
+ entryId: checkpointEntry.id,
266
+ idempotencyKey: idempotencyKey ?? null,
267
+ },
268
+ 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.',
269
+ }));
101
270
  }
102
271
  }
272
+ const openIssueMaxAgeLabel = formatAgeThreshold(openIssueMaxAgeMs);
103
273
  for (const openIssueEntry of openIssueEntries) {
104
274
  const ageMs = nowMs - Date.parse(openIssueEntry.ts);
105
- if (ageMs <= OPEN_ISSUE_MAX_AGE_MS || resolvedLedgerIds.has(openIssueEntry.id)) {
275
+ if (ageMs <= openIssueMaxAgeMs || resolvedLedgerIds.has(openIssueEntry.id)) {
106
276
  continue;
107
277
  }
108
- issues.push(`Open issue note ${openIssueEntry.id} is older than 30 days and has no resolution link.`);
278
+ pushFinding(findings, createFinding({
279
+ code: 'open-issue-stale',
280
+ message: `Open issue note ${openIssueEntry.id} is older than ${openIssueMaxAgeLabel} and has no resolution link.`,
281
+ metadata: { entryId: openIssueEntry.id },
282
+ remediation: 'Append a correction or superseding note that links back to the open issue once the follow-up is complete.',
283
+ }));
109
284
  }
110
285
  };
111
- const inspectManifestLocation = ({ entry, issues, latestByPhase, manifest, }) => {
286
+ const inspectManifestLocation = ({ entry, findings, latestByPhase, manifest, }) => {
112
287
  const manifestLocation = manifest.entryLocations[entry.id];
113
288
  if (!manifestLocation) {
114
- issues.push(`Manifest is missing entry location for ${entry.id}.`);
289
+ pushFinding(findings, createFinding({
290
+ code: 'manifest-entry-location-missing',
291
+ message: `Manifest is missing entry location for ${entry.id}.`,
292
+ metadata: { entryId: entry.id },
293
+ remediation: 'Repair manifest.entryLocations so every durable entry has a phase/sequence location before archiving.',
294
+ }));
115
295
  return null;
116
296
  }
117
297
  if (manifestLocation.phase !== entry.phase) {
118
- issues.push(`Manifest phase mismatch for ${entry.id}: expected ${entry.phase}, found ${manifestLocation.phase}.`);
298
+ pushFinding(findings, createFinding({
299
+ code: 'manifest-phase-mismatch',
300
+ message: `Manifest phase mismatch for ${entry.id}: expected ${entry.phase}, found ${manifestLocation.phase}.`,
301
+ metadata: {
302
+ entryId: entry.id,
303
+ expectedPhase: entry.phase,
304
+ foundPhase: manifestLocation.phase,
305
+ },
306
+ remediation: 'Move the entry back to the correct phase directory or repair manifest.entryLocations so the recorded phase matches the stored entry.',
307
+ }));
119
308
  }
120
309
  const currentLatest = latestByPhase.get(entry.phase);
121
310
  if (!currentLatest || manifestLocation.sequence > currentLatest.sequence) {
@@ -123,16 +312,26 @@ const inspectManifestLocation = ({ entry, issues, latestByPhase, manifest, }) =>
123
312
  }
124
313
  return manifestLocation;
125
314
  };
126
- const inspectNarrativeEntry = ({ checkpointEntries, entry, issues, openIssueEntries, }) => {
315
+ const inspectNarrativeEntry = ({ checkpointEntries, entry, findings, openIssueEntries, }) => {
127
316
  if (isChangeLogEntry(entry)) {
128
317
  if (entry.subkind === 'pre-change-checkpoint') {
129
318
  checkpointEntries.push(entry);
130
319
  }
131
320
  if (entry.smokeResult === 'fail' && !entry.rollbackPlan) {
132
- issues.push(`Change-log entry ${entry.id} has smokeResult=fail but no rollbackPlan.`);
321
+ pushFinding(findings, createFinding({
322
+ code: 'change-log-smoke-failure-missing-rollback-plan',
323
+ message: `Change-log entry ${entry.id} has smokeResult=fail but no rollbackPlan.`,
324
+ metadata: { entryId: entry.id },
325
+ remediation: 'Append a correction or follow-up change-log entry documenting the rollback plan before treating the failure as closed.',
326
+ }));
133
327
  }
134
328
  if (entry.subkind === 'rollback' && !entry.rollsBack) {
135
- issues.push(`Change-log rollback entry ${entry.id} is missing rollsBack.`);
329
+ pushFinding(findings, createFinding({
330
+ code: 'change-log-rollback-missing-target',
331
+ message: `Change-log rollback entry ${entry.id} is missing rollsBack.`,
332
+ metadata: { entryId: entry.id },
333
+ remediation: 'Append a correction or replacement rollback entry with rollsBack pointing at the reverted ledger entry id.',
334
+ }));
136
335
  }
137
336
  }
138
337
  if (isOpenIssueNote(entry)) {
@@ -147,14 +346,14 @@ const inspectPatchEntry = ({ blobChecks, entry, }) => {
147
346
  blobChecks.push({ blobHash, entryId: entry.id });
148
347
  }
149
348
  };
150
- const inspectDoctorEntry = ({ blobChecks, checkpointEntries, entry, entriesByIdempotencyKey, issues, latestByPhase, manifest, openIssueEntries, previousByPhase, resolvedLedgerIds, unseenManifestEntryIds, }) => {
349
+ const inspectDoctorEntry = ({ blobChecks, checkpointEntries, entriesByIdempotencyKey, entry, findings, latestByPhase, manifest, openIssueEntries, previousByPhase, resolvedLedgerIds, unseenManifestEntryIds, }) => {
151
350
  unseenManifestEntryIds.delete(entry.id);
152
- checkPrevChain(entry, previousByPhase, issues);
351
+ checkPrevChain(entry, findings, previousByPhase);
153
352
  trackIdempotencyEntry(entry, entriesByIdempotencyKey);
154
353
  trackResolutionLinks(entry, resolvedLedgerIds);
155
354
  if (!inspectManifestLocation({
156
355
  entry,
157
- issues,
356
+ findings,
158
357
  latestByPhase,
159
358
  manifest,
160
359
  })) {
@@ -163,7 +362,7 @@ const inspectDoctorEntry = ({ blobChecks, checkpointEntries, entry, entriesByIde
163
362
  inspectNarrativeEntry({
164
363
  checkpointEntries,
165
364
  entry,
166
- issues,
365
+ findings,
167
366
  openIssueEntries,
168
367
  });
169
368
  inspectPatchEntry({
@@ -172,7 +371,7 @@ const inspectDoctorEntry = ({ blobChecks, checkpointEntries, entry, entriesByIde
172
371
  });
173
372
  };
174
373
  const collectDoctorState = async (workspaceRoot, manifest, readIndex) => {
175
- const issues = [];
374
+ const findings = [];
176
375
  const previousByPhase = new Map();
177
376
  const latestByPhase = new Map();
178
377
  const unseenManifestEntryIds = new Set(Object.keys(manifest.entryLocations));
@@ -183,13 +382,15 @@ const collectDoctorState = async (workspaceRoot, manifest, readIndex) => {
183
382
  const resolvedLedgerIds = new Set();
184
383
  let entryCount = 0;
185
384
  const nowMs = Date.now();
385
+ const { doctorCheckpointMaxAgeMs, doctorOpenIssueMaxAgeMs, scanBatchSize, scanConcurrency } = getLedgerRuntimeConfig();
186
386
  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);
387
+ checkManifestSequenceOrder(orderedEntries, findings);
388
+ for (let index = 0; index < orderedEntries.length; index += scanBatchSize) {
389
+ const batch = orderedEntries.slice(index, index + scanBatchSize);
190
390
  const resolvedEntries = await readManifestEntryBatch({
191
391
  allowMissing: true,
192
392
  entryLocations: batch,
393
+ entryReadConcurrency: scanConcurrency,
193
394
  workspaceRoot,
194
395
  });
195
396
  for (const resolvedEntry of resolvedEntries) {
@@ -202,7 +403,7 @@ const collectDoctorState = async (workspaceRoot, manifest, readIndex) => {
202
403
  checkpointEntries,
203
404
  entriesByIdempotencyKey,
204
405
  entry: resolvedEntry.entry,
205
- issues,
406
+ findings,
206
407
  latestByPhase,
207
408
  manifest,
208
409
  openIssueEntries,
@@ -214,16 +415,18 @@ const collectDoctorState = async (workspaceRoot, manifest, readIndex) => {
214
415
  }
215
416
  checkChangeLogWarnings({
216
417
  checkpointEntries,
418
+ checkpointMaxAgeMs: doctorCheckpointMaxAgeMs,
217
419
  entriesByIdempotencyKey,
218
- issues,
420
+ findings,
219
421
  nowMs,
422
+ openIssueMaxAgeMs: doctorOpenIssueMaxAgeMs,
220
423
  openIssueEntries,
221
424
  resolvedLedgerIds,
222
425
  });
223
426
  return {
224
427
  blobChecks,
225
428
  entryCount,
226
- issues,
429
+ findings,
227
430
  latestByPhase,
228
431
  unseenManifestEntryIds,
229
432
  };
@@ -256,17 +459,15 @@ export const runLedgerDoctor = async (workspaceRoot, options = {}) => {
256
459
  catch (error) {
257
460
  return buildReadFailure(error);
258
461
  }
259
- const { blobChecks, entryCount, issues, latestByPhase, unseenManifestEntryIds } = doctorState;
260
- issues.push(...(await checkBlobPresence(workspaceRoot, blobChecks)));
462
+ const { blobChecks, entryCount, findings, latestByPhase, unseenManifestEntryIds } = doctorState;
463
+ findings.push(...(await checkPendingCommitQuarantines(workspaceRoot)));
464
+ findings.push(...(await checkBlobPresence(workspaceRoot, blobChecks)));
261
465
  finalizeManifestChecks({
262
466
  entryCount,
263
- issues,
467
+ findings,
264
468
  latestByPhase,
265
469
  manifest: preparedState.manifest,
266
470
  unseenManifestEntryIds,
267
471
  });
268
- return {
269
- issues,
270
- ok: issues.length === 0,
271
- };
472
+ return buildDoctorReport(findings);
272
473
  };
@@ -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,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"}
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;AAQ9E,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;AA+KF,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,CA6E5E,CAAC"}
package/dist/handle.js CHANGED
@@ -13,7 +13,7 @@ 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 { createAtomicTextFileWriter, resolveLedgerPaths, writeAtomicTextFile } from "./storage/filesystem.js";
16
+ import { createAtomicTextFileWriter, createExternalTempFileRegistrar, resolveLedgerPaths, writeAtomicTextFile, } from "./storage/filesystem.js";
17
17
  const createEntryIteratorFactory = ({ filter, state, workspaceRoot, }) => {
18
18
  return (entryOptions = {}) => iterateEntriesFromManifest(workspaceRoot, state.manifest, state.readIndex, {
19
19
  kind: entryOptions.kind,
@@ -29,6 +29,22 @@ const collectRenderedText = async (emit) => {
29
29
  });
30
30
  return chunks.join('');
31
31
  };
32
+ const attachCleanupCause = (error, cleanupError) => {
33
+ if (error instanceof Error) {
34
+ const errorWithCause = error;
35
+ if (errorWithCause.cause === undefined) {
36
+ Object.defineProperty(errorWithCause, 'cause', {
37
+ configurable: true,
38
+ enumerable: false,
39
+ value: cleanupError,
40
+ writable: true,
41
+ });
42
+ }
43
+ return error;
44
+ }
45
+ return new Error(String(error), { cause: cleanupError });
46
+ };
47
+ const getExternalTempRegistrar = (workspaceRoot, outPath) => outPath ? createExternalTempFileRegistrar(workspaceRoot) : undefined;
32
48
  const emitRenderedTarget = async ({ createEntries, manifest, target, write, workspaceName, }) => {
33
49
  switch (target) {
34
50
  case 'dependency-graph':
@@ -82,6 +98,40 @@ const resolveRenderOutputPath = (workspaceRoot, target) => {
82
98
  return null;
83
99
  }
84
100
  };
101
+ const runRenderToWriters = async ({ atomicWriter, createEntries, manifest, target, workspaceName, writer, }) => {
102
+ let hasWrittenContent = false;
103
+ let endsWithNewline = false;
104
+ try {
105
+ await emitRenderedTarget({
106
+ createEntries,
107
+ manifest,
108
+ target,
109
+ workspaceName,
110
+ write: async (chunk) => {
111
+ if (chunk.length > 0) {
112
+ hasWrittenContent = true;
113
+ endsWithNewline = chunk.endsWith('\n');
114
+ }
115
+ await writer?.(chunk);
116
+ await atomicWriter?.write(chunk);
117
+ },
118
+ });
119
+ if (!hasWrittenContent || !endsWithNewline) {
120
+ await writer?.('\n');
121
+ await atomicWriter?.write('\n');
122
+ }
123
+ await atomicWriter?.close();
124
+ }
125
+ catch (error) {
126
+ try {
127
+ await atomicWriter?.abort();
128
+ }
129
+ catch (cleanupError) {
130
+ throw attachCleanupCause(error, cleanupError);
131
+ }
132
+ throw error;
133
+ }
134
+ };
85
135
  /** Open a workspace ledger handle after reconciling any pending crash-recovery state. */
86
136
  export const openLedger = async (workspaceRoot) => {
87
137
  await readLabManifestMin(workspaceRoot);
@@ -115,7 +165,9 @@ export const openLedger = async (workspaceRoot) => {
115
165
  }));
116
166
  const outputPath = options.out ?? resolveRenderOutputPath(workspaceRoot, options.to);
117
167
  if (outputPath) {
118
- await writeAtomicTextFile(outputPath, `${content}${content.endsWith('\n') ? '' : '\n'}`);
168
+ await writeAtomicTextFile(outputPath, `${content}${content.endsWith('\n') ? '' : '\n'}`, {
169
+ registerTempFile: getExternalTempRegistrar(workspaceRoot, options.out),
170
+ });
119
171
  }
120
172
  return content;
121
173
  },
@@ -127,38 +179,23 @@ export const openLedger = async (workspaceRoot) => {
127
179
  workspaceRoot,
128
180
  });
129
181
  const outputPath = options.out ?? resolveRenderOutputPath(workspaceRoot, options.to);
130
- const atomicWriter = outputPath ? await createAtomicTextFileWriter(outputPath) : null;
182
+ const atomicWriter = outputPath
183
+ ? await createAtomicTextFileWriter(outputPath, {
184
+ registerTempFile: getExternalTempRegistrar(workspaceRoot, options.out),
185
+ })
186
+ : null;
131
187
  const writer = options.write;
132
- let hasWrittenContent = false;
133
- let endsWithNewline = false;
134
188
  if (!atomicWriter && !writer) {
135
189
  throw new Error(`Render target ${options.to} requires either a writer callback or an output path.`);
136
190
  }
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
- }
191
+ await runRenderToWriters({
192
+ atomicWriter,
193
+ createEntries,
194
+ manifest: state.manifest,
195
+ target: options.to,
196
+ workspaceName: path.basename(workspaceRoot),
197
+ writer,
198
+ });
162
199
  },
163
200
  show: async (entryId) => {
164
201
  const { manifest } = await loadLedgerState(workspaceRoot);
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"}
package/dist/helpers.js CHANGED
@@ -36,3 +36,26 @@ export const waitForFile = async (filePath, timeoutMs = 1_000) => {
36
36
  }
37
37
  throw new Error(`Timed out waiting for file: ${filePath}`);
38
38
  };
39
+ export const withEnvOverrides = async (overrides, action) => {
40
+ const previousValues = new Map();
41
+ for (const [key, value] of Object.entries(overrides)) {
42
+ previousValues.set(key, process.env[key]);
43
+ if (value === undefined) {
44
+ delete process.env[key];
45
+ continue;
46
+ }
47
+ process.env[key] = value;
48
+ }
49
+ try {
50
+ return await action();
51
+ }
52
+ finally {
53
+ for (const [key, value] of previousValues.entries()) {
54
+ if (value === undefined) {
55
+ delete process.env[key];
56
+ continue;
57
+ }
58
+ process.env[key] = value;
59
+ }
60
+ }
61
+ };