dsh-workflow-map 0.3.2 → 0.3.5
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 +131 -6
- package/README.en.md +76 -28
- package/README.md +74 -27
- package/client.js +163 -19
- package/cordis.patch.yml +3 -3
- package/docs/OPEN-SOURCE-GUIDE.md +8 -8
- package/docs/host-compatibility.md +320 -0
- package/docs/screenshots/01-main-dark.png +0 -0
- package/docs/screenshots/02-main-light.png +0 -0
- package/docs/screenshots/03-running-step.png +0 -0
- package/docs/screenshots/04-step-detail.png +0 -0
- package/docs/screenshots/05-hot-inject.png +0 -0
- package/docs/screenshots/06-settings.png +0 -0
- package/package.json +20 -3
- package/src/client/ProgressUnder.js +1 -1
- package/src/client/StepColumn.js +4 -1
- package/src/client/panel.js +97 -4
- package/src/client/pulse.js +11 -1
- package/src/client/store.js +23 -1
- package/src/client/theme.js +27 -11
- package/src/fold.mjs +210 -19
- package/src/orchestrator.mjs +43 -1
package/src/client/store.js
CHANGED
|
@@ -175,6 +175,22 @@ function wfPollUrl(sessionId) {
|
|
|
175
175
|
return "/dsh-workflow/state?sessionId=" + encodeURIComponent(String(sessionId));
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* May the poll loop re-arm after this result?
|
|
180
|
+
*
|
|
181
|
+
* A tick aborts the request it replaces, and an aborted request resolves as status -1.
|
|
182
|
+
* That is the ORDINARY path whenever a poll outlives its interval, so -1 must not end
|
|
183
|
+
* the loop: it only defers to the replacement that is already in the air. Returning
|
|
184
|
+
* false there is what keeps two loops from running; returning false for anything else
|
|
185
|
+
* (a real error, a 4xx) would stop the panel from ever updating again.
|
|
186
|
+
*
|
|
187
|
+
* Extracted from the hook so the rule is testable without a renderer.
|
|
188
|
+
*/
|
|
189
|
+
function wfPollContinues(result, inflight) {
|
|
190
|
+
if (!result || result.status !== -1) return true;
|
|
191
|
+
return wfFinite(inflight) === null ? true : inflight <= 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
178
194
|
/**
|
|
179
195
|
* One conditional GET. Never throws: every outcome is a value, so the loop can
|
|
180
196
|
* always decide what to render and how long to wait.
|
|
@@ -365,6 +381,9 @@ function wfPlanSummary(plan) {
|
|
|
365
381
|
}
|
|
366
382
|
}
|
|
367
383
|
return {
|
|
384
|
+
// A finished plan must not decide a card's status any more; the summary carries
|
|
385
|
+
// the flag so the status policy can stand down without re-reading the phase.
|
|
386
|
+
terminal: plan.terminal === true,
|
|
368
387
|
statuses: plan.statuses && typeof plan.statuses === "object" ? plan.statuses : {},
|
|
369
388
|
anchorSeqs: plan.anchorSeqs && typeof plan.anchorSeqs === "object" ? plan.anchorSeqs : {},
|
|
370
389
|
skipped: Array.isArray(plan.skipped) ? plan.skipped.slice() : [],
|
|
@@ -878,7 +897,10 @@ function wfBuildModel(view, prefs, nowMs) {
|
|
|
878
897
|
errorText: typeof found.error === "string" ? found.error.slice(0, 200) : "",
|
|
879
898
|
seq: wfFinite(found.seq),
|
|
880
899
|
error: typeof found.error === "string" && found.error !== "",
|
|
881
|
-
result:
|
|
900
|
+
// The host's result boolean is the settled fact: a call that returned with an EMPTY body is
|
|
901
|
+
// settled. Falling back to resultHead alone marked such a call unsettled, so the panel kept
|
|
902
|
+
// it in the "calling right now" state and its dot pulsed after the call had already returned.
|
|
903
|
+
result: found.result === true || (typeof found.resultHead === "string" && found.resultHead !== ""),
|
|
882
904
|
durationMs: wfFinite(found.durationMs),
|
|
883
905
|
at: wfFinite(found.at),
|
|
884
906
|
});
|
package/src/client/theme.js
CHANGED
|
@@ -169,7 +169,15 @@ var WF_CSS = [
|
|
|
169
169
|
// the three branch hues live on the ROOT, so the dashed branches and their round
|
|
170
170
|
// icons share one palette with the rest of the panel (and one place to change it).
|
|
171
171
|
"--wf-chain-read:#5b8cff;--wf-chain-edit:#e0a13a;--wf-chain-tool:#8f7bea;" +
|
|
172
|
+
// `--dsw-alias-bg-layer-1` and `-2` carry the SAME value in the host's light theme (both
|
|
173
|
+
// neutral-bluish-00, i.e. #fff), so a card filled with layer-2 is invisible on the page. Every
|
|
174
|
+
// raised surface in the panel therefore paints one translucent lift over that fill. The lift goes
|
|
175
|
+
// towards ink on a light page and towards white on a dark one; the host marks dark by putting
|
|
176
|
+
// `data-ds-dark-theme` on <body>, so the light values are the defaults and the dark block below
|
|
177
|
+
// only wins when that attribute is present.
|
|
178
|
+
"--wf-lift:rgba(15,23,42,.045);--wf-lift-2:rgba(15,23,42,.085);" +
|
|
172
179
|
"font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI','Microsoft YaHei',sans-serif;font-size:12px;line-height:1.5}",
|
|
180
|
+
"body[data-ds-dark-theme] .wf-root{--wf-lift:rgba(255,255,255,.035);--wf-lift-2:rgba(255,255,255,.07)}",
|
|
173
181
|
|
|
174
182
|
/* card: fills the main area; the header and the settings card keep their own height,
|
|
175
183
|
the two-column body takes the rest. No drag grips: the column has no free edges.
|
|
@@ -195,6 +203,10 @@ var WF_CSS = [
|
|
|
195
203
|
".wf-btn:hover{background:var(--dsw-alias-bg-layer-2,#2c3140);color:var(--dsw-alias-label-primary,#e9ecf3)}",
|
|
196
204
|
".wf-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
|
|
197
205
|
".wf-btn[data-active='true']{color:var(--dsw-alias-brand-primary,#5b8cff);border-color:var(--dsw-alias-brand-primary,#5b8cff)}",
|
|
206
|
+
// The refresh button's one-second acknowledgement. Width is pinned to the glyph's own
|
|
207
|
+
// box so swapping "↻" for "已刷新" cannot shift the header row underneath the pointer.
|
|
208
|
+
".wf-btn-ok{color:var(--dsw-alias-state-success-primary,#3fb950);border-color:var(--dsw-alias-state-success-primary,#3fb950);" +
|
|
209
|
+
"width:auto;min-width:23px;padding:0 6px;font-size:11px}",
|
|
198
210
|
// Hot injection is the one header action that injects text into the RUNNING turn, so it is the
|
|
199
211
|
// one that must never be hunted for: a named, filled chip that keeps its name in Chinese.
|
|
200
212
|
// User feedback: "too small, still have to open it first" - so it grows to 96x30 (the other two in the row stay
|
|
@@ -252,13 +264,14 @@ var WF_CSS = [
|
|
|
252
264
|
// `grid-area:1/1` is what makes the triangle and the card share the cell: without it the
|
|
253
265
|
// auto-placement cursor would drop the card into row 2, below the triangle.
|
|
254
266
|
".wf-step-card{grid-area:1/1;display:flex;gap:8px;align-items:flex-start;width:100%;padding:7px 8px;border-radius:8px;text-align:left;" +
|
|
255
|
-
"font:inherit;color:inherit;cursor:pointer;border:1px solid
|
|
256
|
-
"background:var(--dsw-alias-bg-layer-2,#282d3a);
|
|
267
|
+
"font:inherit;color:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
|
|
268
|
+
"background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));" +
|
|
269
|
+
"border-left:3px solid transparent}",
|
|
257
270
|
".wf-step-card:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
|
|
258
|
-
"background:var(--dsw-alias-bg-layer-1,#232733)}",
|
|
271
|
+
"background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
|
|
259
272
|
".wf-step-card:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
|
|
260
273
|
".wf-step-card[data-selected='true']{border-color:var(--dsw-alias-brand-primary,#5b8cff);" +
|
|
261
|
-
"background:var(--dsw-alias-bg-layer-1,#232733)}",
|
|
274
|
+
"background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
|
|
262
275
|
".wf-step-card[data-active='true']{border-left-color:var(--dsw-alias-brand-primary,#5b8cff)}",
|
|
263
276
|
".wf-step-ring{flex:0 0 auto;width:18px;height:18px;margin-top:1px;border-radius:50%;" +
|
|
264
277
|
"display:inline-flex;align-items:center;justify-content:center;font-size:11px;line-height:1;" +
|
|
@@ -412,7 +425,7 @@ var WF_CSS = [
|
|
|
412
425
|
".wf-branch-icon{flex:0 0 auto;width:" + WF_CHAIN_ICON + "px;height:" + WF_CHAIN_ICON + "px;padding:0;" +
|
|
413
426
|
"border-radius:50%;display:inline-flex;align-items:center;justify-content:center;" +
|
|
414
427
|
"font:inherit;font-size:11px;line-height:1;cursor:pointer;position:relative;" +
|
|
415
|
-
"background:var(--dsw-alias-bg-layer-2,#282d3a);color:var(--dsw-alias-label-secondary,#9aa3b2);" +
|
|
428
|
+
"background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));color:var(--dsw-alias-label-secondary,#9aa3b2);" +
|
|
416
429
|
"border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.28))}",
|
|
417
430
|
".wf-branch-icon:hover{color:var(--dsw-alias-label-primary,#e9ecf3)}",
|
|
418
431
|
".wf-branch-icon:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:2px}",
|
|
@@ -442,12 +455,14 @@ var WF_CSS = [
|
|
|
442
455
|
/* node card: icon · title · meta · status dot */
|
|
443
456
|
".wf-node{display:flex;gap:7px;align-items:flex-start;width:100%;padding:6px 8px;border-radius:8px;" +
|
|
444
457
|
"text-align:left;font:inherit;color:inherit;cursor:pointer;" +
|
|
445
|
-
"background:var(--dsw-alias-bg-layer-2,#282d3a);
|
|
458
|
+
"background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));" +
|
|
459
|
+
"border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
|
|
446
460
|
"border-left:3px solid var(--dsw-alias-border-l2,rgba(255,255,255,.2))}",
|
|
447
|
-
".wf-node:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.24));
|
|
461
|
+
".wf-node:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.24));" +
|
|
462
|
+
"background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
|
|
448
463
|
".wf-node:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
|
|
449
464
|
".wf-node[data-selected='true']{border-color:var(--dsw-alias-brand-primary,#5b8cff);" +
|
|
450
|
-
"background:var(--dsw-alias-bg-layer-1,#232733)}",
|
|
465
|
+
"background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
|
|
451
466
|
".wf-node[data-selected='true'] .wf-node-title{font-weight:600}",
|
|
452
467
|
".wf-node[data-kind='tool']{border-left-color:var(--dsw-alias-brand-primary,#5b8cff)}",
|
|
453
468
|
".wf-node[data-kind='report']{border-left-color:var(--dsw-alias-state-success-primary,#45c07a)}",
|
|
@@ -1024,11 +1039,12 @@ var WF_CSS_PAGE = [
|
|
|
1024
1039
|
|
|
1025
1040
|
/* ---- the unit card ---- */
|
|
1026
1041
|
".wf-unit-card{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:flex-start;gap:8px;" +
|
|
1027
|
-
"width:100%;padding:10px 12px;border-radius:10px;text-align:left;border:1px solid
|
|
1028
|
-
"background:var(--dsw-alias-bg-layer-2,#282d3a);
|
|
1042
|
+
"width:100%;padding:10px 12px;border-radius:10px;text-align:left;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
|
|
1043
|
+
"background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));" +
|
|
1044
|
+
"color:inherit;font:inherit;cursor:pointer;" +
|
|
1029
1045
|
"border-left:3px solid transparent;box-sizing:border-box}",
|
|
1030
1046
|
".wf-unit-card:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
|
|
1031
|
-
"background:var(--dsw-alias-bg-layer-1,#232733)}",
|
|
1047
|
+
"background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
|
|
1032
1048
|
".wf-unit-card:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
|
|
1033
1049
|
".wf-unit-card[data-selected='true']{border-color:var(--dsw-alias-brand-primary,#5b8cff)}",
|
|
1034
1050
|
".wf-unit-card[data-active='true']{border-left-color:var(--dsw-alias-brand-primary,#5b8cff)}",
|
package/src/fold.mjs
CHANGED
|
@@ -63,7 +63,12 @@
|
|
|
63
63
|
// own `inboxProjectionDefinition` replays. A checkpoint written before this bump has no ledger,
|
|
64
64
|
// so the registry is told to drop it and rebuild from the log (the same contract as v1→v2: a
|
|
65
65
|
// missing field must never be silently answered with a wrong index).
|
|
66
|
-
|
|
66
|
+
//
|
|
67
|
+
// STATE_VERSION 4 : the step ledger now follows the todo table's own statuses (a `pending` row
|
|
68
|
+
// demotes a step that still carried `done`/`running`, and a step any table no longer names is
|
|
69
|
+
// dropped). Checkpoints written before the bump hold ledgers from the older mapping — wrong
|
|
70
|
+
// statuses and leftover rows — so the same contract applies: drop and rebuild from the log.
|
|
71
|
+
export const STATE_VERSION = 5;
|
|
67
72
|
|
|
68
73
|
export const SCHEMA_VERSION = 1;
|
|
69
74
|
|
|
@@ -515,6 +520,13 @@ function percentFor(state, step, now) {
|
|
|
515
520
|
const pinned = step.percent === null ? 100 : step.percent;
|
|
516
521
|
return { percent: clamp(pinned, 0, 100), source: step.percentSource === "none" ? "closed" : step.percentSource };
|
|
517
522
|
}
|
|
523
|
+
// Only the step that is actually running can be partly done. A pending row is future work and a
|
|
524
|
+
// skipped one never will be done, so NEITHER carries a number. The old code let the todo table's
|
|
525
|
+
// completion ratio paint a bar on every waiting row as well, which is where the wall of bars
|
|
526
|
+
// below 100 % in an already finished session came from.
|
|
527
|
+
if (step.status !== STEP_STATUS.running) {
|
|
528
|
+
return { percent: null, source: "none" };
|
|
529
|
+
}
|
|
518
530
|
// Priority 2 when a self-report exists: the todo table's completion ratio is a measurement (no
|
|
519
531
|
// clock, no extrapolation), so it determines the number whenever it has rows to report on.
|
|
520
532
|
// The self-report can only lower the result, never raise it above the measured ratio.
|
|
@@ -536,7 +548,11 @@ function percentFor(state, step, now) {
|
|
|
536
548
|
const value = measured;
|
|
537
549
|
return { percent: clamp(value, 0, 99), source: "todos" };
|
|
538
550
|
}
|
|
539
|
-
|
|
551
|
+
// The model's latest statement is the number, and an open step is 0..99: 100 % on a running
|
|
552
|
+
// row is a claim the log does not support. The high-water record is deliberately not applied
|
|
553
|
+
// here, as the comment above states; applying it freezes a row at the highest number it ever
|
|
554
|
+
// reached, so later reports cannot move the bar.
|
|
555
|
+
return { percent: clamp(reported.percent, 0, 99), source: "model" };
|
|
540
556
|
}
|
|
541
557
|
// The checklist's own completion ratio — a real measurement, so it sits above the time baseline.
|
|
542
558
|
// It never claims 100 % for an OPEN step: the remaining row is still in progress.
|
|
@@ -608,6 +624,10 @@ function newNode(kind, tool, title, at, extra) {
|
|
|
608
624
|
argsText: "",
|
|
609
625
|
resultHead: "",
|
|
610
626
|
error: "",
|
|
627
|
+
// Whether the call returned. Separate from the body text: a call that succeeds with
|
|
628
|
+
// no output has an empty resultHead and is still settled. The browser half colours by
|
|
629
|
+
// this flag, so deriving it from resultHead leaves every silent call "running".
|
|
630
|
+
settled: false,
|
|
611
631
|
at,
|
|
612
632
|
durationMs: null,
|
|
613
633
|
// filled by appendNode when the caller did not pass one (see toolKey).
|
|
@@ -739,6 +759,25 @@ function argsDigest(args) {
|
|
|
739
759
|
// fold
|
|
740
760
|
// ---------------------------------------------------------------------------
|
|
741
761
|
|
|
762
|
+
/**
|
|
763
|
+
* Reopen a closed row without letting its 100 % speak for the reopened one.
|
|
764
|
+
*
|
|
765
|
+
* A row taken back to in_progress used to keep the percent and the high-water record of its
|
|
766
|
+
* closed state, so the panel showed an in-progress step pinned at 100 %: a bar that can never
|
|
767
|
+
* move again, and a later self-report cannot lower it. A closed record's number belongs to
|
|
768
|
+
* that closure, not to the row that reopens it.
|
|
769
|
+
*/
|
|
770
|
+
function reopenStep(step, time) {
|
|
771
|
+
const reopened = step.status === STEP_STATUS.done || step.status === STEP_STATUS.failed;
|
|
772
|
+
const base = {
|
|
773
|
+
...step,
|
|
774
|
+
status: STEP_STATUS.running,
|
|
775
|
+
endedAt: null,
|
|
776
|
+
startedAt: reopened || !step.startedAt ? time : step.startedAt,
|
|
777
|
+
};
|
|
778
|
+
return reopened ? { ...base, percent: null, percentSource: "none", maxPercent: 0 } : base;
|
|
779
|
+
}
|
|
780
|
+
|
|
742
781
|
function foldTodos(state, event, time) {
|
|
743
782
|
const data = dataOf(event);
|
|
744
783
|
const raw = data && Array.isArray(data.todos) ? data.todos : [];
|
|
@@ -752,12 +791,22 @@ function foldTodos(state, event, time) {
|
|
|
752
791
|
// session (no PAE plan at all) still produces a step list.
|
|
753
792
|
let steps = [...state.steps];
|
|
754
793
|
let durations = state.durations;
|
|
794
|
+
// Every row that matched an existing step, and the status that row states. The table is the one
|
|
795
|
+
// authority on a step's lifecycle, so these are applied in a final pass instead of inline: a row
|
|
796
|
+
// can match a step that a LATER row also matches (slot reuse after the model rewrites the table),
|
|
797
|
+
// and an inline write could then be overwritten by a stale copy of the old status.
|
|
798
|
+
const authority = new Map();
|
|
799
|
+
// The ledger as it stood BEFORE this table. Slot binding has to read this snapshot: by the time a
|
|
800
|
+
// later row is examined, `steps` already holds the rows this very table created, so "the row in
|
|
801
|
+
// this position" would point at a row from the NEW table instead of the one being replaced.
|
|
802
|
+
const prior = state.steps;
|
|
755
803
|
|
|
756
804
|
for (const item of todos) {
|
|
757
805
|
if (item.content === "") continue;
|
|
758
806
|
const planIndex = planIndexOf(item.content);
|
|
759
807
|
let at = planIndex === null ? -1 : steps.findIndex((step) => step.planIndex === planIndex);
|
|
760
808
|
if (at === -1) at = steps.findIndex((step) => normalizeTitle(step.title) === normalizeTitle(item.content));
|
|
809
|
+
const matchedByTitle = at !== -1;
|
|
761
810
|
// REWORDED ROW at the source. The model often rewrites its own todo text between two
|
|
762
811
|
// `todo/write` events (measured: "read-only inventory: read plugin sources..." -> "read-only inventory: sources/config..."). Matching on the
|
|
763
812
|
// title alone then misses the row that already exists, a SECOND row is created for the new
|
|
@@ -765,8 +814,13 @@ function foldTodos(state, event, time) {
|
|
|
765
814
|
// sync then drops. That was "only the running step shows tool types". The fix belongs HERE:
|
|
766
815
|
// reword in place when the slot and lifecycle agree, so the row keeps its ledger.
|
|
767
816
|
if (at === -1) {
|
|
768
|
-
const slotRow =
|
|
769
|
-
|
|
817
|
+
const slotRow = prior[todos.indexOf(item)];
|
|
818
|
+
// A running step is never taken over by a row that is not running: that is how a pending row
|
|
819
|
+
// used to hijack the live step and leave the real one with no running row at all.
|
|
820
|
+
const sameSlot =
|
|
821
|
+
slotRow &&
|
|
822
|
+
(slotRow.status === item.status ||
|
|
823
|
+
(slotRow.status === STEP_STATUS.running && item.status === "in_progress"));
|
|
770
824
|
if (sameSlot) at = steps.indexOf(slotRow);
|
|
771
825
|
}
|
|
772
826
|
|
|
@@ -796,6 +850,12 @@ function foldTodos(state, event, time) {
|
|
|
796
850
|
// its old key, so the nodes it already owns can follow the rename. Both the rename and the
|
|
797
851
|
// lifecycle change are applied in one assignment, so neither can lose the other.
|
|
798
852
|
let next = step;
|
|
853
|
+
// The row's ordinal follows the TABLE, not the row's history. A matched row that used to be
|
|
854
|
+
// unnumbered (or numbered differently) must take the ordinal this table now states, or the
|
|
855
|
+
// view keeps a stale planIndex and `applyPlanToView` patches the wrong plan step onto it.
|
|
856
|
+
// Measured 2026-10-29 on session 164e339f: leftover unnumbered rows kept planIndex 2 / 3 /
|
|
857
|
+
// 4... from an earlier PAE plan and the map rendered plan steps 3-7 twice.
|
|
858
|
+
if (planIndex !== null && next.planIndex !== planIndex) next = { ...next, planIndex };
|
|
799
859
|
const shown = text(stripOrdinal(item.content) || item.content, TITLE_LIMIT);
|
|
800
860
|
const shownKey = normalizeTitle(shown);
|
|
801
861
|
if (shownKey !== "" && shownKey !== next.stepKey) {
|
|
@@ -811,12 +871,53 @@ function foldTodos(state, event, time) {
|
|
|
811
871
|
if (next.startedAt > 0) durations = [...durations, time - next.startedAt].filter((value) => value > 0);
|
|
812
872
|
} else if (item.status === "in_progress" && next.status !== STEP_STATUS.running) {
|
|
813
873
|
// A step re-opened by a re-plan or a retry: keep its ledger row, restart the clock.
|
|
814
|
-
steps[at] =
|
|
874
|
+
steps[at] = reopenStep(next, time);
|
|
815
875
|
} else if (next !== step) {
|
|
816
876
|
steps[at] = next;
|
|
817
877
|
}
|
|
878
|
+
authority.set(at, { status: item.status, byTitle: matchedByTitle });
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// The final pass: one status per step, exactly as the table now states it. `pending` is a real
|
|
882
|
+
// state — a row the model has not started — and a step that still carried `done` or `running`
|
|
883
|
+
// from the previous wording of the table has to be demoted, or the panel shows finished work the
|
|
884
|
+
// table does not claim (and a bar at 100 % next to an empty todo). Demotion is only safe when the
|
|
885
|
+
// row matched on its own title; a slot match after a reword could hit a different row's step.
|
|
886
|
+
for (const [at, claim] of authority) {
|
|
887
|
+
const step = steps[at];
|
|
888
|
+
if (!step) continue;
|
|
889
|
+
if (claim.status === "completed" && step.status !== STEP_STATUS.done) {
|
|
890
|
+
if (step.startedAt > 0) durations = [...durations, time - step.startedAt].filter((value) => value > 0);
|
|
891
|
+
steps[at] = closeStep(step, time);
|
|
892
|
+
} else if (claim.status === "in_progress" && step.status !== STEP_STATUS.running) {
|
|
893
|
+
steps[at] = reopenStep(step, time);
|
|
894
|
+
} else if (claim.status === "pending" && step.status !== STEP_STATUS.pending) {
|
|
895
|
+
// Same rule for a demotion: the row is not done, so it cannot keep a done row's number.
|
|
896
|
+
steps[at] = {
|
|
897
|
+
...step,
|
|
898
|
+
status: STEP_STATUS.pending,
|
|
899
|
+
endedAt: null,
|
|
900
|
+
startedAt: 0,
|
|
901
|
+
percent: null,
|
|
902
|
+
percentSource: "none",
|
|
903
|
+
maxPercent: 0,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
818
906
|
}
|
|
819
907
|
|
|
908
|
+
// A row the table no longer names, that never started and owns no node, is the leftover of an
|
|
909
|
+
// earlier wording of the same list. Keeping it leaves a permanently empty card in the step
|
|
910
|
+
// column, which is how every rewritten table used to grow the panel by one or two rows.
|
|
911
|
+
const named = new Set(todos.map((item) => normalizeTitle(item.content)).filter((key) => key !== ""));
|
|
912
|
+
const nodeKeys = new Set(state.nodes.map((node) => node.stepKey));
|
|
913
|
+
const kept = steps.filter((step) => {
|
|
914
|
+
if (step.status !== STEP_STATUS.pending || step.startedAt > 0) return true;
|
|
915
|
+
const keys = [step.stepKey, ...(Array.isArray(step.aliasKeys) ? step.aliasKeys : [])].filter(Boolean);
|
|
916
|
+
if (keys.some((key) => nodeKeys.has(key))) return true;
|
|
917
|
+
return keys.some((key) => named.has(key));
|
|
918
|
+
});
|
|
919
|
+
if (kept.length !== steps.length) steps = kept;
|
|
920
|
+
|
|
820
921
|
|
|
821
922
|
return { ...state, asOf: time, todos, steps: reindex(steps), durations };
|
|
822
923
|
}
|
|
@@ -900,6 +1001,8 @@ function foldToolResult(state, event, time, seq) {
|
|
|
900
1001
|
...previous,
|
|
901
1002
|
resultHead: isError ? "" : line(body),
|
|
902
1003
|
error: isError ? line(body) : "",
|
|
1004
|
+
// Settled means "the call came back", whatever its body: empty output is not a pending call.
|
|
1005
|
+
settled: true,
|
|
903
1006
|
durationMs: previous.at > 0 && time > previous.at ? time - previous.at : null,
|
|
904
1007
|
};
|
|
905
1008
|
const answered = { ...state, asOf: time, nodes };
|
|
@@ -914,7 +1017,14 @@ function foldToolResult(state, event, time, seq) {
|
|
|
914
1017
|
),
|
|
915
1018
|
}
|
|
916
1019
|
: answered;
|
|
917
|
-
|
|
1020
|
+
// A result is not an activity. Re-asserting the node that just finished as the active one
|
|
1021
|
+
// makes the panel claim "calling right now" about a call that already returned. Keep the
|
|
1022
|
+
// node a newer call opened, and otherwise hand the highlight back to the step.
|
|
1023
|
+
const keepActive =
|
|
1024
|
+
state.active && typeof state.active.nodeId === "string" && state.active.nodeId !== "" && state.active.nodeId !== previous.id
|
|
1025
|
+
? state.active.nodeId
|
|
1026
|
+
: null;
|
|
1027
|
+
const next = withActive(settled, keepActive, time);
|
|
918
1028
|
// A tool result is the moment two branch decisions become possible: a job that is STILL
|
|
919
1029
|
// open (background) is promoted to a lane, and a launcher's acknowledgement attaches its
|
|
920
1030
|
// run id. Both are no-ops for ordinary calls.
|
|
@@ -1134,7 +1244,14 @@ function parsePlanRows(blockText) {
|
|
|
1134
1244
|
for (const raw of blockText.split("\n")) {
|
|
1135
1245
|
const match = /^\s*(\d+)\.\s*(.+)$/.exec(raw);
|
|
1136
1246
|
if (!match) continue;
|
|
1137
|
-
|
|
1247
|
+
// PAE appends each row's own step FILE to the title ("1. title(…)(step-01-x.md)"). That
|
|
1248
|
+
// annotation is not title text: keeping it leaves `normalizeTitle` one parenthetical group short
|
|
1249
|
+
// of the todo row the model writes ("1. title(…)"), so a plan step can carry a title that no
|
|
1250
|
+
// row of the todo table has. Measured 2026-10-30 on session 1bb4baa0: the fold opened step 1 with
|
|
1251
|
+
// the annotated title while its `stepKey` still held the table's wording, `foldTodos` therefore
|
|
1252
|
+
// saw "no change" and the view kept a step the conversation does not list (audit-real ①).
|
|
1253
|
+
const title = match[2].replace(/\s*[((][^(())]*\.(?:md|markdown|mjs|cjs|js|json|ya?ml|txt)[))]\s*$/, "");
|
|
1254
|
+
rows.push({ planIndex: Number(match[1]), title: text(title, TITLE_LIMIT) });
|
|
1138
1255
|
if (rows.length >= 80) break;
|
|
1139
1256
|
}
|
|
1140
1257
|
return rows;
|
|
@@ -1195,10 +1312,26 @@ function openPlanStep(state, time, seq, planIndex, title, blockText) {
|
|
|
1195
1312
|
steps.push(created);
|
|
1196
1313
|
} else {
|
|
1197
1314
|
const step = steps[targetAt];
|
|
1315
|
+
// A plan title must not replace the row's identity key on its own: `stepKey` is what `foldTodos`
|
|
1316
|
+
// compares the live table against and what node attribution follows. Adopting a title without its
|
|
1317
|
+
// key left the pair inconsistent, so the next todo/write judged the row unchanged and a stale
|
|
1318
|
+
// title survived into the view (measured 2026-10-30, session 1bb4baa0). The old key is parked in
|
|
1319
|
+
// `aliasKeys` exactly as a rename does, so nodes already carrying it still migrate.
|
|
1320
|
+
const adopted = title || step.title;
|
|
1321
|
+
const adoptedKey = normalizeTitle(adopted);
|
|
1322
|
+
const renamed = adoptedKey !== "" && adoptedKey !== step.stepKey;
|
|
1323
|
+
const seenKeys = Array.isArray(step.aliasKeys) ? step.aliasKeys : [];
|
|
1198
1324
|
steps[targetAt] = {
|
|
1199
1325
|
...step,
|
|
1200
1326
|
planIndex,
|
|
1201
|
-
title:
|
|
1327
|
+
title: adopted,
|
|
1328
|
+
...(renamed
|
|
1329
|
+
? {
|
|
1330
|
+
prevKey: step.stepKey,
|
|
1331
|
+
stepKey: adoptedKey,
|
|
1332
|
+
aliasKeys: [...new Set([...seenKeys, step.stepKey, adoptedKey])].filter((key) => key !== ""),
|
|
1333
|
+
}
|
|
1334
|
+
: {}),
|
|
1202
1335
|
anchorSeq: step.anchorSeq === undefined ? anchorOf(state, planIndex, seq) : step.anchorSeq,
|
|
1203
1336
|
status: STEP_STATUS.running,
|
|
1204
1337
|
endedAt: null,
|
|
@@ -1261,7 +1394,20 @@ function foldTurnStart(state, _event, time) {
|
|
|
1261
1394
|
}
|
|
1262
1395
|
|
|
1263
1396
|
function foldTurnEnd(state, _event, time) {
|
|
1264
|
-
|
|
1397
|
+
const next = { ...state, asOf: time, turnActive: false };
|
|
1398
|
+
// The turn is over, so the step it was running is over too. Leaving it `running` kept a live,
|
|
1399
|
+
// forever-partial bar on the last step of every finished session.
|
|
1400
|
+
// The one exception is a step the todo table still names as its `in_progress` row: that step
|
|
1401
|
+
// really does continue into the next turn, and closing it here would make the next turn reopen
|
|
1402
|
+
// a step that never stopped.
|
|
1403
|
+
const running = runningStep(next);
|
|
1404
|
+
if (!running) return next;
|
|
1405
|
+
const inProgress = inProgressStepIndex(next);
|
|
1406
|
+
if (isFiniteNumber(inProgress) && inProgress === running.index) return next;
|
|
1407
|
+
const durations =
|
|
1408
|
+
running.startedAt > 0 ? [...next.durations, time - running.startedAt].filter((value) => value > 0) : next.durations;
|
|
1409
|
+
const steps = next.steps.map((step) => (step.index === running.index ? closeStep(step, time) : step));
|
|
1410
|
+
return withPercents({ ...next, steps, durations }, time);
|
|
1265
1411
|
}
|
|
1266
1412
|
|
|
1267
1413
|
function foldReport(state, event, time) {
|
|
@@ -2225,6 +2371,8 @@ function nodeView(node) {
|
|
|
2225
2371
|
files: node.files.map((file) => ({ path: file.path, action: file.action })),
|
|
2226
2372
|
resultHead: node.resultHead,
|
|
2227
2373
|
error: node.error,
|
|
2374
|
+
// the boolean the browser half colours by; see newNode.settled
|
|
2375
|
+
result: node.settled === true,
|
|
2228
2376
|
at: node.at,
|
|
2229
2377
|
durationMs: node.durationMs,
|
|
2230
2378
|
// the detail's meta line names the event that produced the node. Null for the
|
|
@@ -2341,9 +2489,41 @@ function syncToTodos(state) {
|
|
|
2341
2489
|
const key = normalizeTitle(item.content);
|
|
2342
2490
|
if (key === "" || seen.has(key)) continue;
|
|
2343
2491
|
seen.add(key);
|
|
2344
|
-
|
|
2492
|
+
// `planIndex` MUST be parsed from the RAW content, not from `title`: `stripOrdinal`
|
|
2493
|
+
// already removed the "3." prefix, so `planIndexOf(title)` always returned null and every
|
|
2494
|
+
// created row lost its ordinal. Measured 2026-10-29 on session 164e339f: a 14-row numbered
|
|
2495
|
+
// table produced rows whose planIndex fell back to POSITION, so row 9 ("9. n8n 主工作流…")
|
|
2496
|
+
// was stamped planIndex 2 and `applyPlanToView` then rewrote it with plan step 2's title and
|
|
2497
|
+
// status — the map showed steps 3–7 twice and the wrong step as running.
|
|
2498
|
+
wanted.push({ key, title, status: item.status, planIndex: planIndexOf(item.content) });
|
|
2345
2499
|
}
|
|
2346
2500
|
if (wanted.length === 0) return state;
|
|
2501
|
+
// The row's status is the ONE statement of a step's lifecycle: `todo/write` replaces the whole
|
|
2502
|
+
// table every time, so a matched row must not inherit the status of whatever record the matcher
|
|
2503
|
+
// bound it to. Without this pass a row the model has not started kept the `done` of an old record
|
|
2504
|
+
// (a card at 100 % next to an empty todo), while an `in_progress` row showed as `pending` because
|
|
2505
|
+
// the record it landed on was still pending — both seen in one session on 2026-10-30.
|
|
2506
|
+
const rowStatus = (step, row) => {
|
|
2507
|
+
if (row.status === "completed") return step.status === STEP_STATUS.done ? step : closeStep(step, state.asOf || 0);
|
|
2508
|
+
// A closed record cannot speak for an open row. When the table takes a row back to
|
|
2509
|
+
// in_progress/pending, the record it is bound to may be a step this table already closed,
|
|
2510
|
+
// or an older row a reworded title landed on. Keeping that percent and high-water renders
|
|
2511
|
+
// a brand-new first row at 100 % the instant it appears.
|
|
2512
|
+
const reopened = step.status === STEP_STATUS.done || step.status === STEP_STATUS.failed;
|
|
2513
|
+
const carried = reopened
|
|
2514
|
+
? { percent: null, percentSource: "none", maxPercent: 0 }
|
|
2515
|
+
: { percent: step.percent, percentSource: step.percentSource, maxPercent: step.maxPercent };
|
|
2516
|
+
if (row.status === "in_progress") {
|
|
2517
|
+
return {
|
|
2518
|
+
...step,
|
|
2519
|
+
...carried,
|
|
2520
|
+
status: STEP_STATUS.running,
|
|
2521
|
+
endedAt: null,
|
|
2522
|
+
startedAt: reopened || !step.startedAt ? state.asOf || 0 : step.startedAt,
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
return { ...step, ...carried, status: STEP_STATUS.pending, endedAt: null, startedAt: 0, percent: null, percentSource: "none" };
|
|
2526
|
+
};
|
|
2347
2527
|
|
|
2348
2528
|
const byKey = new Map();
|
|
2349
2529
|
for (const step of state.steps) {
|
|
@@ -2390,14 +2570,30 @@ function syncToTodos(state) {
|
|
|
2390
2570
|
// (2) TEXT SHARE as the fallback for a re-numbered or re-ordered table.
|
|
2391
2571
|
let candidate = null;
|
|
2392
2572
|
const slot = wanted.indexOf(row);
|
|
2393
|
-
|
|
2573
|
+
// An explicit ordinal OUTRANKS position. A numbered table states which plan step each row
|
|
2574
|
+
// IS; letting the slot win anyway pairs row 9 with whatever orphan happens to sit at
|
|
2575
|
+
// position 8, and the row inherits that orphan's planIndex. Measured 2026-10-29 on session
|
|
2576
|
+
// 164e339f: after a 7-row unnumbered table was replaced by a 14-row numbered one, row 9
|
|
2577
|
+
// took the leftover row 2 and the map rendered plan steps 3-7 twice.
|
|
2578
|
+
const ordinalConflict = (step) =>
|
|
2579
|
+
row.planIndex !== null && Number.isInteger(step.planIndex) && step.planIndex !== row.planIndex;
|
|
2580
|
+
const sameSlot = orphans.find(
|
|
2581
|
+
(step) => !used.has(step.index) && step.index === slot && !ordinalConflict(step),
|
|
2582
|
+
);
|
|
2394
2583
|
if (sameSlot && (sameSlot.status === row.status || sameSlot.status === STEP_STATUS.running)) {
|
|
2395
2584
|
candidate = sameSlot;
|
|
2396
2585
|
}
|
|
2586
|
+
if (candidate === null && row.planIndex !== null) {
|
|
2587
|
+
const byOrdinal = orphans.find(
|
|
2588
|
+
(step) => !used.has(step.index) && Number.isInteger(step.planIndex) && step.planIndex === row.planIndex,
|
|
2589
|
+
);
|
|
2590
|
+
if (byOrdinal) candidate = byOrdinal;
|
|
2591
|
+
}
|
|
2397
2592
|
if (candidate === null) {
|
|
2398
2593
|
let bestShare = 0;
|
|
2399
2594
|
for (const step of orphans) {
|
|
2400
2595
|
if (used.has(step.index)) continue;
|
|
2596
|
+
if (ordinalConflict(step)) continue;
|
|
2401
2597
|
const other = normalizeTitle(step.title);
|
|
2402
2598
|
const score = commonPrefix(other, row.key);
|
|
2403
2599
|
if (score < REWORD_PREFIX_MIN) continue;
|
|
@@ -2417,7 +2613,7 @@ function syncToTodos(state) {
|
|
|
2417
2613
|
// reworded title - the two did not match, so the nodes were dropped as orphans in the view.
|
|
2418
2614
|
const oldKey = candidate.stepKey || normalizeTitle(candidate.title);
|
|
2419
2615
|
const newKey = normalizeTitle(row.title);
|
|
2420
|
-
const nextStep = { ...candidate, title: text(row.title, TITLE_LIMIT), stepKey: newKey };
|
|
2616
|
+
const nextStep = rowStatus({ ...candidate, title: text(row.title, TITLE_LIMIT), stepKey: newKey }, row);
|
|
2421
2617
|
reworded.set(oldKey, newKey);
|
|
2422
2618
|
ordered.push(nextStep);
|
|
2423
2619
|
origins.push(candidate.index);
|
|
@@ -2430,13 +2626,8 @@ function syncToTodos(state) {
|
|
|
2430
2626
|
// node-remap nor the node attribution can ever find it.
|
|
2431
2627
|
created.stepKey = row.key;
|
|
2432
2628
|
created.startedAt = 0;
|
|
2433
|
-
created.planIndex =
|
|
2434
|
-
created
|
|
2435
|
-
if (created.status === STEP_STATUS.done) {
|
|
2436
|
-
created.percent = 100;
|
|
2437
|
-
created.percentSource = "closed";
|
|
2438
|
-
created.maxPercent = 100;
|
|
2439
|
-
}
|
|
2629
|
+
created.planIndex = row.planIndex ?? undefined;
|
|
2630
|
+
Object.assign(created, rowStatus(created, row));
|
|
2440
2631
|
ordered.push(created);
|
|
2441
2632
|
origins.push(-1);
|
|
2442
2633
|
}
|
package/src/orchestrator.mjs
CHANGED
|
@@ -37,6 +37,21 @@ export const PLAN_VERSION = 1;
|
|
|
37
37
|
export const PLAN_ROOT = ".pae";
|
|
38
38
|
export const PLAN_FILE = "orchestrator.json";
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Plan phases that are history rather than a live view.
|
|
42
|
+
*
|
|
43
|
+
* PAE's own vocabulary (dsh-plan-and-execute): none / planning / scheduled /
|
|
44
|
+
* executing / paused / completed / aborted. The last two never resume, and the
|
|
45
|
+
* file that carries them is never removed, so they must not speak for a row the
|
|
46
|
+
* session created after the plan ended.
|
|
47
|
+
*/
|
|
48
|
+
const TERMINAL_PHASES = new Set(["completed", "aborted"]);
|
|
49
|
+
|
|
50
|
+
/** Is this normalized plan finished? True for completed and aborted. */
|
|
51
|
+
export function planIsTerminal(plan) {
|
|
52
|
+
return isRecord(plan) && TERMINAL_PHASES.has(String(plan.phase ?? "").trim().toLowerCase());
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
/** Bounds. The plan is display data; a runaway file must not grow the session cache. */
|
|
41
56
|
export const MAX_PLAN_STEPS = 200;
|
|
42
57
|
export const MAX_STEP_REPORTS = 200;
|
|
@@ -197,9 +212,14 @@ export function normalizePlanDocument(raw, meta) {
|
|
|
197
212
|
}
|
|
198
213
|
|
|
199
214
|
const rawTask = typeof doc.task === "string" ? doc.task : "";
|
|
215
|
+
const phase = text(doc.phase, MAX_PHASE);
|
|
200
216
|
const result = {
|
|
201
217
|
ver: PLAN_VERSION,
|
|
202
|
-
phase
|
|
218
|
+
phase,
|
|
219
|
+
// Published as a boolean as well: the browser half decides card statuses from this
|
|
220
|
+
// section alone, and "is this plan finished" must not be re-derived from a string
|
|
221
|
+
// vocabulary in two places.
|
|
222
|
+
terminal: TERMINAL_PHASES.has(phase.trim().toLowerCase()),
|
|
203
223
|
task: text(rawTask, MAX_TASK),
|
|
204
224
|
// The first non-empty line, before flattening: it is the panel's header.
|
|
205
225
|
taskTitle: line(rawTask, MAX_TASK_TITLE),
|
|
@@ -263,6 +283,10 @@ export function applyPlanToView(view, plan) {
|
|
|
263
283
|
const normalized = isRecord(plan) ? plan : null;
|
|
264
284
|
const result = { ...view, plan: normalized };
|
|
265
285
|
if (normalized === null) return result;
|
|
286
|
+
// A finished plan is published for the detail page but patches nothing: its
|
|
287
|
+
// rows belong to a turn that is over, and the current todo table is the newer
|
|
288
|
+
// statement. See planIsTerminal.
|
|
289
|
+
if (planIsTerminal(normalized)) return result;
|
|
266
290
|
|
|
267
291
|
if (normalized.taskTitle !== "") result.task = normalized.taskTitle;
|
|
268
292
|
|
|
@@ -300,6 +324,24 @@ export function applyPlanToView(view, plan) {
|
|
|
300
324
|
}
|
|
301
325
|
}
|
|
302
326
|
}
|
|
327
|
+
// The plan's own `requiresConfirmation` is the OTHER half of the needs-confirmation flag:
|
|
328
|
+
// `buildView` derives the log half (an open approval, an unanswered question), and this
|
|
329
|
+
// adds the plan half. It belongs HERE because this is the only place that has both the
|
|
330
|
+
// normalised plan step and the view row it lands on. Measured 2026-10-29 on session
|
|
331
|
+
// 164e339f: plan steps 2 / 12 / 13 carry requiresConfirmation and the map showed none of
|
|
332
|
+
// them, while `pendingConfirmSteps` in fold.mjs only ever consulted the log.
|
|
333
|
+
//
|
|
334
|
+
// A finished step never carries the flag (the same rule `buildView` applies): a decision a
|
|
335
|
+
// done step was waiting on is history, not a request.
|
|
336
|
+
if (planStep.requiresConfirmation === true && step.status !== "done" && step.status !== "failed") {
|
|
337
|
+
const rows = Array.isArray(step.pendingConfirm) ? step.pendingConfirm : [];
|
|
338
|
+
if (!rows.some((row) => isRecord(row) && row.kind === "plan")) {
|
|
339
|
+
patch.pendingConfirm = [
|
|
340
|
+
...rows,
|
|
341
|
+
{ kind: "plan", label: planStep.title !== "" ? planStep.title : `第 ${planStep.index} 步`, at: 0 },
|
|
342
|
+
];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
303
345
|
if (Object.keys(patch).length === 0) continue;
|
|
304
346
|
steps[position] = { ...step, ...patch };
|
|
305
347
|
changed = true;
|