@nanobpm/nano-workforce 0.55.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +9 -1
- package/SPEC.md +6 -2
- package/app/agentGuide.ts +1 -1
- package/app/agentic/cockpit/supply-render.test.ts +36 -0
- package/app/agentic/cockpit/supply-render.ts +22 -1
- package/app/agentic/cockpit/supply-view.test.ts +40 -0
- package/app/agentic/cockpit/supply-view.ts +81 -3
- package/app/agentic/correlation.test.ts +132 -0
- package/app/agentic/correlation.ts +193 -0
- package/app/agentic/families/correlation.family.test.ts +47 -0
- package/app/agentic/families/correlation.family.ts +39 -0
- package/app/github.test.ts +179 -1
- package/app/github.ts +132 -0
- package/app/plan.test.ts +268 -20
- package/app/plan.ts +147 -15
- package/docs/agentic-cockpit.md +135 -0
- package/nano.app.json +4 -0
- package/openapi.yaml +89 -12
- package/operations/getAgenticSupply.test.ts +40 -0
- package/operations/getAgenticSupply.ts +32 -9
- package/operations/startAndMessage.test.ts +62 -2
- package/operations/startPlanFanout.admission.integration.test.ts +263 -0
- package/operations/startPlanFanout.ts +70 -11
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +17 -0
- package/pages/cockpit/mount.js +35 -4
- package/pages/epic.page.json +4 -1
- package/resources/agent-guide.md +38 -2
- package/resources/processes/plan-fanout.bpmn +168 -149
- package/test/agentic-e2e.test.ts +258 -0
- package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
- package/workers/ensure-base-branch/worker.test.ts +104 -0
- package/workers/ensure-base-branch/worker.ts +31 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# The agentic visibility cockpit — operator guide
|
|
2
|
+
|
|
3
|
+
> **Scope: SUPPLY side only.** This guide covers the *supply* half of the agentic
|
|
4
|
+
> visibility plane (ADR 0056) — the live worker registry and the drill-into-a-worker
|
|
5
|
+
> terminal. The **demand** side — the demand×supply matrix by network,
|
|
6
|
+
> missing-agent-type reds, and diversity-SLO lights — is a separate concern,
|
|
7
|
+
> deferred to the **enrolment epic #152**. Nothing here shows demand.
|
|
8
|
+
|
|
9
|
+
## What the cockpit shows you
|
|
10
|
+
|
|
11
|
+
The cockpit is a read-only, advisory window onto the fleet of agentic workers
|
|
12
|
+
connected to this app. It answers two operator questions:
|
|
13
|
+
|
|
14
|
+
1. **Who is here?** — every connected worker, grouped by the leaf token it
|
|
15
|
+
authenticated under, with its declared **family** and **host**, its current
|
|
16
|
+
**jobs**, the **process instance / plan** each job belongs to, and a
|
|
17
|
+
**liveness** dot (live / stale / down).
|
|
18
|
+
2. **What is that worker doing right now?** — click a worker (or a specific
|
|
19
|
+
process/plan) to open its **live terminal**, streamed off the relay.
|
|
20
|
+
|
|
21
|
+
It is **advisory**: it never gates, locks, or influences any BPMN sequence flow.
|
|
22
|
+
Turning the cockpit off changes nothing about how work runs — it only changes
|
|
23
|
+
what you can *see*.
|
|
24
|
+
|
|
25
|
+
## The architecture in one breath
|
|
26
|
+
|
|
27
|
+
The cockpit rides the **agentic channel** — one WebSocket the app serves on its
|
|
28
|
+
*own* port at `/agentic`, alongside its pages and hooks (no sidecar port). Four
|
|
29
|
+
cooperating families sit on that channel, each mounted through a single
|
|
30
|
+
extension **seam** (`app/agentic/registry.ts`) so no family ever touches the boot
|
|
31
|
+
script:
|
|
32
|
+
|
|
33
|
+
| Family | Module | What it owns |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| **presence** (H1) | `app/agentic/families/presence.family.ts` | The live worker registry over the app's SQLite store — REGISTER / heartbeat / disconnect. |
|
|
36
|
+
| **relay** (H3) | `app/agentic/families/relay.family.ts` | The bounded replay ring + three-lane QoS scheduler + transcript store — the terminal stream. |
|
|
37
|
+
| **blackboard** (H4) | `app/agentic/families/blackboard.family.ts` | The advisory coordination blackboard. |
|
|
38
|
+
| **correlation** (H6) | `app/agentic/families/correlation.family.ts` | The jobKey ⇄ process-instance / plan join. |
|
|
39
|
+
|
|
40
|
+
The supply report the cockpit polls is served by
|
|
41
|
+
`GET /app/api/agentic/supply` (`operations/getAgenticSupply.ts`), which projects
|
|
42
|
+
the presence snapshot — enriched with correlation — into the view.
|
|
43
|
+
|
|
44
|
+
## Reading a worker row
|
|
45
|
+
|
|
46
|
+
Each row in a leaf-token section is one connected worker:
|
|
47
|
+
|
|
48
|
+
- **worker** — the worker instance id. Click it to drill into its terminal on its
|
|
49
|
+
default stream.
|
|
50
|
+
- **family** — the declared agent family (e.g. `senior`, `junior`), or `—`.
|
|
51
|
+
- **host** — where the worker runs, or `—`.
|
|
52
|
+
- **jobs** — the jobKeys the worker is currently processing. Empty (`—`) when the
|
|
53
|
+
worker is idle *or* when nothing has correlated a job to it yet.
|
|
54
|
+
- **process / plan** — the engine context for each current job: the BPMN process,
|
|
55
|
+
element, process-instance key, and plan/epic key, rendered as
|
|
56
|
+
`plan-fanout · implement-task · inst 4612 · owner/repo#142`. **Click it to open
|
|
57
|
+
that job's live terminal** (`job:<jobKey>`), not just the worker's default
|
|
58
|
+
stream.
|
|
59
|
+
- **liveness** — `live` (heartbeating), `stale` (no refresh past the threshold,
|
|
60
|
+
default 15 s), or `down` (disconnected). Rendered as a coloured dot.
|
|
61
|
+
|
|
62
|
+
### How jobs and process/plan get populated — the correlation seam (H6)
|
|
63
|
+
|
|
64
|
+
A worker's channel frames don't carry job attribution — the relay only knows a
|
|
65
|
+
*stream id*. So correlation is an explicit, advisory **registry**
|
|
66
|
+
(`app/agentic/correlation.ts`) that the orchestrator populates when it dispatches
|
|
67
|
+
an agentic job:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { currentCorrelation } from "./app/agentic/correlation.ts";
|
|
71
|
+
|
|
72
|
+
// When a worker instance picks up a Camunda-8 job:
|
|
73
|
+
currentCorrelation()?.link("wk-a", jobKey, {
|
|
74
|
+
processInstanceKey,
|
|
75
|
+
bpmnProcessId,
|
|
76
|
+
elementId,
|
|
77
|
+
planKey, // e.g. owner/repo#142
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// When the job finishes (or the worker disconnects):
|
|
81
|
+
currentCorrelation()?.releaseJob(jobKey); // one job
|
|
82
|
+
currentCorrelation()?.releaseInstance("wk-a"); // every job the worker held
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
One `link` write is the single canonical join — it projects to **both**
|
|
86
|
+
directions the cockpit needs:
|
|
87
|
+
|
|
88
|
+
- `instance → jobKeys` feeds the presence snapshot's `jobKeysFor` seam, so a
|
|
89
|
+
worker's **jobs** column lights up;
|
|
90
|
+
- `jobKey → context` (with the derived `job:<jobKey>` **stream**) drives the
|
|
91
|
+
**process / plan** cell and the drill-in.
|
|
92
|
+
|
|
93
|
+
A jobKey belongs to at most one worker at a time — re-linking it moves it. The
|
|
94
|
+
relay stream a job's terminal rides is always `job:<jobKey>` (see
|
|
95
|
+
`jobStream` / `jobKeyOfStream` in `app/agentic/correlation.ts`); repointing the
|
|
96
|
+
drill stream there is what lets you open the *live job's* terminal from the
|
|
97
|
+
process/plan cell.
|
|
98
|
+
|
|
99
|
+
If the correlation family is not mounted (or nothing has linked a job), the
|
|
100
|
+
report still serves — jobs stay empty and every worker drills into its default
|
|
101
|
+
instance stream. Correlation is **additive and advisory**; its absence never
|
|
102
|
+
errors.
|
|
103
|
+
|
|
104
|
+
## Drilling into a worker — resume-from-offset
|
|
105
|
+
|
|
106
|
+
Clicking a worker (or a process/plan) opens a `TerminalSession`
|
|
107
|
+
(`@nanobpm/agentic/cockpit`) subscribed to the relay stream. The session is
|
|
108
|
+
**resume-from-offset**: it tracks the offset just past the last chunk it applied,
|
|
109
|
+
and on every (re)connect it re-subscribes from there. This means:
|
|
110
|
+
|
|
111
|
+
- A **cockpit reconnect** replays only the un-applied tail — no lost output, no
|
|
112
|
+
double-printed lines (within the ring's retained window).
|
|
113
|
+
- A **hub restart** (the ring is in memory and is lost; the app's SQLite store is
|
|
114
|
+
durable) is survived the same way: the worker reconnects and replays its
|
|
115
|
+
transcript on a bumped incarnation, and your terminal resumes from its own
|
|
116
|
+
offset — receiving only what it hadn't already seen. Incarnation fencing stops
|
|
117
|
+
a stale producer from double-attaching. This exact path is pinned by the
|
|
118
|
+
end-to-end wiring test (`test/agentic-e2e.test.ts`).
|
|
119
|
+
|
|
120
|
+
## Liveness and cleanup
|
|
121
|
+
|
|
122
|
+
Presence rows are kept live by worker heartbeats and removed on disconnect or
|
|
123
|
+
when a worker ages out past the liveness TTL. On (re)mount the presence family
|
|
124
|
+
reconciles the store against live connections, so a worker that vanished while
|
|
125
|
+
the app was down does not linger as a ghost row after a restart.
|
|
126
|
+
|
|
127
|
+
## What you will NOT find here (and where it lives)
|
|
128
|
+
|
|
129
|
+
- **Demand×supply matrix, missing-agent-type reds, diversity-SLO lights** →
|
|
130
|
+
enrolment epic **#152**. They depend on the vocab / capability→SERVE /
|
|
131
|
+
diversity-SLO machinery this epic deliberately de-scopes. This report carries
|
|
132
|
+
no demand-side fields and the renderer draws none.
|
|
133
|
+
- **Engine / job-protocol changes** → none. The visibility plane is app-tier
|
|
134
|
+
only; the Camunda-8 worker⇄engine job protocol is untouched. The agentic
|
|
135
|
+
channel is the only new conversation.
|
package/nano.app.json
CHANGED
package/openapi.yaml
CHANGED
|
@@ -127,7 +127,7 @@ components:
|
|
|
127
127
|
description: Declared host (where the worker runs), if any.
|
|
128
128
|
jobKeys:
|
|
129
129
|
type: array
|
|
130
|
-
description: The jobKeys this worker is currently processing (
|
|
130
|
+
description: The jobKeys this worker is currently processing (populated by the H6 correlation registry).
|
|
131
131
|
items:
|
|
132
132
|
type: string
|
|
133
133
|
live:
|
|
@@ -149,6 +149,32 @@ components:
|
|
|
149
149
|
type: array
|
|
150
150
|
items:
|
|
151
151
|
$ref: "#/components/schemas/AgenticSupplyWorker"
|
|
152
|
+
AgenticJobCorrelation:
|
|
153
|
+
type: object
|
|
154
|
+
description: One current job's engine context (H6) — lines a worker's terminal up with its process
|
|
155
|
+
instance / plan. The relay stream a job's terminal is on is the jobKey-scoped stream `job:<jobKey>`.
|
|
156
|
+
required:
|
|
157
|
+
- jobKey
|
|
158
|
+
- stream
|
|
159
|
+
properties:
|
|
160
|
+
jobKey:
|
|
161
|
+
type: string
|
|
162
|
+
description: The Camunda-8 job key the worker activated.
|
|
163
|
+
stream:
|
|
164
|
+
type: string
|
|
165
|
+
description: The relay stream id the job's terminal is relayed on (`job:<jobKey>`).
|
|
166
|
+
processInstanceKey:
|
|
167
|
+
type: string
|
|
168
|
+
description: The owning process instance key, if known.
|
|
169
|
+
bpmnProcessId:
|
|
170
|
+
type: string
|
|
171
|
+
description: The BPMN process id the job belongs to, if known.
|
|
172
|
+
elementId:
|
|
173
|
+
type: string
|
|
174
|
+
description: The BPMN element id (activity/task) the job is for, if known.
|
|
175
|
+
planKey:
|
|
176
|
+
type: string
|
|
177
|
+
description: The plan / epic key this job is part of (e.g. owner/repo#142), if known.
|
|
152
178
|
AgenticSupplyReport:
|
|
153
179
|
type: object
|
|
154
180
|
description: The SUPPLY-ONLY visibility report — the live worker list grouped by leaf. No demand-side
|
|
@@ -172,6 +198,12 @@ components:
|
|
|
172
198
|
type: array
|
|
173
199
|
items:
|
|
174
200
|
$ref: "#/components/schemas/AgenticSupplyLeaf"
|
|
201
|
+
correlations:
|
|
202
|
+
type: array
|
|
203
|
+
description: The engine context (process instance / plan) for every jobKey currently being
|
|
204
|
+
processed, so the cockpit can line each worker's terminal up with its process instance / plan (H6).
|
|
205
|
+
items:
|
|
206
|
+
$ref: "#/components/schemas/AgenticJobCorrelation"
|
|
175
207
|
VersionInfo:
|
|
176
208
|
type: object
|
|
177
209
|
description: The running app's identity (which code is actually live).
|
|
@@ -329,9 +361,10 @@ components:
|
|
|
329
361
|
per-request review-only override; defaults to false (the global auto-merge default applies).
|
|
330
362
|
PlanStart:
|
|
331
363
|
description: The start-plan-fanout request body. Names the target issue by EXACTLY ONE of
|
|
332
|
-
`issue` (an `owner/repo#123` reference) or `url` (a bare issue URL)
|
|
333
|
-
|
|
334
|
-
|
|
364
|
+
`issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a REQUIRED
|
|
365
|
+
`baseBranch` (ADR 0003) the fleet branches off — a blank/absent base is a 400. Modeled as
|
|
366
|
+
`oneOf` named variants (Camunda REST v2 pattern) so an ambiguous or empty target is a 400 at
|
|
367
|
+
the edge, not a silent `issue ?? url` coalesce in the delegate.
|
|
335
368
|
oneOf:
|
|
336
369
|
- $ref: "#/components/schemas/PlanStartByIssue"
|
|
337
370
|
- $ref: "#/components/schemas/PlanStartByUrl"
|
|
@@ -340,33 +373,77 @@ components:
|
|
|
340
373
|
additionalProperties: false
|
|
341
374
|
required:
|
|
342
375
|
- issue
|
|
376
|
+
- baseBranch
|
|
343
377
|
properties:
|
|
344
378
|
issue:
|
|
345
379
|
type: string
|
|
346
380
|
description: "Issue reference: owner/repo#123."
|
|
347
381
|
baseBranch:
|
|
348
382
|
type: string
|
|
383
|
+
minLength: 1
|
|
384
|
+
maxLength: 255
|
|
385
|
+
pattern: '\S'
|
|
386
|
+
description: >-
|
|
387
|
+
REQUIRED target branch the fleet branches off and opens every PR against. Every epic
|
|
388
|
+
launch must name its base explicitly (ADR 0003): a blank/absent value is rejected with a
|
|
389
|
+
400, not silently coalesced to the repository default branch. Use it to land an entire
|
|
390
|
+
epic on a long-lived integration branch (e.g. `epic/agent-protocol`) so nothing reaches
|
|
391
|
+
the default branch — and any merge-to-default side effect, such as auto-publishing a
|
|
392
|
+
package — until you deliberately merge the integration branch. NOTE: this slice (B0)
|
|
393
|
+
only enforces that `baseBranch` is present and a plausible branch name; branch-existence
|
|
394
|
+
admission (auto-creating a missing `epic/*` base off the default branch HEAD, and
|
|
395
|
+
rejecting a missing non-`epic/*` base with a 400) is specified by ADR 0003 but NOT yet
|
|
396
|
+
enforced here — it lands in a later admission slice.
|
|
397
|
+
allowSharedBase:
|
|
398
|
+
type: boolean
|
|
399
|
+
description: >-
|
|
400
|
+
Reserved for a later ADR 0003 admission slice — NOT yet enforced in this slice (B0). When
|
|
401
|
+
implemented it will opt in to sharing a custom integration base branch with another
|
|
402
|
+
already-active plan: admission will otherwise reject (409) when another active plan already
|
|
403
|
+
targets the same repo + same custom base branch, to stop two epics interleaving commits on
|
|
404
|
+
one integration branch (the repository default branch is exempt from that guard). Accepted
|
|
405
|
+
by the schema today but currently has no runtime effect.
|
|
406
|
+
confirmDefaultBase:
|
|
407
|
+
type: boolean
|
|
349
408
|
description: >-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
branch
|
|
353
|
-
merge-to-default side effect
|
|
354
|
-
|
|
355
|
-
|
|
409
|
+
Reserved for a later ADR 0003 admission slice — NOT yet enforced in this slice (B0). When
|
|
410
|
+
implemented it will be the required acknowledgement when `baseBranch` names the repository
|
|
411
|
+
default branch: targeting the default lands every task directly on it with no integration
|
|
412
|
+
buffer — and fires any merge-to-default side effect per task — so admission will reject
|
|
413
|
+
(400) unless confirmed with true (no effect for a non-default base). Accepted by the schema
|
|
414
|
+
today but currently has no runtime effect.
|
|
356
415
|
PlanStartByUrl:
|
|
357
416
|
type: object
|
|
358
417
|
additionalProperties: false
|
|
359
418
|
required:
|
|
360
419
|
- url
|
|
420
|
+
- baseBranch
|
|
361
421
|
properties:
|
|
362
422
|
url:
|
|
363
423
|
type: string
|
|
364
424
|
description: A bare issue URL, when no `owner/repo#123` reference is supplied.
|
|
365
425
|
baseBranch:
|
|
366
426
|
type: string
|
|
427
|
+
minLength: 1
|
|
428
|
+
maxLength: 255
|
|
429
|
+
pattern: '\S'
|
|
430
|
+
description: >-
|
|
431
|
+
REQUIRED target branch the fleet branches off and opens every PR against. See
|
|
432
|
+
`PlanStartByIssue.baseBranch`.
|
|
433
|
+
allowSharedBase:
|
|
434
|
+
type: boolean
|
|
435
|
+
description: >-
|
|
436
|
+
Reserved for a later ADR 0003 admission slice — NOT yet enforced in this slice (B0).
|
|
437
|
+
Accepted by the schema today but currently has no runtime effect. When implemented it
|
|
438
|
+
will opt in to sharing a custom integration base branch with another already-active plan.
|
|
439
|
+
See `PlanStartByIssue.allowSharedBase`.
|
|
440
|
+
confirmDefaultBase:
|
|
441
|
+
type: boolean
|
|
367
442
|
description: >-
|
|
368
|
-
|
|
369
|
-
|
|
443
|
+
Reserved for a later ADR 0003 admission slice — NOT yet enforced in this slice (B0).
|
|
444
|
+
Accepted by the schema today but currently has no runtime effect. When implemented it
|
|
445
|
+
will be the required acknowledgement when `baseBranch` names the repository default
|
|
446
|
+
branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
370
447
|
MessageResult:
|
|
371
448
|
type: object
|
|
372
449
|
description: The result of publishing a message / answering an escalation. Shape varies by message
|
|
@@ -12,6 +12,8 @@ import { encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
|
|
|
12
12
|
import type { SqliteDb } from "@nanobpm/agentic/presence";
|
|
13
13
|
import type { AppApi, DataLayer } from "@nanobpm/urban";
|
|
14
14
|
import { assert, assertEquals } from "#test-assert";
|
|
15
|
+
import { currentCorrelation } from "../app/agentic/correlation.ts";
|
|
16
|
+
import { family as correlationFamily } from "../app/agentic/families/correlation.family.ts";
|
|
15
17
|
import { family } from "../app/agentic/families/presence.family.ts";
|
|
16
18
|
import type { AgenticContext } from "../app/agentic/registry.ts";
|
|
17
19
|
import { noopLog } from "../test/log.ts";
|
|
@@ -151,3 +153,41 @@ test("shared-secret guard rejects a missing secret when configured", async () =>
|
|
|
151
153
|
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
152
154
|
}
|
|
153
155
|
});
|
|
156
|
+
|
|
157
|
+
test("H6: with the correlation family mounted, jobKeys populate, stream repoints, and correlations are reported", async () => {
|
|
158
|
+
const hub = await mountPresence(memSqlite());
|
|
159
|
+
correlationFamily.mount({
|
|
160
|
+
hub,
|
|
161
|
+
registry: hub.registry,
|
|
162
|
+
transport: undefined as never,
|
|
163
|
+
data: undefined,
|
|
164
|
+
log: noopLog(),
|
|
165
|
+
});
|
|
166
|
+
const correlation = currentCorrelation();
|
|
167
|
+
assert(correlation !== undefined, "the correlation family installs the singleton");
|
|
168
|
+
correlation.link("wk-a", "6494", { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "o/r#142" });
|
|
169
|
+
try {
|
|
170
|
+
const res = (await handler(input(), app)) as {
|
|
171
|
+
status: number;
|
|
172
|
+
body: {
|
|
173
|
+
workers: Array<Record<string, unknown>>;
|
|
174
|
+
correlations: Array<Record<string, unknown>>;
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
assertEquals(res.status, 200);
|
|
178
|
+
const w = res.body.workers[0];
|
|
179
|
+
assertEquals(w.jobKeys, ["6494"], "the correlation registry feeds the jobKeys seam");
|
|
180
|
+
assertEquals(w.stream, "job:6494", "the drill stream repoints at the live job's stream");
|
|
181
|
+
assertEquals(res.body.correlations.length, 1);
|
|
182
|
+
const c = res.body.correlations[0];
|
|
183
|
+
assertEquals(c.jobKey, "6494");
|
|
184
|
+
assertEquals(c.stream, "job:6494");
|
|
185
|
+
assertEquals(c.processInstanceKey, "4612");
|
|
186
|
+
assertEquals(c.bpmnProcessId, "plan-fanout");
|
|
187
|
+
assertEquals(c.planKey, "o/r#142");
|
|
188
|
+
} finally {
|
|
189
|
+
correlationFamily.teardown?.();
|
|
190
|
+
family.teardown?.();
|
|
191
|
+
await hub.close();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// worker list — family, host, current jobs, liveness — grouped by leaf token, sourced from the H1
|
|
4
4
|
// presence registry (#144). Read-only projection; it NEVER gates control flow (advisory-only, ADR 0056).
|
|
5
5
|
//
|
|
6
|
+
// H6 (#149) closes the loop: the correlation registry (`app/agentic/correlation.ts`) supplies the
|
|
7
|
+
// `jobKeysFor` resolver the presence snapshot exposes as a seam, so each worker's current jobKeys light
|
|
8
|
+
// up; each worker's drill `stream` is repointed at its jobKey-scoped relay stream (`job:<jobKey>`); and
|
|
9
|
+
// the report carries the `correlations` — the process-instance / plan context for every current job —
|
|
10
|
+
// so the cockpit lines a worker's terminal up with "that process instance / this plan".
|
|
11
|
+
//
|
|
6
12
|
// This is the supply half of the visibility plane only. The demand×supply matrix, missing-agent-type
|
|
7
13
|
// reds, and diversity-SLO lights are DE-SCOPED to the enrolment epic #152 — this report carries no
|
|
8
14
|
// demand-side fields, and the cockpit renders none.
|
|
@@ -10,22 +16,24 @@
|
|
|
10
16
|
// The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`): when
|
|
11
17
|
// NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset → open.
|
|
12
18
|
|
|
19
|
+
import { type CorrelationRegistry, currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
|
|
13
20
|
import { currentPresenceRegistry, type SupplyWorker } from "../app/agentic/families/presence.family.ts";
|
|
14
21
|
import { envVar } from "../app/version.ts";
|
|
15
|
-
import type { AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
|
|
22
|
+
import type { AgenticJobCorrelation, AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
|
|
16
23
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
17
24
|
|
|
18
25
|
// The optional shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via
|
|
19
26
|
// the x-hook-secret header. Captured once, at module load.
|
|
20
27
|
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
21
28
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
29
|
+
// Project a presence-registry row to the wire worker. The drill `stream` defaults to the worker
|
|
30
|
+
// instance (H5) but is repointed at the worker's jobKey-scoped relay stream (`job:<jobKey>`) when the
|
|
31
|
+
// correlation registry knows a current job for it (H6) — so drilling in opens the LIVE job's terminal.
|
|
32
|
+
function toWorker(w: SupplyWorker, correlation: CorrelationRegistry | undefined): AgenticSupplyWorker {
|
|
25
33
|
const out: AgenticSupplyWorker = {
|
|
26
34
|
instance: w.instance,
|
|
27
35
|
identity: w.identity,
|
|
28
|
-
stream: w.instance,
|
|
36
|
+
stream: correlation?.primaryStreamFor(w.instance) ?? w.instance,
|
|
29
37
|
jobKeys: [...w.jobKeys],
|
|
30
38
|
live: w.live,
|
|
31
39
|
staleMs: w.staleMs,
|
|
@@ -35,6 +43,17 @@ function toWorker(w: SupplyWorker): AgenticSupplyWorker {
|
|
|
35
43
|
return out;
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
// Project a correlation-registry entry to the wire correlation. Optional fields are only set when
|
|
47
|
+
// known (biome bans `undefined`-valued keys crossing the boundary).
|
|
48
|
+
function toCorrelation(c: JobCorrelation): AgenticJobCorrelation {
|
|
49
|
+
const out: AgenticJobCorrelation = { jobKey: c.jobKey, stream: c.stream };
|
|
50
|
+
if (c.processInstanceKey !== undefined) out.processInstanceKey = c.processInstanceKey;
|
|
51
|
+
if (c.bpmnProcessId !== undefined) out.bpmnProcessId = c.bpmnProcessId;
|
|
52
|
+
if (c.elementId !== undefined) out.elementId = c.elementId;
|
|
53
|
+
if (c.planKey !== undefined) out.planKey = c.planKey;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
export default defineOperation("getAgenticSupply", async ({ req }, app) => {
|
|
39
58
|
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
40
59
|
app.log.warn("getAgenticSupply rejected: missing/invalid shared secret");
|
|
@@ -44,16 +63,20 @@ export default defineOperation("getAgenticSupply", async ({ req }, app) => {
|
|
|
44
63
|
const registry = currentPresenceRegistry();
|
|
45
64
|
if (!registry) {
|
|
46
65
|
// The presence family has not mounted (or has torn down) — no supply to report, not an error.
|
|
47
|
-
const empty: AgenticSupplyReport = { count: 0, generatedAt: new Date().toISOString(), workers: [], leaves: [] };
|
|
66
|
+
const empty: AgenticSupplyReport = { count: 0, generatedAt: new Date().toISOString(), workers: [], leaves: [], correlations: [] };
|
|
48
67
|
return { status: 200, body: empty };
|
|
49
68
|
}
|
|
50
69
|
|
|
51
|
-
|
|
70
|
+
// Thread the H6 correlation registry (if mounted) as the presence snapshot's jobKeysFor resolver so a
|
|
71
|
+
// worker's current jobKeys populate; absent → jobKeys stay empty (advisory, never an error).
|
|
72
|
+
const correlation = currentCorrelation();
|
|
73
|
+
const snapshot = registry.snapshot(correlation ? { jobKeysFor: (instance) => correlation.jobKeysFor(instance) } : {});
|
|
52
74
|
const report: AgenticSupplyReport = {
|
|
53
75
|
count: snapshot.count,
|
|
54
76
|
generatedAt: new Date().toISOString(),
|
|
55
|
-
workers: snapshot.workers.map(toWorker),
|
|
56
|
-
leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map(toWorker) })),
|
|
77
|
+
workers: snapshot.workers.map((w) => toWorker(w, correlation)),
|
|
78
|
+
leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map((w) => toWorker(w, correlation)) })),
|
|
79
|
+
correlations: correlation ? correlation.snapshot().correlations.map(toCorrelation) : [],
|
|
57
80
|
};
|
|
58
81
|
return { status: 200, body: report };
|
|
59
82
|
});
|
|
@@ -12,6 +12,7 @@ import { noopLog } from "../test/log.ts";
|
|
|
12
12
|
import startConvergenceLoop from "./startConvergenceLoop.ts";
|
|
13
13
|
import startPlanFanout from "./startPlanFanout.ts";
|
|
14
14
|
import postMessage from "./postMessage.ts";
|
|
15
|
+
import { resetDefaultBranchCache } from "../app/github.ts";
|
|
15
16
|
|
|
16
17
|
const app = { log: noopLog() } as any as AppApi;
|
|
17
18
|
|
|
@@ -72,13 +73,60 @@ function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
|
72
73
|
const prevTok = process.env["GITHUB_TOKEN"];
|
|
73
74
|
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token"; // no token → meta fetch is skipped
|
|
74
75
|
delete process.env["GITHUB_TOKEN"];
|
|
76
|
+
resetDefaultBranchCache(); // start cold so a prior warmed cache can't mask the no-transport path
|
|
75
77
|
return run().finally(() => {
|
|
78
|
+
resetDefaultBranchCache();
|
|
76
79
|
if (prev !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prev;
|
|
77
80
|
else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
78
81
|
if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
|
|
79
82
|
});
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
// Force the token transport with a stubbed `globalThis.fetch` that serves a minimal in-memory
|
|
86
|
+
// github model, so `admitPlan` (which now calls `ensureBaseBranch` + `fetchDefaultBranch`) can run
|
|
87
|
+
// without touching the network. The default branch is `main`; an epic/* base is auto-created off it.
|
|
88
|
+
function withGithubStub(run: () => Promise<void>): Promise<void> {
|
|
89
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
90
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
91
|
+
const prevFetch = globalThis.fetch;
|
|
92
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
93
|
+
process.env["GITHUB_TOKEN"] = "tok";
|
|
94
|
+
resetDefaultBranchCache(); // isolate: don't inherit or leak the owner/repo default-branch entry
|
|
95
|
+
const branches = new Map<string, string>([["main", "mainsha"]]);
|
|
96
|
+
globalThis.fetch = ((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
97
|
+
const u = new URL(String(url));
|
|
98
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
99
|
+
const path = u.pathname;
|
|
100
|
+
const json = (obj: unknown, status = 200) =>
|
|
101
|
+
new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
|
|
102
|
+
if (method === "GET" && path === "/repos/owner/repo") return Promise.resolve(json({ default_branch: "main" }));
|
|
103
|
+
const refPrefix = "/repos/owner/repo/git/ref/heads/";
|
|
104
|
+
if (method === "GET" && path.startsWith(refPrefix)) {
|
|
105
|
+
const branch = decodeURIComponent(path.slice(refPrefix.length));
|
|
106
|
+
const sha = branches.get(branch);
|
|
107
|
+
if (sha === undefined) return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
108
|
+
return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha } }));
|
|
109
|
+
}
|
|
110
|
+
if (method === "POST" && path === "/repos/owner/repo/git/refs") {
|
|
111
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
112
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
|
|
113
|
+
const branch = String(body.ref ?? "").replace(/^refs\/heads\//, "");
|
|
114
|
+
if (branches.has(branch)) return Promise.resolve(json({ message: "Reference already exists" }, 422));
|
|
115
|
+
branches.set(branch, String(body.sha ?? ""));
|
|
116
|
+
return Promise.resolve(json({ ref: body.ref }, 201));
|
|
117
|
+
}
|
|
118
|
+
return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
|
|
119
|
+
}) as typeof fetch;
|
|
120
|
+
return run().finally(() => {
|
|
121
|
+
resetDefaultBranchCache();
|
|
122
|
+
globalThis.fetch = prevFetch;
|
|
123
|
+
if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
124
|
+
else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
125
|
+
if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
|
|
126
|
+
else delete process.env["GITHUB_TOKEN"];
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
82
130
|
test("startConvergenceLoop → 400 (not 500) on a missing request body", async () => {
|
|
83
131
|
const res = await startConvergenceLoop(input(undefined), app);
|
|
84
132
|
const r = res as any;
|
|
@@ -133,6 +181,15 @@ test("startPlanFanout → 400 on an invalid baseBranch (not persisted/rendered)"
|
|
|
133
181
|
assertEquals(typeof r.body.error, "string");
|
|
134
182
|
});
|
|
135
183
|
|
|
184
|
+
test("startPlanFanout → 400 on a missing baseBranch (not persisted/rendered)", async () => {
|
|
185
|
+
// A blank/absent baseBranch must be rejected at the edge as a 400 (MissingBaseBranchError),
|
|
186
|
+
// never silently coalesced to the repository default branch (ADR 0003, B0).
|
|
187
|
+
const res = await startPlanFanout(input({ issue: "owner/repo#123" }), app);
|
|
188
|
+
const r = res as any;
|
|
189
|
+
assertEquals(r.status, 400);
|
|
190
|
+
assertEquals(typeof r.body.error, "string");
|
|
191
|
+
});
|
|
192
|
+
|
|
136
193
|
test("startConvergenceLoop narrows the `url` variant (no `pr` key)", async () => {
|
|
137
194
|
await withGithubOff(async () => {
|
|
138
195
|
const { app: capApp } = captureApp();
|
|
@@ -142,9 +199,12 @@ test("startConvergenceLoop narrows the `url` variant (no `pr` key)", async () =>
|
|
|
142
199
|
});
|
|
143
200
|
|
|
144
201
|
test("startPlanFanout narrows the `url` variant (no `issue` key)", async () => {
|
|
145
|
-
await
|
|
202
|
+
await withGithubStub(async () => {
|
|
146
203
|
const { app: capApp } = captureApp();
|
|
147
|
-
const res = await startPlanFanout(
|
|
204
|
+
const res = await startPlanFanout(
|
|
205
|
+
input({ url: "https://github.com/owner/repo/issues/12", baseBranch: "epic/agent-protocol" }),
|
|
206
|
+
capApp,
|
|
207
|
+
);
|
|
148
208
|
assertEquals((res as any).status, 202);
|
|
149
209
|
});
|
|
150
210
|
});
|