@nanobpm/nano-workforce 0.167.4 → 0.168.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/agentCompletion.ts +3 -0
- package/app/agentic/cockpit/cockpit-browser-bundle.test.ts +102 -0
- package/app/agentic/cockpit/mount.test.ts +162 -0
- package/app/pollEpicPhase.test.ts +64 -0
- package/app/pollUserTasks.test.ts +76 -2
- package/app/service.ts +202 -39
- package/app/userTasks.ts +12 -0
- package/package.json +3 -1
- package/pages/cockpit/cockpit.css +118 -0
- package/pages/cockpit/embed.html +1 -3
- package/pages/cockpit/generated/transcript-derive.js +260 -0
- package/pages/cockpit/generated/transcript-events.js +488 -0
- package/pages/cockpit/mount.js +85 -39
- package/pages/cockpit/standalone.html +7 -8
- package/scripts/build-cockpit-browser.ts +110 -0
package/app/service.ts
CHANGED
|
@@ -88,6 +88,7 @@ import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
|
88
88
|
import { trialMergeAudits } from "./trialMerge.ts";
|
|
89
89
|
import {
|
|
90
90
|
buildUserTaskRow,
|
|
91
|
+
HUMAN_ESCALATION_ELEMENT,
|
|
91
92
|
latestFeatureEscalationQuestion,
|
|
92
93
|
latestOpenEscalationQuestion,
|
|
93
94
|
latestPlanReviewFindings,
|
|
@@ -2270,6 +2271,14 @@ interface UserTaskSearchItem {
|
|
|
2270
2271
|
userTaskKey?: string | number;
|
|
2271
2272
|
elementId?: string;
|
|
2272
2273
|
processInstanceKey?: string | number;
|
|
2274
|
+
/** The top-level ancestor process instance in this task's callActivity hierarchy — present for
|
|
2275
|
+
* hierarchies created on an engine carrying the read-model parent/root keys (Magikcraft/nano-bpm#977,
|
|
2276
|
+
* engine-wasm ≥ 0.8.4). Equals `processInstanceKey` for a top-level task; for a task parked inside a
|
|
2277
|
+
* callActivity CHILD instance (the shared `human-escalation`/`implement-cell` cells, ADR 0006 S4) it
|
|
2278
|
+
* is the PARENT run's instance, so the poller can correlate the child-instance task back to the
|
|
2279
|
+
* tracked feature/plan subject (issue #633). Absent (`null`) on a pre-#977 engine or a top-level
|
|
2280
|
+
* task. The wire may send a JSON number or string. */
|
|
2281
|
+
rootProcessInstanceKey?: string | number | null;
|
|
2273
2282
|
state?: string;
|
|
2274
2283
|
/** The engine's resolution of the task's `.form` linkage (its `formId="X"`) to the deployed form's
|
|
2275
2284
|
* key, attached to the open task. Denormalised onto the row so the collapsed Tasks grid can render
|
|
@@ -2282,6 +2291,10 @@ interface OpenUserTask {
|
|
|
2282
2291
|
userTaskKey: string;
|
|
2283
2292
|
elementId: string;
|
|
2284
2293
|
processInstanceKey: string;
|
|
2294
|
+
/** The top-level ancestor instance key (`""` when the engine did not report one — a pre-#977 engine
|
|
2295
|
+
* or a top-level task), used to correlate a callActivity child-instance task back to its parent run
|
|
2296
|
+
* (issue #633). */
|
|
2297
|
+
rootProcessInstanceKey: string;
|
|
2285
2298
|
/** The engine-reported `formKey`, or "" when the search omitted it (the poller then falls back to the
|
|
2286
2299
|
* kind's static `.form` linkage). */
|
|
2287
2300
|
formKey: string;
|
|
@@ -2326,7 +2339,7 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
|
|
|
2326
2339
|
const userTaskKey = it.userTaskKey == null ? "" : String(it.userTaskKey);
|
|
2327
2340
|
if (!userTaskKey || seen.has(userTaskKey)) continue;
|
|
2328
2341
|
seen.add(userTaskKey);
|
|
2329
|
-
out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey), formKey: it.formKey == null ? "" : String(it.formKey) });
|
|
2342
|
+
out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey), rootProcessInstanceKey: it.rootProcessInstanceKey == null ? "" : String(it.rootProcessInstanceKey), formKey: it.formKey == null ? "" : String(it.formKey) });
|
|
2330
2343
|
}
|
|
2331
2344
|
if (items.length < limit) break; // last page
|
|
2332
2345
|
from += items.length;
|
|
@@ -2360,23 +2373,121 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
|
|
|
2360
2373
|
* completed task's row is deleted (answered here, via the task inbox, or out-of-band) and `showCount`
|
|
2361
2374
|
* reflects live pending work. Best-effort + idempotent — per-instance failures are isolated so one bad
|
|
2362
2375
|
* instance never stalls the pass. */
|
|
2376
|
+
/** The subset of a Camunda-8 `/v2/process-instances/search` result item this app reads to walk a
|
|
2377
|
+
* callActivity instance hierarchy (issue #633). `processInstanceKey` is the instance; the engine's
|
|
2378
|
+
* read-model parent/root keys (Magikcraft/nano-bpm#977) let a caller relate a child instance to its
|
|
2379
|
+
* ancestors — this pass only needs `processInstanceKey` (it filters BY `parentProcessInstanceKey`, so
|
|
2380
|
+
* every returned row is by construction a child of the queried parent). Keys are stringified
|
|
2381
|
+
* defensively (the wire may send a JSON number or string). */
|
|
2382
|
+
interface ProcessInstanceSearchItem {
|
|
2383
|
+
processInstanceKey?: string | number;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/** Enumerate every DESCENDANT process instance of `rootKey` in its callActivity hierarchy (issue #633)
|
|
2387
|
+
* over the raw Camunda-8 `/v2/process-instances/search` surface, walking `parentProcessInstanceKey`
|
|
2388
|
+
* breadth-first. Returns the descendant instance keys (NOT including `rootKey` itself). The engine
|
|
2389
|
+
* exposes a `parentProcessInstanceKey` filter but no `rootProcessInstanceKey` one, so the hierarchy is
|
|
2390
|
+
* walked level by level (parent → children → grandchildren): the fine-grained cells nest two deep
|
|
2391
|
+
* (`plan-fanout` → `implement-cell` → `human-escalation`), and a deeper future graph is covered by the
|
|
2392
|
+
* BFS. Bounded by a total-instance guard so a pathological/looping hierarchy can neither explode nor
|
|
2393
|
+
* spin; best-effort transport — a failed page/level returns what was gathered so far. Deduped so a
|
|
2394
|
+
* re-parented row can't be walked twice. */
|
|
2395
|
+
async function searchDescendantInstanceKeys(base: string, headers: Record<string, string>, rootKey: string): Promise<string[]> {
|
|
2396
|
+
const out: string[] = [];
|
|
2397
|
+
const seen = new Set<string>([rootKey]);
|
|
2398
|
+
let frontier = [rootKey];
|
|
2399
|
+
const limit = 100;
|
|
2400
|
+
const MAX_INSTANCES = 1000; // hard cap so a pathological hierarchy can't unbounded-fan-out this pass
|
|
2401
|
+
for (let depth = 0; depth < 64 && frontier.length > 0 && out.length < MAX_INSTANCES; depth++) {
|
|
2402
|
+
const next: string[] = [];
|
|
2403
|
+
for (const parent of frontier) {
|
|
2404
|
+
let from = 0;
|
|
2405
|
+
for (let guard = 0; guard < 1000; guard++) {
|
|
2406
|
+
let items: ProcessInstanceSearchItem[];
|
|
2407
|
+
try {
|
|
2408
|
+
const res = await fetch(`${base}/process-instances/search`, {
|
|
2409
|
+
method: "POST",
|
|
2410
|
+
headers,
|
|
2411
|
+
body: JSON.stringify({ filter: { parentProcessInstanceKey: parent }, page: { from, limit } }),
|
|
2412
|
+
});
|
|
2413
|
+
if (!res.ok) break; // engine unhappy → walk what we have, retry next pass
|
|
2414
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
2415
|
+
const body = (await res.json()) as { items?: ProcessInstanceSearchItem[] };
|
|
2416
|
+
items = body.items ?? [];
|
|
2417
|
+
} catch (err) {
|
|
2418
|
+
console.error(`[poller] process-instance hierarchy walk (${parent}): ${err}`);
|
|
2419
|
+
break;
|
|
2420
|
+
}
|
|
2421
|
+
for (const it of items) {
|
|
2422
|
+
const key = it.processInstanceKey == null ? "" : String(it.processInstanceKey);
|
|
2423
|
+
if (!key || seen.has(key)) continue;
|
|
2424
|
+
seen.add(key);
|
|
2425
|
+
out.push(key);
|
|
2426
|
+
next.push(key);
|
|
2427
|
+
if (out.length >= MAX_INSTANCES) break;
|
|
2428
|
+
}
|
|
2429
|
+
if (items.length < limit || out.length >= MAX_INSTANCES) break; // last page / capped
|
|
2430
|
+
from += items.length;
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
frontier = next;
|
|
2434
|
+
}
|
|
2435
|
+
return out;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/** Gather the element instances of an epic's plan-fanout instance AND every callActivity DESCENDANT of
|
|
2439
|
+
* it (issue #633) into ONE flat list the canonical `deriveEpicPhaseLive` reads — so once a wave/slice
|
|
2440
|
+
* runs as a child cell instance (ADR 0006 S4) the "furthest-reached live element" derivation still sees
|
|
2441
|
+
* the token position INSIDE the child, not just the parent spine. Extends the derivation's INPUT via the
|
|
2442
|
+
* engine's native parent/root traversal rather than adding a second phase deriver (derivation over
|
|
2443
|
+
* duplication). Without the raw-REST surface (`rest` null — unit tests / a degraded no-REST host) it
|
|
2444
|
+
* degrades to the parent instance alone (the pre-composition behaviour), since the typed seam cannot
|
|
2445
|
+
* enumerate children. */
|
|
2446
|
+
async function gatherHierarchyElementInstances(
|
|
2447
|
+
engine: Pick<EngineClient, "searchElementInstances">,
|
|
2448
|
+
rest: { base: string; headers: Record<string, string> } | null,
|
|
2449
|
+
rootKey: string,
|
|
2450
|
+
): Promise<Awaited<ReturnType<EngineClient["searchElementInstances"]>>> {
|
|
2451
|
+
const instanceKeys = [rootKey];
|
|
2452
|
+
if (rest) {
|
|
2453
|
+
const descendants = await searchDescendantInstanceKeys(rest.base, rest.headers, rootKey);
|
|
2454
|
+
instanceKeys.push(...descendants);
|
|
2455
|
+
}
|
|
2456
|
+
const all: Awaited<ReturnType<EngineClient["searchElementInstances"]>> = [];
|
|
2457
|
+
for (const key of instanceKeys) {
|
|
2458
|
+
all.push(...(await engine.searchElementInstances({ processInstanceKey: key })));
|
|
2459
|
+
}
|
|
2460
|
+
return all;
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2363
2463
|
/** Poll pass (S8, #542 / ADR 0006 §4b): reconcile each LIVE epic's `plans.epic_phase` from the engine
|
|
2364
2464
|
* element-instance model — the PURE read-model derivation that RETIRES the write-time stamp the spine
|
|
2365
2465
|
* workers used to write. For each plan still live (`EPIC_LIVE_STATUSES`) with a running instance, read
|
|
2366
|
-
* its element instances (`searchElementInstances`, nano-ide#473)
|
|
2367
|
-
*
|
|
2368
|
-
*
|
|
2369
|
-
*
|
|
2370
|
-
*
|
|
2371
|
-
*
|
|
2372
|
-
*
|
|
2373
|
-
*
|
|
2466
|
+
* its element instances (`searchElementInstances`, nano-ide#473) — the plan-fanout instance AND, once
|
|
2467
|
+
* the raw-REST surface is available, every callActivity CHILD cell instance (ADR 0006 S4, #603/#633),
|
|
2468
|
+
* gathered via the engine's native parent/root traversal (`gatherHierarchyElementInstances`) so the
|
|
2469
|
+
* furthest-reached element INSIDE a child cell is seen — and project the furthest-reached active spine
|
|
2470
|
+
* element onto its domain phase (`deriveEpicPhaseLive`, app/epicPhase.ts — the SAME `ELEMENT_PHASE`
|
|
2471
|
+
* structural map the stamp used). The wave label rides the `plan_wave_progress` rollup VIEW (the single
|
|
2472
|
+
* wave-frontier source, 060/082), so the Implementing band reads `wave n/t` without a second wave
|
|
2473
|
+
* derivation. Writes only on a real change (a steady-state pass is a no-op) and leaves the last phase
|
|
2474
|
+
* untouched when nothing active marks one (`null`), so a plan parked on non-spine plumbing never
|
|
2475
|
+
* clobbers to blank. The terminal `Dispatched` phase is a COMPLETION marker (no ACTIVE token to read
|
|
2476
|
+
* once the instance ends), so a second pass derives it from the durable terminal status
|
|
2374
2477
|
* (`deriveTerminalEpicPhase` over `done` epics) rather than the fleeting ACTIVE `record-results` token
|
|
2375
2478
|
* a coarse poll would miss. Best-effort + idempotent — a per-plan failure is isolated. */
|
|
2376
2479
|
export async function pollEpicPhase(
|
|
2377
2480
|
data: DataLayer,
|
|
2378
2481
|
engine: Pick<EngineClient, "searchElementInstances">,
|
|
2482
|
+
engineRest?: { restAddress: string; token?: string },
|
|
2379
2483
|
) {
|
|
2484
|
+
const rest = engineRest
|
|
2485
|
+
? (() => {
|
|
2486
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
2487
|
+
if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
|
|
2488
|
+
return { base: engineRest.restAddress.replace(/\/+$/, ""), headers };
|
|
2489
|
+
})()
|
|
2490
|
+
: null;
|
|
2380
2491
|
const waveByPlan = new Map<string, { current: number | null; total: number | null }>();
|
|
2381
2492
|
for (const w of await data
|
|
2382
2493
|
.table<{ plan_key: string; wave_count: number | null; current_wave: number | null }>(
|
|
@@ -2396,7 +2507,7 @@ export async function pollEpicPhase(
|
|
|
2396
2507
|
for (const plan of await plans(data).find({ status })) {
|
|
2397
2508
|
if (!plan.process_key) continue;
|
|
2398
2509
|
try {
|
|
2399
|
-
const elements = await engine
|
|
2510
|
+
const elements = await gatherHierarchyElementInstances(engine, rest, plan.process_key);
|
|
2400
2511
|
const phase = deriveEpicPhaseLive(elements, waveByPlan.get(plan.plan_key) ?? undefined);
|
|
2401
2512
|
if (phase !== null && phase !== plan.epic_phase) {
|
|
2402
2513
|
await plans(data).update(plan.plan_key, { epic_phase: phase, updated_at: now() });
|
|
@@ -2535,6 +2646,15 @@ export async function pollUserTasks(
|
|
|
2535
2646
|
engineRest?: { restAddress: string; token?: string },
|
|
2536
2647
|
) {
|
|
2537
2648
|
const at = now();
|
|
2649
|
+
// Raw-REST context (present in production; the reduced-capability seam host passes no `engineRest`) —
|
|
2650
|
+
// used by the engine-first sweep AND the escalation self-heal's callActivity-hierarchy confirmation.
|
|
2651
|
+
const rest = engineRest
|
|
2652
|
+
? (() => {
|
|
2653
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
2654
|
+
if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
|
|
2655
|
+
return { base: engineRest.restAddress.replace(/\/+$/, ""), headers };
|
|
2656
|
+
})()
|
|
2657
|
+
: null;
|
|
2538
2658
|
|
|
2539
2659
|
// ── Enrichment: subject descriptors keyed by the ENGINE process-instance the task parks on ────────
|
|
2540
2660
|
// Built from EVERY subject row (regardless of status), so a task on a subject whose row already went
|
|
@@ -2578,6 +2698,7 @@ export async function pollUserTasks(
|
|
|
2578
2698
|
// when tracking is lost, so the fallback row still buckets correctly on the page.
|
|
2579
2699
|
const DEFAULT_SUBJECT_TYPE: Readonly<Record<string, "feature" | "plan" | "pr">> = {
|
|
2580
2700
|
[FEATURE_ESCALATION_ELEMENT]: "feature",
|
|
2701
|
+
[HUMAN_ESCALATION_ELEMENT]: "feature",
|
|
2581
2702
|
[FEATURE_BLOCKED_ELEMENT]: "feature",
|
|
2582
2703
|
[PLAN_REVIEW_ELEMENT]: "plan",
|
|
2583
2704
|
[TRIAL_MERGE_ELEMENT]: "plan",
|
|
@@ -2590,9 +2711,17 @@ export async function pollUserTasks(
|
|
|
2590
2711
|
// element + the instance it parks on) into its desired-row context, enriching from its subject row
|
|
2591
2712
|
// when the instance is tracked or a per-kind fallback when it is orphaned. Returns `null` for a
|
|
2592
2713
|
// non-escalation element (the leak guard) so an arbitrary internal user task can never reach the inbox.
|
|
2593
|
-
|
|
2714
|
+
//
|
|
2715
|
+
// Parent/root correlation (#603/#633): a task parked inside a callActivity CHILD instance (the shared
|
|
2716
|
+
// `human-escalation`/`implement-cell` cells, ADR 0006 S4) has a `processInstanceKey` no subject row
|
|
2717
|
+
// tracks — its owning feature/plan run is the PARENT run, tracked under the `rootProcessInstanceKey`
|
|
2718
|
+
// the engine reports (Magikcraft/nano-bpm#977). So the subject resolves off the DIRECT instance first
|
|
2719
|
+
// and falls back to the ROOT (top-level ancestor) instance, correlating the child-instance escalation
|
|
2720
|
+
// back to the parent run rather than stranding it as an orphan.
|
|
2721
|
+
const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string, rootProcessInstanceKey: string, formKey: string): Promise<UserTaskContext | null> => {
|
|
2594
2722
|
if (userTaskKindLabel(elementId) === undefined) return null;
|
|
2595
|
-
const
|
|
2723
|
+
const root = rootProcessInstanceKey.trim();
|
|
2724
|
+
const subj = subjectByInstance.get(processInstanceKey) ?? (root && root !== processInstanceKey ? subjectByInstance.get(root) : undefined);
|
|
2596
2725
|
// Orphaned-task fallback: the kind implies its aggregate even when no subject row references the
|
|
2597
2726
|
// instance. A delivery-human node's id is inlined (`delivery-human-task__<node>`), so its bucket is
|
|
2598
2727
|
// derived from the predicate rather than the static per-element table.
|
|
@@ -2606,9 +2735,13 @@ export async function pollUserTasks(
|
|
|
2606
2735
|
let question: string | null = null;
|
|
2607
2736
|
switch (elementId) {
|
|
2608
2737
|
case FEATURE_ESCALATION_ELEMENT:
|
|
2738
|
+
case HUMAN_ESCALATION_ELEMENT:
|
|
2609
2739
|
// The escalate arm writes the synthesised question to `feature_escalations` keyed by the subject
|
|
2610
2740
|
// (a standalone slice's `feature_key`, or the epic's `plan_key` for a plan-embedded slice, which
|
|
2611
2741
|
// has no standalone `feature_runs` row) — the same key `subjectKey` resolves to for either subject.
|
|
2742
|
+
// The shared `human-escalation` cell (`escalation`) is the same feature-escalation task relocated
|
|
2743
|
+
// into a callActivity child, so it reads the SAME `feature_escalations` log via the correlated
|
|
2744
|
+
// (parent/root) subject.
|
|
2612
2745
|
question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: subjectKey }));
|
|
2613
2746
|
break;
|
|
2614
2747
|
case FEATURE_BLOCKED_ELEMENT:
|
|
@@ -2634,25 +2767,25 @@ export async function pollUserTasks(
|
|
|
2634
2767
|
// Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a
|
|
2635
2768
|
// subject seen under two statuses mid-pass).
|
|
2636
2769
|
const desiredByKey = new Map<string, UserTaskRow>();
|
|
2637
|
-
const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string, formKey: string) => {
|
|
2770
|
+
const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string, rootProcessInstanceKey: string, formKey: string) => {
|
|
2638
2771
|
if (!elementId) return;
|
|
2639
2772
|
const rowKey = userTaskKey.trim();
|
|
2640
2773
|
if (!rowKey || desiredByKey.has(rowKey)) return;
|
|
2641
|
-
const ctx = await contextFor(elementId, userTaskKey, processInstanceKey, formKey);
|
|
2774
|
+
const ctx = await contextFor(elementId, userTaskKey, processInstanceKey, rootProcessInstanceKey, formKey);
|
|
2642
2775
|
if (!ctx) return;
|
|
2643
2776
|
const row = buildUserTaskRow(ctx, at);
|
|
2644
2777
|
if (row) desiredByKey.set(rowKey, row);
|
|
2645
2778
|
};
|
|
2646
2779
|
|
|
2647
|
-
if (
|
|
2648
|
-
const
|
|
2649
|
-
|
|
2650
|
-
if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
|
|
2651
|
-
for (const t of await sweepOpenEscalationTasks(base, headers)) {
|
|
2652
|
-
await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.formKey);
|
|
2780
|
+
if (rest) {
|
|
2781
|
+
for (const t of await sweepOpenEscalationTasks(rest.base, rest.headers)) {
|
|
2782
|
+
await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.rootProcessInstanceKey, t.formKey);
|
|
2653
2783
|
}
|
|
2654
2784
|
} else {
|
|
2655
2785
|
// Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan, tracked-only.
|
|
2786
|
+
// The typed `openUserTasks` seam carries no parent/root key, so a child-instance task cannot be
|
|
2787
|
+
// correlated here — this path reaches a task only THROUGH the tracked subject whose OWN instance it
|
|
2788
|
+
// parks on (root correlation is a no-op, passed as "").
|
|
2656
2789
|
const seen = new Set<string>();
|
|
2657
2790
|
const scanInstance = async (processKey: string | null | undefined) => {
|
|
2658
2791
|
if (!processKey || seen.has(processKey)) return;
|
|
@@ -2664,7 +2797,7 @@ export async function pollUserTasks(
|
|
|
2664
2797
|
console.error(`[poller] user tasks (${processKey}): ${err}`);
|
|
2665
2798
|
return;
|
|
2666
2799
|
}
|
|
2667
|
-
for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey, t.formKey ?? "");
|
|
2800
|
+
for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey, "", t.formKey ?? "");
|
|
2668
2801
|
};
|
|
2669
2802
|
for (const status of FEATURE_ACTIVE_STATUSES) for (const run of await featureRuns(data).find({ status })) await scanInstance(run.process_key);
|
|
2670
2803
|
for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
|
|
@@ -2702,32 +2835,62 @@ export async function pollUserTasks(
|
|
|
2702
2835
|
// is never guessed at.
|
|
2703
2836
|
//
|
|
2704
2837
|
// Presence in THIS pass's `desired` set is itself POSITIVE evidence of parking (truncation only ever
|
|
2705
|
-
// DROPS tasks, never invents one), so a run whose
|
|
2706
|
-
//
|
|
2707
|
-
//
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
)
|
|
2838
|
+
// DROPS tasks, never invents one), so a run whose escalation task was already swept is genuinely
|
|
2839
|
+
// parked — skip its per-instance `openUserTasks` RPC entirely (an avoidable N+1 on every tick). Only a
|
|
2840
|
+
// run NOT confirmed parked by the sweep falls through to the per-instance check below.
|
|
2841
|
+
//
|
|
2842
|
+
// A run's escalation may park on its OWN inline `feature-escalation` (same instance as `process_key`)
|
|
2843
|
+
// OR — once its implement step runs as a callActivity child cell (ADR 0006 S4, #603/#633) — on the
|
|
2844
|
+
// shared `human-escalation` cell's `escalation` element inside a CHILD instance, correlated back to
|
|
2845
|
+
// the run via the parent/root key (so the desired row's `subject_key` is the run's `feature_key`, not
|
|
2846
|
+
// the child instance). So the swept set is keyed by BOTH the task's own `process_key` AND its
|
|
2847
|
+
// correlated `subject_key`, and a run counts as parked when either matches (`process_key` for the
|
|
2848
|
+
// inline case, `feature_key` for the child-cell case).
|
|
2849
|
+
const sweptParkedEscalations = new Set<string>();
|
|
2850
|
+
for (const r of desired) {
|
|
2851
|
+
if (r.element_id !== FEATURE_ESCALATION_ELEMENT && r.element_id !== HUMAN_ESCALATION_ELEMENT) continue;
|
|
2852
|
+
if (r.subject_type !== "feature") continue;
|
|
2853
|
+
if (r.process_key) sweptParkedEscalations.add(r.process_key);
|
|
2854
|
+
if (r.subject_key) sweptParkedEscalations.add(r.subject_key);
|
|
2855
|
+
}
|
|
2711
2856
|
for (const run of await featureRuns(data).find({ status: "escalated" })) {
|
|
2712
2857
|
if (!run.process_key) continue;
|
|
2713
|
-
if (sweptParkedEscalations.has(run.process_key)) continue; // already seen parked this pass — no RPC, no heal
|
|
2858
|
+
if (sweptParkedEscalations.has(run.process_key) || sweptParkedEscalations.has(run.feature_key)) continue; // already seen parked this pass — no RPC, no heal
|
|
2714
2859
|
// A just-written escalation may not have its `feature-escalation` user task yet: the sole writer,
|
|
2715
2860
|
// `record-feature-escalation`, stamps `updated_at` immediately BEFORE the engine creates the task.
|
|
2716
2861
|
// Skip healing inside the grace window so this pass never races that transition and steals a fresh
|
|
2717
2862
|
// escalation; a genuinely-stranded (old, or timestamp-less) row is past the window and still healed.
|
|
2718
2863
|
const escalatedAt = Date.parse(run.updated_at ?? "");
|
|
2719
2864
|
if (Number.isFinite(escalatedAt) && Date.now() - escalatedAt < FEATURE_ESCALATION_HEAL_GRACE_MS) continue;
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2865
|
+
// Confirm across the run's WHOLE callActivity hierarchy, not just its parent instance: a child-cell
|
|
2866
|
+
// escalation (ADR 0006 S4, #603/#633) parks on the shared `human-escalation` cell's `escalation`
|
|
2867
|
+
// element inside a DESCENDANT instance that the parent's `openUserTasks` never reports. Confirming
|
|
2868
|
+
// the parent alone would read "no escalation open" and wrongly flip a genuinely-parked child-cell run
|
|
2869
|
+
// back to `running` whenever THIS pass's sweep missed it (truncated/unavailable, so it is absent from
|
|
2870
|
+
// `desired`). Include the callActivity descendants when the raw-REST surface is available; the
|
|
2871
|
+
// reduced-capability seam (no `rest`) cannot walk the hierarchy, so it stays parent-only (child-cell
|
|
2872
|
+
// correlation is a no-op on that path anyway). Any per-instance query error is negative evidence, not
|
|
2873
|
+
// proof the run is unparked — skip the heal and leave the row for a later pass (parity with before).
|
|
2874
|
+
const confirmInstances = [run.process_key];
|
|
2875
|
+
if (rest) confirmInstances.push(...(await searchDescendantInstanceKeys(rest.base, rest.headers, run.process_key)));
|
|
2876
|
+
let stillParked = false;
|
|
2877
|
+
let queryErrored = false;
|
|
2878
|
+
for (const instanceKey of confirmInstances) {
|
|
2879
|
+
let openTasks: { elementId?: string }[];
|
|
2880
|
+
try {
|
|
2881
|
+
openTasks = await engine.openUserTasks({ processInstanceKey: instanceKey });
|
|
2882
|
+
} catch (err) {
|
|
2883
|
+
console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${instanceKey}): ${err}`);
|
|
2884
|
+
queryErrored = true;
|
|
2885
|
+
break;
|
|
2886
|
+
}
|
|
2887
|
+
if (openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT || t.elementId === HUMAN_ESCALATION_ELEMENT)) {
|
|
2888
|
+
stillParked = true;
|
|
2889
|
+
break;
|
|
2890
|
+
}
|
|
2730
2891
|
}
|
|
2892
|
+
if (queryErrored || stillParked) continue;
|
|
2893
|
+
await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });
|
|
2731
2894
|
}
|
|
2732
2895
|
}
|
|
2733
2896
|
|
|
@@ -2751,7 +2914,7 @@ export async function pollOnce(
|
|
|
2751
2914
|
await pollFeatureDelivery(data);
|
|
2752
2915
|
await pollLineage(data);
|
|
2753
2916
|
await pollUserTasks(data, engine, engineRest);
|
|
2754
|
-
await pollEpicPhase(data, engine);
|
|
2917
|
+
await pollEpicPhase(data, engine, engineRest);
|
|
2755
2918
|
await pollTasklessPlanTermination(data, engine);
|
|
2756
2919
|
await pollDeliveryGraphPhase(data, engine);
|
|
2757
2920
|
await pollDeliveryProposals(data);
|
package/app/userTasks.ts
CHANGED
|
@@ -60,6 +60,17 @@ export const PR_WAIT_MERGE_ANSWER_ELEMENT = "wait-merge-answer";
|
|
|
60
60
|
* hire — a `yolo`-policy request never reaches this path (see `app/agentic/permission-bridge.ts`). */
|
|
61
61
|
export const ACP_PERMISSION_ELEMENT = "acp-permission";
|
|
62
62
|
|
|
63
|
+
/** The shared human-escalation cell's escalation user task (`human-escalation.bpmn`, ADR 0006 S4,
|
|
64
|
+
* issue #603/#633). The fine-grained `implement-cell` delegates its escalation to the reusable
|
|
65
|
+
* `human-escalation` cell via `callActivity`, so once a wave/slice runs as a child instance the
|
|
66
|
+
* agent-stuck escalation parks on THIS element id (`escalation`) inside that child — NOT on the
|
|
67
|
+
* parent's inline `feature-escalation`. It renders the SAME `feature-escalation` form and is the same
|
|
68
|
+
* agent-answerable task escalation, so the inbox surfaces it under the one "Feature escalation" kind
|
|
69
|
+
* and the poller correlates it back to the parent run via the engine's native parent/root instance
|
|
70
|
+
* keys (Magikcraft/nano-bpm#977, the #464 option-B decision). Without this the child-instance
|
|
71
|
+
* escalation is filtered out by the unknown-kind guard and silently vanishes from the Tasks inbox. */
|
|
72
|
+
export const HUMAN_ESCALATION_ELEMENT = "escalation";
|
|
73
|
+
|
|
63
74
|
/** One row per currently-open native user-task escalation, denormalised for the Tasks page. Keyed on
|
|
64
75
|
* the completable `user_task_key` (a task is open at most once). Present iff the engine reports the
|
|
65
76
|
* task open; `pollUserTasks` deletes it once the task is gone. */
|
|
@@ -92,6 +103,7 @@ export const userTasks = (data: DataLayer) => data.table<UserTaskRow>("user_task
|
|
|
92
103
|
* ignored by `buildUserTaskRow`, so an arbitrary internal user task can never leak into the inbox. */
|
|
93
104
|
export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
|
|
94
105
|
[FEATURE_ESCALATION_ELEMENT]: "Feature escalation",
|
|
106
|
+
[HUMAN_ESCALATION_ELEMENT]: "Feature escalation",
|
|
95
107
|
[FEATURE_BLOCKED_ELEMENT]: "Blocked feature run",
|
|
96
108
|
[PLAN_REVIEW_ELEMENT]: "Plan review",
|
|
97
109
|
[EMPTY_PLAN_ELEMENT]: "Empty plan",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.168.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",
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"sync:nav:check": "node --experimental-strip-types scripts/sync-nav.ts --check",
|
|
52
52
|
"gen:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts",
|
|
53
53
|
"check:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts --check",
|
|
54
|
+
"gen:cockpit-browser": "node --experimental-strip-types scripts/build-cockpit-browser.ts",
|
|
55
|
+
"check:cockpit-browser": "node --experimental-strip-types scripts/build-cockpit-browser.ts --check",
|
|
54
56
|
"dev": "urban dev",
|
|
55
57
|
"pretest": "urban gen",
|
|
56
58
|
"test": "node --experimental-strip-types --test",
|
|
@@ -305,3 +305,121 @@
|
|
|
305
305
|
.cockpit-terminal[data-terminal-mode="live"] .cockpit-panel-title {
|
|
306
306
|
color: var(--cockpit-green);
|
|
307
307
|
}
|
|
308
|
+
|
|
309
|
+
/* ── Rendered transcript (#660): the derived message turns + tool/diff/permission cards the Worker ──
|
|
310
|
+
terminal shows for both a live drill and a past-session replay, in place of a raw nwfTranscriptEvent
|
|
311
|
+
dump. Mounted into `.cockpit-terminal-host` by renderDerivedTranscript(). */
|
|
312
|
+
|
|
313
|
+
.cockpit-transcript-derived {
|
|
314
|
+
display: grid;
|
|
315
|
+
gap: 12px;
|
|
316
|
+
padding: 8px;
|
|
317
|
+
font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
318
|
+
max-height: 60vh;
|
|
319
|
+
overflow: auto;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.cockpit-transcript-empty {
|
|
323
|
+
color: var(--cockpit-muted);
|
|
324
|
+
font-style: italic;
|
|
325
|
+
padding: 8px;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.cockpit-transcript-turn {
|
|
329
|
+
display: grid;
|
|
330
|
+
gap: 8px;
|
|
331
|
+
border-left: 2px solid var(--cockpit-edge);
|
|
332
|
+
padding-left: 10px;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.cockpit-transcript-turn-title {
|
|
336
|
+
font-size: 11px;
|
|
337
|
+
margin: 0;
|
|
338
|
+
color: var(--cockpit-muted);
|
|
339
|
+
text-transform: uppercase;
|
|
340
|
+
letter-spacing: 0.04em;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.cockpit-transcript-message {
|
|
344
|
+
white-space: pre-wrap;
|
|
345
|
+
overflow-wrap: anywhere;
|
|
346
|
+
padding: 6px 8px;
|
|
347
|
+
border-radius: 6px;
|
|
348
|
+
background: rgba(34, 48, 65, 0.35);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.cockpit-transcript-message[data-role="user"] { border-left: 3px solid #58a6ff; }
|
|
352
|
+
.cockpit-transcript-message[data-role="assistant"] { border-left: 3px solid var(--cockpit-green); }
|
|
353
|
+
.cockpit-transcript-message[data-role="system"],
|
|
354
|
+
.cockpit-transcript-message[data-role="tool"] { border-left: 3px solid var(--cockpit-muted); color: var(--cockpit-muted); }
|
|
355
|
+
|
|
356
|
+
.cockpit-transcript-tool {
|
|
357
|
+
border: 1px solid var(--cockpit-edge);
|
|
358
|
+
border-radius: 6px;
|
|
359
|
+
padding: 8px;
|
|
360
|
+
background: rgba(19, 26, 34, 0.6);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.cockpit-transcript-tool-name {
|
|
364
|
+
font-weight: 600;
|
|
365
|
+
color: #b392f0;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.cockpit-transcript-tool[data-status="error"] .cockpit-transcript-tool-name { color: var(--cockpit-red); }
|
|
369
|
+
.cockpit-transcript-tool[data-status="ok"] .cockpit-transcript-tool-name::after { content: " ✓"; color: var(--cockpit-green); }
|
|
370
|
+
|
|
371
|
+
.cockpit-transcript-tool-args,
|
|
372
|
+
.cockpit-transcript-tool-result,
|
|
373
|
+
.cockpit-transcript-diff {
|
|
374
|
+
margin: 6px 0 0;
|
|
375
|
+
white-space: pre-wrap;
|
|
376
|
+
overflow-wrap: anywhere;
|
|
377
|
+
background: #05080b;
|
|
378
|
+
border-radius: 4px;
|
|
379
|
+
padding: 6px 8px;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.cockpit-transcript-diff-line[data-diff-line="add"] { color: var(--cockpit-green); }
|
|
383
|
+
.cockpit-transcript-diff-line[data-diff-line="del"] { color: var(--cockpit-red); }
|
|
384
|
+
.cockpit-transcript-diff-line[data-diff-line="ctx"] { color: var(--cockpit-muted); }
|
|
385
|
+
|
|
386
|
+
.cockpit-transcript-permission {
|
|
387
|
+
border: 1px solid var(--cockpit-amber);
|
|
388
|
+
border-radius: 6px;
|
|
389
|
+
padding: 8px;
|
|
390
|
+
background: rgba(210, 153, 34, 0.08);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.cockpit-transcript-permission-title { font-weight: 600; }
|
|
394
|
+
.cockpit-transcript-permission-reason,
|
|
395
|
+
.cockpit-transcript-permission-note { color: var(--cockpit-muted); font-size: 12px; }
|
|
396
|
+
|
|
397
|
+
.cockpit-transcript-permission[data-status="allowed"] { border-color: var(--cockpit-green); }
|
|
398
|
+
.cockpit-transcript-permission[data-status="denied"] { border-color: var(--cockpit-red); }
|
|
399
|
+
|
|
400
|
+
.cockpit-transcript-permission-actions {
|
|
401
|
+
display: flex;
|
|
402
|
+
flex-wrap: wrap;
|
|
403
|
+
gap: 8px;
|
|
404
|
+
margin-top: 8px;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.cockpit-transcript-permission-option {
|
|
408
|
+
cursor: pointer;
|
|
409
|
+
font: inherit;
|
|
410
|
+
color: var(--cockpit-text);
|
|
411
|
+
background: var(--cockpit-panel);
|
|
412
|
+
border: 1px solid var(--cockpit-edge);
|
|
413
|
+
border-radius: 6px;
|
|
414
|
+
padding: 4px 10px;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.cockpit-transcript-permission-option[data-allowed="true"]:hover { border-color: var(--cockpit-green); }
|
|
418
|
+
.cockpit-transcript-permission-option[data-allowed="false"]:hover { border-color: var(--cockpit-red); }
|
|
419
|
+
|
|
420
|
+
.cockpit-transcript-raw {
|
|
421
|
+
color: var(--cockpit-muted);
|
|
422
|
+
font-size: 11px;
|
|
423
|
+
border-top: 1px solid var(--cockpit-edge);
|
|
424
|
+
padding-top: 6px;
|
|
425
|
+
}
|
package/pages/cockpit/embed.html
CHANGED
|
@@ -5,15 +5,13 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Agent cockpit — supply (App View embed)</title>
|
|
7
7
|
<link rel="stylesheet" href="./cockpit.css" />
|
|
8
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5/css/xterm.min.css" />
|
|
9
8
|
<style>
|
|
10
9
|
html, body { margin: 0; height: 100%; background: #0b0f14; }
|
|
11
10
|
</style>
|
|
12
11
|
<script type="importmap">
|
|
13
12
|
{
|
|
14
13
|
"imports": {
|
|
15
|
-
"@nanobpm/agentic/cockpit": "https://cdn.jsdelivr.net/npm/@nanobpm/agentic@0.1.0/dist/cockpit/index.js"
|
|
16
|
-
"@xterm/xterm": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5/+esm"
|
|
14
|
+
"@nanobpm/agentic/cockpit": "https://cdn.jsdelivr.net/npm/@nanobpm/agentic@0.1.0/dist/cockpit/index.js"
|
|
17
15
|
}
|
|
18
16
|
}
|
|
19
17
|
</script>
|