@nanobpm/nano-workforce 0.185.0 → 0.186.0
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/readiness.test.ts +51 -0
- package/app/readiness.ts +55 -12
- 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/scripts/build-cockpit-browser.ts +11 -1
|
@@ -1,30 +1,53 @@
|
|
|
1
|
-
// The cockpit STRUCTURED transcript view — derived from the one
|
|
1
|
+
// The cockpit STRUCTURED transcript view — derived from the one ORDERED DISPLAY projection (#566, #757).
|
|
2
2
|
//
|
|
3
3
|
// Beside the byte-level replay (`transcript-render.ts` feeds stored chunks through the live terminal
|
|
4
|
-
// renderer for pixel-faithful playback), the cockpit
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
4
|
+
// renderer for pixel-faithful playback), the cockpit shows a STRUCTURED view of a captured session. That
|
|
5
|
+
// view is a DERIVATION of the one typed event log — it re-parses nothing. It folds the stored chunks
|
|
6
|
+
// through the single {@link parseTranscriptEvent} parser and the canonical ORDERED DISPLAY projection
|
|
7
|
+
// ({@link createDisplayProjection}, agentic #566): the projection coalesces transport-fragmented message
|
|
8
|
+
// deltas ("I", "not", "ice I am act", …) back into ONE growing block per logical message, and interleaves
|
|
9
|
+
// text blocks, tool cards and permission prompts in strict chronological (offset) order. So a split word
|
|
10
|
+
// reconstructs into exactly that word in one coherent block — never one bordered card per delta — and a
|
|
11
|
+
// tool call issued mid-message renders between the text before and after it (issue #757). The raw log,
|
|
12
|
+
// its offsets and byte-faithful replay are untouched; the drift-guard test enforces this module never
|
|
13
|
+
// parses chunks itself.
|
|
11
14
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
+
// INCREMENTAL, not rebuild-on-every-chunk. {@link createIncrementalTranscript} keeps the display
|
|
16
|
+
// projection and a `blockId → DOM node` map as mutable state, so a live delta updates the ONE active
|
|
17
|
+
// block's node in place (append a new node, or patch an existing one) instead of rebuilding the whole
|
|
18
|
+
// transcript tree. That is what lets the browser adapter (`pages/cockpit/mount.js`) preserve the
|
|
19
|
+
// operator's selection, expansion and scroll position and auto-follow only at the tail. {@link
|
|
20
|
+
// renderDerivedTranscript} is the pure batch convenience over the same fold — historical replay renders
|
|
21
|
+
// IDENTICALLY to the final live rendering because both drive the one incremental renderer.
|
|
22
|
+
//
|
|
23
|
+
// Framework-free and DOM-agnostic, like the sibling cockpit views: it draws into the injected {@link
|
|
24
|
+
// DocumentLike} subset so a real DOM satisfies it at runtime and an in-memory fake satisfies it for
|
|
25
|
+
// DOM-free Node tests.
|
|
15
26
|
import type { DocumentLike, ElementLike } from "@nanobpm/agentic/cockpit";
|
|
27
|
+
import { createDisplayProjection } from "../transcript-display.ts";
|
|
16
28
|
import {
|
|
17
29
|
type DerivedPermission,
|
|
18
30
|
type DerivedTool,
|
|
19
31
|
type DerivedView,
|
|
32
|
+
type DisplayBlock,
|
|
33
|
+
type DisplayGapBlock,
|
|
34
|
+
type DisplayProjection,
|
|
35
|
+
type DisplayTextBlock,
|
|
20
36
|
deriveViewFromChunks,
|
|
21
37
|
optionKindAllows,
|
|
38
|
+
parseTranscriptEvent,
|
|
39
|
+
type StoredChunk,
|
|
40
|
+
type TranscriptEvent,
|
|
41
|
+
utf8ByteLength,
|
|
22
42
|
} from "../transcript-events.ts";
|
|
23
43
|
import type { TranscriptDataReport } from "./transcript-render.ts";
|
|
24
44
|
|
|
25
45
|
/**
|
|
26
|
-
* Derive the structured view of a fetched transcript page
|
|
27
|
-
*
|
|
46
|
+
* Derive the structured (event-fold) view of a fetched transcript page — the flat message/tool/permission
|
|
47
|
+
* history, per-turn structure and raw-byte accounting. This is the {@link DerivedView} fold, kept for the
|
|
48
|
+
* raw-fidelity footer and summary counts; the ordered, human-facing block SEQUENCE is the separate
|
|
49
|
+
* display projection {@link renderDerivedTranscript} draws. Pure: the cockpit reads THESE instead of
|
|
50
|
+
* re-parsing raw frame bytes.
|
|
28
51
|
*/
|
|
29
52
|
export function deriveTranscript(data: TranscriptDataReport): DerivedView {
|
|
30
53
|
return deriveViewFromChunks(data.entries);
|
|
@@ -156,9 +179,11 @@ function detectDiff(tool: DerivedTool): DetectedDiff | undefined {
|
|
|
156
179
|
return undefined;
|
|
157
180
|
}
|
|
158
181
|
|
|
159
|
-
/**
|
|
160
|
-
|
|
161
|
-
|
|
182
|
+
/** Fill an EXISTING tool card node with one tool's content (name, status, args/result, diff block). Clears
|
|
183
|
+
* the node first so it is safe to re-invoke in place when the tool's result later arrives (bounded to
|
|
184
|
+
* this one card — no sibling block is touched). */
|
|
185
|
+
function applyTool(card: ElementLike, doc: DocumentLike, tool: DerivedTool): void {
|
|
186
|
+
card.replaceChildren();
|
|
162
187
|
card.setAttribute("data-tool", tool.name);
|
|
163
188
|
card.setAttribute("data-offset", String(tool.offset));
|
|
164
189
|
card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
|
|
@@ -195,6 +220,12 @@ function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
|
|
|
195
220
|
resEl.setAttribute("data-tool-result", "true");
|
|
196
221
|
card.appendChild(resEl);
|
|
197
222
|
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
|
|
226
|
+
function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
|
|
227
|
+
const card = el(doc, "div", "cockpit-transcript-tool");
|
|
228
|
+
applyTool(card, doc, tool);
|
|
198
229
|
return card;
|
|
199
230
|
}
|
|
200
231
|
|
|
@@ -204,8 +235,8 @@ function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
|
|
|
204
235
|
* - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
|
|
205
236
|
* - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
|
|
206
237
|
*/
|
|
207
|
-
function
|
|
208
|
-
|
|
238
|
+
function applyPermission(card: ElementLike, doc: DocumentLike, perm: DerivedPermission, options: RenderDerivedTranscriptOptions): void {
|
|
239
|
+
card.replaceChildren();
|
|
209
240
|
card.setAttribute("data-permission", "request");
|
|
210
241
|
card.setAttribute("data-policy", perm.policy);
|
|
211
242
|
card.setAttribute("data-call-id", perm.callId);
|
|
@@ -222,14 +253,14 @@ function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: R
|
|
|
222
253
|
settled.setAttribute("data-chosen-option", perm.resolved.optionId);
|
|
223
254
|
if (perm.resolved.by !== undefined) settled.setAttribute("data-by", perm.resolved.by);
|
|
224
255
|
card.appendChild(settled);
|
|
225
|
-
return
|
|
256
|
+
return;
|
|
226
257
|
}
|
|
227
258
|
|
|
228
259
|
if (perm.policy === "yolo") {
|
|
229
260
|
// Informational: yolo auto-allows and never prompts a human, so no Allow/Deny buttons.
|
|
230
261
|
card.setAttribute("data-status", "auto");
|
|
231
262
|
card.appendChild(el(doc, "div", "cockpit-transcript-permission-note", "Auto-allowed (yolo) — no operator prompt."));
|
|
232
|
-
return
|
|
263
|
+
return;
|
|
233
264
|
}
|
|
234
265
|
|
|
235
266
|
// Pending escalate: one interactive button per offered option, wired to the resolve seam.
|
|
@@ -249,65 +280,268 @@ function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: R
|
|
|
249
280
|
actions.appendChild(button);
|
|
250
281
|
}
|
|
251
282
|
card.appendChild(actions);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Render one permission prompt card from a {@link DerivedPermission}:
|
|
287
|
+
* - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
|
|
288
|
+
* - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
|
|
289
|
+
* - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
|
|
290
|
+
*/
|
|
291
|
+
function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: RenderDerivedTranscriptOptions): ElementLike {
|
|
292
|
+
const card = el(doc, "div", "cockpit-transcript-permission");
|
|
293
|
+
applyPermission(card, doc, perm, options);
|
|
252
294
|
return card;
|
|
253
295
|
}
|
|
254
296
|
|
|
297
|
+
/** Fill an EXISTING text-block node with a coalesced message's text + offsets. The block is ONE growing
|
|
298
|
+
* node per logical message — a delta patches this node's `textContent` in place (never a new card per
|
|
299
|
+
* fragment), so a split word reconstructs into exactly that word. */
|
|
300
|
+
function applyText(node: ElementLike, block: DisplayTextBlock): void {
|
|
301
|
+
node.setAttribute("data-role", block.role);
|
|
302
|
+
node.setAttribute("data-offset", String(block.startOffset));
|
|
303
|
+
node.setAttribute("data-end-offset", String(block.endOffset));
|
|
304
|
+
node.setAttribute("data-block-id", block.id);
|
|
305
|
+
if (block.messageId !== undefined) node.setAttribute("data-message-id", block.messageId);
|
|
306
|
+
node.setAttribute("data-complete", String(block.complete));
|
|
307
|
+
node.textContent = block.text;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Render one coalesced-message text block (a single growing node). */
|
|
311
|
+
function renderText(doc: DocumentLike, block: DisplayTextBlock): ElementLike {
|
|
312
|
+
const node = el(doc, "div", "cockpit-transcript-message");
|
|
313
|
+
applyText(node, block);
|
|
314
|
+
return node;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Fill an EXISTING retention-gap node. A gap is a first-class visible break so a reattach that dropped
|
|
318
|
+
* chunks never implies the surrounding text is continuous; its `beforeOffset` is anchored once the first
|
|
319
|
+
* post-gap block opens. */
|
|
320
|
+
function applyGap(node: ElementLike, block: DisplayGapBlock): void {
|
|
321
|
+
node.setAttribute("data-gap", "true");
|
|
322
|
+
node.setAttribute("data-block-id", block.id);
|
|
323
|
+
if (block.beforeOffset !== undefined) node.setAttribute("data-before-offset", String(block.beforeOffset));
|
|
324
|
+
node.textContent = "⋯ retained-data gap — earlier output was evicted ⋯";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Render one retention-gap block. */
|
|
328
|
+
function renderGap(doc: DocumentLike, block: DisplayGapBlock): ElementLike {
|
|
329
|
+
const node = el(doc, "div", "cockpit-transcript-gap");
|
|
330
|
+
applyGap(node, block);
|
|
331
|
+
return node;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Build a fresh DOM node for any display block kind. */
|
|
335
|
+
function renderBlock(doc: DocumentLike, block: DisplayBlock, options: RenderDerivedTranscriptOptions): ElementLike {
|
|
336
|
+
switch (block.kind) {
|
|
337
|
+
case "text":
|
|
338
|
+
return renderText(doc, block);
|
|
339
|
+
case "tool":
|
|
340
|
+
return renderTool(doc, block.tool);
|
|
341
|
+
case "permission":
|
|
342
|
+
return renderPermission(doc, block.permission, options);
|
|
343
|
+
case "gap":
|
|
344
|
+
return renderGap(doc, block);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Patch an EXISTING block node in place (bounded to that one block — no sibling node is touched). */
|
|
349
|
+
function patchBlock(node: ElementLike, doc: DocumentLike, block: DisplayBlock, options: RenderDerivedTranscriptOptions): void {
|
|
350
|
+
switch (block.kind) {
|
|
351
|
+
case "text":
|
|
352
|
+
applyText(node, block);
|
|
353
|
+
return;
|
|
354
|
+
case "tool":
|
|
355
|
+
applyTool(node, doc, block.tool);
|
|
356
|
+
return;
|
|
357
|
+
case "permission":
|
|
358
|
+
applyPermission(node, doc, block.permission, options);
|
|
359
|
+
return;
|
|
360
|
+
case "gap":
|
|
361
|
+
applyGap(node, block);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
255
366
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
367
|
+
* A STATEFUL, incremental transcript renderer over a `host` element. It maintains the ordered
|
|
368
|
+
* {@link DisplayBlock} sequence (via the canonical {@link createDisplayProjection}) and a `blockId → DOM
|
|
369
|
+
* node` map, so feeding it one live chunk at a time updates just the ONE touched block's node in place —
|
|
370
|
+
* append a brand-new block node, or patch an existing block's node (a growing text delta, a tool result
|
|
371
|
+
* pairing, a permission resolution, or a now-anchored gap) — instead of rebuilding the whole transcript
|
|
372
|
+
* tree. That bounded update is what lets the browser adapter preserve selection, expansion and scroll.
|
|
373
|
+
*
|
|
374
|
+
* DOM shape (stable across live growth so unaffected nodes are never replaced):
|
|
375
|
+
* div.cockpit-transcript-derived[data-*]
|
|
376
|
+
* div.cockpit-transcript-blocks ← ordered block nodes are appended here / patched in place
|
|
377
|
+
* div.cockpit-transcript-empty ← shown (data-empty="true") only while there are zero blocks
|
|
378
|
+
* footer.cockpit-transcript-raw ← retained raw bytes/chunks (byte-replay is preserved alongside)
|
|
262
379
|
*/
|
|
263
|
-
export
|
|
380
|
+
export interface IncrementalTranscript {
|
|
381
|
+
/** The rendered root (a `cockpit-transcript-derived` element) appended under the host. */
|
|
382
|
+
readonly root: ElementLike;
|
|
383
|
+
/**
|
|
384
|
+
* Fold ONE stored chunk (by offset) into the display and update the DOM minimally. Idempotent on
|
|
385
|
+
* offset — re-feeding an already-applied offset (reconnect, pagination overlap, a duplicated chunk) is
|
|
386
|
+
* a no-op, so replayed text never doubles. Feed chunks in offset order (the projection drops a late
|
|
387
|
+
* lower offset rather than merging it out of place).
|
|
388
|
+
*/
|
|
389
|
+
applyChunk(chunk: StoredChunk): void;
|
|
390
|
+
/**
|
|
391
|
+
* Record a retention gap at the current tail BEFORE feeding the post-gap chunks: the consumer resumed
|
|
392
|
+
* from an offset older than the oldest retained chunk, so what follows is NOT continuous with what
|
|
393
|
+
* precedes. Renders a visible break; its `beforeOffset` is anchored when the next block opens.
|
|
394
|
+
*/
|
|
395
|
+
noteGap(): void;
|
|
396
|
+
/** A snapshot of the ordered display blocks as they stand now (for tests/inspection). */
|
|
397
|
+
blocks(): readonly DisplayBlock[];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Running summary tallies for the root attributes + raw footer, maintained WITHOUT re-folding. */
|
|
401
|
+
interface Tallies {
|
|
402
|
+
text: number;
|
|
403
|
+
tool: number;
|
|
404
|
+
permission: number;
|
|
405
|
+
gap: number;
|
|
406
|
+
turns: number;
|
|
407
|
+
rawBytes: number;
|
|
408
|
+
rawChunks: number;
|
|
409
|
+
lifecycle: "open" | "completed" | "exited";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Build the incremental renderer's stable DOM scaffold under `host` and return the mutable render state.
|
|
414
|
+
* Shared by {@link createIncrementalTranscript} (live) and {@link renderDerivedTranscript} (batch) so
|
|
415
|
+
* historical replay renders IDENTICALLY to the final live rendering.
|
|
416
|
+
*/
|
|
417
|
+
export function createIncrementalTranscript(
|
|
264
418
|
host: ElementLike,
|
|
265
419
|
doc: DocumentLike,
|
|
266
|
-
|
|
420
|
+
stream: string,
|
|
267
421
|
options: RenderDerivedTranscriptOptions = {},
|
|
268
|
-
):
|
|
269
|
-
const view = deriveTranscript(data);
|
|
422
|
+
): IncrementalTranscript {
|
|
270
423
|
host.replaceChildren();
|
|
424
|
+
const projection: DisplayProjection = createDisplayProjection();
|
|
425
|
+
const nodes = new Map<string, ElementLike>();
|
|
426
|
+
const tallies: Tallies = { text: 0, tool: 0, permission: 0, gap: 0, turns: 0, rawBytes: 0, rawChunks: 0, lifecycle: "open" };
|
|
427
|
+
// A turn opens implicitly before the first structured block even without an explicit `turn` event
|
|
428
|
+
// (mirrors deriveView's implicit turn 0), so any structured content means at least one turn.
|
|
429
|
+
let structured = false;
|
|
430
|
+
|
|
271
431
|
const root = el(doc, "div", "cockpit-transcript-derived");
|
|
272
|
-
root.setAttribute("data-stream",
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
root.
|
|
277
|
-
root.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
432
|
+
root.setAttribute("data-stream", stream);
|
|
433
|
+
const blocksHost = el(doc, "div", "cockpit-transcript-blocks");
|
|
434
|
+
const empty = el(doc, "div", "cockpit-transcript-empty");
|
|
435
|
+
const footer = el(doc, "footer", "cockpit-transcript-raw");
|
|
436
|
+
root.appendChild(blocksHost);
|
|
437
|
+
root.appendChild(empty);
|
|
438
|
+
root.appendChild(footer);
|
|
439
|
+
host.appendChild(root);
|
|
440
|
+
|
|
441
|
+
function refreshSummary(): void {
|
|
442
|
+
const turnCount = tallies.turns > 0 ? tallies.turns : structured ? 1 : 0;
|
|
443
|
+
root.setAttribute("data-lifecycle", tallies.lifecycle);
|
|
444
|
+
root.setAttribute("data-turn-count", String(turnCount));
|
|
445
|
+
root.setAttribute("data-message-count", String(tallies.text));
|
|
446
|
+
root.setAttribute("data-tool-count", String(tallies.tool));
|
|
447
|
+
root.setAttribute("data-permission-count", String(tallies.permission));
|
|
448
|
+
root.setAttribute("data-gap-count", String(tallies.gap));
|
|
449
|
+
root.setAttribute("data-block-count", String(nodes.size));
|
|
450
|
+
|
|
451
|
+
const hasBlocks = tallies.text + tallies.tool + tallies.permission > 0;
|
|
452
|
+
// Toggle (never remove — ElementLike has no removeChild) so a live first block clears the empty note
|
|
453
|
+
// without rebuilding, and an all-raw page still shows exactly one data-empty="true" element.
|
|
454
|
+
empty.setAttribute("data-empty", String(!hasBlocks));
|
|
455
|
+
empty.textContent = hasBlocks ? "" : "No structured events derived — raw replay only.";
|
|
456
|
+
|
|
457
|
+
footer.setAttribute("data-raw-bytes", String(tallies.rawBytes));
|
|
458
|
+
footer.setAttribute("data-raw-chunks", String(tallies.rawChunks));
|
|
459
|
+
footer.textContent = `${tallies.rawChunks} raw chunk(s) · ${tallies.rawBytes} B retained for replay`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function countAppended(block: DisplayBlock): void {
|
|
463
|
+
structured = structured || block.kind !== "gap";
|
|
464
|
+
if (block.kind === "text") tallies.text++;
|
|
465
|
+
else if (block.kind === "tool") tallies.tool++;
|
|
466
|
+
else if (block.kind === "permission") tallies.permission++;
|
|
467
|
+
else tallies.gap++;
|
|
283
468
|
}
|
|
284
469
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
470
|
+
/** Reconcile ONE projection apply-result into the DOM: append a new node, patch an existing one, and/or
|
|
471
|
+
* patch a secondary now-anchored gap. Bounded to the touched block(s) — no unaffected node is replaced. */
|
|
472
|
+
function reconcile(changed: DisplayBlock | undefined, appended: boolean, anchored: DisplayBlock | undefined): void {
|
|
473
|
+
if (changed !== undefined) {
|
|
474
|
+
if (appended) {
|
|
475
|
+
const node = renderBlock(doc, changed, options);
|
|
476
|
+
nodes.set(changed.id, node);
|
|
477
|
+
blocksHost.appendChild(node);
|
|
478
|
+
countAppended(changed);
|
|
479
|
+
} else {
|
|
480
|
+
const node = nodes.get(changed.id);
|
|
481
|
+
if (node !== undefined) patchBlock(node, doc, changed, options);
|
|
482
|
+
}
|
|
295
483
|
}
|
|
296
|
-
|
|
297
|
-
|
|
484
|
+
if (anchored !== undefined) {
|
|
485
|
+
const node = nodes.get(anchored.id);
|
|
486
|
+
if (node !== undefined) patchBlock(node, doc, anchored, options);
|
|
298
487
|
}
|
|
299
|
-
|
|
300
|
-
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function applyEvent(event: TranscriptEvent): void {
|
|
491
|
+
// Raw bytes feed the byte-replay footer but produce no display block (the projection ignores them).
|
|
492
|
+
if (event.kind === "stream-chunk") {
|
|
493
|
+
tallies.rawChunks++;
|
|
494
|
+
tallies.rawBytes += utf8ByteLength(event.chunk);
|
|
495
|
+
} else if (event.kind === "turn") {
|
|
496
|
+
tallies.turns++;
|
|
497
|
+
} else if (event.kind === "lifecycle") {
|
|
498
|
+
tallies.lifecycle = event.phase;
|
|
301
499
|
}
|
|
302
|
-
|
|
500
|
+
const result = projection.apply(event);
|
|
501
|
+
reconcile(result.changed, result.appended, result.anchored);
|
|
303
502
|
}
|
|
304
503
|
|
|
305
|
-
|
|
306
|
-
footer.setAttribute("data-raw-bytes", String(view.rawByteLength));
|
|
307
|
-
footer.setAttribute("data-raw-chunks", String(view.rawChunkCount));
|
|
308
|
-
footer.textContent = `${view.rawChunkCount} raw chunk(s) · ${view.rawByteLength} B retained for replay`;
|
|
309
|
-
root.appendChild(footer);
|
|
504
|
+
refreshSummary();
|
|
310
505
|
|
|
311
|
-
|
|
312
|
-
|
|
506
|
+
return {
|
|
507
|
+
root,
|
|
508
|
+
applyChunk(chunk: StoredChunk): void {
|
|
509
|
+
applyEvent(parseTranscriptEvent(chunk));
|
|
510
|
+
refreshSummary();
|
|
511
|
+
},
|
|
512
|
+
noteGap(): void {
|
|
513
|
+
const result = projection.noteGap();
|
|
514
|
+
reconcile(result.changed, result.appended, result.anchored);
|
|
515
|
+
refreshSummary();
|
|
516
|
+
},
|
|
517
|
+
blocks(): readonly DisplayBlock[] {
|
|
518
|
+
return projection.blocks();
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Render the DERIVED, ORDERED display of a fetched transcript page into `host`, replacing whatever was
|
|
525
|
+
* there — the pure BATCH convenience over {@link createIncrementalTranscript}. Draws one growing text
|
|
526
|
+
* block per logical message, with tool/diff cards and permission prompts interleaved in chronological
|
|
527
|
+
* (offset) order, plus a raw-fidelity footer (retained bytes/chunks) so the byte-replay stays visibly
|
|
528
|
+
* preserved. A retention `gap` on the page (`data.gap`) renders a leading visible break. Idempotent —
|
|
529
|
+
* call again on each refresh. Everything it shows is a derivation of the one event log, and it renders
|
|
530
|
+
* IDENTICALLY to the final live rendering (same incremental fold). `options.onPermissionResolve`, when
|
|
531
|
+
* provided, is invoked by a pending escalate-permission prompt's Allow/Deny buttons.
|
|
532
|
+
*/
|
|
533
|
+
export function renderDerivedTranscript(
|
|
534
|
+
host: ElementLike,
|
|
535
|
+
doc: DocumentLike,
|
|
536
|
+
data: TranscriptDataReport,
|
|
537
|
+
options: RenderDerivedTranscriptOptions = {},
|
|
538
|
+
): DerivedTranscriptDom {
|
|
539
|
+
const incremental = createIncrementalTranscript(host, doc, data.stream, options);
|
|
540
|
+
// A page-level retention gap precedes the page's first chunk: note it before folding so a leading
|
|
541
|
+
// visible break renders (a reattach that dropped chunks never implies false continuity).
|
|
542
|
+
if (data.gap) incremental.noteGap();
|
|
543
|
+
// The batch path folds the SAME chunks the live path does, in offset order, through the ONE projection,
|
|
544
|
+
// so historical and live rendering are byte-for-byte the same tree.
|
|
545
|
+
for (const chunk of [...data.entries].sort((a, b) => a.offset - b.offset)) incremental.applyChunk(chunk);
|
|
546
|
+
return { root: incremental.root };
|
|
313
547
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// nano-workforce — the ordered DISPLAY projection, DERIVED from its one owner (agentic #566).
|
|
2
|
+
//
|
|
3
|
+
// Sibling of `transcript-events.ts`: where that barrel re-exports the transcript EVENT grammar (the one
|
|
4
|
+
// parser + fold), THIS barrel re-exports the canonical ordered DISPLAY derivation — the projection that
|
|
5
|
+
// coalesces transport-fragmented message deltas back into logical blocks and interleaves them
|
|
6
|
+
// chronologically with tool cards and permission prompts (`createDisplayProjection` / `deriveDisplay`).
|
|
7
|
+
// The cockpit renders THIS instead of the raw per-event groups, so a split word reconstructs into one
|
|
8
|
+
// block and text/tool/permission order never scrambles.
|
|
9
|
+
//
|
|
10
|
+
// Why a SEPARATE barrel rather than folding these into `transcript-events.ts`: the browser bundle
|
|
11
|
+
// (`scripts/build-cockpit-browser.ts`) emits one generated ESM sibling per agentic source module, and
|
|
12
|
+
// the display projection lives in agentic's own `dist/transcript/display.js` — a self-contained,
|
|
13
|
+
// browser-safe module distinct from `events.js`. Keeping the display import on its own specifier lets
|
|
14
|
+
// the bundle rewrite it to `./transcript-display.js` (agentic's display module) while the event import
|
|
15
|
+
// still rewrites to `./transcript-events.js`. Both remain thin re-exports of the ONE agentic source of
|
|
16
|
+
// truth — there is no local display algorithm here (Derivation Over Duplication).
|
|
17
|
+
export { createDisplayProjection, deriveDisplay } from "@nanobpm/agentic/transcript";
|
package/app/readiness.test.ts
CHANGED
|
@@ -220,6 +220,57 @@ test("matchCapability: the bare '#274' ref form resolves identically to 'nano-id
|
|
|
220
220
|
assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@1.2.3");
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
// ── #764: single-package repos tag releases `v<version>` / bare `<version>` (semantic-release default) ──
|
|
224
|
+
|
|
225
|
+
const c8Match = { capabilityRef: "c8ctl-plugin-nano#206", package: "c8ctl-plugin-nano" };
|
|
226
|
+
|
|
227
|
+
test("#764 matchCapability: a `v<version>` release (body refs the cap) resolves in a single-package repo", () => {
|
|
228
|
+
// Red before the fix: `v1.58.0` never matched versionForPackage(tag, 'c8ctl-plugin-nano'),
|
|
229
|
+
// so the gate reported 'not published yet' though the npm package WAS published.
|
|
230
|
+
const res = matchCapability(c8Match, [rel("v1.58.0", [206])]);
|
|
231
|
+
assert(res.ready);
|
|
232
|
+
assertEquals(res.bind?.resolvedArtifact, "c8ctl-plugin-nano@1.58.0");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("#764 matchCapability: a bare `<version>` release resolves the same way in a single-package repo", () => {
|
|
236
|
+
const res = matchCapability(c8Match, [rel("1.58.0", [206])]);
|
|
237
|
+
assert(res.ready);
|
|
238
|
+
assertEquals(res.bind?.resolvedArtifact, "c8ctl-plugin-nano@1.58.0");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("#764 monorepo guard: a stray `v2.0.0` alongside package-scoped tags is NOT attributed to another package", () => {
|
|
242
|
+
// The repo emits package-scoped tags → it is a monorepo → bare `v` tags are ignored, so the stray
|
|
243
|
+
// v2.0.0 can never leak into @nanobpm/other's resolution.
|
|
244
|
+
const releases = [rel("@nanobpm/other@1.0.0", [200]), rel("v2.0.0", [274])];
|
|
245
|
+
const res = matchCapability({ capabilityRef: "nano-ide#274", package: "@nanobpm/other" }, releases);
|
|
246
|
+
assert(!res.ready, "the stray v2.0.0 must not resolve #274 for @nanobpm/other");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("#764 summariseCapabilityCandidates: shows the extended `v`-tag candidate in a single-package repo", () => {
|
|
250
|
+
const summary = summariseCapabilityCandidates(c8Match, [rel("v1.58.0", [206]), rel("v1.57.0", [200])]);
|
|
251
|
+
assertStringIncludes(summary, "2 c8ctl-plugin-nano release(s) observed");
|
|
252
|
+
assertStringIncludes(summary, "c8ctl-plugin-nano@1.58.0 refs #206");
|
|
253
|
+
assertStringIncludes(summary, "c8ctl-plugin-nano@1.57.0 no #206");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("#764 summariseCapabilityCandidates: in a monorepo a stray `v` tag is not summarised as another package's candidate", () => {
|
|
257
|
+
const summary = summariseCapabilityCandidates({ capabilityRef: "nano-ide#274", package: "@nanobpm/other" }, [
|
|
258
|
+
rel("@nanobpm/other@1.0.0", [200]),
|
|
259
|
+
rel("v2.0.0", [274]),
|
|
260
|
+
]);
|
|
261
|
+
assertStringIncludes(summary, "1 @nanobpm/other release(s) observed");
|
|
262
|
+
assert(!summary.includes("2.0.0"), "the stray monorepo v2.0.0 is not a candidate for @nanobpm/other");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("#764 newestPublishedVersion: resolves from `v`-tags under the single-package condition", () => {
|
|
266
|
+
assertEquals(newestPublishedVersion("c8ctl-plugin-nano", [rel("v1.57.0", []), rel("v1.58.0", [])]), "1.58.0");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("#764 newestPublishedVersion: a stray `v` tag in a monorepo is ignored for a scoped package", () => {
|
|
270
|
+
const releases = [rel("@nanobpm/urban@0.9.0", []), rel("v9.9.9", [])];
|
|
271
|
+
assertEquals(newestPublishedVersion("@nanobpm/urban", releases), "0.9.0");
|
|
272
|
+
});
|
|
273
|
+
|
|
223
274
|
// ── #514 Defect A: the capability gate is self-diagnosing on escalation ──────────────────────────
|
|
224
275
|
|
|
225
276
|
test("#514 Defect A summariseCapabilityCandidates: lists candidate releases (newest first) and whether each references the ref", () => {
|
package/app/readiness.ts
CHANGED
|
@@ -527,18 +527,58 @@ function bodyReferences(body: string, num: string): boolean {
|
|
|
527
527
|
return new RegExp(`#${num}(?!\\d)`).test(body);
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
-
/**
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
530
|
+
/** A numeric-dotted SemVer-ish core, e.g. `1.58.0`. */
|
|
531
|
+
const VERSION_CORE = /^\d+(\.\d+)*$/;
|
|
532
|
+
|
|
533
|
+
/** Is `tag` a **package-scoped** version tag — `<anything>@<version>` (numeric-dotted), the monorepo
|
|
534
|
+
* convention (e.g. `@nanobpm/urban@0.54.0`, `c8ctl-plugin-nano@1.58.0`)? Used to decide whether a
|
|
535
|
+
* repo follows the single-package (`v1.58.0` / bare) convention: a repo is "single-package" only when
|
|
536
|
+
* it emits NO package-scoped tags at all. Anchored on the LAST `@` so scoped npm names (`@org/name@1.0.0`)
|
|
537
|
+
* are recognised. */
|
|
538
|
+
function isPackageScopedVersionTag(tag: string): boolean {
|
|
539
|
+
const at = tag.lastIndexOf("@");
|
|
540
|
+
if (at <= 0) return false; // no `@`, or a leading `@` (scoped-name start) with nothing before it
|
|
541
|
+
return VERSION_CORE.test(tag.slice(at + 1).trim());
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** True when the repo's observed release set contains NO `<anything>@<version>`-prefixed package-scoped
|
|
545
|
+
* tags at all — i.e. it follows the single-package `v<version>` / bare `<version>` convention
|
|
546
|
+
* (semantic-release default), so a bare `v1.58.0` tag may be attributed to the sole package (#764).
|
|
547
|
+
* A repo using package-scoped tags is a monorepo: bare `v` tags are ignored there, keeping
|
|
548
|
+
* per-package provenance scoping airtight (a sibling package's provenance can never leak). */
|
|
549
|
+
function repoIsSinglePackage(releases: readonly GithubRelease[]): boolean {
|
|
550
|
+
for (const rel of releases) {
|
|
551
|
+
if (rel && typeof rel.tag === "string" && isPackageScopedVersionTag(rel.tag)) return false;
|
|
552
|
+
}
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The `<version>` a release tag carries for `pkg`, or undefined when it belongs to another package /
|
|
557
|
+
* is not a version tag. A tag is a candidate for `pkg` when **either**:
|
|
558
|
+
*
|
|
559
|
+
* 1. it is `<pkg>@<version>` (numeric-dotted) — the monorepo convention, per-package scoped so a
|
|
560
|
+
* sibling package's provenance can never leak; **or**
|
|
561
|
+
* 2. `singlePackage` is true (the repo emits no package-scoped tags — see {@link repoIsSinglePackage})
|
|
562
|
+
* AND the tag is `v<version>` or a bare `<version>` — the single-package / semantic-release-default
|
|
563
|
+
* convention (#764). In a monorepo (`singlePackage` false) bare `v` tags are ignored, so scoping
|
|
564
|
+
* stays airtight. */
|
|
565
|
+
function versionForPackage(tag: string, pkg: string, singlePackage: boolean): string | undefined {
|
|
534
566
|
const prefix = `${pkg}@`;
|
|
535
|
-
if (
|
|
536
|
-
|
|
537
|
-
|
|
567
|
+
if (tag.startsWith(prefix)) {
|
|
568
|
+
const v = tag.slice(prefix.length).trim();
|
|
569
|
+
return VERSION_CORE.test(v) ? v : undefined;
|
|
570
|
+
}
|
|
571
|
+
if (singlePackage) {
|
|
572
|
+
const v = (tag.startsWith("v") ? tag.slice(1) : tag).trim();
|
|
573
|
+
if (VERSION_CORE.test(v)) return v;
|
|
574
|
+
}
|
|
575
|
+
return undefined;
|
|
538
576
|
}
|
|
539
577
|
|
|
540
578
|
/** A compact, deterministic, BOUNDED summary of the capability candidate releases a probe observed
|
|
541
|
-
* (issue #514 Defect A). Lists the releases
|
|
579
|
+
* (issue #514 Defect A). Lists the releases carrying a resolvable `<match.package>` version — tagged
|
|
580
|
+
* `<match.package>@<version>`, or (for a single-package repo) a `v<version>`/bare `<version>` tag —
|
|
581
|
+
* newest first, and,
|
|
542
582
|
* for each, whether its body referenced `match.capabilityRef` — so an escalated capability gate can
|
|
543
583
|
* show a human/agent EXACTLY what the probe saw (a genuine "not published yet" vs. a transient
|
|
544
584
|
* false-negative where a matching release was live but its provenance body was momentarily empty).
|
|
@@ -548,6 +588,7 @@ function versionForPackage(tag: string, pkg: string): string | undefined {
|
|
|
548
588
|
export function summariseCapabilityCandidates(
|
|
549
589
|
match: ProbeMatch | undefined,
|
|
550
590
|
releases: readonly GithubRelease[],
|
|
591
|
+
singlePackage: boolean = repoIsSinglePackage(releases),
|
|
551
592
|
): string {
|
|
552
593
|
const pkg = match?.package;
|
|
553
594
|
const ref = match?.capabilityRef;
|
|
@@ -556,7 +597,7 @@ export function summariseCapabilityCandidates(
|
|
|
556
597
|
const candidates: { version: string; refs: boolean }[] = [];
|
|
557
598
|
for (const rel of releases) {
|
|
558
599
|
if (!rel || typeof rel.tag !== "string" || typeof rel.body !== "string") continue;
|
|
559
|
-
const version = versionForPackage(rel.tag, pkg);
|
|
600
|
+
const version = versionForPackage(rel.tag, pkg, singlePackage);
|
|
560
601
|
if (!version) continue;
|
|
561
602
|
candidates.push({ version, refs: num !== undefined && bodyReferences(rel.body, num) });
|
|
562
603
|
}
|
|
@@ -591,7 +632,8 @@ const CAPABILITY_SUMMARY_LIMIT = 8;
|
|
|
591
632
|
export function matchCapability(match: ProbeMatch | undefined, releases: readonly GithubRelease[]): ProbeResult {
|
|
592
633
|
const pkg = match?.package;
|
|
593
634
|
const ref = match?.capabilityRef;
|
|
594
|
-
const
|
|
635
|
+
const singlePackage = repoIsSinglePackage(releases);
|
|
636
|
+
const observed = summariseCapabilityCandidates(match, releases, singlePackage);
|
|
595
637
|
if (!pkg || !ref) return { ready: false, detail: "capability: missing package/capabilityRef", observed };
|
|
596
638
|
const num = capabilityNumber(ref);
|
|
597
639
|
if (!num) return { ready: false, detail: "capability: unparseable capabilityRef (no #NNN)", observed };
|
|
@@ -599,7 +641,7 @@ export function matchCapability(match: ProbeMatch | undefined, releases: readonl
|
|
|
599
641
|
let firstVersion: string | undefined;
|
|
600
642
|
for (const rel of releases) {
|
|
601
643
|
if (!rel || typeof rel.tag !== "string" || typeof rel.body !== "string") continue;
|
|
602
|
-
const version = versionForPackage(rel.tag, pkg);
|
|
644
|
+
const version = versionForPackage(rel.tag, pkg, singlePackage);
|
|
603
645
|
if (!version) continue;
|
|
604
646
|
if (!bodyReferences(rel.body, num)) continue;
|
|
605
647
|
if (firstVersion === undefined || cmpVersion(version, firstVersion) < 0) firstVersion = version;
|
|
@@ -615,9 +657,10 @@ export function matchCapability(match: ProbeMatch | undefined, releases: readonl
|
|
|
615
657
|
export function newestPublishedVersion(pkg: string | undefined, releases: readonly GithubRelease[]): string | undefined {
|
|
616
658
|
if (!pkg) return undefined;
|
|
617
659
|
let newest: string | undefined;
|
|
660
|
+
const singlePackage = repoIsSinglePackage(releases);
|
|
618
661
|
for (const rel of releases) {
|
|
619
662
|
if (!rel || typeof rel.tag !== "string") continue;
|
|
620
|
-
const version = versionForPackage(rel.tag, pkg);
|
|
663
|
+
const version = versionForPackage(rel.tag, pkg, singlePackage);
|
|
621
664
|
if (!version) continue;
|
|
622
665
|
if (newest === undefined || cmpVersion(version, newest) > 0) newest = version;
|
|
623
666
|
}
|