@nanobpm/nano-workforce 0.127.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/release.yml +29 -6
- package/AGENTS.md +19 -0
- package/CHANGELOG.md +7 -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/db/migrations/078_agentic_correlation.sql +32 -0
- package/openapi.yaml +26 -0
- package/operations/getAgenticTranscript.ts +3 -2
- package/operations/listAgenticTranscripts.ts +2 -1
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +65 -2
- package/pages/cockpit/mount.js +187 -16
|
@@ -74,22 +74,45 @@ jobs:
|
|
|
74
74
|
if: needs.gate.outputs.should_release == 'true'
|
|
75
75
|
runs-on: ubuntu-latest
|
|
76
76
|
permissions:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
80
81
|
id-token: write # OIDC token for npm Trusted Publishing (no NPM_TOKEN)
|
|
81
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
|
+
|
|
82
96
|
- name: Checkout
|
|
83
97
|
uses: actions/checkout@v4
|
|
84
98
|
with:
|
|
85
99
|
ref: ${{ github.event.workflow_run.head_sha }} # release the exact gated commit
|
|
86
100
|
fetch-depth: 0 # semantic-release needs full history + tags
|
|
87
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"
|
|
88
111
|
|
|
89
112
|
- name: Configure git author
|
|
90
113
|
run: |
|
|
91
|
-
git config user.name "
|
|
92
|
-
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"
|
|
93
116
|
|
|
94
117
|
- name: Setup Node.js
|
|
95
118
|
uses: actions/setup-node@v4
|
|
@@ -107,7 +130,7 @@ jobs:
|
|
|
107
130
|
|
|
108
131
|
- name: Release
|
|
109
132
|
env:
|
|
110
|
-
GITHUB_TOKEN: ${{
|
|
133
|
+
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # bypass-actor App token
|
|
111
134
|
# No NPM_TOKEN / NODE_AUTH_TOKEN: publishing uses OIDC Trusted Publishing.
|
|
112
135
|
NPM_CONFIG_PROVENANCE: "true"
|
|
113
136
|
run: npx semantic-release
|
package/AGENTS.md
CHANGED
|
@@ -388,6 +388,25 @@ already reddened `main`. Grouping is `ALLGREEN` (SQUASH merge), so any red entry
|
|
|
388
388
|
invalidates the whole batch. `Conventional PR title` passes through on
|
|
389
389
|
`merge_group` (the title was validated when the PR entered the queue).
|
|
390
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
|
+
|
|
391
410
|
## Distributed fleet: NANO_WORKFORCE_BASE_URL
|
|
392
411
|
|
|
393
412
|
The abandon and blackboard hooks are how a **distributed worker fleet** calls back
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
|
|
1
8
|
# [0.127.0](https://github.com/nanobpm/nano-workforce/compare/v0.126.0...v0.127.0) (2026-08-23)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -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
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
|
|
5
|
+
import { renderWorkerDetail } from "./worker-detail-render.ts";
|
|
6
|
+
import { supplyView, type SupplyReport } from "./supply-view.ts";
|
|
7
|
+
import { workerDetailView } from "./worker-detail-view.ts";
|
|
8
|
+
|
|
9
|
+
const doc = new FakeDocument();
|
|
10
|
+
|
|
11
|
+
const report: SupplyReport = {
|
|
12
|
+
count: 1,
|
|
13
|
+
workers: [
|
|
14
|
+
{
|
|
15
|
+
instance: "wk-a",
|
|
16
|
+
identity: "leaf-1",
|
|
17
|
+
stream: "job:6494",
|
|
18
|
+
family: "senior",
|
|
19
|
+
host: "h1",
|
|
20
|
+
jobKeys: ["6494"],
|
|
21
|
+
live: true,
|
|
22
|
+
staleMs: 0,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
leaves: [
|
|
26
|
+
{
|
|
27
|
+
token: "leaf-1",
|
|
28
|
+
workers: [
|
|
29
|
+
{
|
|
30
|
+
instance: "wk-a",
|
|
31
|
+
identity: "leaf-1",
|
|
32
|
+
stream: "job:6494",
|
|
33
|
+
family: "senior",
|
|
34
|
+
host: "h1",
|
|
35
|
+
jobKeys: ["6494"],
|
|
36
|
+
live: true,
|
|
37
|
+
staleMs: 0,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
correlations: [
|
|
43
|
+
{
|
|
44
|
+
jobKey: "6494",
|
|
45
|
+
stream: "job:6494",
|
|
46
|
+
bpmnProcessId: "plan-fanout",
|
|
47
|
+
processInstanceKey: "4612",
|
|
48
|
+
planKey: "o/r#142",
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
test("derives and renders the worker detail header and current job drill", () => {
|
|
54
|
+
const host = new FakeElement("body");
|
|
55
|
+
const drilled: string[] = [];
|
|
56
|
+
const backed: string[] = [];
|
|
57
|
+
const detail = workerDetailView(supplyView(report), "wk-a");
|
|
58
|
+
renderWorkerDetail(host, doc, detail, { onBack: () => backed.push("back"), onDrill: (stream) => drilled.push(stream) });
|
|
59
|
+
|
|
60
|
+
assert.equal(host.byData("worker-detail", "wk-a").length, 1);
|
|
61
|
+
assert.equal(host.byClass("cockpit-worker-detail-identity")[0]?.text(), "leaf-1");
|
|
62
|
+
assert.equal(host.byClass("cockpit-worker-detail-family")[0]?.text(), "senior");
|
|
63
|
+
assert.equal(host.byClass("cockpit-worker-detail-host")[0]?.text(), "h1");
|
|
64
|
+
assert.equal(host.byData("liveness", "live").length >= 1, true);
|
|
65
|
+
|
|
66
|
+
const job = host.byClass("cockpit-worker-current-job")[0];
|
|
67
|
+
assert.equal(job?.getAttribute("data-job-key"), "6494");
|
|
68
|
+
assert.equal(job?.getAttribute("data-stream"), "job:6494");
|
|
69
|
+
assert.equal(job?.text(), "plan-fanout · inst 4612 · o/r#142");
|
|
70
|
+
job?.dispatch("click");
|
|
71
|
+
assert.deepEqual(drilled, ["job:6494"]);
|
|
72
|
+
|
|
73
|
+
host.byClass("cockpit-worker-detail-back")[0]?.dispatch("click");
|
|
74
|
+
assert.deepEqual(backed, ["back"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("renders a missing-worker detail state with a back action", () => {
|
|
78
|
+
const host = new FakeElement("body");
|
|
79
|
+
const backed: string[] = [];
|
|
80
|
+
renderWorkerDetail(host, doc, workerDetailView(supplyView(report), "missing"), { onBack: () => backed.push("back") });
|
|
81
|
+
|
|
82
|
+
assert.equal(host.byData("worker-missing", "missing").length, 1);
|
|
83
|
+
assert.equal(host.byClass("cockpit-worker-detail-empty")[0]?.text(), "Worker missing is not in the current supply report.");
|
|
84
|
+
host.byClass("cockpit-worker-detail-back")[0]?.dispatch("click");
|
|
85
|
+
assert.deepEqual(backed, ["back"]);
|
|
86
|
+
});
|