@nanobpm/nano-workforce 0.126.0 → 0.128.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/.github/workflows/invariants.yml +8 -0
- package/.github/workflows/pr-title-lint.yml +9 -1
- package/.github/workflows/release.yml +37 -8
- package/AGENTS.md +37 -0
- package/CHANGELOG.md +14 -0
- package/app/agentCompletion.ts +11 -0
- package/app/agentic/cockpit/cockpit-route.test.ts +21 -0
- package/app/agentic/cockpit/cockpit-route.ts +17 -0
- package/app/agentic/cockpit/index.ts +16 -0
- package/app/agentic/cockpit/supply-boot-past.test.ts +44 -0
- package/app/agentic/cockpit/supply-boot.test.ts +2 -2
- package/app/agentic/cockpit/supply-boot.ts +76 -10
- package/app/agentic/cockpit/supply-render.test.ts +13 -4
- package/app/agentic/cockpit/supply-render.ts +14 -2
- package/app/agentic/cockpit/transcript-render.ts +6 -2
- package/app/agentic/cockpit/transcript-view.ts +9 -0
- package/app/agentic/cockpit/worker-detail-render.test.ts +86 -0
- package/app/agentic/cockpit/worker-detail-render.ts +88 -0
- package/app/agentic/cockpit/worker-detail-view.ts +43 -0
- package/app/agentic/correlation-store.test.ts +99 -0
- package/app/agentic/correlation-store.ts +162 -0
- package/app/agentic/families/presence.family.test.ts +12 -0
- package/app/agentic/families/presence.family.ts +14 -0
- package/app/agentic/families/relay.family.test.ts +72 -0
- package/app/agentic/families/relay.family.ts +130 -1
- package/app/agentic/transcript-read.test.ts +55 -3
- package/app/agentic/transcript-read.ts +49 -9
- package/app/agentic/vocab/demand-report.test.ts +23 -10
- package/app/pollUserTasks.test.ts +66 -2
- package/app/service.ts +21 -8
- package/app/tasksPage.test.ts +44 -0
- package/app/userTasks.test.ts +19 -0
- package/app/userTasks.ts +13 -1
- package/db/migrations/077_user_tasks_form_key.sql +25 -0
- package/db/migrations/078_agentic_correlation.sql +32 -0
- package/docs/adr/0006-delivery-units-one-representation.md +59 -20
- package/e2e/delivery-graph.e2e.ts +7 -0
- package/e2e/feature-preflight.e2e.ts +7 -0
- package/e2e/inter-epic-dependency.e2e.ts +8 -0
- package/e2e/plan-fanout-preflight.e2e.ts +7 -0
- package/e2e/plan-fanout-sla.e2e.ts +4 -3
- package/e2e/plan-fanout.e2e.ts +2 -2
- package/e2e/readiness-gate.e2e.ts +8 -37
- package/e2e/support/probe-exec.test.ts +78 -0
- package/e2e/support/probe-exec.ts +69 -0
- package/e2e/support/time.test.ts +42 -0
- package/e2e/support/time.ts +33 -0
- package/openapi.yaml +26 -0
- package/operations/getAgenticTranscript.ts +3 -2
- package/operations/listAgenticTranscripts.ts +2 -1
- package/package.json +3 -3
- package/pages/cockpit/cockpit.css +65 -2
- package/pages/cockpit/mount.js +187 -16
- package/pages/tasks.page.json +24 -554
|
@@ -11,11 +11,19 @@ name: Whole-repo invariants (merge-skew guard)
|
|
|
11
11
|
#
|
|
12
12
|
# This lean workflow re-asserts those whole-repo invariants where the merge actually happens:
|
|
13
13
|
# - `merge_group` — the queue's PROSPECTIVE merged commit, so a skew is blocked BEFORE it lands.
|
|
14
|
+
# THIS is the merge-skew guard: `derived == f(sources_A ∪ sources_B)`.
|
|
15
|
+
# - `pull_request` — required so the SAME check (matched by job name) also reports on the PR head.
|
|
16
|
+
# GitHub's merge queue will not enqueue a PR until every required status check
|
|
17
|
+
# has reported on it, and a check that fires only on `merge_group` never does —
|
|
18
|
+
# it stays "expected" and the PR can never enter the queue (deadlock). The PR-head
|
|
19
|
+
# run also validates the PR's OWN tree (a self-collision / stale artifact the PR
|
|
20
|
+
# introduces alone), catching it before the queue rather than at merge time.
|
|
14
21
|
# - `push: [main]` — a fast backstop that fails a `main`-scoped build within minutes if something
|
|
15
22
|
# slipped through, instead of first surfacing on an unrelated open PR.
|
|
16
23
|
# - `schedule` — a daily catch-all for any skew introduced by a merge that bypassed the queue.
|
|
17
24
|
on:
|
|
18
25
|
merge_group:
|
|
26
|
+
pull_request:
|
|
19
27
|
push:
|
|
20
28
|
branches: [main]
|
|
21
29
|
schedule:
|
|
@@ -7,6 +7,13 @@ name: PR title lint
|
|
|
7
7
|
on:
|
|
8
8
|
pull_request:
|
|
9
9
|
types: [opened, edited, synchronize, reopened]
|
|
10
|
+
# Required status checks in the `main` merge queue must ALSO report on the
|
|
11
|
+
# queue's speculative `merge_group` commit, or the queue stalls forever waiting
|
|
12
|
+
# for a check that only ran on the PR. A `merge_group` commit has no PR title to
|
|
13
|
+
# lint (the title was already validated when the PR entered the queue), so the
|
|
14
|
+
# job runs with its only step skipped and reports success — the green the queue
|
|
15
|
+
# needs, without re-linting.
|
|
16
|
+
merge_group:
|
|
10
17
|
|
|
11
18
|
permissions:
|
|
12
19
|
pull-requests: read
|
|
@@ -16,7 +23,8 @@ jobs:
|
|
|
16
23
|
name: Conventional PR title
|
|
17
24
|
runs-on: ubuntu-latest
|
|
18
25
|
steps:
|
|
19
|
-
-
|
|
26
|
+
- if: github.event_name == 'pull_request'
|
|
27
|
+
uses: amannn/action-semantic-pull-request@v5
|
|
20
28
|
env:
|
|
21
29
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
22
30
|
with:
|
|
@@ -43,8 +43,14 @@ jobs:
|
|
|
43
43
|
REPO: ${{ github.repository }}
|
|
44
44
|
run: |
|
|
45
45
|
set -euo pipefail
|
|
46
|
-
# These must ALL be `success` on the release commit before we ship.
|
|
47
|
-
#
|
|
46
|
+
# These must ALL be `success` on the release commit before we ship.
|
|
47
|
+
# This is the `main` ruleset's required-check set MINUS the PR-only
|
|
48
|
+
# checks: `Conventional PR title` gates the merge queue but never runs
|
|
49
|
+
# on `push:main` (it has no PR title to lint on a merge commit — see
|
|
50
|
+
# pr-title-lint.yml), so requiring it here would deadlock every release.
|
|
51
|
+
# With the merge queue enabled these checks already passed on the
|
|
52
|
+
# speculative merged commit before it landed; this `push:main` gate is
|
|
53
|
+
# the backstop. Keep in lock-step with the ruleset's required checks.
|
|
48
54
|
required=("typecheck + test (Node)" "whole-repo invariants")
|
|
49
55
|
# Dedupe to ONE row per check name — the latest run (max .id) — so re-runs
|
|
50
56
|
# can't leave us reading a stale conclusion. Slurp every page into a
|
|
@@ -68,22 +74,45 @@ jobs:
|
|
|
68
74
|
if: needs.gate.outputs.should_release == 'true'
|
|
69
75
|
runs-on: ubuntu-latest
|
|
70
76
|
permissions:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
# The privileged work (push to `main`, tags, Releases, issue/PR comments) is
|
|
78
|
+
# done with the release App token below — it is the ruleset bypass actor. The
|
|
79
|
+
# job's own GITHUB_TOKEN only needs OIDC for npm Trusted Publishing.
|
|
80
|
+
contents: read
|
|
74
81
|
id-token: write # OIDC token for npm Trusted Publishing (no NPM_TOKEN)
|
|
75
82
|
steps:
|
|
83
|
+
# Mint a short-lived token for the dedicated release GitHub App. This App is
|
|
84
|
+
# the ruleset's bypass actor, so @semantic-release/git can push the version
|
|
85
|
+
# bump + tags straight to a protected `main` (the default GITHUB_TOKEN can't:
|
|
86
|
+
# it isn't — and at repo level can't be — a ruleset bypass actor). See
|
|
87
|
+
# AGENTS.md "Releases bypass the ruleset via a dedicated App" and the
|
|
88
|
+
# `main` ruleset's bypass_actors.
|
|
89
|
+
- name: Mint release App token
|
|
90
|
+
id: app-token
|
|
91
|
+
uses: actions/create-github-app-token@v1
|
|
92
|
+
with:
|
|
93
|
+
app-id: ${{ secrets.RELEASE_APP_ID }}
|
|
94
|
+
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
|
95
|
+
|
|
76
96
|
- name: Checkout
|
|
77
97
|
uses: actions/checkout@v4
|
|
78
98
|
with:
|
|
79
99
|
ref: ${{ github.event.workflow_run.head_sha }} # release the exact gated commit
|
|
80
100
|
fetch-depth: 0 # semantic-release needs full history + tags
|
|
81
101
|
persist-credentials: true # let @semantic-release/git push the release commit
|
|
102
|
+
token: ${{ steps.app-token.outputs.token }} # push as the bypass-actor App
|
|
103
|
+
|
|
104
|
+
- name: Resolve App bot user id
|
|
105
|
+
id: app-user
|
|
106
|
+
env:
|
|
107
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
108
|
+
run: |
|
|
109
|
+
id="$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)"
|
|
110
|
+
echo "id=$id" >> "$GITHUB_OUTPUT"
|
|
82
111
|
|
|
83
112
|
- name: Configure git author
|
|
84
113
|
run: |
|
|
85
|
-
git config user.name "
|
|
86
|
-
git config user.email "
|
|
114
|
+
git config user.name "${{ steps.app-token.outputs.app-slug }}[bot]"
|
|
115
|
+
git config user.email "${{ steps.app-user.outputs.id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com"
|
|
87
116
|
|
|
88
117
|
- name: Setup Node.js
|
|
89
118
|
uses: actions/setup-node@v4
|
|
@@ -101,7 +130,7 @@ jobs:
|
|
|
101
130
|
|
|
102
131
|
- name: Release
|
|
103
132
|
env:
|
|
104
|
-
GITHUB_TOKEN: ${{
|
|
133
|
+
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # bypass-actor App token
|
|
105
134
|
# No NPM_TOKEN / NODE_AUTH_TOKEN: publishing uses OIDC Trusted Publishing.
|
|
106
135
|
NPM_CONFIG_PROVENANCE: "true"
|
|
107
136
|
run: npx semantic-release
|
package/AGENTS.md
CHANGED
|
@@ -370,6 +370,43 @@ agents:
|
|
|
370
370
|
- **Never `git push --force` on `main`;** use `--force-with-lease` on feature
|
|
371
371
|
branches.
|
|
372
372
|
|
|
373
|
+
### Merging to `main` (merge queue)
|
|
374
|
+
|
|
375
|
+
`main` is protected by an **active ruleset** with a **GitHub merge queue**. You
|
|
376
|
+
do not merge a PR directly — you **add it to the queue** (UI "Merge when ready",
|
|
377
|
+
or the product merge loop's queued-merge path). The queue then builds the
|
|
378
|
+
**prospective merged commit** (`merge_group`) and only lands it if the required
|
|
379
|
+
checks pass **on that speculative tree**:
|
|
380
|
+
|
|
381
|
+
- `typecheck + test (Node)`, `whole-repo invariants`, `Conventional PR title`.
|
|
382
|
+
|
|
383
|
+
`whole-repo invariants` (the merge-skew guard, #366) runs only on the
|
|
384
|
+
`merge_group` commit and on `push:main`, never on a plain PR — so a
|
|
385
|
+
green-in-isolation PR that would collide once merged (a duplicate migration
|
|
386
|
+
prefix, a layout/artifact drift) is **blocked at merge time**, not after it has
|
|
387
|
+
already reddened `main`. Grouping is `ALLGREEN` (SQUASH merge), so any red entry
|
|
388
|
+
invalidates the whole batch. `Conventional PR title` passes through on
|
|
389
|
+
`merge_group` (the title was validated when the PR entered the queue).
|
|
390
|
+
|
|
391
|
+
### Releases bypass the ruleset via a dedicated App (do not break this)
|
|
392
|
+
|
|
393
|
+
The ruleset that protects `main` would also reject the **release** automation:
|
|
394
|
+
`semantic-release` (`@semantic-release/git`) pushes the `chore(release): X
|
|
395
|
+
[skip ci]` version/CHANGELOG commit and tags **directly** to `main`, which the
|
|
396
|
+
`pull_request` / `merge_queue` / `required_status_checks` rules forbid.
|
|
397
|
+
|
|
398
|
+
To allow exactly that push — and nothing else — the `main` ruleset lists a
|
|
399
|
+
**dedicated release GitHub App** as an `Integration` **bypass actor**
|
|
400
|
+
(`bypass_mode: always`). The `Release` workflow mints a short-lived token for
|
|
401
|
+
that App (`actions/create-github-app-token`) and hands it to `semantic-release`;
|
|
402
|
+
the job's own `GITHUB_TOKEN` is scoped to `contents: read` + `id-token: write`
|
|
403
|
+
(OIDC npm publish) and is **not** a bypass actor.
|
|
404
|
+
|
|
405
|
+
> ⚠️ **Coupling:** the default `GITHUB_TOKEN` cannot be a repo-level ruleset
|
|
406
|
+
> bypass actor, so the release depends on the App bypass actor staying in the
|
|
407
|
+
> ruleset. **Whenever you edit the `main` ruleset, keep the release App in
|
|
408
|
+
> `bypass_actors`** — dropping it wedges every release (GH013 on the push).
|
|
409
|
+
|
|
373
410
|
## Distributed fleet: NANO_WORKFORCE_BASE_URL
|
|
374
411
|
|
|
375
412
|
The abandon and blackboard hooks are how a **distributed worker fleet** calls back
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.128.0](https://github.com/nanobpm/nano-workforce/compare/v0.127.0...v0.128.0) (2026-08-23)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **cockpit:** release worker correlation on job end + durable attribution + worker detail history page ([#488](https://github.com/nanobpm/nano-workforce/issues/488)) ([51c1a53](https://github.com/nanobpm/nano-workforce/commit/51c1a539535d6d65177aa4e2e46ff3ce390bec18)), closes [#232](https://github.com/nanobpm/nano-workforce/issues/232)
|
|
7
|
+
|
|
8
|
+
# [0.127.0](https://github.com/nanobpm/nano-workforce/compare/v0.126.0...v0.127.0) (2026-08-23)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **tasks:** collapse Tasks page to one user_tasks list, complete via engine forms ([#461](https://github.com/nanobpm/nano-workforce/issues/461)) ([#484](https://github.com/nanobpm/nano-workforce/issues/484)) ([bbc0745](https://github.com/nanobpm/nano-workforce/commit/bbc0745ddc7b5f79cfee5096dfbd07cf29ea0f38)), closes [nano-ide#457](https://github.com/nano-ide/issues/457) [#445](https://github.com/nanobpm/nano-workforce/issues/445) [#458](https://github.com/nanobpm/nano-workforce/issues/458)
|
|
14
|
+
|
|
1
15
|
# [0.126.0](https://github.com/nanobpm/nano-workforce/compare/v0.125.0...v0.126.0) (2026-08-23)
|
|
2
16
|
|
|
3
17
|
|
package/app/agentCompletion.ts
CHANGED
|
@@ -122,6 +122,17 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
|
122
122
|
// (returns `null`) and the completer accepts the captured form variables for the emit binder to type.
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
+
/** The fixed `.form` linkage (a `zeebe:formDefinition formId`) that governs a fixed-form escalation
|
|
126
|
+
* kind's completion, or `undefined` for a kind with no static form (the delivery-graph `human` node,
|
|
127
|
+
* which renders DIFFERENT forms per node — its `formKey` only ever comes from the engine at runtime).
|
|
128
|
+
* Exposed so the Tasks-inbox poller can denormalise a row's `form_key` from this SAME single source of
|
|
129
|
+
* truth when the raw `/v2/user-tasks/search` result omits the engine-resolved key (issue #461) — the
|
|
130
|
+
* REST gateway addresses a deployed form by value whether that value is a deploy key or an authored
|
|
131
|
+
* form id, so this id resolves the same deployed `.form` the completer validates against. */
|
|
132
|
+
export function escalationFormId(elementId: string): string | undefined {
|
|
133
|
+
return ESCALATION_FORM_BY_ELEMENT[elementId];
|
|
134
|
+
}
|
|
135
|
+
|
|
125
136
|
/** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
|
|
126
137
|
* (`=<ref> != "<value>"` / `=<ref> == "<value>"`). A required field is only enforced when it is
|
|
127
138
|
* actually shown, so an "answer only when resolution=answer" field is not demanded on the abandon
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { parseCockpitRoute } from "./cockpit-route.ts";
|
|
5
|
+
|
|
6
|
+
test("parses empty and main cockpit hashes as the main route", () => {
|
|
7
|
+
assert.deepEqual(parseCockpitRoute(""), { kind: "main" });
|
|
8
|
+
assert.deepEqual(parseCockpitRoute("#/cockpit"), { kind: "main" });
|
|
9
|
+
assert.deepEqual(parseCockpitRoute("#/cockpit/"), { kind: "main" });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("parses URL-decoded worker detail hashes", () => {
|
|
13
|
+
assert.deepEqual(parseCockpitRoute("#/cockpit/worker/wk-a"), { kind: "worker", instance: "wk-a" });
|
|
14
|
+
assert.deepEqual(parseCockpitRoute("#/cockpit/worker/leaf%2Fwk%201"), { kind: "worker", instance: "leaf/wk 1" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("empty worker and junk hashes fall back to the main route", () => {
|
|
18
|
+
assert.deepEqual(parseCockpitRoute("#/cockpit/worker/"), { kind: "main" });
|
|
19
|
+
assert.deepEqual(parseCockpitRoute("#/elsewhere"), { kind: "main" });
|
|
20
|
+
assert.deepEqual(parseCockpitRoute("#/cockpit/worker/%E0%A4%A"), { kind: "main" });
|
|
21
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type CockpitRoute = { readonly kind: "main" } | { readonly kind: "worker"; readonly instance: string };
|
|
2
|
+
|
|
3
|
+
/** Parse the cockpit hash route. Unknown or malformed hashes fall back to the main list. */
|
|
4
|
+
export function parseCockpitRoute(hash: string): CockpitRoute {
|
|
5
|
+
const route = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
6
|
+
if (route === "" || route === "/cockpit" || route === "/cockpit/") return { kind: "main" };
|
|
7
|
+
const prefix = "/cockpit/worker/";
|
|
8
|
+
if (!route.startsWith(prefix)) return { kind: "main" };
|
|
9
|
+
const raw = route.slice(prefix.length);
|
|
10
|
+
if (raw === "") return { kind: "main" };
|
|
11
|
+
try {
|
|
12
|
+
const instance = decodeURIComponent(raw);
|
|
13
|
+
return instance === "" ? { kind: "main" } : { kind: "worker", instance };
|
|
14
|
+
} catch {
|
|
15
|
+
return { kind: "main" };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
//
|
|
6
6
|
// The DEMAND×supply matrix, missing-agent-type reds, and diversity-SLO lights are OUT OF SCOPE for
|
|
7
7
|
// this epic (#142) and deferred to the paired enrolment epic #152.
|
|
8
|
+
export {
|
|
9
|
+
type CockpitRoute,
|
|
10
|
+
parseCockpitRoute,
|
|
11
|
+
} from "./cockpit-route.ts";
|
|
8
12
|
export {
|
|
9
13
|
bootSupplyCockpit,
|
|
10
14
|
type CreateTerminal,
|
|
@@ -51,3 +55,15 @@ export {
|
|
|
51
55
|
type TranscriptView,
|
|
52
56
|
transcriptsView,
|
|
53
57
|
} from "./transcript-view.ts";
|
|
58
|
+
export {
|
|
59
|
+
type RenderWorkerDetailOptions,
|
|
60
|
+
renderWorkerDetail,
|
|
61
|
+
type WorkerDetailDom,
|
|
62
|
+
} from "./worker-detail-render.ts";
|
|
63
|
+
export {
|
|
64
|
+
type FoundWorkerDetailView,
|
|
65
|
+
type MissingWorkerDetailView,
|
|
66
|
+
type WorkerCurrentJobView,
|
|
67
|
+
type WorkerDetailView,
|
|
68
|
+
workerDetailView,
|
|
69
|
+
} from "./worker-detail-view.ts";
|
|
@@ -138,6 +138,50 @@ test("clicking a past-session button drives a replay", async () => {
|
|
|
138
138
|
assert.deepEqual(r.terminalWrites, ["PA", "ST"]);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test("opening a worker detail fetches that worker's transcript history and wires replay", async () => {
|
|
142
|
+
const r = rig();
|
|
143
|
+
const fetchedFor: Array<string | undefined> = [];
|
|
144
|
+
const env: SupplyCockpitEnv = {
|
|
145
|
+
...r.env,
|
|
146
|
+
fetchTranscripts: (instance?: string) => {
|
|
147
|
+
fetchedFor.push(instance);
|
|
148
|
+
return Promise.resolve(transcripts);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
const cockpit = bootSupplyCockpit(env);
|
|
152
|
+
await cockpit.refresh();
|
|
153
|
+
await flush();
|
|
154
|
+
|
|
155
|
+
cockpit.openWorker("wk-a");
|
|
156
|
+
await flush();
|
|
157
|
+
assert.equal(cockpit.currentRoute.kind, "worker");
|
|
158
|
+
assert.equal(cockpit.currentRoute.kind === "worker" ? cockpit.currentRoute.instance : undefined, "wk-a");
|
|
159
|
+
assert.equal(r.host.byData("worker-detail", "wk-a").length, 1, "worker detail header rendered");
|
|
160
|
+
assert.deepEqual(fetchedFor, [undefined, "wk-a"], "main history is global; detail history is instance-filtered");
|
|
161
|
+
|
|
162
|
+
const button = r.host.byClass("cockpit-past-replay").find((b) => b.getAttribute("data-stream") === "job:past");
|
|
163
|
+
button?.dispatch("click");
|
|
164
|
+
await flush();
|
|
165
|
+
assert.equal(cockpit.currentMode, "replay");
|
|
166
|
+
assert.deepEqual(r.terminalWrites, ["PA", "ST"]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("clicking a worker name opens the detail page without breaking current-job drill", async () => {
|
|
170
|
+
const r = rig();
|
|
171
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
172
|
+
await cockpit.refresh();
|
|
173
|
+
await flush();
|
|
174
|
+
|
|
175
|
+
r.host.byClass("cockpit-worker")[0]?.dispatch("click");
|
|
176
|
+
await flush();
|
|
177
|
+
assert.equal(cockpit.currentRoute.kind, "worker");
|
|
178
|
+
assert.equal(r.host.byData("worker-detail", "wk-a").length, 1);
|
|
179
|
+
|
|
180
|
+
r.host.byClass("cockpit-worker-current-job")[0]?.dispatch("click");
|
|
181
|
+
assert.equal(cockpit.currentMode, "live");
|
|
182
|
+
assert.equal(cockpit.currentStream, "job:live");
|
|
183
|
+
});
|
|
184
|
+
|
|
141
185
|
test("replaying after a live drill tears the live stream down and switches to replay", async () => {
|
|
142
186
|
const r = rig();
|
|
143
187
|
const cockpit = bootSupplyCockpit(r.env);
|
|
@@ -126,11 +126,11 @@ test("drilling into a worker subscribes its relay stream on connect", async () =
|
|
|
126
126
|
assert.deepEqual(subs[0]?.payload, { op: "subscribe", stream: "wk-a", from: 0, credit: 1024 });
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
test("clicking a rendered worker button drills its stream", async () => {
|
|
129
|
+
test("clicking a rendered worker drill button drills its stream", async () => {
|
|
130
130
|
const r = rig();
|
|
131
131
|
const cockpit = bootSupplyCockpit(r.env);
|
|
132
132
|
await cockpit.refresh();
|
|
133
|
-
const button = r.host.byClass("cockpit-worker").find((b) => b.getAttribute("data-stream") === "wk-a");
|
|
133
|
+
const button = r.host.byClass("cockpit-worker-drill").find((b) => b.getAttribute("data-stream") === "wk-a");
|
|
134
134
|
button?.dispatch("click");
|
|
135
135
|
assert.equal(cockpit.currentStream, "wk-a");
|
|
136
136
|
assert.equal(r.sockets.length, 1);
|
|
@@ -28,12 +28,15 @@ import {
|
|
|
28
28
|
TerminalSession,
|
|
29
29
|
type TerminalSink,
|
|
30
30
|
} from "@nanobpm/agentic/cockpit";
|
|
31
|
+
import type { CockpitRoute } from "./cockpit-route.ts";
|
|
31
32
|
import { renderSupply } from "./supply-render.ts";
|
|
32
|
-
import type { SupplyReport } from "./supply-view.ts";
|
|
33
|
+
import type { SupplyReport, SupplyView } from "./supply-view.ts";
|
|
33
34
|
import { supplyView } from "./supply-view.ts";
|
|
34
35
|
import { renderTranscripts, replayTranscript, type TranscriptDataReport } from "./transcript-render.ts";
|
|
35
36
|
import type { TranscriptListReport } from "./transcript-view.ts";
|
|
36
37
|
import { transcriptsView } from "./transcript-view.ts";
|
|
38
|
+
import { renderWorkerDetail } from "./worker-detail-render.ts";
|
|
39
|
+
import { workerDetailView } from "./worker-detail-view.ts";
|
|
37
40
|
|
|
38
41
|
/** Mounts a terminal into `host` and returns the sink relay output is written to. */
|
|
39
42
|
export type CreateTerminal = (host: ElementLike) => TerminalSink;
|
|
@@ -55,7 +58,7 @@ export interface SupplyCockpitEnv {
|
|
|
55
58
|
* Fetches the captured-session list (`GET /agentic/transcripts`) for the "past sessions" history.
|
|
56
59
|
* Optional: when omitted the past-sessions panel is not rendered (live-only cockpit).
|
|
57
60
|
*/
|
|
58
|
-
readonly fetchTranscripts?: () => Promise<TranscriptListReport>;
|
|
61
|
+
readonly fetchTranscripts?: (instance?: string) => Promise<TranscriptListReport>;
|
|
59
62
|
/**
|
|
60
63
|
* Fetches a stored transcript's bytes (`GET /agentic/transcripts/{stream}`) for static replay.
|
|
61
64
|
* Required for the "past sessions" replay to work; must be provided together with {@link fetchTranscripts}.
|
|
@@ -99,6 +102,12 @@ export interface SupplyCockpitHandle {
|
|
|
99
102
|
drill(stream: string): void;
|
|
100
103
|
/** Replay a captured past session's stored transcript statically into the terminal (no live worker). */
|
|
101
104
|
replay(stream: string): Promise<void>;
|
|
105
|
+
/** Open a worker's dedicated detail page. */
|
|
106
|
+
openWorker(instance: string): void;
|
|
107
|
+
/** Return to the main worker list. */
|
|
108
|
+
back(): void;
|
|
109
|
+
/** The current cockpit route. */
|
|
110
|
+
readonly currentRoute: CockpitRoute;
|
|
102
111
|
/** The stream currently drilled into or replayed, if any. */
|
|
103
112
|
readonly currentStream: string | undefined;
|
|
104
113
|
/** Whether the terminal is showing a LIVE stream or a REPLAYED transcript (undefined when idle). */
|
|
@@ -152,6 +161,9 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
152
161
|
// True while a #refreshPast() fetch is in flight, so the supply poll never stacks past-fetches and a
|
|
153
162
|
// hung transcripts endpoint can't accumulate pending calls.
|
|
154
163
|
#pastRefreshing = false;
|
|
164
|
+
#pastRefreshPending = false;
|
|
165
|
+
#route: CockpitRoute = { kind: "main" };
|
|
166
|
+
#view: SupplyView | undefined;
|
|
155
167
|
// Bumped by every start()/stop() so an in-flight #tick() from a previous start cycle can't
|
|
156
168
|
// reschedule after a stop→start race and leave two overlapping poll chains running.
|
|
157
169
|
#generation = 0;
|
|
@@ -248,6 +260,10 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
248
260
|
return this.#mode;
|
|
249
261
|
}
|
|
250
262
|
|
|
263
|
+
get currentRoute(): CockpitRoute {
|
|
264
|
+
return this.#route;
|
|
265
|
+
}
|
|
266
|
+
|
|
251
267
|
/** Reflect the terminal region's playback mode on the panel (title + `data-terminal-mode`). */
|
|
252
268
|
#setMode(mode: TerminalMode | undefined, stream: string | undefined): void {
|
|
253
269
|
this.#mode = mode;
|
|
@@ -270,47 +286,75 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
270
286
|
}
|
|
271
287
|
if (this.#disposed) return;
|
|
272
288
|
try {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
});
|
|
289
|
+
this.#view = supplyView(report, { staleAfterMs: this.#env.staleAfterMs });
|
|
290
|
+
this.#renderRoute();
|
|
276
291
|
} catch (err) {
|
|
277
292
|
this.#env.onError?.(err);
|
|
278
293
|
}
|
|
279
294
|
// Fire-and-forget: the "past sessions" refresh must never gate the supply poll's next tick. A
|
|
280
295
|
// transcripts endpoint that hangs (not just rejects) would otherwise stall #refresh() forever and
|
|
281
296
|
// wedge the live worker list. #refreshPast is single-flight, so a slow fetch can't pile up either.
|
|
282
|
-
void this.#refreshPast();
|
|
297
|
+
void this.#refreshPast(this.#route.kind === "worker" ? this.#route.instance : undefined);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#renderRoute(): void {
|
|
301
|
+
const view = this.#view;
|
|
302
|
+
if (view === undefined) return;
|
|
303
|
+
if (this.#route.kind === "worker") {
|
|
304
|
+
renderWorkerDetail(this.#listRegion, this.#env.doc, workerDetailView(view, this.#route.instance), {
|
|
305
|
+
onBack: () => this.back(),
|
|
306
|
+
onDrill: (stream) => this.drill(stream),
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
renderSupply(this.#listRegion, this.#env.doc, view, {
|
|
311
|
+
onDrill: (stream) => this.drill(stream),
|
|
312
|
+
onOpenWorker: (instance) => this.openWorker(instance),
|
|
313
|
+
});
|
|
283
314
|
}
|
|
284
315
|
|
|
285
316
|
/** Fetch + render the "past sessions" history list, when a transcript source is wired. Independent
|
|
286
317
|
* of the supply fetch: a transcript-endpoint fault (or hang) never blocks the live worker list. */
|
|
287
|
-
async #refreshPast(): Promise<void> {
|
|
318
|
+
async #refreshPast(instance?: string): Promise<void> {
|
|
288
319
|
const fetchTranscripts = this.#env.fetchTranscripts;
|
|
289
320
|
if (fetchTranscripts === undefined || this.#pastRegion === undefined) return;
|
|
290
321
|
// Single-flight: while one past-fetch is outstanding (including a hung one), skip starting another
|
|
291
322
|
// so the poll can't stack pending fetches against a slow/unresponsive transcripts endpoint.
|
|
292
|
-
if (this.#pastRefreshing)
|
|
323
|
+
if (this.#pastRefreshing) {
|
|
324
|
+
this.#pastRefreshPending = true;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
293
327
|
this.#pastRefreshing = true;
|
|
294
328
|
try {
|
|
295
329
|
let report: TranscriptListReport;
|
|
296
330
|
try {
|
|
297
|
-
report = await this.#bounded(fetchTranscripts, "transcripts");
|
|
331
|
+
report = await this.#bounded(() => fetchTranscripts(instance), "transcripts");
|
|
298
332
|
} catch (err) {
|
|
299
333
|
if (this.#disposed) return;
|
|
300
334
|
this.#env.onError?.(err);
|
|
301
335
|
return;
|
|
302
336
|
}
|
|
303
337
|
if (this.#disposed || this.#pastRegion === undefined) return;
|
|
338
|
+
const currentInstance = this.#route.kind === "worker" ? this.#route.instance : undefined;
|
|
339
|
+
if (currentInstance !== instance) {
|
|
340
|
+
this.#pastRefreshPending = true;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
304
343
|
try {
|
|
305
344
|
renderTranscripts(this.#pastRegion, this.#env.doc, transcriptsView(report), {
|
|
306
345
|
onReplay: (stream) => void this.replay(stream),
|
|
307
346
|
...(this.#mode === "replay" && this.#shownStream !== undefined ? { activeStream: this.#shownStream } : {}),
|
|
347
|
+
...(instance !== undefined ? { title: "Job history", emptyText: "No captured sessions for this worker yet." } : {}),
|
|
308
348
|
});
|
|
309
349
|
} catch (err) {
|
|
310
350
|
this.#env.onError?.(err);
|
|
311
351
|
}
|
|
312
352
|
} finally {
|
|
313
353
|
this.#pastRefreshing = false;
|
|
354
|
+
if (this.#pastRefreshPending && !this.#disposed) {
|
|
355
|
+
this.#pastRefreshPending = false;
|
|
356
|
+
void this.#refreshPast(this.#route.kind === "worker" ? this.#route.instance : undefined);
|
|
357
|
+
}
|
|
314
358
|
}
|
|
315
359
|
}
|
|
316
360
|
|
|
@@ -492,10 +536,32 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
492
536
|
replayTranscript(session, data);
|
|
493
537
|
this.#setMode("replay", stream);
|
|
494
538
|
// Re-render the past list so the just-selected session shows as active (best-effort).
|
|
495
|
-
void this.#refreshPast();
|
|
539
|
+
void this.#refreshPast(this.#route.kind === "worker" ? this.#route.instance : undefined);
|
|
540
|
+
} catch (err) {
|
|
541
|
+
this.#env.onError?.(err);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
openWorker(instance: string): void {
|
|
546
|
+
if (this.#disposed) return;
|
|
547
|
+
this.#route = { kind: "worker", instance };
|
|
548
|
+
try {
|
|
549
|
+
this.#renderRoute();
|
|
496
550
|
} catch (err) {
|
|
497
551
|
this.#env.onError?.(err);
|
|
498
552
|
}
|
|
553
|
+
void this.#refreshPast(instance);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
back(): void {
|
|
557
|
+
if (this.#disposed) return;
|
|
558
|
+
this.#route = { kind: "main" };
|
|
559
|
+
try {
|
|
560
|
+
this.#renderRoute();
|
|
561
|
+
} catch (err) {
|
|
562
|
+
this.#env.onError?.(err);
|
|
563
|
+
}
|
|
564
|
+
void this.#refreshPast();
|
|
499
565
|
}
|
|
500
566
|
|
|
501
567
|
dispose(): void {
|
|
@@ -44,13 +44,22 @@ test("renders one leaf section with a worker row per worker (family, host, jobs,
|
|
|
44
44
|
assert.equal(rowB?.byClass("cockpit-supply-jobs")[0]?.text(), "—");
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
test("worker buttons
|
|
47
|
+
test("worker name buttons open the worker detail route and the inline terminal drill still drills", () => {
|
|
48
48
|
const host = new FakeElement("body");
|
|
49
49
|
const drilled: string[] = [];
|
|
50
|
-
|
|
50
|
+
const opened: string[] = [];
|
|
51
|
+
renderSupply(host, doc, supplyView(sample), {
|
|
52
|
+
onDrill: (stream) => drilled.push(stream),
|
|
53
|
+
onOpenWorker: (instance) => opened.push(instance),
|
|
54
|
+
});
|
|
51
55
|
|
|
52
|
-
const
|
|
53
|
-
assert.ok(
|
|
56
|
+
const worker = host.byClass("cockpit-worker").find((b) => b.getAttribute("data-instance") === "wk-a");
|
|
57
|
+
assert.ok(worker, "the worker name button was rendered with its instance id");
|
|
58
|
+
worker?.dispatch("click");
|
|
59
|
+
assert.deepEqual(opened, ["wk-a"]);
|
|
60
|
+
|
|
61
|
+
const button = host.byClass("cockpit-worker-drill").find((b) => b.getAttribute("data-stream") === "wk-a");
|
|
62
|
+
assert.ok(button, "the inline live-terminal drill button was rendered with its stream id");
|
|
54
63
|
button?.dispatch("click");
|
|
55
64
|
assert.deepEqual(drilled, ["wk-a"]);
|
|
56
65
|
});
|
|
@@ -23,6 +23,8 @@ import type { Liveness, SupplyLeafView, SupplyView, SupplyWorkerView } from "./s
|
|
|
23
23
|
export interface RenderSupplyOptions {
|
|
24
24
|
/** Called with a worker's relay stream id when the operator drills into it. */
|
|
25
25
|
readonly onDrill?: (stream: string) => void;
|
|
26
|
+
/** Called with a worker instance when the operator opens its dedicated detail page. */
|
|
27
|
+
readonly onOpenWorker?: (instance: string) => void;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/** Handles into the rendered tree the caller may need. */
|
|
@@ -54,12 +56,22 @@ function workerRow(doc: DocumentLike, worker: SupplyWorkerView, options: RenderS
|
|
|
54
56
|
nameCell.appendChild(dot(doc, worker.liveness));
|
|
55
57
|
const button = el(doc, "button", "cockpit-worker", worker.instance);
|
|
56
58
|
button.setAttribute("type", "button");
|
|
59
|
+
button.setAttribute("data-instance", worker.instance);
|
|
60
|
+
const onOpenWorker = options.onOpenWorker;
|
|
61
|
+
if (onOpenWorker !== undefined) {
|
|
62
|
+
button.addEventListener("click", () => onOpenWorker(worker.instance));
|
|
63
|
+
}
|
|
64
|
+
nameCell.appendChild(button);
|
|
65
|
+
const drill = el(doc, "button", "cockpit-worker-drill", "terminal");
|
|
66
|
+
drill.setAttribute("type", "button");
|
|
67
|
+
drill.setAttribute("data-instance", worker.instance);
|
|
68
|
+
drill.setAttribute("data-stream", worker.stream);
|
|
57
69
|
button.setAttribute("data-stream", worker.stream);
|
|
58
70
|
const onDrill = options.onDrill;
|
|
59
71
|
if (onDrill !== undefined) {
|
|
60
|
-
|
|
72
|
+
drill.addEventListener("click", () => onDrill(worker.stream));
|
|
61
73
|
}
|
|
62
|
-
nameCell.appendChild(
|
|
74
|
+
nameCell.appendChild(drill);
|
|
63
75
|
row.appendChild(nameCell);
|
|
64
76
|
|
|
65
77
|
row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
|
|
@@ -23,6 +23,10 @@ export interface RenderTranscriptsOptions {
|
|
|
23
23
|
readonly onReplay?: (stream: string) => void;
|
|
24
24
|
/** The stream currently being replayed, if any — highlighted in the list. */
|
|
25
25
|
readonly activeStream?: string;
|
|
26
|
+
/** Panel title. Defaults to the global cockpit history label. */
|
|
27
|
+
readonly title?: string;
|
|
28
|
+
/** Empty-state copy. Defaults to the global cockpit history copy. */
|
|
29
|
+
readonly emptyText?: string;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
/** Handles into the rendered tree the caller may need. */
|
|
@@ -74,14 +78,14 @@ export function renderTranscripts(
|
|
|
74
78
|
root.setAttribute("data-session-count", String(view.count));
|
|
75
79
|
|
|
76
80
|
const header = el(doc, "header", "cockpit-past-header");
|
|
77
|
-
header.appendChild(el(doc, "h2", "cockpit-past-title", "Past sessions"));
|
|
81
|
+
header.appendChild(el(doc, "h2", "cockpit-past-title", options.title ?? "Past sessions"));
|
|
78
82
|
const summary = el(doc, "span", "cockpit-past-summary", view.retention !== undefined ? `${view.count} · kept ${view.retention}` : `${view.count}`);
|
|
79
83
|
summary.setAttribute("data-summary", "past");
|
|
80
84
|
header.appendChild(summary);
|
|
81
85
|
root.appendChild(header);
|
|
82
86
|
|
|
83
87
|
if (view.count === 0) {
|
|
84
|
-
const empty = el(doc, "div", "cockpit-past-empty", "No captured sessions yet.");
|
|
88
|
+
const empty = el(doc, "div", "cockpit-past-empty", options.emptyText ?? "No captured sessions yet.");
|
|
85
89
|
empty.setAttribute("data-empty", "true");
|
|
86
90
|
root.appendChild(empty);
|
|
87
91
|
host.appendChild(root);
|
|
@@ -26,6 +26,9 @@ export interface TranscriptSummaryReport {
|
|
|
26
26
|
readonly bpmnProcessId?: string;
|
|
27
27
|
readonly elementId?: string;
|
|
28
28
|
readonly planKey?: string;
|
|
29
|
+
readonly instance?: string;
|
|
30
|
+
readonly identity?: string;
|
|
31
|
+
readonly host?: string;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
/** The transcript list report the cockpit polls (mirrors `AgenticTranscriptList`). */
|
|
@@ -54,6 +57,9 @@ export interface TranscriptView {
|
|
|
54
57
|
readonly byteLength: number;
|
|
55
58
|
/** When the session was captured — completedAt when sealed, else createdAt. */
|
|
56
59
|
readonly capturedAt: string;
|
|
60
|
+
readonly instance?: string;
|
|
61
|
+
readonly identity?: string;
|
|
62
|
+
readonly host?: string;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
/** The full renderable "past sessions" view. */
|
|
@@ -106,6 +112,9 @@ function sessionView(t: TranscriptSummaryReport): TranscriptView {
|
|
|
106
112
|
size: humanBytes(t.byteLength),
|
|
107
113
|
byteLength: t.byteLength,
|
|
108
114
|
capturedAt: t.completedAt ?? t.createdAt,
|
|
115
|
+
...(t.instance !== undefined ? { instance: t.instance } : {}),
|
|
116
|
+
...(t.identity !== undefined ? { identity: t.identity } : {}),
|
|
117
|
+
...(t.host !== undefined ? { host: t.host } : {}),
|
|
109
118
|
};
|
|
110
119
|
}
|
|
111
120
|
|