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
package/lib/index.js
ADDED
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-workflow-map — host half, registration + PAE plan section.
|
|
3
|
+
*
|
|
4
|
+
* ONE registration: the session projection `workflowView`. It folds the session's own event
|
|
5
|
+
* log into "the step list + each step's flow nodes + the running step/node + per-step percentage" and publishes
|
|
6
|
+
* that as a wire view the browser half can read.
|
|
7
|
+
*
|
|
8
|
+
* WHY A PROJECTION AND NOT A SERVICE OF OUR OWN: the projection registry is the one host
|
|
9
|
+
* surface that (a) already replays the full session log into every plugin that asked for it,
|
|
10
|
+
* (b) survives a cold session read (its state is persisted and restored), and (c) carries a
|
|
11
|
+
* `wire` block, which is the only channel a client-visible snapshot reads. Building any of
|
|
12
|
+
* that ourselves would be a second, worse copy of a solved problem.
|
|
13
|
+
*
|
|
14
|
+
* THE FOLD IS NOT HERE. It lives in ../src/fold.mjs and is imported, never copied: the same
|
|
15
|
+
* object the offline self-test drives (tools/selftest-fold.mjs), so "tested" and "shipped"
|
|
16
|
+
* cannot drift. This file adds registration and the two schemas — no clock read, no network,
|
|
17
|
+
* and no filesystem call of its own: the one file this plugin reads (the PAE plan) is opened by
|
|
18
|
+
* ../src/orchestrator.mjs through a per-session reader created in init and used on the wire.
|
|
19
|
+
* ORDERING: apply does exactly two registration-only things — register the projection, then
|
|
20
|
+
* attach the HTTP view route — and touches neither the clock nor a session. The PAE
|
|
21
|
+
* plan is read inside the WIRE VIEW, never in apply: a broken or slow plan file may degrade the
|
|
22
|
+
* view, but it can never stop the fold or the route.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
SCHEMA_VERSION,
|
|
27
|
+
STATE_VERSION,
|
|
28
|
+
buildView,
|
|
29
|
+
createInitialState,
|
|
30
|
+
foldEvent,
|
|
31
|
+
} from "../src/fold.mjs";
|
|
32
|
+
|
|
33
|
+
import { createPlanReader, PLAN_VERSION } from "../src/orchestrator.mjs";
|
|
34
|
+
import { STATE_PATH, STEER_PATH, UNSTEER_PATH, composeView, registerViewRoute } from "../src/route.js";
|
|
35
|
+
import { randomUUID } from "node:crypto";
|
|
36
|
+
|
|
37
|
+
/** Plugin row id — must equal the `id` this bundle's patch inserts. */
|
|
38
|
+
export const name = "workflow-map";
|
|
39
|
+
|
|
40
|
+
/** Package name, kept as data so the shape self-test can compare it to the patch row. */
|
|
41
|
+
export const packageName = "dsh-workflow-map";
|
|
42
|
+
|
|
43
|
+
/** Projection key — also the key the browser half will pass to `useProjection(...)`. */
|
|
44
|
+
export const PROJECTION_KEY = "workflowView";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Host services this row needs: the projection registry the registration below lands in, the
|
|
48
|
+
* session store and the web server the view route is attached to , and — from the
|
|
49
|
+
* hot-inject step — the AGENT REGISTRY the steer channel addresses.
|
|
50
|
+
*
|
|
51
|
+
* Cordis only calls apply once they all resolve, so a host with no web server does not mount
|
|
52
|
+
* this row at all. That is the honest failure mode for a WEB panel: without an HTTP surface the
|
|
53
|
+
* browser half has nothing to read. The route still resolves its server defensively
|
|
54
|
+
* (`ctx.get("webServer") ?? ctx.get("httpServer")`) and refuses quietly rather than throwing.
|
|
55
|
+
*
|
|
56
|
+
* WHY `agents` IS SAFE TO REQUIRE. Every host that can run a session has it: the agent registry
|
|
57
|
+
* is what `@deepseek-ai/dsh-agent-loop` registers into, and a session only exists because a
|
|
58
|
+
* loop drove one. The steer endpoint degrades per-request (503) if the service is somehow absent
|
|
59
|
+
* at call time, so a future host without agents still mounts the panel and only loses the button.
|
|
60
|
+
*/
|
|
61
|
+
export const inject = ["sessionProjections", "sessions", "webServer", "agents"];
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// small type helpers (src/fold.mjs keeps its own — nothing is re-exported from it)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const isObject = (value) => value !== null && typeof value === "object";
|
|
68
|
+
const isRecord = (value) => isObject(value) && !Array.isArray(value);
|
|
69
|
+
const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
70
|
+
|
|
71
|
+
/** Safe value rendering for error messages (a throw here would mask the real problem). */
|
|
72
|
+
function show(value) {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.stringify(value) ?? String(value);
|
|
75
|
+
} catch {
|
|
76
|
+
return String(value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// schemas — hand-written `.parse` implementations, deliberately
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
//
|
|
84
|
+
// The registry only ever calls `.parse(value)` on these two objects, so that single method is
|
|
85
|
+
// the whole contract to satisfy. No zod/schemastery import: this package is installed as a
|
|
86
|
+
// `link:` (its real path lies outside the profile's node_modules tree), so a bare specifier is
|
|
87
|
+
// not guaranteed to resolve, and two structural checks are not worth a dependency.
|
|
88
|
+
//
|
|
89
|
+
// The two schemas fail in OPPOSITE directions, on purpose:
|
|
90
|
+
// * viewSchema guards a view this file just built from this file's own module. A mismatch
|
|
91
|
+
// is OUR bug, so it must be loud — it is the tripwire that keeps the wire
|
|
92
|
+
// contract honest for the browser half and for 's drill-down.
|
|
93
|
+
// * stateSchema guards a row read back FROM DISK (a checkpoint restore or a cold read). A
|
|
94
|
+
// throw there would abort the cold read of the whole session, so a damaged or
|
|
95
|
+
// older row degrades to a repaired (worst case: clean) state instead: losing
|
|
96
|
+
// our view is acceptable, losing the session is not.
|
|
97
|
+
//
|
|
98
|
+
// Neither schema validates text CONTENT (titles, node details): those are bounded by the fold
|
|
99
|
+
// and are display-only. Validating them here would double the per-event cost for no safety.
|
|
100
|
+
|
|
101
|
+
const STEP_STATUSES = new Set(["pending", "running", "done", "failed"]);
|
|
102
|
+
|
|
103
|
+
function badView(what, actual) {
|
|
104
|
+
throw new Error(`workflowView view: ${what}; got ${show(actual)}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Validate the `plan` section (null when this session has no PAE plan). This block is written
|
|
109
|
+
* by ../src/orchestrator.mjs and is the ONE part of the view this file does not build itself,
|
|
110
|
+
* so it is checked field by field: a shape surprise must fail loudly here, before it reaches
|
|
111
|
+
* the browser half.
|
|
112
|
+
*/
|
|
113
|
+
function checkPlan(plan) {
|
|
114
|
+
if (plan === null) return;
|
|
115
|
+
if (!isRecord(plan)) badView("plan must be null or an object", plan);
|
|
116
|
+
if (plan.ver !== PLAN_VERSION) badView(`plan.ver must be ${PLAN_VERSION}`, plan.ver);
|
|
117
|
+
if (typeof plan.phase !== "string") badView("plan.phase must be a string", plan.phase);
|
|
118
|
+
if (typeof plan.task !== "string") badView("plan.task must be a string", plan.task);
|
|
119
|
+
if (typeof plan.taskTitle !== "string") badView("plan.taskTitle must be a string", plan.taskTitle);
|
|
120
|
+
if (typeof plan.summary !== "string") badView("plan.summary must be a string", plan.summary);
|
|
121
|
+
if (typeof plan.planDir !== "string") badView("plan.planDir must be a string", plan.planDir);
|
|
122
|
+
if (typeof plan.pausedReason !== "string") badView("plan.pausedReason must be a string", plan.pausedReason);
|
|
123
|
+
if (plan.stepIndex !== null && !(Number.isInteger(plan.stepIndex) && plan.stepIndex >= 1)) {
|
|
124
|
+
badView("plan.stepIndex must be null or a positive integer", plan.stepIndex);
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(plan.steps)) badView("plan.steps must be an array", plan.steps);
|
|
127
|
+
plan.steps.forEach((step, position) => {
|
|
128
|
+
if (!isRecord(step)) badView(`plan.steps[${position}] must be an object`, step);
|
|
129
|
+
if (step.index !== position + 1) {
|
|
130
|
+
badView(`plan.steps[${position}].index must equal its position + 1`, step.index);
|
|
131
|
+
}
|
|
132
|
+
if (typeof step.title !== "string") badView(`plan.steps[${position}].title must be a string`, step.title);
|
|
133
|
+
if (typeof step.file !== "string") badView(`plan.steps[${position}].file must be a string`, step.file);
|
|
134
|
+
if (typeof step.requiresConfirmation !== "boolean") {
|
|
135
|
+
badView(`plan.steps[${position}].requiresConfirmation must be a boolean`, step.requiresConfirmation);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (!Array.isArray(plan.stepReports)) badView("plan.stepReports must be an array", plan.stepReports);
|
|
139
|
+
plan.stepReports.forEach((report, position) => {
|
|
140
|
+
if (!isRecord(report)) badView(`plan.stepReports[${position}] must be an object`, report);
|
|
141
|
+
if (!Number.isInteger(report.stepIndex) || report.stepIndex < 1) {
|
|
142
|
+
badView(`plan.stepReports[${position}].stepIndex must be a positive integer`, report.stepIndex);
|
|
143
|
+
}
|
|
144
|
+
if (typeof report.status !== "string") {
|
|
145
|
+
badView(`plan.stepReports[${position}].status must be a string`, report.status);
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(report.artifacts) || report.artifacts.some((item) => typeof item !== "string")) {
|
|
148
|
+
badView(`plan.stepReports[${position}].artifacts must be a string array`, report.artifacts);
|
|
149
|
+
}
|
|
150
|
+
if (typeof report.summary !== "string") {
|
|
151
|
+
badView(`plan.stepReports[${position}].summary must be a string`, report.summary);
|
|
152
|
+
}
|
|
153
|
+
if (report.exitCode !== null && !Number.isInteger(report.exitCode)) {
|
|
154
|
+
badView(`plan.stepReports[${position}].exitCode must be null or an integer`, report.exitCode);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
if (!isRecord(plan.statuses)) badView("plan.statuses must be an object", plan.statuses);
|
|
158
|
+
for (const value of Object.values(plan.statuses)) {
|
|
159
|
+
if (typeof value !== "string") badView("plan.statuses values must be strings", value);
|
|
160
|
+
}
|
|
161
|
+
if (!isRecord(plan.anchorSeqs)) badView("plan.anchorSeqs must be an object", plan.anchorSeqs);
|
|
162
|
+
for (const value of Object.values(plan.anchorSeqs)) {
|
|
163
|
+
if (!isFiniteNumber(value)) badView("plan.anchorSeqs values must be finite numbers", value);
|
|
164
|
+
}
|
|
165
|
+
if (!Array.isArray(plan.skipped) || plan.skipped.some((value) => !Number.isInteger(value) || value < 1)) {
|
|
166
|
+
badView("plan.skipped must be an array of positive integers", plan.skipped);
|
|
167
|
+
}
|
|
168
|
+
if (!isFiniteNumber(plan.asOfMtime) || plan.asOfMtime < 0) {
|
|
169
|
+
badView("plan.asOfMtime must be a finite number", plan.asOfMtime);
|
|
170
|
+
}
|
|
171
|
+
if (typeof plan.sourcePath !== "string") badView("plan.sourcePath must be a string", plan.sourcePath);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Wire-view schema: structure only, and strict about it. Returns the value untouched. */
|
|
175
|
+
function makeViewSchema() {
|
|
176
|
+
return {
|
|
177
|
+
parse(value) {
|
|
178
|
+
if (!isRecord(value)) badView("must be an object", value);
|
|
179
|
+
if (value.ver !== SCHEMA_VERSION) badView(`ver must be ${SCHEMA_VERSION}`, value.ver);
|
|
180
|
+
if (typeof value.task !== "string") badView("task must be a string", value.task);
|
|
181
|
+
if (!Array.isArray(value.todos)) badView("todos must be an array", value.todos);
|
|
182
|
+
if (typeof value.asOf !== "number" || !Number.isFinite(value.asOf)) {
|
|
183
|
+
badView("asOf must be a finite number", value.asOf);
|
|
184
|
+
}
|
|
185
|
+
if (!Array.isArray(value.steps)) badView("steps must be an array", value.steps);
|
|
186
|
+
if (!Array.isArray(value.nodes)) badView("nodes must be an array", value.nodes);
|
|
187
|
+
|
|
188
|
+
value.steps.forEach((step, position) => {
|
|
189
|
+
if (!isRecord(step)) badView(`steps[${position}] must be an object`, step);
|
|
190
|
+
if (step.index !== position) badView(`steps[${position}].index must equal its position`, step.index);
|
|
191
|
+
if (typeof step.title !== "string") badView(`steps[${position}].title must be a string`, step.title);
|
|
192
|
+
if (!STEP_STATUSES.has(step.status)) badView(`steps[${position}].status is unknown`, step.status);
|
|
193
|
+
if (!Array.isArray(step.nodeIds)) badView(`steps[${position}].nodeIds must be an array`, step.nodeIds);
|
|
194
|
+
if (!Array.isArray(step.groups)) badView(`steps[${position}].groups must be an array`, step.groups);
|
|
195
|
+
if (step.percent !== null && !(isFiniteNumber(step.percent) && step.percent >= 0 && step.percent <= 100)) {
|
|
196
|
+
badView(`steps[${position}].percent must be null or 0..100`, step.percent);
|
|
197
|
+
}
|
|
198
|
+
if (typeof step.lastEstimateAt !== "number" || !isFiniteNumber(step.lastEstimateAt) || step.lastEstimateAt < 0) {
|
|
199
|
+
badView(`steps[${position}].lastEstimateAt must be a non-negative finite number`, step.lastEstimateAt);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
value.nodes.forEach((node, position) => {
|
|
204
|
+
if (!isRecord(node)) badView(`nodes[${position}] must be an object`, node);
|
|
205
|
+
if (typeof node.id !== "string" || node.id === "") badView(`nodes[${position}].id must be a string`, node.id);
|
|
206
|
+
if (!Number.isInteger(node.stepIndex)) badView(`nodes[${position}].stepIndex must be an integer`, node.stepIndex);
|
|
207
|
+
if (!Array.isArray(node.files)) badView(`nodes[${position}].files must be an array`, node.files);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const counts = value.counts;
|
|
211
|
+
if (!isRecord(counts)) badView("counts must be an object", counts);
|
|
212
|
+
for (const field of ["steps", "nodes", "running", "completed"]) {
|
|
213
|
+
if (!Number.isInteger(counts[field]) || counts[field] < 0) {
|
|
214
|
+
badView(`counts.${field} must be a non-negative integer`, counts[field]);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (counts.steps !== value.steps.length) {
|
|
218
|
+
badView("counts.steps must equal steps.length", counts.steps);
|
|
219
|
+
}
|
|
220
|
+
if (counts.nodes !== value.nodes.length) {
|
|
221
|
+
badView("counts.nodes must equal nodes.length", counts.nodes);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const active = value.active;
|
|
225
|
+
if (active !== null) {
|
|
226
|
+
if (!isRecord(active)) badView("active must be null or an object", active);
|
|
227
|
+
if (!Number.isInteger(active.stepIndex)) badView("active.stepIndex must be an integer", active.stepIndex);
|
|
228
|
+
if (active.nodeId !== null && typeof active.nodeId !== "string") {
|
|
229
|
+
badView("active.nodeId must be null or a string", active.nodeId);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// the hot-inject history and the per-step "needs confirm" flags. Both are OPTIONAL on the
|
|
234
|
+
// wire (an older view has neither), and both are shape-checked because the browser half
|
|
235
|
+
// dereferences them: `id` is the join key with the admitting user message, and
|
|
236
|
+
// `pendingConfirm` is what the flag renders from.
|
|
237
|
+
if (value.injects !== undefined) {
|
|
238
|
+
if (!Array.isArray(value.injects)) badView("injects must be an array", value.injects);
|
|
239
|
+
value.injects.forEach((row, position) => {
|
|
240
|
+
if (!isRecord(row)) badView(`injects[${position}] must be an object`, row);
|
|
241
|
+
if (typeof row.id !== "string" || row.id === "") badView(`injects[${position}].id must be a string`, row.id);
|
|
242
|
+
if (typeof row.text !== "string") badView(`injects[${position}].text must be a string`, row.text);
|
|
243
|
+
if (row.pickedUp !== true && row.pickedUp !== false) {
|
|
244
|
+
badView(`injects[${position}].pickedUp must be a boolean`, row.pickedUp);
|
|
245
|
+
}
|
|
246
|
+
// the revocation pair. OPTIONAL (a row that was never revoked has
|
|
247
|
+
// neither), but when present both must be well-formed: the browser half prints
|
|
248
|
+
// "revoked" derives from the canceled flag, and the row's clock takes canceledAt, so a
|
|
249
|
+
// negative stamp here would reach the DOM as a lie rather than as a failure.
|
|
250
|
+
if (row.canceled !== undefined && row.canceled !== true && row.canceled !== false) {
|
|
251
|
+
badView(`injects[${position}].canceled must be a boolean`, row.canceled);
|
|
252
|
+
}
|
|
253
|
+
if (row.canceledAt !== undefined && row.canceledAt !== null && !isFiniteNumber(row.canceledAt)) {
|
|
254
|
+
badView(`injects[${position}].canceledAt must be a number or null`, row.canceledAt);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
value.steps.forEach((step, position) => {
|
|
259
|
+
if (step.pendingConfirm !== undefined && !Array.isArray(step.pendingConfirm)) {
|
|
260
|
+
badView(`steps[${position}].pendingConfirm must be an array`, step.pendingConfirm);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
checkPlan(value.plan);
|
|
265
|
+
|
|
266
|
+
return value;
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** One todo row, repaired. Only content/status are ever read. */
|
|
272
|
+
function repairTodo(raw) {
|
|
273
|
+
if (!isRecord(raw)) return { content: "", status: "" };
|
|
274
|
+
return {
|
|
275
|
+
content: typeof raw.content === "string" ? raw.content : "",
|
|
276
|
+
status: typeof raw.status === "string" ? raw.status : "",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One step row, repaired: the fields `buildView` dereferences must exist with the right type. */
|
|
281
|
+
function repairStep(raw, position) {
|
|
282
|
+
const step = isRecord(raw) ? raw : {};
|
|
283
|
+
const status = STEP_STATUSES.has(step.status) ? step.status : "pending";
|
|
284
|
+
return {
|
|
285
|
+
...step,
|
|
286
|
+
index: position,
|
|
287
|
+
title: typeof step.title === "string" ? step.title : "",
|
|
288
|
+
source: typeof step.source === "string" ? step.source : "todo",
|
|
289
|
+
status,
|
|
290
|
+
startedAt: isFiniteNumber(step.startedAt) && step.startedAt > 0 ? step.startedAt : 0,
|
|
291
|
+
endedAt: isFiniteNumber(step.endedAt) && step.endedAt > 0 ? step.endedAt : null,
|
|
292
|
+
percent: isFiniteNumber(step.percent) && step.percent >= 0 && step.percent <= 100 ? step.percent : null,
|
|
293
|
+
percentSource: typeof step.percentSource === "string" ? step.percentSource : "none",
|
|
294
|
+
// the event time of the last progress estimate. 0 is meaningful ("never"), so it
|
|
295
|
+
// repairs to 0 rather than null — the rail hides the segment instead of printing a fake 0.
|
|
296
|
+
lastEstimateAt: isFiniteNumber(step.lastEstimateAt) && step.lastEstimateAt > 0 ? step.lastEstimateAt : 0,
|
|
297
|
+
maxPercent: isFiniteNumber(step.maxPercent) ? Math.max(0, Math.min(100, step.maxPercent)) : 0,
|
|
298
|
+
reportSummary: typeof step.reportSummary === "string" ? step.reportSummary : "",
|
|
299
|
+
nodeIds: Array.isArray(step.nodeIds) ? step.nodeIds.filter((id) => typeof id === "string") : [],
|
|
300
|
+
nodeCount: Number.isInteger(step.nodeCount) && step.nodeCount >= 0 ? step.nodeCount : 0,
|
|
301
|
+
truncatedNodes: Number.isInteger(step.truncatedNodes) && step.truncatedNodes >= 0 ? step.truncatedNodes : 0,
|
|
302
|
+
groups: Array.isArray(step.groups) ? step.groups.filter(isRecord) : [],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** One node row, repaired. A node without an id cannot be referenced, so it is dropped. */
|
|
307
|
+
function repairNode(raw) {
|
|
308
|
+
if (!isRecord(raw) || typeof raw.id !== "string" || raw.id === "") return null;
|
|
309
|
+
return {
|
|
310
|
+
...raw,
|
|
311
|
+
id: raw.id,
|
|
312
|
+
stepIndex: Number.isInteger(raw.stepIndex) ? raw.stepIndex : 0,
|
|
313
|
+
kind: typeof raw.kind === "string" ? raw.kind : "tool",
|
|
314
|
+
tool: typeof raw.tool === "string" ? raw.tool : "",
|
|
315
|
+
title: typeof raw.title === "string" ? raw.title : "",
|
|
316
|
+
detail: typeof raw.detail === "string" ? raw.detail : "",
|
|
317
|
+
argsText: typeof raw.argsText === "string" ? raw.argsText : "",
|
|
318
|
+
files: Array.isArray(raw.files)
|
|
319
|
+
? raw.files.filter(isRecord).map((file) => ({
|
|
320
|
+
path: typeof file.path === "string" ? file.path : "",
|
|
321
|
+
action: typeof file.action === "string" ? file.action : "",
|
|
322
|
+
}))
|
|
323
|
+
: [],
|
|
324
|
+
resultHead: typeof raw.resultHead === "string" ? raw.resultHead : "",
|
|
325
|
+
error: typeof raw.error === "string" ? raw.error : "",
|
|
326
|
+
at: isFiniteNumber(raw.at) && raw.at > 0 ? raw.at : 0,
|
|
327
|
+
durationMs: isFiniteNumber(raw.durationMs) ? raw.durationMs : null,
|
|
328
|
+
// event seq, carried through a checkpoint restore (null when the node has none).
|
|
329
|
+
seq: isFiniteNumber(raw.seq) ? raw.seq : null,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** One branch activity row, repaired (ids are what the lane UI keys on). */
|
|
334
|
+
function repairBranchNode(raw, position) {
|
|
335
|
+
const node = isRecord(raw) ? raw : {};
|
|
336
|
+
return {
|
|
337
|
+
...node,
|
|
338
|
+
id: typeof node.id === "string" && node.id !== "" ? node.id : `b.n${position}`,
|
|
339
|
+
kind: typeof node.kind === "string" ? node.kind : "row",
|
|
340
|
+
tool: typeof node.tool === "string" ? node.tool : "",
|
|
341
|
+
title: typeof node.title === "string" ? node.title : "",
|
|
342
|
+
detail: typeof node.detail === "string" ? node.detail : "",
|
|
343
|
+
at: isFiniteNumber(node.at) && node.at > 0 ? node.at : 0,
|
|
344
|
+
status: typeof node.status === "string" ? node.status : "",
|
|
345
|
+
key: typeof node.key === "string" ? node.key : "",
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** One branch lane, repaired. A lane without an id cannot be referenced, so it is dropped. */
|
|
350
|
+
function repairBranch(raw) {
|
|
351
|
+
if (!isRecord(raw) || typeof raw.id !== "string" || raw.id === "") return null;
|
|
352
|
+
return {
|
|
353
|
+
...raw,
|
|
354
|
+
id: raw.id,
|
|
355
|
+
kind: typeof raw.kind === "string" ? raw.kind : "subagent",
|
|
356
|
+
label: typeof raw.label === "string" ? raw.label : "",
|
|
357
|
+
status: raw.status === "failed" ? "failed" : raw.status === "done" ? "done" : "running",
|
|
358
|
+
startedAt: isFiniteNumber(raw.startedAt) && raw.startedAt > 0 ? raw.startedAt : 0,
|
|
359
|
+
endedAt: isFiniteNumber(raw.endedAt) && raw.endedAt > 0 ? raw.endedAt : null,
|
|
360
|
+
stepIndex: Number.isInteger(raw.stepIndex) ? raw.stepIndex : null,
|
|
361
|
+
seq: isFiniteNumber(raw.seq) ? raw.seq : null,
|
|
362
|
+
callId: typeof raw.callId === "string" ? raw.callId : "",
|
|
363
|
+
runId: typeof raw.runId === "string" ? raw.runId : "",
|
|
364
|
+
jobId: typeof raw.jobId === "string" ? raw.jobId : "",
|
|
365
|
+
count: Number.isInteger(raw.count) && raw.count >= 0 ? raw.count : 0,
|
|
366
|
+
nodes: Array.isArray(raw.nodes) ? raw.nodes.map(repairBranchNode) : [],
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Persisted-state schema. Never throws: a non-object becomes a clean initial state, and a
|
|
372
|
+
* record is repaired field by field so a damaged row keeps whatever is still usable instead of
|
|
373
|
+
* resetting the whole view. A well-formed row survives byte-identically (the offline self-test
|
|
374
|
+
* asserts that through a JSON round trip).
|
|
375
|
+
*/
|
|
376
|
+
function makeStateSchema() {
|
|
377
|
+
return {
|
|
378
|
+
parse(value) {
|
|
379
|
+
if (!isRecord(value)) return createInitialState();
|
|
380
|
+
const steps = Array.isArray(value.steps) ? value.steps.map(repairStep) : [];
|
|
381
|
+
const nodes = Array.isArray(value.nodes) ? value.nodes.map(repairNode).filter((node) => node !== null) : [];
|
|
382
|
+
const reports = Array.isArray(value.reports)
|
|
383
|
+
? value.reports
|
|
384
|
+
.filter(isRecord)
|
|
385
|
+
.filter((entry) => Number.isInteger(entry.stepIndex) && isFiniteNumber(entry.percent))
|
|
386
|
+
.map((entry) => ({ stepIndex: entry.stepIndex, percent: entry.percent }))
|
|
387
|
+
: [];
|
|
388
|
+
const pending = isRecord(value.pendingAnchor) ? value.pendingAnchor : null;
|
|
389
|
+
return {
|
|
390
|
+
stateVersion: STATE_VERSION,
|
|
391
|
+
asOf: isFiniteNumber(value.asOf) && value.asOf > 0 ? value.asOf : 0,
|
|
392
|
+
task: typeof value.task === "string" ? value.task : "",
|
|
393
|
+
// Session identity: persisted with the state so a cold checkpoint restore can still
|
|
394
|
+
// name this session's `.pae` directory (the plan reader needs both halves).
|
|
395
|
+
sessionId: typeof value.sessionId === "string" ? value.sessionId : "",
|
|
396
|
+
cwd: typeof value.cwd === "string" ? value.cwd : "",
|
|
397
|
+
todos: Array.isArray(value.todos) ? value.todos.map(repairTodo) : [],
|
|
398
|
+
steps,
|
|
399
|
+
nodes,
|
|
400
|
+
// Branch lanes : repaired row by row so a damaged lane loses its bad rows,
|
|
401
|
+
// never the whole map. `nextBranchId` only ever moves forward.
|
|
402
|
+
branches: Array.isArray(value.branches) ? value.branches.map(repairBranch).filter((branch) => branch !== null) : [],
|
|
403
|
+
nextBranchId:
|
|
404
|
+
Number.isInteger(value.nextBranchId) && value.nextBranchId > 0
|
|
405
|
+
? value.nextBranchId
|
|
406
|
+
: (Array.isArray(value.branches) ? value.branches.length : 0) + 1,
|
|
407
|
+
droppedBranches: Number.isInteger(value.droppedBranches) && value.droppedBranches >= 0 ? value.droppedBranches : 0,
|
|
408
|
+
// Jobs registered but not yet settled: the input of “is this a background task?”.
|
|
409
|
+
// Persisted because a checkpoint restored mid-run must still be able to promote one.
|
|
410
|
+
pendingJobs: Array.isArray(value.pendingJobs)
|
|
411
|
+
? value.pendingJobs
|
|
412
|
+
.filter(isRecord)
|
|
413
|
+
.map((job) => ({
|
|
414
|
+
jobId: typeof job.jobId === "string" ? job.jobId : "",
|
|
415
|
+
label: typeof job.label === "string" ? job.label : "",
|
|
416
|
+
kind: typeof job.kind === "string" ? job.kind : "",
|
|
417
|
+
startedAt: isFiniteNumber(job.startedAt) ? job.startedAt : 0,
|
|
418
|
+
callId: typeof job.callId === "string" ? job.callId : "",
|
|
419
|
+
stepIndex: Number.isInteger(job.stepIndex) ? job.stepIndex : null,
|
|
420
|
+
}))
|
|
421
|
+
.slice(-24)
|
|
422
|
+
: [],
|
|
423
|
+
active:
|
|
424
|
+
isRecord(value.active) && Number.isInteger(value.active.stepIndex)
|
|
425
|
+
? {
|
|
426
|
+
stepIndex: value.active.stepIndex,
|
|
427
|
+
nodeId: typeof value.active.nodeId === "string" ? value.active.nodeId : null,
|
|
428
|
+
}
|
|
429
|
+
: null,
|
|
430
|
+
// an open turn is the one honest "is the session working" flag. Persisted so a
|
|
431
|
+
// checkpoint restored mid-turn does not freeze the panel's curve behind a live turn.
|
|
432
|
+
turnActive: value.turnActive === true,
|
|
433
|
+
// hot-inject history rows. Repaired row by row: a damaged row loses its bad text
|
|
434
|
+
// but keeps its identity, because the id is what the admitting `user/message` matches on.
|
|
435
|
+
injects: Array.isArray(value.injects)
|
|
436
|
+
? value.injects
|
|
437
|
+
.filter(isRecord)
|
|
438
|
+
.map((row) => ({
|
|
439
|
+
id: typeof row.id === "string" ? row.id : "",
|
|
440
|
+
at: isFiniteNumber(row.at) && row.at > 0 ? row.at : 0,
|
|
441
|
+
target: typeof row.target === "string" ? row.target : "",
|
|
442
|
+
text: typeof row.text === "string" ? row.text : "",
|
|
443
|
+
pickedUp: row.pickedUp === true,
|
|
444
|
+
pickedUpAt: isFiniteNumber(row.pickedUpAt) && row.pickedUpAt > 0 ? row.pickedUpAt : null,
|
|
445
|
+
// The revoke marker must survive a checkpoint restore, otherwise after a restart
|
|
446
|
+
// the same injection would be "revoked" again and then revert to "awaiting pickup",
|
|
447
|
+
// so the UI would no longer describe the same fact as the log.
|
|
448
|
+
canceled: row.canceled === true,
|
|
449
|
+
canceledAt: isFiniteNumber(row.canceledAt) && row.canceledAt > 0 ? row.canceledAt : null,
|
|
450
|
+
}))
|
|
451
|
+
.filter((row) => row.id !== "")
|
|
452
|
+
.slice(-40)
|
|
453
|
+
: [],
|
|
454
|
+
// the inbox LEDGER (pending message ids per loop-owned list). Dropping it here would
|
|
455
|
+
// be silent but destructive: a hydrated checkpoint would replay later cancellations against
|
|
456
|
+
// an empty ledger and every one of them would resolve to “which id?” → unknown.
|
|
457
|
+
inboxIds: {
|
|
458
|
+
"next-turn": isRecord(value.inboxIds) && Array.isArray(value.inboxIds["next-turn"])
|
|
459
|
+
? value.inboxIds["next-turn"].filter((id) => typeof id === "string" && id !== "")
|
|
460
|
+
: [],
|
|
461
|
+
"next-step": isRecord(value.inboxIds) && Array.isArray(value.inboxIds["next-step"])
|
|
462
|
+
? value.inboxIds["next-step"].filter((id) => typeof id === "string" && id !== "")
|
|
463
|
+
: [],
|
|
464
|
+
},
|
|
465
|
+
// the approval audit pair (open asks, and the decided ones still worth keeping).
|
|
466
|
+
approvals: Array.isArray(value.approvals)
|
|
467
|
+
? value.approvals
|
|
468
|
+
.filter(isRecord)
|
|
469
|
+
.map((row) => ({
|
|
470
|
+
id: typeof row.id === "string" ? row.id : "",
|
|
471
|
+
state: row.state === "decided" ? "decided" : "asked",
|
|
472
|
+
// REQUIRED field: an approval that was not allowed (rejected/cancelled) still counts as "needs confirm", and that decision reads
|
|
473
|
+
// exactly this field. The old repair function copied only `state`, so one restart erased it - and in the restarted session
|
|
474
|
+
// a rejected approval stopped being flagged, disagreeing with the in-memory behaviour.
|
|
475
|
+
outcome: typeof row.outcome === "string" ? row.outcome : "",
|
|
476
|
+
stepIndex: Number.isInteger(row.stepIndex) ? row.stepIndex : null,
|
|
477
|
+
label: typeof row.label === "string" ? row.label : "",
|
|
478
|
+
at: isFiniteNumber(row.at) && row.at > 0 ? row.at : 0,
|
|
479
|
+
}))
|
|
480
|
+
.filter((row) => row.id !== "")
|
|
481
|
+
.slice(-40)
|
|
482
|
+
: [],
|
|
483
|
+
// carried through a restore so a step that was waiting on a human keeps its flag.
|
|
484
|
+
pendingConfirm: Array.isArray(value.pendingConfirm) ? value.pendingConfirm.slice() : [],
|
|
485
|
+
reports,
|
|
486
|
+
durations: Array.isArray(value.durations)
|
|
487
|
+
? value.durations.filter((ms) => isFiniteNumber(ms) && ms > 0)
|
|
488
|
+
: [],
|
|
489
|
+
truncated: {
|
|
490
|
+
stepNodes: isRecord(value.truncated) && Number.isInteger(value.truncated.stepNodes) ? value.truncated.stepNodes : 0,
|
|
491
|
+
totalNodes: isRecord(value.truncated) && Number.isInteger(value.truncated.totalNodes) ? value.truncated.totalNodes : 0,
|
|
492
|
+
},
|
|
493
|
+
nextNodeId: Number.isInteger(value.nextNodeId) && value.nextNodeId > 0 ? value.nextNodeId : nodes.length + 1,
|
|
494
|
+
pendingAnchor:
|
|
495
|
+
pending && Number.isInteger(pending.planIndex)
|
|
496
|
+
? { planIndex: pending.planIndex, seq: isFiniteNumber(pending.seq) ? pending.seq : null }
|
|
497
|
+
: null,
|
|
498
|
+
seq: isFiniteNumber(value.seq) ? value.seq : null,
|
|
499
|
+
};
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// registration
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The host's "now": the timestamp of the last event this projection folded — never the wall
|
|
510
|
+
* clock. `wire.view` receives the state and nothing else, so this is the only honest instant
|
|
511
|
+
* available to it, and it is exactly the instant the folded state describes.
|
|
512
|
+
*/
|
|
513
|
+
function asOfOf(state) {
|
|
514
|
+
return isRecord(state) && isFiniteNumber(state.asOf) && state.asOf > 0 ? state.asOf : 0;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// the PAE plan : one reader per session, read on the wire, never in apply
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Readers live here, not in the projection state: a reader holds a cache key and counters, is
|
|
523
|
+
* not JSON, and the state is persisted. `init(header)` registers one per session; `wire.view`
|
|
524
|
+
* (which receives the state and nothing else) finds it again through `state.sessionId/cwd`.
|
|
525
|
+
* Bounded, because a long-lived host must not keep one reader per session it ever served.
|
|
526
|
+
*/
|
|
527
|
+
const MAX_READERS = 32;
|
|
528
|
+
const readers = new Map();
|
|
529
|
+
|
|
530
|
+
const readerKey = (sessionId, cwd) => `${sessionId}\u0000${cwd}`;
|
|
531
|
+
|
|
532
|
+
/** The reader for one session, created on first use (never reads the disk itself). */
|
|
533
|
+
function readerFor(sessionId, cwd) {
|
|
534
|
+
if (sessionId === "" || cwd === "") return null;
|
|
535
|
+
const key = readerKey(sessionId, cwd);
|
|
536
|
+
const existing = readers.get(key);
|
|
537
|
+
if (existing !== undefined) return existing;
|
|
538
|
+
const reader = createPlanReader({ sessionId, cwd });
|
|
539
|
+
readers.set(key, reader);
|
|
540
|
+
if (readers.size > MAX_READERS) {
|
|
541
|
+
const oldest = readers.keys().next();
|
|
542
|
+
if (!oldest.done) readers.delete(oldest.value);
|
|
543
|
+
}
|
|
544
|
+
return reader;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* `{id, cwd}` from a session header — the two facts that name a `.pae` directory.
|
|
549
|
+
*
|
|
550
|
+
* The cwd fallback is NOT a guess: PAE itself computes its plan directory as
|
|
551
|
+
* `agent.session.header.cwd ?? process.cwd` (dsh-plan-and-execute/lib/index.js:1539-1542), so
|
|
552
|
+
* a real session without a header cwd has its plan in the HOST's cwd — the only place worth
|
|
553
|
+
* looking. An EMPTY header (init called with nothing, which the offline self-test does) is not
|
|
554
|
+
* a session at all and keeps an empty identity, so it can never name a plan.
|
|
555
|
+
*/
|
|
556
|
+
function headerIdentity(header) {
|
|
557
|
+
const record = isRecord(header) ? header : {};
|
|
558
|
+
const sessionId = typeof record.id === "string" ? record.id : "";
|
|
559
|
+
const cwd = typeof record.cwd === "string" && record.cwd !== "" ? record.cwd : "";
|
|
560
|
+
return { sessionId, cwd: cwd === "" && sessionId !== "" ? process.cwd() : cwd };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** The same two facts as they were persisted into the state by `init`. */
|
|
564
|
+
function stateIdentity(state) {
|
|
565
|
+
const record = isRecord(state) ? state : {};
|
|
566
|
+
return {
|
|
567
|
+
sessionId: typeof record.sessionId === "string" ? record.sessionId : "",
|
|
568
|
+
cwd: typeof record.cwd === "string" ? record.cwd : "",
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* The plan section for a state: the reader's cached value (one stat), or null. `read` never
|
|
574
|
+
* throws; the extra guard keeps a future edit from taking the whole view down with it.
|
|
575
|
+
*/
|
|
576
|
+
function planFor(state) {
|
|
577
|
+
const { sessionId, cwd } = stateIdentity(state);
|
|
578
|
+
const reader = readerFor(sessionId, cwd);
|
|
579
|
+
if (reader === null) return null;
|
|
580
|
+
try {
|
|
581
|
+
return reader.read();
|
|
582
|
+
} catch {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
// the HTTP view route
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Resolve one host service without assuming it exists.
|
|
593
|
+
*
|
|
594
|
+
* `inject` guarantees these services before apply runs, so the null path here covers what is
|
|
595
|
+
* NOT the composed web host: the offline self-tests (a bare object with one property) and any
|
|
596
|
+
* future headless mount. Both accessors are tried so the plugin is not pinned to one Cordis
|
|
597
|
+
* generation — `ctx.get(name)` is the documented one, `ctx[name]` the injected property.
|
|
598
|
+
*/
|
|
599
|
+
function serviceOf(ctx, serviceName) {
|
|
600
|
+
try {
|
|
601
|
+
if (typeof ctx.get === "function") {
|
|
602
|
+
const viaGet = ctx.get(serviceName);
|
|
603
|
+
if (viaGet !== undefined && viaGet !== null) return viaGet;
|
|
604
|
+
}
|
|
605
|
+
} catch {
|
|
606
|
+
// An unknown service name throws on some Cordis versions; treat it as absent.
|
|
607
|
+
}
|
|
608
|
+
const direct = ctx[serviceName];
|
|
609
|
+
return direct === undefined || direct === null ? null : direct;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Short, bounded text for a diagnostic (a throw here would mask the real problem). */
|
|
613
|
+
function describe(error) {
|
|
614
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
615
|
+
return text.length <= 200 ? text : `${text.slice(0, 199)}…`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Web servers this module has a route on.
|
|
620
|
+
*
|
|
621
|
+
* The shared server outlives this plugin instance: a hot reload re-runs apply in the same
|
|
622
|
+
* process, and the server throws on a duplicate (kind, path). Keying the seat by SERVER with a
|
|
623
|
+
* refcount means a second apply reuses the live registration while the LAST disposer removes
|
|
624
|
+
* it — no duplicate throw, and no route left behind either. `routeSeatCount` exists because a
|
|
625
|
+
* WeakMap has no `size`.
|
|
626
|
+
*/
|
|
627
|
+
const routeSeats = new WeakMap();
|
|
628
|
+
let routeSeatCount = 0;
|
|
629
|
+
const routeDisposers = [];
|
|
630
|
+
let routeNote = "not attempted";
|
|
631
|
+
|
|
632
|
+
/** What the route attachment did — for diagnostics and for tools/probe-route.mjs. */
|
|
633
|
+
export function routeInfo() {
|
|
634
|
+
return { note: routeNote, path: STATE_PATH, servers: routeSeatCount, owners: routeDisposers.length };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The route's view source: session id → the composed view.
|
|
639
|
+
*
|
|
640
|
+
* This is the only place that knows about sessions. It reads the SAME registry the row registered
|
|
641
|
+
* into (captured in apply), so the route can never serve a view built by a different registry.
|
|
642
|
+
* `snapshot` returns one consistent cut (`{asOfSeq, values}`) whose value already passed the
|
|
643
|
+
* unit's `viewSchema`; `composeView` then applies the plan exactly as the wire path does — a
|
|
644
|
+
* no-op for a view that already carries its plan section.
|
|
645
|
+
*
|
|
646
|
+
* Failures are returned as data, not thrown: each maps onto the status the client should see.
|
|
647
|
+
*/
|
|
648
|
+
function makeViewSource(ctx, registry) {
|
|
649
|
+
return function viewSource(sessionId) {
|
|
650
|
+
const sessions = serviceOf(ctx, "sessions");
|
|
651
|
+
if (sessions === null || typeof sessions.get !== "function") {
|
|
652
|
+
return { ok: false, status: 503, error: "the sessions service is unavailable" };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
let session;
|
|
656
|
+
try {
|
|
657
|
+
session = sessions.get(sessionId);
|
|
658
|
+
} catch (error) {
|
|
659
|
+
return { ok: false, status: 500, error: `session lookup failed: ${describe(error)}` };
|
|
660
|
+
}
|
|
661
|
+
if (session === undefined || session === null) {
|
|
662
|
+
return { ok: false, status: 404, error: "unknown session" };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
let snapshot;
|
|
666
|
+
try {
|
|
667
|
+
snapshot = registry.snapshot(session, [PROJECTION_KEY]);
|
|
668
|
+
} catch (error) {
|
|
669
|
+
return { ok: false, status: 500, error: `projection snapshot failed: ${describe(error)}` };
|
|
670
|
+
}
|
|
671
|
+
const view = isRecord(snapshot) && isRecord(snapshot.values) ? snapshot.values[PROJECTION_KEY] : undefined;
|
|
672
|
+
if (view === undefined) {
|
|
673
|
+
// Possible mid-mount, or after a failed apply left the key unregistered.
|
|
674
|
+
return { ok: false, status: 503, error: `${PROJECTION_KEY} is not registered` };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return { ok: true, view: composeView(view, null), rev: isFiniteNumber(snapshot.asOfSeq) ? snapshot.asOfSeq : 0 };
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Acquire (or share) this module's route seat on one server.
|
|
683
|
+
*
|
|
684
|
+
* @returns {( => void) | null} an idempotent release function, or null when there is nothing
|
|
685
|
+
* to release (no server, or the server refused the registration). Never throws: a route that
|
|
686
|
+
* cannot be attached must not take the projection down with it.
|
|
687
|
+
*/
|
|
688
|
+
/**
|
|
689
|
+
* The steer channel's source: session id + text → one `user/message` in that session's inbox.
|
|
690
|
+
*
|
|
691
|
+
* `agent.steer(input)` is the agent loop's own API for "take this at the next STEP boundary of the
|
|
692
|
+
* running turn, and wake the driver". It does NOT cancel the current turn — `cancel` is the
|
|
693
|
+
* only path that aborts, and nothing here calls it. That is the whole point of the feature: the
|
|
694
|
+
* session keeps going and picks the new prompt up at its next step.
|
|
695
|
+
*
|
|
696
|
+
* THE MESSAGE IS BUILT HERE, NOT BY THE ROUTE. `createUserMessage` comes from
|
|
697
|
+
* `@deepseek-ai/dsh-llm`, which this package cannot import: it is installed as a `link:`, so a
|
|
698
|
+
* bare specifier is not guaranteed to resolve (see the note on the schemas above). So the message
|
|
699
|
+
* is constructed as a plain object with the four fields the session's shape check
|
|
700
|
+
* (`assertMessageEventShape`) requires — `id`, `role`, `content`, `source` — and the loop's
|
|
701
|
+
* `inbox.splice` folds it into the durable `agent/inbox/spliced` event.
|
|
702
|
+
*
|
|
703
|
+
* WHY `source.kind` IS THE PRODUCER-OWNED `plugin:<name>` AND NOT "user". A non-human producer
|
|
704
|
+
* must carry its own source (dsh-tool-goal/README.md is explicit that it may not inherit
|
|
705
|
+
* `{kind:"user"}`); the loop owns the reserved `"runtime-context"` kind. Session format v4
|
|
706
|
+
* retired the bare `"plugin"` wrapper (reader-side admission rejects it with "format v4
|
|
707
|
+
* message requires a producer-owned source kind"), so the producer-owned `plugin:<name>`
|
|
708
|
+
* kind says who produced the message, and the id is a fresh random UUID, exactly like
|
|
709
|
+
* `createUserMessage`'s own.
|
|
710
|
+
*
|
|
711
|
+
* `agent.status` IS READ, NOT WRITTEN. `ReactLoopAgent.status` is `"running"` unless the phase is
|
|
712
|
+
* idle/maintenance, so a non-running session answers 409 with the plain reason instead of
|
|
713
|
+
* silently queueing a message nobody will ever claim.
|
|
714
|
+
*/
|
|
715
|
+
export function makeSteerSource(ctx) {
|
|
716
|
+
return function steerSource(sessionId, text) {
|
|
717
|
+
const agents = serviceOf(ctx, "agents");
|
|
718
|
+
if (agents === null || typeof agents.get !== "function") {
|
|
719
|
+
return { ok: false, status: 503, error: "the agents service is unavailable" };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
let agent;
|
|
723
|
+
try {
|
|
724
|
+
agent = agents.get(sessionId);
|
|
725
|
+
} catch (error) {
|
|
726
|
+
return { ok: false, status: 500, error: `agent lookup failed: ${describe(error)}` };
|
|
727
|
+
}
|
|
728
|
+
if (agent === undefined || agent === null) {
|
|
729
|
+
return { ok: false, status: 404, error: "unknown session" };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// steer is only meaningful while the loop is driving this session: the next-step inbox is
|
|
733
|
+
// claimed by a running turn, so an idle agent would hold the message until the next turn —
|
|
734
|
+
// which is `followup`'s job, not this button's.
|
|
735
|
+
if (agent.status !== "running" || typeof agent.steer !== "function") {
|
|
736
|
+
return {
|
|
737
|
+
ok: false,
|
|
738
|
+
status: 409,
|
|
739
|
+
error: "会话未在运行,热注入不可用",
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
let message;
|
|
744
|
+
try {
|
|
745
|
+
message = {
|
|
746
|
+
id: randomUUID(),
|
|
747
|
+
role: "user",
|
|
748
|
+
content: [{ type: "text", text }],
|
|
749
|
+
source: { kind: `plugin:${packageName}` },
|
|
750
|
+
};
|
|
751
|
+
} catch (error) {
|
|
752
|
+
return { ok: false, status: 500, error: `message could not be built: ${describe(error)}` };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
try {
|
|
756
|
+
agent.steer(message);
|
|
757
|
+
} catch (error) {
|
|
758
|
+
return { ok: false, status: 500, error: `steer failed: ${describe(error)}` };
|
|
759
|
+
}
|
|
760
|
+
// The id travels back to the browser so the panel can offer "Revoke" for exactly this message
|
|
761
|
+
// while it is still unclaimed: `POST /dsh-workflow/unsteer { sessionId, messageId }`.
|
|
762
|
+
return { ok: true, messageId: message.id };
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* The unsteer channel's source: session id + message id -> that message OUT of the inbox.
|
|
768
|
+
*
|
|
769
|
+
* WHY `inbox.remove` AND NOT A SECOND `steer`. A revocation is not new input; it is the loop's own
|
|
770
|
+
* "take that one back" operation, and the loop exposes it as `agent.inbox.remove(messageId)`
|
|
771
|
+
* (`ReactLoopInbox.remove`). Verified against the host's own source 2026-10-26
|
|
772
|
+
* (`@deepseek-ai/dsh-agent-loop/lib/index.js`, `ReactLoopInbox`): it locates the id across both
|
|
773
|
+
* pending lists, splices it out, durably appends
|
|
774
|
+
* `agent/inbox/spliced { target, start, removedCount, inserted: [], outcome: 'canceled' }`, and
|
|
775
|
+
* emits `agent/inbox/discarded { message }` on the agent's dispatch bus. Those two facts are the
|
|
776
|
+
* whole feature: the durable event is what the fold reads (so the row flips on the next poll, and
|
|
777
|
+
* survives a reload), and the plugin writes NOTHING itself — one fact, one writer.
|
|
778
|
+
*
|
|
779
|
+
* WHY `true`/`false` IS THE WHOLE ANSWER. `remove` answers false when the message is no longer
|
|
780
|
+
* pending — the running turn claimed it, or it never existed. Both are the same honest reply to the
|
|
781
|
+
* user ("that injection was already claimed or does not exist"), and neither is a server fault, so this is a 409 and never a
|
|
782
|
+
* 500.
|
|
783
|
+
*
|
|
784
|
+
* WHY `agent.status` IS CHECKED TOO. `remove` happens to work on an idle agent, but the panel's
|
|
785
|
+
* button only exists in the window where the running turn could still claim the message; answering
|
|
786
|
+
* 409 for an idle session keeps this route's meaning identical to `/steer`'s ("hot injection is a
|
|
787
|
+
* live-session operation") instead of quietly becoming a general inbox editor.
|
|
788
|
+
*
|
|
789
|
+
* WHY A MISSING `inbox.remove` IS 503 AND NOT 409. A host whose agent object has no inbox is a
|
|
790
|
+
* composition problem (an older or partial agent implementation), not something the caller did
|
|
791
|
+
* wrong — the same reason `/steer` answers 503 when the agents service is absent.
|
|
792
|
+
* @param {unknown} ctx - the plugin context (`ctx.agents` is the only service read here).
|
|
793
|
+
* @returns {(sessionId: string, messageId: string) => {ok: true} | {ok: false, status: number, error: string}}
|
|
794
|
+
*/
|
|
795
|
+
export function makeUnsteerSource(ctx) {
|
|
796
|
+
return function unsteerSource(sessionId, messageId) {
|
|
797
|
+
const agents = serviceOf(ctx, "agents");
|
|
798
|
+
if (agents === null || typeof agents.get !== "function") {
|
|
799
|
+
return { ok: false, status: 503, error: "the agents service is unavailable" };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
let agent;
|
|
803
|
+
try {
|
|
804
|
+
agent = agents.get(sessionId);
|
|
805
|
+
} catch (error) {
|
|
806
|
+
return { ok: false, status: 500, error: `agent lookup failed: ${describe(error)}` };
|
|
807
|
+
}
|
|
808
|
+
if (agent === undefined || agent === null) {
|
|
809
|
+
return { ok: false, status: 404, error: "unknown session" };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (agent.status !== "running") {
|
|
813
|
+
return {
|
|
814
|
+
ok: false,
|
|
815
|
+
status: 409,
|
|
816
|
+
error: "会话未在运行,热注入不可用",
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const inbox = agent.inbox;
|
|
821
|
+
if (inbox === null || typeof inbox !== "object" || typeof inbox.remove !== "function") {
|
|
822
|
+
return { ok: false, status: 503, error: "the host exposes no inbox revoke API" };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
let removed;
|
|
826
|
+
try {
|
|
827
|
+
removed = inbox.remove(messageId);
|
|
828
|
+
} catch (error) {
|
|
829
|
+
return { ok: false, status: 500, error: `unsteer failed: ${describe(error)}` };
|
|
830
|
+
}
|
|
831
|
+
if (removed !== true) {
|
|
832
|
+
return { ok: false, status: 409, error: "该注入已被会话接下或不存在" };
|
|
833
|
+
}
|
|
834
|
+
return { ok: true };
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Acquire (or share) this module's route seat on one server.
|
|
840
|
+
*
|
|
841
|
+
* @returns {( => void) | null} an idempotent release function, or null when there is nothing
|
|
842
|
+
* to release (no server, or the server refused the registration). Never throws: a route that
|
|
843
|
+
* cannot be attached must not take the projection down with it.
|
|
844
|
+
*/
|
|
845
|
+
function attachViewRoute(ctx, registry) {
|
|
846
|
+
const server = serviceOf(ctx, "webServer") ?? serviceOf(ctx, "httpServer");
|
|
847
|
+
if (server === null || typeof server.register !== "function") {
|
|
848
|
+
routeNote = "no webServer service (headless host?): the view route was not registered";
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const existing = routeSeats.get(server);
|
|
853
|
+
let seat = existing;
|
|
854
|
+
if (seat !== undefined) {
|
|
855
|
+
seat.refs += 1;
|
|
856
|
+
routeNote = `view route already registered on this server (${seat.refs} owner(s))`;
|
|
857
|
+
} else {
|
|
858
|
+
let dispose;
|
|
859
|
+
try {
|
|
860
|
+
dispose = registerViewRoute(server, {
|
|
861
|
+
snapshot: makeViewSource(ctx, registry),
|
|
862
|
+
steer: makeSteerSource(ctx),
|
|
863
|
+
unsteer: makeUnsteerSource(ctx),
|
|
864
|
+
note: (text) => {
|
|
865
|
+
routeNote = text;
|
|
866
|
+
},
|
|
867
|
+
});
|
|
868
|
+
} catch (error) {
|
|
869
|
+
routeNote = `view route registration failed: ${describe(error)}`;
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
seat = { refs: 1, dispose, released: false };
|
|
873
|
+
routeSeats.set(server, seat);
|
|
874
|
+
routeSeatCount += 1;
|
|
875
|
+
routeNote = `registered ${STATE_PATH} + ${STEER_PATH} + ${UNSTEER_PATH} on the host web server`;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const seatRef = seat;
|
|
879
|
+
let released = false;
|
|
880
|
+
const release = () => {
|
|
881
|
+
if (released) return;
|
|
882
|
+
released = true;
|
|
883
|
+
const index = routeDisposers.indexOf(release);
|
|
884
|
+
if (index >= 0) routeDisposers.splice(index, 1);
|
|
885
|
+
seatRef.refs -= 1;
|
|
886
|
+
if (seatRef.refs > 0) return;
|
|
887
|
+
seatRef.released = true;
|
|
888
|
+
if (routeSeats.get(server) === seatRef) {
|
|
889
|
+
routeSeats.delete(server);
|
|
890
|
+
routeSeatCount -= 1;
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
seatRef.dispose();
|
|
894
|
+
routeNote = `released ${STATE_PATH}`;
|
|
895
|
+
} catch (error) {
|
|
896
|
+
routeNote = `view route dispose failed: ${describe(error)}`;
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
routeDisposers.push(release);
|
|
900
|
+
return release;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export function apply(ctx) {
|
|
904
|
+
if (!isRecord(ctx) || !isRecord(ctx.sessionProjections) || typeof ctx.sessionProjections.register !== "function") {
|
|
905
|
+
throw new Error("workflow-map: the sessionProjections service is unavailable");
|
|
906
|
+
}
|
|
907
|
+
// Captured once: the route's view source must read the SAME registry this row registers into,
|
|
908
|
+
// not whatever `ctx.get("sessionProjections")` happens to resolve to at request time.
|
|
909
|
+
const registry = ctx.sessionProjections;
|
|
910
|
+
|
|
911
|
+
registry.register({
|
|
912
|
+
key: PROJECTION_KEY,
|
|
913
|
+
stateSchema: makeStateSchema(),
|
|
914
|
+
// Must stay a non-negative safe integer and must not change for a given key while it
|
|
915
|
+
// shares a session: a bump tells the registry to drop older checkpoints and rebuild.
|
|
916
|
+
stateVersion: STATE_VERSION,
|
|
917
|
+
// init is handed (header, inheritedEventCount); this fold ignores the inherited count —
|
|
918
|
+
// a forked session starts from the same empty ledger — and uses the header only to carry
|
|
919
|
+
// the session identity into the state.
|
|
920
|
+
// `init(header)` is where the session identity is captured. Create (do NOT read) this
|
|
921
|
+
// session's plan reader here: keeps the only file access on the wire path, so init
|
|
922
|
+
// records WHERE the plan would be and nothing else.
|
|
923
|
+
init: (header) => {
|
|
924
|
+
const { sessionId, cwd } = headerIdentity(header);
|
|
925
|
+
readerFor(sessionId, cwd);
|
|
926
|
+
return { ...createInitialState(), sessionId, cwd };
|
|
927
|
+
},
|
|
928
|
+
// One implementation, two consumers: the very fold the offline self-test drives.
|
|
929
|
+
apply: (state, event) => foldEvent(state, event),
|
|
930
|
+
wire: {
|
|
931
|
+
viewSchema: makeViewSchema(),
|
|
932
|
+
// `buildView` at the last folded instant, plus the PAE plan section merged on top
|
|
933
|
+
// (header task title; title/status of todo-derived rows only). The plan is READ here —
|
|
934
|
+
// one stat per view, and never inside apply, which stays pure and disk-free.
|
|
935
|
+
view: (state) => composeView(buildView(state, asOfOf(state)), planFor(state)),
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
// The HTTP view route , attached AFTER the projection so it can never serve a session
|
|
940
|
+
// whose projection is not registered yet. A host without a web server, or one that refuses the
|
|
941
|
+
// seat, only leaves a diagnostic behind: the projection is still useful without the poll route,
|
|
942
|
+
// so this must never fail the mount.
|
|
943
|
+
const releaseRoute = attachViewRoute(ctx, registry);
|
|
944
|
+
if (releaseRoute !== null && typeof ctx.effect === "function") {
|
|
945
|
+
ctx.effect(() => releaseRoute);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Process-level self-check, evaluated once at module load. Pure data: the shape self-test
|
|
950
|
+
* asserts these fields, so a drift between the package manifest and the patch row fails a
|
|
951
|
+
* test instead of a profile.
|
|
952
|
+
*/
|
|
953
|
+
export const selfCheck = Object.freeze({
|
|
954
|
+
name,
|
|
955
|
+
packageName,
|
|
956
|
+
/** Subpath of the browser half, loaded through `dsh.client` in package.json. */
|
|
957
|
+
clientEntry: "./client",
|
|
958
|
+
/** Locale namespace — identical in locale/zh.json, locale/en.json and client.js. */
|
|
959
|
+
localeNamespace: packageName,
|
|
960
|
+
/** Everything this row registers: one projection and three HTTP endpoints (read + two writes). */
|
|
961
|
+
registrations: Object.freeze([
|
|
962
|
+
`sessionProjection:${PROJECTION_KEY}`,
|
|
963
|
+
`httpRoute:${STATE_PATH}`,
|
|
964
|
+
`httpRoute:${STEER_PATH}`,
|
|
965
|
+
`httpRoute:${UNSTEER_PATH}`,
|
|
966
|
+
]),
|
|
967
|
+
/** The route paths as data, for the same reason `planSource` is: assertion targets. */
|
|
968
|
+
httpRoute: STATE_PATH,
|
|
969
|
+
steerRoute: STEER_PATH,
|
|
970
|
+
unsteerRoute: UNSTEER_PATH,
|
|
971
|
+
/** The one file this plugin reads: read-only, one stat per view, never in apply. */
|
|
972
|
+
planSource: ".pae/<sessionId>/orchestrator.json",
|
|
973
|
+
});
|
|
974
|
+
export default { name, inject, apply, selfCheck, packageName, PROJECTION_KEY, makeSteerSource, makeUnsteerSource };
|