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.
@@ -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
+ }