dsh-workflow-map 0.0.0-stage → 0.3.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.
- package/CHANGELOG.md +98 -0
- package/LICENSE +21 -0
- package/README.en.md +447 -0
- package/README.md +423 -2
- package/client.js +9673 -0
- package/cordis.patch.yml +11 -0
- package/docs/OPEN-SOURCE-GUIDE.md +256 -0
- package/docs/screenshot-detail.png +0 -0
- package/docs/screenshot.png +0 -0
- package/icon.svg +6 -0
- package/index.js +12 -0
- package/lib/index.js +974 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +65 -4
- package/src/client/Branches.js +241 -0
- package/src/client/FlowColumn.js +572 -0
- package/src/client/FlowEffects.js +677 -0
- package/src/client/HotInject.js +628 -0
- package/src/client/MainFlow.js +359 -0
- package/src/client/NodeCard.js +545 -0
- package/src/client/NodeDetail.js +569 -0
- package/src/client/ProgressUnder.js +216 -0
- package/src/client/RightPage.js +285 -0
- package/src/client/Settings.js +212 -0
- package/src/client/StepColumn.js +479 -0
- package/src/client/StepDetail.js +250 -0
- package/src/client/export.js +280 -0
- package/src/client/panel.js +1911 -0
- package/src/client/pulse.js +126 -0
- package/src/client/store.js +1090 -0
- package/src/client/theme.js +1164 -0
- package/src/fold.mjs +2633 -0
- package/src/orchestrator.mjs +426 -0
- package/src/route.js +621 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-workflow-map — PAE plan reader .
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS. The fold in ../src/fold.mjs already recognises plan steps from PAE's
|
|
5
|
+
* own context messages, but that is a reconstruction of the log. The AUTHORITATIVE plan — its
|
|
6
|
+
* phase, PAE's canonical task text, the per-step files and the orchestrator's own status /
|
|
7
|
+
* report / anchor bookkeeping — lives in `<cwd>/.pae/<sessionId>/orchestrator.json`. This module
|
|
8
|
+
* reads exactly that file and nothing else.
|
|
9
|
+
*
|
|
10
|
+
* BOUNDARIES (each one is load-bearing):
|
|
11
|
+
* * READ ONLY. Nothing here writes, creates or deletes anything under `.pae`; the self-test
|
|
12
|
+
* asserts the file's mtime and size are unchanged after a full read.
|
|
13
|
+
* * NEVER THROWS. A missing file, a half-written file (PAE rewrites it between steps), a
|
|
14
|
+
* permission problem or a schema surprise must degrade the panel, never the session: every
|
|
15
|
+
* failure returns null and appends ONE line to errors.
|
|
16
|
+
* * ONE STAT PER VIEW. read stats the file and reuses the parsed value while `mtimeMs:size`
|
|
17
|
+
* is unchanged, so a per-event wire build costs one statSync and no read. The parsed value
|
|
18
|
+
* is deep-frozen, so the cache can be handed to every view without aliasing risk.
|
|
19
|
+
* * NO CLOCK, NO AMBIENT STATE. `asOfMtime` is the file's own mtime — the only honest "as of"
|
|
20
|
+
* instant this source has.
|
|
21
|
+
* * PATHS ARE NOT GUESSED. `<cwd>` and the session id must both be supplied. A session id
|
|
22
|
+
* that is not a single path segment is refused, because it would escape `.pae`.
|
|
23
|
+
*
|
|
24
|
+
* WHAT IT DOES NOT DO: it never invents a step. `steps` comes from `plan.steps` verbatim (with
|
|
25
|
+
* the 1-based ordinal PAE prints), so a plan row the fold has no evidence for is still listed
|
|
26
|
+
* with `index`/`title`/`file` and no fabricated timings or percentages.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFileSync, statSync } from "node:fs";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { TEXT_LIMIT, TITLE_LIMIT, line, text } from "./fold.mjs";
|
|
32
|
+
|
|
33
|
+
/** Version of the `plan` wire section. Bump only when its SHAPE changes. */
|
|
34
|
+
export const PLAN_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
/** PAE's plan root, relative to the session cwd, and the state file inside it. */
|
|
37
|
+
export const PLAN_ROOT = ".pae";
|
|
38
|
+
export const PLAN_FILE = "orchestrator.json";
|
|
39
|
+
|
|
40
|
+
/** Bounds. The plan is display data; a runaway file must not grow the session cache. */
|
|
41
|
+
export const MAX_PLAN_STEPS = 200;
|
|
42
|
+
export const MAX_STEP_REPORTS = 200;
|
|
43
|
+
export const MAX_ARTIFACTS = 40;
|
|
44
|
+
export const MAX_ERRORS = 8;
|
|
45
|
+
export const MAX_TASK = 2000;
|
|
46
|
+
export const MAX_TASK_TITLE = 400;
|
|
47
|
+
export const MAX_SUMMARY = 400;
|
|
48
|
+
export const MAX_PATH = 400;
|
|
49
|
+
export const MAX_PHASE = 40;
|
|
50
|
+
|
|
51
|
+
/** PAE's `statuses` / `anchorSeqs` keys are 1-based ordinals printed as strings. */
|
|
52
|
+
const ORDINAL = /^[1-9][0-9]{0,3}$/;
|
|
53
|
+
|
|
54
|
+
const isObject = (value) => value !== null && typeof value === "object";
|
|
55
|
+
const isRecord = (value) => isObject(value) && !Array.isArray(value);
|
|
56
|
+
const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A path is NOT passed through `text`: that helper flattens whitespace, which would silently
|
|
60
|
+
* rewrite a legitimate `C:\Users\A B\…`. Paths are only length-bounded.
|
|
61
|
+
*/
|
|
62
|
+
function pathText(value) {
|
|
63
|
+
if (typeof value !== "string") return "";
|
|
64
|
+
return value.length <= MAX_PATH ? value : `${value.slice(0, MAX_PATH - 1)}…`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** PAE's status vocabulary → the fold's four step states. A Map, not an object literal, so a
|
|
68
|
+
* crafted `"constructor"` key cannot resolve to Object.prototype. */
|
|
69
|
+
const PLAN_STATUS = new Map([
|
|
70
|
+
["completed", "done"],
|
|
71
|
+
["done", "done"],
|
|
72
|
+
["success", "done"],
|
|
73
|
+
["in_progress", "running"],
|
|
74
|
+
["in-progress", "running"],
|
|
75
|
+
["running", "running"],
|
|
76
|
+
["executing", "running"],
|
|
77
|
+
["pending", "pending"],
|
|
78
|
+
["queued", "pending"],
|
|
79
|
+
["failed", "failed"],
|
|
80
|
+
["failure", "failed"],
|
|
81
|
+
["error", "failed"],
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Where this session's plan file is, or null when the inputs cannot name one.
|
|
86
|
+
*
|
|
87
|
+
* `sessionId` comes from the session header (`header.id`, already `session-<uuid>` on this
|
|
88
|
+
* host — that is the name PAE itself creates under `.pae`). Both inputs are required: an
|
|
89
|
+
* invented path would read somebody else's plan or nothing at all, and a null is honest.
|
|
90
|
+
*
|
|
91
|
+
* @param {{cwd?: unknown, sessionId?: unknown}} input
|
|
92
|
+
* @returns {string|null}
|
|
93
|
+
*/
|
|
94
|
+
export function planFilePath(input) {
|
|
95
|
+
const source = isRecord(input) ? input : {};
|
|
96
|
+
const cwd = typeof source.cwd === "string" ? source.cwd.trim() : "";
|
|
97
|
+
const sessionId = typeof source.sessionId === "string" ? source.sessionId.trim() : "";
|
|
98
|
+
if (cwd === "" || sessionId === "") return null;
|
|
99
|
+
// One path segment only, and not a traversal token.
|
|
100
|
+
if (sessionId === "." || sessionId === "..") return null;
|
|
101
|
+
if (sessionId.includes("/") || sessionId.includes("\\") || sessionId.includes(":")) return null;
|
|
102
|
+
return join(cwd, PLAN_ROOT, sessionId, PLAN_FILE);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Normalize one parsed `orchestrator.json` into the wire `plan` section.
|
|
107
|
+
*
|
|
108
|
+
* Every field is defended individually: PAE writes this file incrementally, so a field can be
|
|
109
|
+
* absent in one phase and present in another (a `planning` document has no `plan`, a completed
|
|
110
|
+
* one has no `pausedReason`). A missing field degrades to its empty value — it never fails the
|
|
111
|
+
* whole read, and it never invents a value.
|
|
112
|
+
*
|
|
113
|
+
* @param {unknown} raw - the parsed JSON document.
|
|
114
|
+
* @param {{asOfMtime?: unknown, sourcePath?: unknown}} [meta]
|
|
115
|
+
* @returns {object} a frozen, JSON-safe plan section.
|
|
116
|
+
*/
|
|
117
|
+
export function normalizePlanDocument(raw, meta) {
|
|
118
|
+
const doc = isRecord(raw) ? raw : {};
|
|
119
|
+
const plan = isRecord(doc.plan) ? doc.plan : {};
|
|
120
|
+
const source = isRecord(meta) ? meta : {};
|
|
121
|
+
|
|
122
|
+
// -- steps: the published plan list, in order, with PAE's own 1-based ordinals -----------
|
|
123
|
+
const steps = [];
|
|
124
|
+
const rawSteps = Array.isArray(plan.steps) ? plan.steps : [];
|
|
125
|
+
const stepCount = Math.min(rawSteps.length, MAX_PLAN_STEPS);
|
|
126
|
+
for (let position = 0; position < stepCount; position += 1) {
|
|
127
|
+
const item = rawSteps[position];
|
|
128
|
+
const row = isRecord(item) ? item : {};
|
|
129
|
+
steps.push(
|
|
130
|
+
Object.freeze({
|
|
131
|
+
// Position, not "number of rows kept": a damaged row must not shift every ordinal
|
|
132
|
+
// after it away from the `statuses` / `anchorSeqs` keys that point at it.
|
|
133
|
+
index: position + 1,
|
|
134
|
+
title: text(row.title, TITLE_LIMIT),
|
|
135
|
+
file: pathText(row.file),
|
|
136
|
+
requiresConfirmation: row.requiresConfirmation === true,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// -- statuses: {"1":"completed","2":"in_progress"} ---------------------------------------
|
|
142
|
+
const statuses = {};
|
|
143
|
+
if (isRecord(doc.statuses)) {
|
|
144
|
+
for (const [key, value] of Object.entries(doc.statuses)) {
|
|
145
|
+
if (!ORDINAL.test(key)) continue;
|
|
146
|
+
if (typeof value !== "string" || value === "") continue;
|
|
147
|
+
statuses[key] = text(value, MAX_PHASE);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// -- anchorSeqs: {"1":502,"2":942} — the seq of the user/message that OPENS step n ---------
|
|
152
|
+
const anchorSeqs = {};
|
|
153
|
+
if (isRecord(doc.anchorSeqs)) {
|
|
154
|
+
for (const [key, value] of Object.entries(doc.anchorSeqs)) {
|
|
155
|
+
if (!ORDINAL.test(key)) continue;
|
|
156
|
+
if (!isFiniteNumber(value)) continue;
|
|
157
|
+
anchorSeqs[key] = value;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// -- stepReports: one bounded report per step; a retried step keeps its LAST report -------
|
|
162
|
+
const byStep = new Map();
|
|
163
|
+
const rawReports = Array.isArray(doc.stepReports) ? doc.stepReports : [];
|
|
164
|
+
for (const item of rawReports) {
|
|
165
|
+
const row = isRecord(item) ? item : null;
|
|
166
|
+
if (row === null) continue;
|
|
167
|
+
if (!Number.isInteger(row.stepIndex) || row.stepIndex < 1) continue;
|
|
168
|
+
const artifacts = Array.isArray(row.artifacts)
|
|
169
|
+
? row.artifacts
|
|
170
|
+
.filter((entry) => typeof entry === "string")
|
|
171
|
+
.slice(0, MAX_ARTIFACTS)
|
|
172
|
+
.map((entry) => pathText(entry))
|
|
173
|
+
: [];
|
|
174
|
+
byStep.set(row.stepIndex, {
|
|
175
|
+
stepIndex: row.stepIndex,
|
|
176
|
+
status: text(row.status, MAX_PHASE),
|
|
177
|
+
artifacts: Object.freeze(artifacts),
|
|
178
|
+
summary: text(row.summary, MAX_SUMMARY),
|
|
179
|
+
exitCode: Number.isInteger(row.exit_code) ? row.exit_code : null,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const stepReports = [...byStep.values()]
|
|
183
|
+
.sort((left, right) => left.stepIndex - right.stepIndex)
|
|
184
|
+
.slice(0, MAX_STEP_REPORTS)
|
|
185
|
+
.map((report) => Object.freeze(report));
|
|
186
|
+
|
|
187
|
+
// -- skipped: [7] — 1-based ordinals PAE skipped ------------------------------------------
|
|
188
|
+
const skipped = [];
|
|
189
|
+
if (Array.isArray(doc.skipped)) {
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
for (const value of doc.skipped) {
|
|
192
|
+
if (!Number.isInteger(value) || value < 1 || seen.has(value)) continue;
|
|
193
|
+
seen.add(value);
|
|
194
|
+
skipped.push(value);
|
|
195
|
+
}
|
|
196
|
+
skipped.sort((left, right) => left - right);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rawTask = typeof doc.task === "string" ? doc.task : "";
|
|
200
|
+
const result = {
|
|
201
|
+
ver: PLAN_VERSION,
|
|
202
|
+
phase: text(doc.phase, MAX_PHASE),
|
|
203
|
+
task: text(rawTask, MAX_TASK),
|
|
204
|
+
// The first non-empty line, before flattening: it is the panel's header.
|
|
205
|
+
taskTitle: line(rawTask, MAX_TASK_TITLE),
|
|
206
|
+
summary: text(plan.summary, MAX_SUMMARY),
|
|
207
|
+
planDir: pathText(typeof doc.planDir === "string" && doc.planDir !== "" ? doc.planDir : plan.planDir),
|
|
208
|
+
pausedReason: text(doc.pausedReason, MAX_PHASE),
|
|
209
|
+
stepIndex: Number.isInteger(doc.stepIndex) && doc.stepIndex >= 1 ? doc.stepIndex : null,
|
|
210
|
+
steps: Object.freeze(steps),
|
|
211
|
+
stepReports: Object.freeze(stepReports),
|
|
212
|
+
statuses: Object.freeze(statuses),
|
|
213
|
+
anchorSeqs: Object.freeze(anchorSeqs),
|
|
214
|
+
skipped: Object.freeze(skipped),
|
|
215
|
+
asOfMtime: isFiniteNumber(source.asOfMtime) && source.asOfMtime >= 0 ? source.asOfMtime : 0,
|
|
216
|
+
sourcePath: pathText(source.sourcePath),
|
|
217
|
+
};
|
|
218
|
+
return Object.freeze(result);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The plan's status for step `planIndex`, mapped to the fold's vocabulary, or null. */
|
|
222
|
+
function planStatusOf(plan, planIndex) {
|
|
223
|
+
const label = plan.statuses[String(planIndex)];
|
|
224
|
+
if (typeof label === "string") {
|
|
225
|
+
const mapped = PLAN_STATUS.get(label.trim().toLowerCase());
|
|
226
|
+
if (mapped !== undefined) return mapped;
|
|
227
|
+
}
|
|
228
|
+
const report = plan.stepReports.find((row) => row.stepIndex === planIndex);
|
|
229
|
+
if (report !== undefined) {
|
|
230
|
+
if (report.status === "success") return "done";
|
|
231
|
+
if (report.status === "failed") return "failed";
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Merge the plan section into a wire view.
|
|
238
|
+
*
|
|
239
|
+
* The plan is the PRIMARY source ("PAE plan first"), the todo table the fallback - but the fold's
|
|
240
|
+
* own event replay is fresher than a file PAE only rewrites at step boundaries. The five rules
|
|
241
|
+
* below are that judgement, written down so it cannot drift:
|
|
242
|
+
*
|
|
243
|
+
* 1. `task` prefers the plan's first line. The fold's task is the first user message capped
|
|
244
|
+
* at 120 chars; the plan carries PAE's canonical objective.
|
|
245
|
+
* 2. A plan step matches a folded row by `planIndex` first. ONLY a row the fold derived from
|
|
246
|
+
* the todo table may fall back to ordinal position - that is the "todo fallback" case.
|
|
247
|
+
* 3. A matched row's TITLE always comes from the plan: it is the published plan text.
|
|
248
|
+
* 4. A matched row's STATUS comes from the plan ONLY when the row is todo-derived. A row the
|
|
249
|
+
* fold opened from a PAE step instruction has an event-derived status that is at least as
|
|
250
|
+
* fresh as the file, and `running` there means "this is happening now".
|
|
251
|
+
* 5. A plan step with no folded row is NOT invented here: `plan.steps` already carries it and
|
|
252
|
+
* the client renders that list. A fabricated row would need a start time, a percent and
|
|
253
|
+
* node bindings that no source provides.
|
|
254
|
+
*
|
|
255
|
+
* The returned view is a new object; the input (and every frozen plan object) is never mutated.
|
|
256
|
+
*
|
|
257
|
+
* @param {unknown} view - the wire view produced by `buildView`.
|
|
258
|
+
* @param {unknown} plan - a normalized plan, or null.
|
|
259
|
+
* @returns {object}
|
|
260
|
+
*/
|
|
261
|
+
export function applyPlanToView(view, plan) {
|
|
262
|
+
if (!isRecord(view)) return view;
|
|
263
|
+
const normalized = isRecord(plan) ? plan : null;
|
|
264
|
+
const result = { ...view, plan: normalized };
|
|
265
|
+
if (normalized === null) return result;
|
|
266
|
+
|
|
267
|
+
if (normalized.taskTitle !== "") result.task = normalized.taskTitle;
|
|
268
|
+
|
|
269
|
+
const steps = Array.isArray(view.steps) ? view.steps.map((step) => (isRecord(step) ? { ...step } : step)) : [];
|
|
270
|
+
const byPlanIndex = new Map();
|
|
271
|
+
for (let position = 0; position < steps.length; position += 1) {
|
|
272
|
+
const step = steps[position];
|
|
273
|
+
if (isRecord(step) && Number.isInteger(step.planIndex)) byPlanIndex.set(step.planIndex, position);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let changed = false;
|
|
277
|
+
for (const planStep of normalized.steps) {
|
|
278
|
+
let position = byPlanIndex.get(planStep.index);
|
|
279
|
+
if (position === undefined) {
|
|
280
|
+
const candidate = steps[planStep.index - 1];
|
|
281
|
+
if (isRecord(candidate) && candidate.source === "todo" && !Number.isInteger(candidate.planIndex)) {
|
|
282
|
+
position = planStep.index - 1;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (position === undefined) continue;
|
|
286
|
+
|
|
287
|
+
const step = steps[position];
|
|
288
|
+
if (!isRecord(step)) continue;
|
|
289
|
+
const patch = {};
|
|
290
|
+
if (planStep.title !== "" && planStep.title !== step.title) patch.title = planStep.title;
|
|
291
|
+
if (step.planIndex !== planStep.index) patch.planIndex = planStep.index;
|
|
292
|
+
if (step.source === "todo") {
|
|
293
|
+
const status = planStatusOf(normalized, planStep.index);
|
|
294
|
+
if (status !== null && status !== step.status) {
|
|
295
|
+
patch.status = status;
|
|
296
|
+
// `buildView` pins a closed step at 100 when no report gave a number; the same rule
|
|
297
|
+
// applies after this late status change, or the bar would show "—" on a finished step.
|
|
298
|
+
if ((status === "done" || status === "failed") && (step.percent === null || step.percent === undefined)) {
|
|
299
|
+
patch.percent = 100;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (Object.keys(patch).length === 0) continue;
|
|
304
|
+
steps[position] = { ...step, ...patch };
|
|
305
|
+
changed = true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (changed) {
|
|
309
|
+
result.steps = steps;
|
|
310
|
+
const running = steps.filter((step) => isRecord(step) && step.status === "running").length;
|
|
311
|
+
const completed = steps.filter((step) => isRecord(step) && step.status === "done").length;
|
|
312
|
+
result.counts = {
|
|
313
|
+
...(isRecord(view.counts) ? view.counts : {}),
|
|
314
|
+
steps: steps.length,
|
|
315
|
+
nodes: Array.isArray(view.nodes) ? view.nodes.length : 0,
|
|
316
|
+
running,
|
|
317
|
+
completed,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* One per-session reader: path + mtime/size cache + a bounded error log.
|
|
326
|
+
*
|
|
327
|
+
* The instance is created by the host half's `init(header)` and lives outside the projection
|
|
328
|
+
* STATE on purpose — a reader is not JSON, and the persisted state is. `read` is called from
|
|
329
|
+
* the wire view, where it costs one `statSync`; `apply` never touches the disk.
|
|
330
|
+
*
|
|
331
|
+
* @param {{cwd?: unknown, sessionId?: unknown}} [options]
|
|
332
|
+
* @returns {{path: string|null, read: => object|null, stats: => object, errors: => object[], reset: => void}}
|
|
333
|
+
*/
|
|
334
|
+
export function createPlanReader(options) {
|
|
335
|
+
const source = isRecord(options) ? options : {};
|
|
336
|
+
const filePath = planFilePath(source);
|
|
337
|
+
|
|
338
|
+
const counters = { stat: 0, read: 0, hit: 0, miss: 0, error: 0 };
|
|
339
|
+
const errors = [];
|
|
340
|
+
let cacheKey = null;
|
|
341
|
+
let cached = null;
|
|
342
|
+
|
|
343
|
+
function noteError(code, message) {
|
|
344
|
+
counters.error += 1;
|
|
345
|
+
const messageText = text(message, TEXT_LIMIT);
|
|
346
|
+
const last = errors[errors.length - 1];
|
|
347
|
+
// A session without PAE stats a missing file on every event: one line, not thousands.
|
|
348
|
+
if (last !== undefined && last.code === code && last.message === messageText) return;
|
|
349
|
+
errors.push({ code, message: messageText });
|
|
350
|
+
if (errors.length > MAX_ERRORS) errors.splice(0, errors.length - MAX_ERRORS);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function fail(code, error) {
|
|
354
|
+
cacheKey = null;
|
|
355
|
+
cached = null;
|
|
356
|
+
counters.miss += 1;
|
|
357
|
+
noteError(code, error instanceof Error ? error.message : String(error));
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function read() {
|
|
362
|
+
if (filePath === null) return null;
|
|
363
|
+
|
|
364
|
+
counters.stat += 1;
|
|
365
|
+
let stat;
|
|
366
|
+
try {
|
|
367
|
+
stat = statSync(filePath);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
return fail(errorCode(error), error);
|
|
370
|
+
}
|
|
371
|
+
if (!stat.isFile()) {
|
|
372
|
+
return fail("ENOTFILE", `${PLAN_FILE} is not a regular file`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const key = `${stat.mtimeMs}:${stat.size}`;
|
|
376
|
+
if (cacheKey === key && cached !== null) {
|
|
377
|
+
counters.hit += 1;
|
|
378
|
+
return cached;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
counters.read += 1;
|
|
382
|
+
let raw;
|
|
383
|
+
try {
|
|
384
|
+
raw = readFileSync(filePath, "utf8");
|
|
385
|
+
} catch (error) {
|
|
386
|
+
return fail(errorCode(error), error);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let document;
|
|
390
|
+
try {
|
|
391
|
+
// A BOM is legal in a text file and illegal to JSON.parse: strip it, do not fail on it.
|
|
392
|
+
document = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
return fail("PARSE_FAILED", error);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
cacheKey = key;
|
|
398
|
+
cached = normalizePlanDocument(document, { asOfMtime: stat.mtimeMs, sourcePath: filePath });
|
|
399
|
+
counters.miss += 1;
|
|
400
|
+
return cached;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
path: filePath,
|
|
405
|
+
read,
|
|
406
|
+
stats: () => ({ ...counters }),
|
|
407
|
+
errors: () => errors.map((entry) => ({ ...entry })),
|
|
408
|
+
/** Forget the cached value (the counters and the error log restart with it). */
|
|
409
|
+
reset() {
|
|
410
|
+
cacheKey = null;
|
|
411
|
+
cached = null;
|
|
412
|
+
counters.stat = 0;
|
|
413
|
+
counters.read = 0;
|
|
414
|
+
counters.hit = 0;
|
|
415
|
+
counters.miss = 0;
|
|
416
|
+
counters.error = 0;
|
|
417
|
+
errors.length = 0;
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** `error.code` when it is a string (ENOENT / EACCES …), else a stable label. */
|
|
423
|
+
function errorCode(error) {
|
|
424
|
+
if (isRecord(error) && typeof error.code === "string" && error.code !== "") return error.code;
|
|
425
|
+
return "READ_FAILED";
|
|
426
|
+
}
|