@nanobpm/nano-workforce 0.127.0 → 0.129.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 +14 -0
- package/app/abandon.test.ts +16 -2
- package/app/abandon.ts +39 -17
- 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/conformance.test.ts +2 -1
- package/app/conformance.ts +9 -3
- package/app/featureDelivery.test.ts +2 -1
- package/app/instanceTracking.ts +97 -0
- package/app/lineage.test.ts +2 -1
- package/app/lineage.ts +15 -2
- package/app/promotionPoll.test.ts +2 -1
- package/app/retro.test.ts +2 -1
- package/app/retro.ts +9 -2
- package/app/service.test.ts +15 -14
- package/app/service.ts +17 -24
- package/db/migrations/078_agentic_correlation.sql +32 -0
- package/e2e/convergence-loop.e2e.ts +41 -8
- package/openapi.yaml +26 -0
- package/operations/acknowledgeEpic.test.ts +2 -1
- package/operations/checkAbandon.test.ts +2 -1
- package/operations/getAgenticTranscript.ts +3 -2
- package/operations/getLineage.test.ts +2 -1
- 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/test/trackingViews.ts +50 -0
- package/test/worldDb.ts +2 -1
- package/workers/retro-gather/worker.test.ts +2 -1
|
@@ -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,17 @@
|
|
|
1
|
+
# [0.129.0](https://github.com/nanobpm/nano-workforce/compare/v0.128.0...v0.129.0) (2026-08-23)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* adopt ADR-0065 derived instanceTracking read-models (urban 0.81.0) ([#489](https://github.com/nanobpm/nano-workforce/issues/489)) ([ce25501](https://github.com/nanobpm/nano-workforce/commit/ce255013b383eb4d593526c5f34e74aaab535d25)), closes [#318](https://github.com/nanobpm/nano-workforce/issues/318) [nano-workforce#422](https://github.com/nano-workforce/issues/422) [#76](https://github.com/nanobpm/nano-workforce/issues/76)
|
|
7
|
+
|
|
8
|
+
# [0.128.0](https://github.com/nanobpm/nano-workforce/compare/v0.127.0...v0.128.0) (2026-08-23)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **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)
|
|
14
|
+
|
|
1
15
|
# [0.127.0](https://github.com/nanobpm/nano-workforce/compare/v0.126.0...v0.127.0) (2026-08-23)
|
|
2
16
|
|
|
3
17
|
|
package/app/abandon.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Tests for the cooperative abandon-check helpers (issue #76).
|
|
2
2
|
import { test } from "node:test";
|
|
3
|
-
import { assertEquals, assertNotEquals } from "#test-assert";
|
|
3
|
+
import { assertEquals, assertNotEquals, assertRejects } from "#test-assert";
|
|
4
4
|
import type { DataLayer } from "@nanobpm/urban";
|
|
5
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
5
6
|
import {
|
|
6
7
|
abandonStatusForToken,
|
|
7
8
|
abandonTokenFromUrl,
|
|
@@ -26,7 +27,7 @@ function memData(): DataLayer {
|
|
|
26
27
|
},
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
|
-
return { table: (n: string) => tbl(n) } as any as DataLayer;
|
|
30
|
+
return { table: withTrackingViews((n: string) => tbl(n)) } as any as DataLayer;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
async function seedPr(data: DataLayer, pr_key: string, abandon_token: string, status: string) {
|
|
@@ -106,3 +107,16 @@ test("abandonStatusForToken derives abandoned from the row status", async () =>
|
|
|
106
107
|
});
|
|
107
108
|
assertEquals(await abandonStatusForToken(data, "nope"), undefined);
|
|
108
109
|
});
|
|
110
|
+
|
|
111
|
+
test("abandonStatusForToken fails CLOSED on a non-string derived status", async () => {
|
|
112
|
+
// A malformed/missing derived_status must never be reported as `abandoned:false`
|
|
113
|
+
// (that would let a cancelled run proceed with irreversible side effects). It throws
|
|
114
|
+
// instead, which the operation dispatcher maps to a 500 so the agent's `curl -f` aborts.
|
|
115
|
+
const data = memData();
|
|
116
|
+
await data.table("pull_requests", "pr_key").insert({
|
|
117
|
+
pr_key: "o/r#3",
|
|
118
|
+
abandon_token: "weird",
|
|
119
|
+
status: 123,
|
|
120
|
+
});
|
|
121
|
+
await assertRejects(() => abandonStatusForToken(data, "weird"), Error, "is not a string");
|
|
122
|
+
});
|
package/app/abandon.ts
CHANGED
|
@@ -10,23 +10,28 @@
|
|
|
10
10
|
// Design invariants (mirroring the blackboard, app/blackboard.ts):
|
|
11
11
|
// - CAPABILITY URL. The per-PR token IS the credential; the agent curls the exact URL it was
|
|
12
12
|
// handed in its prompt. An unknown token is a 404 (never leaks which PRs exist).
|
|
13
|
-
// - DERIVED, not a separate marker. `abandoned` is read
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// - DERIVED, not a separate marker. `abandoned` is read off the PR's ADR-0065 derived tracking
|
|
14
|
+
// VIEW (`pull_requests__tracking.derived_status`). Since urban 0.81.0 the `instanceTracking`
|
|
15
|
+
// reconciler no longer WRITES 'abandoned' onto the base row on cancel; it feeds urban's instance
|
|
16
|
+
// projection and the `onTerminated.set` edge is recomputed on every read as `derived_status`. So
|
|
17
|
+
// an out-of-band cancel leaves the base `status` at its transient (e.g. `converging`) but the
|
|
18
|
+
// derived view reports 'abandoned' immediately — reading the view keeps the abort-check correct
|
|
19
|
+
// with no new state to sync. (`abandonClosedPr`, #352, still writes 'abandoned' onto the base
|
|
20
|
+
// row directly; the view passes that worker-written terminal through unchanged.)
|
|
17
21
|
// - ADVISORY. Like the blackboard, this never hard-locks; it narrows an unavoidable
|
|
18
22
|
// check-then-push (TOCTOU) window to near-zero. Job fencing in the harness (issue #76 layer 2)
|
|
19
23
|
// is what makes it airtight.
|
|
20
24
|
import type { DataLayer } from "@nanobpm/urban";
|
|
21
25
|
import { publicBaseUrl } from "./blackboard.ts";
|
|
26
|
+
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
22
27
|
|
|
23
|
-
/** The one
|
|
24
|
-
* further. Two disjoint producers
|
|
28
|
+
/** The one derived-status value meaning a PR is terminally abandoned — the run must not be worked on
|
|
29
|
+
* further. Two disjoint producers surface a row here, and both are non-completion terminals that must
|
|
25
30
|
* stop a servicing agent:
|
|
26
|
-
* 1. an explicit **cancel** of a live convergence/merge run (Urban's cancel primitive
|
|
27
|
-
* `instanceTracking` `onTerminated.set`
|
|
31
|
+
* 1. an explicit **cancel** of a live convergence/merge run (Urban's cancel primitive terminates
|
|
32
|
+
* the instance; the `instanceTracking` `onTerminated.set` edge derives `abandoned` on read), and
|
|
28
33
|
* 2. **`abandonClosedPr`** reconciling a wave-member PR that was **closed on GitHub without
|
|
29
|
-
* merging** (#352) — for both `pull_requests` and its `plan_tasks
|
|
34
|
+
* merging** (#352) — for both `pull_requests` and its `plan_tasks` — by writing the base row.
|
|
30
35
|
* Convergence/merge terminal states `converged`/`merged` are NOT abandonment. In either abandoned
|
|
31
36
|
* case a servicing agent should stop, so the abandon-check endpoint treating both as `abandoned:
|
|
32
37
|
* true` is correct. */
|
|
@@ -69,32 +74,49 @@ export function abandonTokenFromUrl(url: string | null | undefined): string | un
|
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
/** Resolve an abandon token back to its PR key, or undefined when the token is unknown.
|
|
77
|
+
/** Resolve an abandon token back to its PR key, or undefined when the token is unknown. Reads the
|
|
78
|
+
* derived tracking VIEW (a strict superset of the base row) so this stays valid post-ADR-0065. */
|
|
73
79
|
export async function prKeyForAbandonToken(
|
|
74
80
|
data: DataLayer,
|
|
75
81
|
token: string,
|
|
76
82
|
): Promise<string | undefined> {
|
|
77
83
|
if (!token) return undefined;
|
|
78
84
|
const row = await data
|
|
79
|
-
.table<{ pr_key: string; abandon_token: string | null }>(
|
|
85
|
+
.table<{ pr_key: string; abandon_token: string | null }>(
|
|
86
|
+
trackingTargetFor("pull_requests").view,
|
|
87
|
+
"pr_key",
|
|
88
|
+
)
|
|
80
89
|
.findOne({ abandon_token: token });
|
|
81
90
|
return row?.pr_key;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
/** The abandon status of a PR, or undefined when the token is unknown.
|
|
93
|
+
/** The abandon status of a PR, or undefined when the token is unknown. Reads the ADR-0065 derived
|
|
94
|
+
* tracking VIEW's `derived_status`, so an out-of-band-cancelled run (whose base row is still
|
|
95
|
+
* `converging`) is correctly reported `abandoned: true` the instant the instance terminates. */
|
|
85
96
|
export async function abandonStatusForToken(
|
|
86
97
|
data: DataLayer,
|
|
87
98
|
token: string,
|
|
88
99
|
): Promise<{ prKey: string; status: string; abandoned: boolean } | undefined> {
|
|
89
100
|
if (!token) return undefined;
|
|
101
|
+
const target = trackingTargetFor("pull_requests");
|
|
90
102
|
const row = await data
|
|
91
|
-
.table<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
103
|
+
.table<
|
|
104
|
+
{ pr_key: string; abandon_token: string | null } & Record<string, unknown>
|
|
105
|
+
>(target.view, "pr_key")
|
|
95
106
|
.findOne({ abandon_token: token });
|
|
96
107
|
if (!row) return undefined;
|
|
97
|
-
|
|
108
|
+
const rawStatus = row[target.statusColumn];
|
|
109
|
+
if (typeof rawStatus !== "string") {
|
|
110
|
+
// Fail CLOSED: a missing/non-string derived_status must never be reported as
|
|
111
|
+
// `abandoned:false`. The abort brief tells agents to proceed on a 200 with
|
|
112
|
+
// `abandoned:false`, so surfacing this as a thrown error (→ 500, which trips the
|
|
113
|
+
// agent's `curl -f` and aborts) is the safe direction for a cancelled run.
|
|
114
|
+
throw new Error(
|
|
115
|
+
`abandonStatusForToken: ${target.view}.${target.statusColumn} is not a string`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const status = rawStatus;
|
|
119
|
+
return { prKey: row.pr_key, status, abandoned: isAbandoned(status) };
|
|
98
120
|
}
|
|
99
121
|
|
|
100
122
|
/** The instruction block appended (verbatim, via `appendPrompt`) to each side-effecting agent's
|
|
@@ -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);
|