@nanobpm/nano-workforce 0.185.1 → 0.186.1
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 +12 -0
- package/app/agentic/cockpit/cockpit-browser-bundle.test.ts +14 -5
- package/app/agentic/cockpit/transcript-derive.display.test.ts +231 -0
- package/app/agentic/cockpit/transcript-derive.ts +297 -63
- package/app/agentic/transcript-display.ts +17 -0
- package/app/deliveryHuman.test.ts +123 -0
- package/app/deliveryHuman.ts +29 -0
- package/app/pollUserTasks.test.ts +56 -0
- package/app/service.ts +13 -2
- package/package.json +2 -2
- package/pages/cockpit/generated/transcript-derive.js +213 -50
- package/pages/cockpit/generated/transcript-display.js +316 -0
- package/pages/cockpit/generated/transcript-events.js +35 -1
- package/pages/cockpit/mount.js +83 -29
- package/resources/forms/delivery-human-generic.form +3 -32
- package/scripts/build-cockpit-browser.ts +11 -1
|
@@ -661,6 +661,62 @@ test("pollUserTasks (engine-first): surfaces an inlined delivery-graph human tas
|
|
|
661
661
|
assertEquals(byKey["35002"].subject_title, "release runbook");
|
|
662
662
|
});
|
|
663
663
|
|
|
664
|
+
test("pollUserTasks: a parked delivery-human node carries its instruction as `question` (Decision context, issue #772)", async () => {
|
|
665
|
+
// The Tasks surface seeds no form variables, so a delivery-graph `human` node's instruction can only
|
|
666
|
+
// reach the operator through the read-model `question` (rendered as "Decision context"). Source it
|
|
667
|
+
// from the run's stamped `human_labels`, keyed by the parked user-task element id — else the panel is
|
|
668
|
+
// blank and the human has no idea what the run is waiting on.
|
|
669
|
+
const { data, stores } = memData({
|
|
670
|
+
delivery_graph_runs: [
|
|
671
|
+
{
|
|
672
|
+
run_key: "delivery-graph-403eb22e",
|
|
673
|
+
process_key: "dg-1",
|
|
674
|
+
status: "running",
|
|
675
|
+
title: "release runbook",
|
|
676
|
+
human_labels: JSON.stringify({
|
|
677
|
+
"delivery-human-task__n7": "Run the manual OTP publish for @nanobpm/urban",
|
|
678
|
+
}),
|
|
679
|
+
},
|
|
680
|
+
],
|
|
681
|
+
});
|
|
682
|
+
const restore = stubUserTaskSearch([
|
|
683
|
+
{ userTaskKey: "20411", elementId: "delivery-human-task__n7", processInstanceKey: "dg-1", state: "CREATED" },
|
|
684
|
+
// the bounded-timeout escalation twin parks on the `…__esc` id but resolves the same base label
|
|
685
|
+
{ userTaskKey: "20412", elementId: "delivery-human-task__n7__esc", processInstanceKey: "dg-1", state: "CREATED" },
|
|
686
|
+
]);
|
|
687
|
+
try {
|
|
688
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
689
|
+
} finally {
|
|
690
|
+
restore();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
694
|
+
assertEquals(byKey["20411"].question, "Run the manual OTP publish for @nanobpm/urban");
|
|
695
|
+
assertEquals(byKey["20412"].question, "Run the manual OTP publish for @nanobpm/urban");
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("pollUserTasks: a delivery-human node with no stored label still gets a non-blank Decision context (issue #772)", async () => {
|
|
699
|
+
// An untracked run (or a run whose label wasn't stamped) must not leave the panel blank — a static,
|
|
700
|
+
// node-NEUTRAL fallback still tells the operator a delivery-graph step is waiting. It must read true
|
|
701
|
+
// for a non-human escalation twin too, so it must not claim "human step".
|
|
702
|
+
const { data, stores } = memData({});
|
|
703
|
+
const restore = stubUserTaskSearch([
|
|
704
|
+
{ userTaskKey: "20411", elementId: "delivery-human-task__n1", processInstanceKey: "dg-9", state: "CREATED" },
|
|
705
|
+
// a bounded `agent`/`wait`/`connector` node's escalation twin: `isDeliveryHumanElement` matches it,
|
|
706
|
+
// but it carries no stored human label, so it gets the neutral fallback, not a "human step" claim.
|
|
707
|
+
{ userTaskKey: "20499", elementId: "delivery-human-task__agent5__esc", processInstanceKey: "dg-9", state: "CREATED" },
|
|
708
|
+
]);
|
|
709
|
+
try {
|
|
710
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
711
|
+
} finally {
|
|
712
|
+
restore();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
716
|
+
assertEquals(byKey["20411"].question, "A scheduled delivery-graph step is waiting to be completed.");
|
|
717
|
+
assertEquals(byKey["20499"].question, "A scheduled delivery-graph step is waiting to be completed.");
|
|
718
|
+
});
|
|
719
|
+
|
|
664
720
|
test("pollUserTasks (engine-first): a delivery-human task on an UNTRACKED run still surfaces (bucketed `delivery`, instance fallback) (issue #442)", async () => {
|
|
665
721
|
// Even with no `delivery_graph_runs` row referencing the instance, the kind implies its aggregate, so
|
|
666
722
|
// the row renders and stays answerable — mirroring the orphaned-escalation guarantee (#358).
|
package/app/service.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
|
30
30
|
import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
|
|
31
31
|
import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
|
|
32
32
|
import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
|
|
33
|
-
import { isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
33
|
+
import { deliveryHumanContextQuestion, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
34
34
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
35
35
|
import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
|
|
36
36
|
import { deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
@@ -2613,6 +2613,10 @@ export async function pollUserTasks(
|
|
|
2613
2613
|
url?: string | null;
|
|
2614
2614
|
deliveryLabel?: string | null;
|
|
2615
2615
|
conformanceSummary?: string | null;
|
|
2616
|
+
/** The parked human-node instruction labels for a delivery run, keyed by user-task element id
|
|
2617
|
+
* (`delivery-human-task__<node>`) — the run row's stamped `human_labels`, denormalised so the
|
|
2618
|
+
* Tasks-inbox "Decision context" can explain a parked delivery-graph `human` node (issue #772). */
|
|
2619
|
+
humanLabels?: Record<string, string>;
|
|
2616
2620
|
/** The subject's at-a-glance "waiting on <capability> · …" rollup, denormalised for the readiness
|
|
2617
2621
|
* escalation question (issue #674): the wait-gate projection on `plans.wait_gate_label`, or the
|
|
2618
2622
|
* feature run's `delivery_label` for the inline preflight. */
|
|
@@ -2640,7 +2644,7 @@ export async function pollUserTasks(
|
|
|
2640
2644
|
// feature/plan/pr enrichment. The row's inlined `delivery-human-task__<node>` id is recognised by
|
|
2641
2645
|
// the shared `userTaskKindLabel` predicate, and buckets as `delivery` (below).
|
|
2642
2646
|
for (const run of await deliveryGraphRuns(data).all()) {
|
|
2643
|
-
if (run.process_key) subjectByInstance.set(run.process_key, { type: "delivery", key: run.run_key, title: run.title, url: null });
|
|
2647
|
+
if (run.process_key) subjectByInstance.set(run.process_key, { type: "delivery", key: run.run_key, title: run.title, url: null, humanLabels: parseHumanLabels(run.human_labels) });
|
|
2644
2648
|
}
|
|
2645
2649
|
|
|
2646
2650
|
// Per-element subject type for an ORPHANED task (no subject row) — the kind implies its aggregate even
|
|
@@ -2719,6 +2723,13 @@ export async function pollUserTasks(
|
|
|
2719
2723
|
question = readinessEscalationQuestion(subj?.waitGateLabel ?? null);
|
|
2720
2724
|
break;
|
|
2721
2725
|
}
|
|
2726
|
+
// A delivery-graph `human` node's user-task id is inlined per node (`delivery-human-task__<node>`
|
|
2727
|
+
// and its `…__esc` twin), so it can't be a static `case` above (issue #772). Fill its "Decision
|
|
2728
|
+
// context" from the run's stamped `human_labels` — otherwise the panel is blank and, because this
|
|
2729
|
+
// surface seeds no form variables, the operator has no idea what the run is waiting on.
|
|
2730
|
+
if (question === null && isDeliveryHumanElement(elementId)) {
|
|
2731
|
+
question = deliveryHumanContextQuestion(subj?.humanLabels, elementId);
|
|
2732
|
+
}
|
|
2722
2733
|
return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey, formKey: resolvedFormKey };
|
|
2723
2734
|
};
|
|
2724
2735
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.186.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@nanobpm/agentic": "^0.
|
|
67
|
+
"@nanobpm/agentic": "^0.14.0",
|
|
68
68
|
"@nanobpm/urban": "^0.93.0",
|
|
69
69
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
70
70
|
},
|
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
// renders the agentic transcript from ONE source of truth (#660). Regenerate with:
|
|
5
5
|
// node --experimental-strip-types scripts/build-cockpit-browser.ts
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { createDisplayProjection } from "./transcript-display.js";
|
|
8
|
+
import { deriveViewFromChunks, optionKindAllows, parseTranscriptEvent, utf8ByteLength, } from "./transcript-events.js";
|
|
8
9
|
/**
|
|
9
|
-
* Derive the structured view of a fetched transcript page
|
|
10
|
-
*
|
|
10
|
+
* Derive the structured (event-fold) view of a fetched transcript page — the flat message/tool/permission
|
|
11
|
+
* history, per-turn structure and raw-byte accounting. This is the {@link DerivedView} fold, kept for the
|
|
12
|
+
* raw-fidelity footer and summary counts; the ordered, human-facing block SEQUENCE is the separate
|
|
13
|
+
* display projection {@link renderDerivedTranscript} draws. Pure: the cockpit reads THESE instead of
|
|
14
|
+
* re-parsing raw frame bytes.
|
|
11
15
|
*/
|
|
12
16
|
export function deriveTranscript(data) {
|
|
13
17
|
return deriveViewFromChunks(data.entries);
|
|
@@ -116,9 +120,11 @@ function detectDiff(tool) {
|
|
|
116
120
|
return { lines: structured, source: "args" };
|
|
117
121
|
return undefined;
|
|
118
122
|
}
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
/** Fill an EXISTING tool card node with one tool's content (name, status, args/result, diff block). Clears
|
|
124
|
+
* the node first so it is safe to re-invoke in place when the tool's result later arrives (bounded to
|
|
125
|
+
* this one card — no sibling block is touched). */
|
|
126
|
+
function applyTool(card, doc, tool) {
|
|
127
|
+
card.replaceChildren();
|
|
122
128
|
card.setAttribute("data-tool", tool.name);
|
|
123
129
|
card.setAttribute("data-offset", String(tool.offset));
|
|
124
130
|
card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
|
|
@@ -152,6 +158,11 @@ function renderTool(doc, tool) {
|
|
|
152
158
|
resEl.setAttribute("data-tool-result", "true");
|
|
153
159
|
card.appendChild(resEl);
|
|
154
160
|
}
|
|
161
|
+
}
|
|
162
|
+
/** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
|
|
163
|
+
function renderTool(doc, tool) {
|
|
164
|
+
const card = el(doc, "div", "cockpit-transcript-tool");
|
|
165
|
+
applyTool(card, doc, tool);
|
|
155
166
|
return card;
|
|
156
167
|
}
|
|
157
168
|
/**
|
|
@@ -160,8 +171,8 @@ function renderTool(doc, tool) {
|
|
|
160
171
|
* - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
|
|
161
172
|
* - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
|
|
162
173
|
*/
|
|
163
|
-
function
|
|
164
|
-
|
|
174
|
+
function applyPermission(card, doc, perm, options) {
|
|
175
|
+
card.replaceChildren();
|
|
165
176
|
card.setAttribute("data-permission", "request");
|
|
166
177
|
card.setAttribute("data-policy", perm.policy);
|
|
167
178
|
card.setAttribute("data-call-id", perm.callId);
|
|
@@ -181,13 +192,13 @@ function renderPermission(doc, perm, options) {
|
|
|
181
192
|
if (perm.resolved.by !== undefined)
|
|
182
193
|
settled.setAttribute("data-by", perm.resolved.by);
|
|
183
194
|
card.appendChild(settled);
|
|
184
|
-
return
|
|
195
|
+
return;
|
|
185
196
|
}
|
|
186
197
|
if (perm.policy === "yolo") {
|
|
187
198
|
// Informational: yolo auto-allows and never prompts a human, so no Allow/Deny buttons.
|
|
188
199
|
card.setAttribute("data-status", "auto");
|
|
189
200
|
card.appendChild(el(doc, "div", "cockpit-transcript-permission-note", "Auto-allowed (yolo) — no operator prompt."));
|
|
190
|
-
return
|
|
201
|
+
return;
|
|
191
202
|
}
|
|
192
203
|
// Pending escalate: one interactive button per offered option, wired to the resolve seam.
|
|
193
204
|
card.setAttribute("data-status", "pending");
|
|
@@ -206,55 +217,207 @@ function renderPermission(doc, perm, options) {
|
|
|
206
217
|
actions.appendChild(button);
|
|
207
218
|
}
|
|
208
219
|
card.appendChild(actions);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Render one permission prompt card from a {@link DerivedPermission}:
|
|
223
|
+
* - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
|
|
224
|
+
* - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
|
|
225
|
+
* - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
|
|
226
|
+
*/
|
|
227
|
+
function renderPermission(doc, perm, options) {
|
|
228
|
+
const card = el(doc, "div", "cockpit-transcript-permission");
|
|
229
|
+
applyPermission(card, doc, perm, options);
|
|
209
230
|
return card;
|
|
210
231
|
}
|
|
232
|
+
/** Fill an EXISTING text-block node with a coalesced message's text + offsets. The block is ONE growing
|
|
233
|
+
* node per logical message — a delta patches this node's `textContent` in place (never a new card per
|
|
234
|
+
* fragment), so a split word reconstructs into exactly that word. */
|
|
235
|
+
function applyText(node, block) {
|
|
236
|
+
node.setAttribute("data-role", block.role);
|
|
237
|
+
node.setAttribute("data-offset", String(block.startOffset));
|
|
238
|
+
node.setAttribute("data-end-offset", String(block.endOffset));
|
|
239
|
+
node.setAttribute("data-block-id", block.id);
|
|
240
|
+
if (block.messageId !== undefined)
|
|
241
|
+
node.setAttribute("data-message-id", block.messageId);
|
|
242
|
+
node.setAttribute("data-complete", String(block.complete));
|
|
243
|
+
node.textContent = block.text;
|
|
244
|
+
}
|
|
245
|
+
/** Render one coalesced-message text block (a single growing node). */
|
|
246
|
+
function renderText(doc, block) {
|
|
247
|
+
const node = el(doc, "div", "cockpit-transcript-message");
|
|
248
|
+
applyText(node, block);
|
|
249
|
+
return node;
|
|
250
|
+
}
|
|
251
|
+
/** Fill an EXISTING retention-gap node. A gap is a first-class visible break so a reattach that dropped
|
|
252
|
+
* chunks never implies the surrounding text is continuous; its `beforeOffset` is anchored once the first
|
|
253
|
+
* post-gap block opens. */
|
|
254
|
+
function applyGap(node, block) {
|
|
255
|
+
node.setAttribute("data-gap", "true");
|
|
256
|
+
node.setAttribute("data-block-id", block.id);
|
|
257
|
+
if (block.beforeOffset !== undefined)
|
|
258
|
+
node.setAttribute("data-before-offset", String(block.beforeOffset));
|
|
259
|
+
node.textContent = "⋯ retained-data gap — earlier output was evicted ⋯";
|
|
260
|
+
}
|
|
261
|
+
/** Render one retention-gap block. */
|
|
262
|
+
function renderGap(doc, block) {
|
|
263
|
+
const node = el(doc, "div", "cockpit-transcript-gap");
|
|
264
|
+
applyGap(node, block);
|
|
265
|
+
return node;
|
|
266
|
+
}
|
|
267
|
+
/** Build a fresh DOM node for any display block kind. */
|
|
268
|
+
function renderBlock(doc, block, options) {
|
|
269
|
+
switch (block.kind) {
|
|
270
|
+
case "text":
|
|
271
|
+
return renderText(doc, block);
|
|
272
|
+
case "tool":
|
|
273
|
+
return renderTool(doc, block.tool);
|
|
274
|
+
case "permission":
|
|
275
|
+
return renderPermission(doc, block.permission, options);
|
|
276
|
+
case "gap":
|
|
277
|
+
return renderGap(doc, block);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Patch an EXISTING block node in place (bounded to that one block — no sibling node is touched). */
|
|
281
|
+
function patchBlock(node, doc, block, options) {
|
|
282
|
+
switch (block.kind) {
|
|
283
|
+
case "text":
|
|
284
|
+
applyText(node, block);
|
|
285
|
+
return;
|
|
286
|
+
case "tool":
|
|
287
|
+
applyTool(node, doc, block.tool);
|
|
288
|
+
return;
|
|
289
|
+
case "permission":
|
|
290
|
+
applyPermission(node, doc, block.permission, options);
|
|
291
|
+
return;
|
|
292
|
+
case "gap":
|
|
293
|
+
applyGap(node, block);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
211
297
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* alongside the structure. Idempotent — call again on each refresh. Everything it shows is a derivation
|
|
216
|
-
* of the one event log. `options.onPermissionResolve`, when provided, is invoked by a pending
|
|
217
|
-
* escalate-permission prompt's Allow/Deny buttons.
|
|
298
|
+
* Build the incremental renderer's stable DOM scaffold under `host` and return the mutable render state.
|
|
299
|
+
* Shared by {@link createIncrementalTranscript} (live) and {@link renderDerivedTranscript} (batch) so
|
|
300
|
+
* historical replay renders IDENTICALLY to the final live rendering.
|
|
218
301
|
*/
|
|
219
|
-
export function
|
|
220
|
-
const view = deriveTranscript(data);
|
|
302
|
+
export function createIncrementalTranscript(host, doc, stream, options = {}) {
|
|
221
303
|
host.replaceChildren();
|
|
304
|
+
const projection = createDisplayProjection();
|
|
305
|
+
const nodes = new Map();
|
|
306
|
+
const tallies = { text: 0, tool: 0, permission: 0, gap: 0, turns: 0, rawBytes: 0, rawChunks: 0, lifecycle: "open" };
|
|
307
|
+
// A turn opens implicitly before the first structured block even without an explicit `turn` event
|
|
308
|
+
// (mirrors deriveView's implicit turn 0), so any structured content means at least one turn.
|
|
309
|
+
let structured = false;
|
|
222
310
|
const root = el(doc, "div", "cockpit-transcript-derived");
|
|
223
|
-
root.setAttribute("data-stream",
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
root.
|
|
228
|
-
root.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
311
|
+
root.setAttribute("data-stream", stream);
|
|
312
|
+
const blocksHost = el(doc, "div", "cockpit-transcript-blocks");
|
|
313
|
+
const empty = el(doc, "div", "cockpit-transcript-empty");
|
|
314
|
+
const footer = el(doc, "footer", "cockpit-transcript-raw");
|
|
315
|
+
root.appendChild(blocksHost);
|
|
316
|
+
root.appendChild(empty);
|
|
317
|
+
root.appendChild(footer);
|
|
318
|
+
host.appendChild(root);
|
|
319
|
+
function refreshSummary() {
|
|
320
|
+
const turnCount = tallies.turns > 0 ? tallies.turns : structured ? 1 : 0;
|
|
321
|
+
root.setAttribute("data-lifecycle", tallies.lifecycle);
|
|
322
|
+
root.setAttribute("data-turn-count", String(turnCount));
|
|
323
|
+
root.setAttribute("data-message-count", String(tallies.text));
|
|
324
|
+
root.setAttribute("data-tool-count", String(tallies.tool));
|
|
325
|
+
root.setAttribute("data-permission-count", String(tallies.permission));
|
|
326
|
+
root.setAttribute("data-gap-count", String(tallies.gap));
|
|
327
|
+
root.setAttribute("data-block-count", String(nodes.size));
|
|
328
|
+
const hasBlocks = tallies.text + tallies.tool + tallies.permission > 0;
|
|
329
|
+
// Toggle (never remove — ElementLike has no removeChild) so a live first block clears the empty note
|
|
330
|
+
// without rebuilding, and an all-raw page still shows exactly one data-empty="true" element.
|
|
331
|
+
empty.setAttribute("data-empty", String(!hasBlocks));
|
|
332
|
+
empty.textContent = hasBlocks ? "" : "No structured events derived — raw replay only.";
|
|
333
|
+
footer.setAttribute("data-raw-bytes", String(tallies.rawBytes));
|
|
334
|
+
footer.setAttribute("data-raw-chunks", String(tallies.rawChunks));
|
|
335
|
+
footer.textContent = `${tallies.rawChunks} raw chunk(s) · ${tallies.rawBytes} B retained for replay`;
|
|
336
|
+
}
|
|
337
|
+
function countAppended(block) {
|
|
338
|
+
structured = structured || block.kind !== "gap";
|
|
339
|
+
if (block.kind === "text")
|
|
340
|
+
tallies.text++;
|
|
341
|
+
else if (block.kind === "tool")
|
|
342
|
+
tallies.tool++;
|
|
343
|
+
else if (block.kind === "permission")
|
|
344
|
+
tallies.permission++;
|
|
345
|
+
else
|
|
346
|
+
tallies.gap++;
|
|
233
347
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
348
|
+
/** Reconcile ONE projection apply-result into the DOM: append a new node, patch an existing one, and/or
|
|
349
|
+
* patch a secondary now-anchored gap. Bounded to the touched block(s) — no unaffected node is replaced. */
|
|
350
|
+
function reconcile(changed, appended, anchored) {
|
|
351
|
+
if (changed !== undefined) {
|
|
352
|
+
if (appended) {
|
|
353
|
+
const node = renderBlock(doc, changed, options);
|
|
354
|
+
nodes.set(changed.id, node);
|
|
355
|
+
blocksHost.appendChild(node);
|
|
356
|
+
countAppended(changed);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
const node = nodes.get(changed.id);
|
|
360
|
+
if (node !== undefined)
|
|
361
|
+
patchBlock(node, doc, changed, options);
|
|
362
|
+
}
|
|
244
363
|
}
|
|
245
|
-
|
|
246
|
-
|
|
364
|
+
if (anchored !== undefined) {
|
|
365
|
+
const node = nodes.get(anchored.id);
|
|
366
|
+
if (node !== undefined)
|
|
367
|
+
patchBlock(node, doc, anchored, options);
|
|
247
368
|
}
|
|
248
|
-
|
|
249
|
-
|
|
369
|
+
}
|
|
370
|
+
function applyEvent(event) {
|
|
371
|
+
// Raw bytes feed the byte-replay footer but produce no display block (the projection ignores them).
|
|
372
|
+
if (event.kind === "stream-chunk") {
|
|
373
|
+
tallies.rawChunks++;
|
|
374
|
+
tallies.rawBytes += utf8ByteLength(event.chunk);
|
|
375
|
+
}
|
|
376
|
+
else if (event.kind === "turn") {
|
|
377
|
+
tallies.turns++;
|
|
378
|
+
}
|
|
379
|
+
else if (event.kind === "lifecycle") {
|
|
380
|
+
tallies.lifecycle = event.phase;
|
|
250
381
|
}
|
|
251
|
-
|
|
382
|
+
const result = projection.apply(event);
|
|
383
|
+
reconcile(result.changed, result.appended, result.anchored);
|
|
252
384
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
385
|
+
refreshSummary();
|
|
386
|
+
return {
|
|
387
|
+
root,
|
|
388
|
+
applyChunk(chunk) {
|
|
389
|
+
applyEvent(parseTranscriptEvent(chunk));
|
|
390
|
+
refreshSummary();
|
|
391
|
+
},
|
|
392
|
+
noteGap() {
|
|
393
|
+
const result = projection.noteGap();
|
|
394
|
+
reconcile(result.changed, result.appended, result.anchored);
|
|
395
|
+
refreshSummary();
|
|
396
|
+
},
|
|
397
|
+
blocks() {
|
|
398
|
+
return projection.blocks();
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Render the DERIVED, ORDERED display of a fetched transcript page into `host`, replacing whatever was
|
|
404
|
+
* there — the pure BATCH convenience over {@link createIncrementalTranscript}. Draws one growing text
|
|
405
|
+
* block per logical message, with tool/diff cards and permission prompts interleaved in chronological
|
|
406
|
+
* (offset) order, plus a raw-fidelity footer (retained bytes/chunks) so the byte-replay stays visibly
|
|
407
|
+
* preserved. A retention `gap` on the page (`data.gap`) renders a leading visible break. Idempotent —
|
|
408
|
+
* call again on each refresh. Everything it shows is a derivation of the one event log, and it renders
|
|
409
|
+
* IDENTICALLY to the final live rendering (same incremental fold). `options.onPermissionResolve`, when
|
|
410
|
+
* provided, is invoked by a pending escalate-permission prompt's Allow/Deny buttons.
|
|
411
|
+
*/
|
|
412
|
+
export function renderDerivedTranscript(host, doc, data, options = {}) {
|
|
413
|
+
const incremental = createIncrementalTranscript(host, doc, data.stream, options);
|
|
414
|
+
// A page-level retention gap precedes the page's first chunk: note it before folding so a leading
|
|
415
|
+
// visible break renders (a reattach that dropped chunks never implies false continuity).
|
|
416
|
+
if (data.gap)
|
|
417
|
+
incremental.noteGap();
|
|
418
|
+
// The batch path folds the SAME chunks the live path does, in offset order, through the ONE projection,
|
|
419
|
+
// so historical and live rendering are byte-for-byte the same tree.
|
|
420
|
+
for (const chunk of [...data.entries].sort((a, b) => a.offset - b.offset))
|
|
421
|
+
incremental.applyChunk(chunk);
|
|
422
|
+
return { root: incremental.root };
|
|
260
423
|
}
|