@nanobpm/nano-workforce 0.54.0 → 0.56.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/app/agentic/cockpit/index.ts +30 -0
- package/app/agentic/cockpit/supply-boot.test.ts +279 -0
- package/app/agentic/cockpit/supply-boot.ts +282 -0
- package/app/agentic/cockpit/supply-render.test.ts +112 -0
- package/app/agentic/cockpit/supply-render.ts +165 -0
- package/app/agentic/cockpit/supply-view.test.ts +116 -0
- package/app/agentic/cockpit/supply-view.ts +243 -0
- 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/docs/agentic-cockpit.md +135 -0
- package/openapi.yaml +126 -0
- package/operations/getAgenticSupply.test.ts +193 -0
- package/operations/getAgenticSupply.ts +82 -0
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +162 -0
- package/pages/cockpit/embed.html +42 -0
- package/pages/cockpit/mount.js +362 -0
- package/pages/cockpit/standalone.html +46 -0
- package/pages/cockpit.page.json +46 -0
- package/pages/epic-detail.page.json +2 -1
- package/pages/epic.page.json +2 -1
- package/pages/home.page.json +4 -0
- package/test/agentic-cockpit-doubles.ts +136 -0
- package/test/agentic-e2e.test.ts +258 -0
package/openapi.yaml
CHANGED
|
@@ -99,6 +99,111 @@ components:
|
|
|
99
99
|
type: array
|
|
100
100
|
items:
|
|
101
101
|
$ref: "#/components/schemas/ActivePr"
|
|
102
|
+
AgenticSupplyWorker:
|
|
103
|
+
type: object
|
|
104
|
+
description: One connected worker in the supply mirror (H5 cockpit; sourced from the H1 presence registry).
|
|
105
|
+
required:
|
|
106
|
+
- instance
|
|
107
|
+
- identity
|
|
108
|
+
- stream
|
|
109
|
+
- jobKeys
|
|
110
|
+
- live
|
|
111
|
+
- staleMs
|
|
112
|
+
properties:
|
|
113
|
+
instance:
|
|
114
|
+
type: string
|
|
115
|
+
description: The worker instance id.
|
|
116
|
+
identity:
|
|
117
|
+
type: string
|
|
118
|
+
description: The ADR 0028 leaf token the worker registered under.
|
|
119
|
+
stream:
|
|
120
|
+
type: string
|
|
121
|
+
description: The relay stream id to subscribe when drilling into this worker's terminal.
|
|
122
|
+
family:
|
|
123
|
+
type: string
|
|
124
|
+
description: Declared family (enrolment attribute), if any.
|
|
125
|
+
host:
|
|
126
|
+
type: string
|
|
127
|
+
description: Declared host (where the worker runs), if any.
|
|
128
|
+
jobKeys:
|
|
129
|
+
type: array
|
|
130
|
+
description: The jobKeys this worker is currently processing (populated by the H6 correlation registry).
|
|
131
|
+
items:
|
|
132
|
+
type: string
|
|
133
|
+
live:
|
|
134
|
+
type: boolean
|
|
135
|
+
description: Whether the worker's channel connection is still open.
|
|
136
|
+
staleMs:
|
|
137
|
+
type: integer
|
|
138
|
+
description: Milliseconds since the last liveness refresh (0 when fresh).
|
|
139
|
+
AgenticSupplyLeaf:
|
|
140
|
+
type: object
|
|
141
|
+
description: The supply for one leaf token — the workers registered under it.
|
|
142
|
+
required:
|
|
143
|
+
- token
|
|
144
|
+
- workers
|
|
145
|
+
properties:
|
|
146
|
+
token:
|
|
147
|
+
type: string
|
|
148
|
+
workers:
|
|
149
|
+
type: array
|
|
150
|
+
items:
|
|
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.
|
|
178
|
+
AgenticSupplyReport:
|
|
179
|
+
type: object
|
|
180
|
+
description: The SUPPLY-ONLY visibility report — the live worker list grouped by leaf. No demand-side
|
|
181
|
+
fields (the demand×supply matrix / diversity SLO are deferred to enrolment epic #152).
|
|
182
|
+
required:
|
|
183
|
+
- count
|
|
184
|
+
- workers
|
|
185
|
+
- leaves
|
|
186
|
+
properties:
|
|
187
|
+
count:
|
|
188
|
+
type: integer
|
|
189
|
+
description: The number of connected workers.
|
|
190
|
+
generatedAt:
|
|
191
|
+
type: string
|
|
192
|
+
description: When this snapshot was taken, ISO-8601.
|
|
193
|
+
workers:
|
|
194
|
+
type: array
|
|
195
|
+
items:
|
|
196
|
+
$ref: "#/components/schemas/AgenticSupplyWorker"
|
|
197
|
+
leaves:
|
|
198
|
+
type: array
|
|
199
|
+
items:
|
|
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"
|
|
102
207
|
VersionInfo:
|
|
103
208
|
type: object
|
|
104
209
|
description: The running app's identity (which code is actually live).
|
|
@@ -509,6 +614,27 @@ paths:
|
|
|
509
614
|
application/json:
|
|
510
615
|
schema:
|
|
511
616
|
$ref: "#/components/schemas/ErrorBody"
|
|
617
|
+
/agentic/supply:
|
|
618
|
+
get:
|
|
619
|
+
operationId: getAgenticSupply
|
|
620
|
+
summary: The SUPPLY-ONLY agentic visibility report — the live worker list (family, host, current jobs,
|
|
621
|
+
liveness) grouped by leaf, sourced from the H1 presence registry. Feeds the H5 cockpit page.
|
|
622
|
+
security:
|
|
623
|
+
- hookSecret: []
|
|
624
|
+
- {}
|
|
625
|
+
responses:
|
|
626
|
+
"200":
|
|
627
|
+
description: The supply report.
|
|
628
|
+
content:
|
|
629
|
+
application/json:
|
|
630
|
+
schema:
|
|
631
|
+
$ref: "#/components/schemas/AgenticSupplyReport"
|
|
632
|
+
"401":
|
|
633
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
634
|
+
content:
|
|
635
|
+
application/json:
|
|
636
|
+
schema:
|
|
637
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
512
638
|
/version:
|
|
513
639
|
get:
|
|
514
640
|
operationId: getVersion
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Tests for GET /app/api/agentic/supply → operation `getAgenticSupply` (H5 / #148).
|
|
2
|
+
//
|
|
3
|
+
// Covers: the empty report when no presence family is mounted; the shared-secret guard; and the
|
|
4
|
+
// end-to-end mapping of a mounted presence registry's snapshot into the supply report (stream keyed
|
|
5
|
+
// by instance, family/host/jobKeys/liveness) — driven through a REAL AgenticHub + in-memory transport
|
|
6
|
+
// exactly as the presence family is exercised, so the singleton the operation reads is the live one.
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { AgenticHub } from "@nanobpm/agentic/channel";
|
|
10
|
+
import type { Authenticator, ChannelConnection, ChannelTransport } from "@nanobpm/agentic/channel";
|
|
11
|
+
import { encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
|
|
12
|
+
import type { SqliteDb } from "@nanobpm/agentic/presence";
|
|
13
|
+
import type { AppApi, DataLayer } from "@nanobpm/urban";
|
|
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";
|
|
17
|
+
import { family } from "../app/agentic/families/presence.family.ts";
|
|
18
|
+
import type { AgenticContext } from "../app/agentic/registry.ts";
|
|
19
|
+
import { noopLog } from "../test/log.ts";
|
|
20
|
+
import handler from "./getAgenticSupply.ts";
|
|
21
|
+
|
|
22
|
+
function memSqlite(): SqliteDb {
|
|
23
|
+
const db = new DatabaseSync(":memory:");
|
|
24
|
+
return {
|
|
25
|
+
exec: (sql) => db.exec(sql),
|
|
26
|
+
run: (sql, params = []) => {
|
|
27
|
+
const r = db.prepare(sql).run(...(params as never[]));
|
|
28
|
+
return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
|
|
29
|
+
},
|
|
30
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
|
|
31
|
+
db.prepare(sql).all(...(params as never[])) as T[],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function memData(db: SqliteDb): DataLayer {
|
|
36
|
+
return { source: () => ({ db }) } as unknown as DataLayer;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function memTransport(): { transport: ChannelTransport; connect(conn: ChannelConnection): void } {
|
|
40
|
+
let onConnection: ((conn: ChannelConnection) => void) | undefined;
|
|
41
|
+
const transport: ChannelTransport = {
|
|
42
|
+
onConnection: (l) => {
|
|
43
|
+
onConnection = l;
|
|
44
|
+
},
|
|
45
|
+
address: { port: 0 },
|
|
46
|
+
close: async () => {},
|
|
47
|
+
};
|
|
48
|
+
return { transport, connect: (conn) => onConnection?.(conn) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fakeConn(id: string, identity: string): { conn: ChannelConnection; feed(frame: Frame): void } {
|
|
52
|
+
let onMessage: ((bytes: Uint8Array) => void) | undefined;
|
|
53
|
+
const conn: ChannelConnection = {
|
|
54
|
+
id,
|
|
55
|
+
handshake: { query: { identity }, token: "t", credential: "c" },
|
|
56
|
+
send: () => {},
|
|
57
|
+
close: () => {},
|
|
58
|
+
onMessage: (l) => {
|
|
59
|
+
onMessage = l;
|
|
60
|
+
},
|
|
61
|
+
onClose: () => {},
|
|
62
|
+
};
|
|
63
|
+
return { conn, feed: (frame) => onMessage?.(encodeFrame(frame)) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const authenticator: Authenticator = (req) => ({ ok: true, grant: { identity: req.query?.identity ?? "anon" } });
|
|
67
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
68
|
+
|
|
69
|
+
async function mountPresence(db: SqliteDb): Promise<AgenticHub> {
|
|
70
|
+
const transport = memTransport();
|
|
71
|
+
const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
|
|
72
|
+
const ctx: AgenticContext = {
|
|
73
|
+
hub,
|
|
74
|
+
registry: hub.registry,
|
|
75
|
+
transport: transport.transport as never,
|
|
76
|
+
data: memData(db),
|
|
77
|
+
log: noopLog(),
|
|
78
|
+
};
|
|
79
|
+
await family.mount(ctx);
|
|
80
|
+
// Register one live worker under leaf "leafA" with declared family + host.
|
|
81
|
+
const peer = fakeConn("c1", "leafA");
|
|
82
|
+
transport.connect(peer.conn);
|
|
83
|
+
await flush();
|
|
84
|
+
peer.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: { family: "opus", host: "boxA" } } });
|
|
85
|
+
await flush();
|
|
86
|
+
return hub;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function input(headers: Record<string, string> = {}) {
|
|
90
|
+
return {
|
|
91
|
+
req: {
|
|
92
|
+
method: "GET",
|
|
93
|
+
path: "/app/api/agentic/supply",
|
|
94
|
+
query: new URLSearchParams(),
|
|
95
|
+
headers: new Headers(headers),
|
|
96
|
+
text: async () => "",
|
|
97
|
+
} as never,
|
|
98
|
+
params: {},
|
|
99
|
+
query: {},
|
|
100
|
+
body: undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const app = { log: noopLog() } as unknown as AppApi;
|
|
105
|
+
|
|
106
|
+
test("returns an empty supply report when no presence family is mounted", async () => {
|
|
107
|
+
family.teardown?.();
|
|
108
|
+
const res = (await handler(input(), app)) as { status: number; body: { count: number; workers: unknown[]; leaves: unknown[] } };
|
|
109
|
+
assertEquals(res.status, 200);
|
|
110
|
+
assertEquals(res.body.count, 0);
|
|
111
|
+
assertEquals(res.body.workers.length, 0);
|
|
112
|
+
assertEquals(res.body.leaves.length, 0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("maps the presence snapshot into the supply report (stream, family, host, liveness)", async () => {
|
|
116
|
+
const hub = await mountPresence(memSqlite());
|
|
117
|
+
try {
|
|
118
|
+
const res = (await handler(input(), app)) as {
|
|
119
|
+
status: number;
|
|
120
|
+
body: { count: number; workers: Array<Record<string, unknown>>; leaves: Array<{ token: string; workers: unknown[] }> };
|
|
121
|
+
};
|
|
122
|
+
assertEquals(res.status, 200);
|
|
123
|
+
assertEquals(res.body.count, 1);
|
|
124
|
+
const w = res.body.workers[0];
|
|
125
|
+
assertEquals(w.instance, "wk-a");
|
|
126
|
+
assertEquals(w.identity, "leafA");
|
|
127
|
+
assertEquals(w.stream, "wk-a", "the drill stream is keyed by the worker instance");
|
|
128
|
+
assertEquals(w.family, "opus");
|
|
129
|
+
assertEquals(w.host, "boxA");
|
|
130
|
+
assertEquals(w.live, true);
|
|
131
|
+
assertEquals(w.jobKeys, []);
|
|
132
|
+
assertEquals(res.body.leaves[0]?.token, "leafA");
|
|
133
|
+
assertEquals(res.body.leaves[0]?.workers.length, 1);
|
|
134
|
+
} finally {
|
|
135
|
+
family.teardown?.();
|
|
136
|
+
await hub.close();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("shared-secret guard rejects a missing secret when configured", async () => {
|
|
141
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
142
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
143
|
+
try {
|
|
144
|
+
const mod = await import(`./getAgenticSupply.ts?guard=${Date.now()}`);
|
|
145
|
+
const guarded = mod.default as typeof handler;
|
|
146
|
+
const bad = (await guarded(input(), app)) as { status: number };
|
|
147
|
+
assertEquals(bad.status, 401);
|
|
148
|
+
const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as { status: number; body: Record<string, unknown> };
|
|
149
|
+
assertEquals(ok.status, 200);
|
|
150
|
+
assert("count" in ok.body);
|
|
151
|
+
} finally {
|
|
152
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
153
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
154
|
+
}
|
|
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
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// GET /app/api/agentic/supply → operationId `getAgenticSupply` (ADR 0058/0059 OpenAPI surface, mounted
|
|
2
|
+
// under base /app/api). The SUPPLY-ONLY visibility report the H5 cockpit page (#148) polls: the live
|
|
3
|
+
// worker list — family, host, current jobs, liveness — grouped by leaf token, sourced from the H1
|
|
4
|
+
// presence registry (#144). Read-only projection; it NEVER gates control flow (advisory-only, ADR 0056).
|
|
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
|
+
//
|
|
12
|
+
// This is the supply half of the visibility plane only. The demand×supply matrix, missing-agent-type
|
|
13
|
+
// reds, and diversity-SLO lights are DE-SCOPED to the enrolment epic #152 — this report carries no
|
|
14
|
+
// demand-side fields, and the cockpit renders none.
|
|
15
|
+
//
|
|
16
|
+
// The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`): when
|
|
17
|
+
// NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset → open.
|
|
18
|
+
|
|
19
|
+
import { type CorrelationRegistry, currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
|
|
20
|
+
import { currentPresenceRegistry, type SupplyWorker } from "../app/agentic/families/presence.family.ts";
|
|
21
|
+
import { envVar } from "../app/version.ts";
|
|
22
|
+
import type { AgenticJobCorrelation, AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
|
|
23
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
24
|
+
|
|
25
|
+
// The optional shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via
|
|
26
|
+
// the x-hook-secret header. Captured once, at module load.
|
|
27
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
28
|
+
|
|
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 {
|
|
33
|
+
const out: AgenticSupplyWorker = {
|
|
34
|
+
instance: w.instance,
|
|
35
|
+
identity: w.identity,
|
|
36
|
+
stream: correlation?.primaryStreamFor(w.instance) ?? w.instance,
|
|
37
|
+
jobKeys: [...w.jobKeys],
|
|
38
|
+
live: w.live,
|
|
39
|
+
staleMs: w.staleMs,
|
|
40
|
+
};
|
|
41
|
+
if (w.family !== undefined) out.family = w.family;
|
|
42
|
+
if (w.host !== undefined) out.host = w.host;
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
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
|
+
|
|
57
|
+
export default defineOperation("getAgenticSupply", async ({ req }, app) => {
|
|
58
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
59
|
+
app.log.warn("getAgenticSupply rejected: missing/invalid shared secret");
|
|
60
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const registry = currentPresenceRegistry();
|
|
64
|
+
if (!registry) {
|
|
65
|
+
// The presence family has not mounted (or has torn down) — no supply to report, not an error.
|
|
66
|
+
const empty: AgenticSupplyReport = { count: 0, generatedAt: new Date().toISOString(), workers: [], leaves: [], correlations: [] };
|
|
67
|
+
return { status: 200, body: empty };
|
|
68
|
+
}
|
|
69
|
+
|
|
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) } : {});
|
|
74
|
+
const report: AgenticSupplyReport = {
|
|
75
|
+
count: snapshot.count,
|
|
76
|
+
generatedAt: new Date().toISOString(),
|
|
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) : [],
|
|
80
|
+
};
|
|
81
|
+
return { status: 200, body: report };
|
|
82
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/* The SUPPLY cockpit stylesheet (H5 / #148) — a compact, phone-friendly dark cockpit shared by the
|
|
2
|
+
standalone shell and the console App-View embed, so the two render identically. The three-state
|
|
3
|
+
liveness dot keys off `data-liveness` (live | stale | down). It styles the SUPPLY worker list only
|
|
4
|
+
— no demand×supply matrix / diversity-SLO light (deferred to enrolment epic #152). */
|
|
5
|
+
|
|
6
|
+
:root {
|
|
7
|
+
--cockpit-bg: #0b0f14;
|
|
8
|
+
--cockpit-panel: #131a22;
|
|
9
|
+
--cockpit-edge: #223041;
|
|
10
|
+
--cockpit-text: #e6edf3;
|
|
11
|
+
--cockpit-muted: #8b98a5;
|
|
12
|
+
--cockpit-green: #2ea043;
|
|
13
|
+
--cockpit-amber: #d29922;
|
|
14
|
+
--cockpit-red: #f85149;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.cockpit-shell {
|
|
18
|
+
color: var(--cockpit-text);
|
|
19
|
+
background: var(--cockpit-bg);
|
|
20
|
+
font: 14px/1.4 ui-sans-serif, system-ui, sans-serif;
|
|
21
|
+
display: grid;
|
|
22
|
+
gap: 12px;
|
|
23
|
+
padding: 12px;
|
|
24
|
+
min-height: 100%;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.cockpit-header {
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-wrap: wrap;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 12px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.cockpit-title {
|
|
35
|
+
font-size: 16px;
|
|
36
|
+
margin: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.cockpit-supply-summary {
|
|
40
|
+
color: var(--cockpit-muted);
|
|
41
|
+
font-variant-numeric: tabular-nums;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.cockpit-supply-region,
|
|
45
|
+
.cockpit-terminal {
|
|
46
|
+
background: var(--cockpit-panel);
|
|
47
|
+
border: 1px solid var(--cockpit-edge);
|
|
48
|
+
border-radius: 8px;
|
|
49
|
+
padding: 12px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.cockpit-panel-title {
|
|
53
|
+
font-size: 13px;
|
|
54
|
+
margin: 0 0 8px;
|
|
55
|
+
color: var(--cockpit-muted);
|
|
56
|
+
text-transform: uppercase;
|
|
57
|
+
letter-spacing: 0.04em;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.cockpit-leaf {
|
|
61
|
+
margin-bottom: 12px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.cockpit-leaf-head {
|
|
65
|
+
display: flex;
|
|
66
|
+
justify-content: space-between;
|
|
67
|
+
align-items: baseline;
|
|
68
|
+
margin-bottom: 6px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.cockpit-leaf-name {
|
|
72
|
+
font-weight: 600;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.cockpit-leaf-count {
|
|
76
|
+
color: var(--cockpit-muted);
|
|
77
|
+
font-size: 12px;
|
|
78
|
+
font-variant-numeric: tabular-nums;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.cockpit-supply-table {
|
|
82
|
+
width: 100%;
|
|
83
|
+
border-collapse: collapse;
|
|
84
|
+
font-variant-numeric: tabular-nums;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.cockpit-th {
|
|
88
|
+
text-align: left;
|
|
89
|
+
font-size: 11px;
|
|
90
|
+
text-transform: uppercase;
|
|
91
|
+
letter-spacing: 0.04em;
|
|
92
|
+
color: var(--cockpit-muted);
|
|
93
|
+
padding: 4px 8px;
|
|
94
|
+
border-bottom: 1px solid var(--cockpit-edge);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.cockpit-td {
|
|
98
|
+
padding: 6px 8px;
|
|
99
|
+
border-bottom: 1px solid rgba(34, 48, 65, 0.5);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.cockpit-supply-name {
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
gap: 8px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.cockpit-dot {
|
|
109
|
+
width: 8px;
|
|
110
|
+
height: 8px;
|
|
111
|
+
border-radius: 50%;
|
|
112
|
+
display: inline-block;
|
|
113
|
+
flex: 0 0 auto;
|
|
114
|
+
background: var(--cockpit-muted);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.cockpit-dot[data-liveness="live"] { background: var(--cockpit-green); }
|
|
118
|
+
.cockpit-dot[data-liveness="stale"] { background: var(--cockpit-amber); }
|
|
119
|
+
.cockpit-dot[data-liveness="down"] { background: var(--cockpit-red); }
|
|
120
|
+
|
|
121
|
+
.cockpit-worker {
|
|
122
|
+
background: none;
|
|
123
|
+
border: none;
|
|
124
|
+
color: var(--cockpit-text);
|
|
125
|
+
cursor: pointer;
|
|
126
|
+
font: inherit;
|
|
127
|
+
padding: 0;
|
|
128
|
+
text-decoration: underline;
|
|
129
|
+
text-underline-offset: 2px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.cockpit-worker:hover { color: #58a6ff; }
|
|
133
|
+
|
|
134
|
+
.cockpit-supply-process { color: var(--cockpit-muted); }
|
|
135
|
+
|
|
136
|
+
.cockpit-correlation {
|
|
137
|
+
background: none;
|
|
138
|
+
border: none;
|
|
139
|
+
color: var(--cockpit-text);
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
display: block;
|
|
142
|
+
font: inherit;
|
|
143
|
+
padding: 0;
|
|
144
|
+
text-align: left;
|
|
145
|
+
text-decoration: underline;
|
|
146
|
+
text-underline-offset: 2px;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.cockpit-correlation:hover { color: #58a6ff; }
|
|
150
|
+
|
|
151
|
+
.cockpit-supply-liveness { color: var(--cockpit-muted); }
|
|
152
|
+
|
|
153
|
+
.cockpit-supply-empty {
|
|
154
|
+
color: var(--cockpit-muted);
|
|
155
|
+
padding: 8px 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.cockpit-terminal-host {
|
|
159
|
+
min-height: 220px;
|
|
160
|
+
background: #05080b;
|
|
161
|
+
border-radius: 6px;
|
|
162
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Agent cockpit — supply (App View embed)</title>
|
|
7
|
+
<link rel="stylesheet" href="./cockpit.css" />
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5/css/xterm.min.css" />
|
|
9
|
+
<style>
|
|
10
|
+
html, body { margin: 0; height: 100%; background: #0b0f14; }
|
|
11
|
+
</style>
|
|
12
|
+
<script type="importmap">
|
|
13
|
+
{
|
|
14
|
+
"imports": {
|
|
15
|
+
"@nanobpm/agentic/cockpit": "https://cdn.jsdelivr.net/npm/@nanobpm/agentic@0.1.0/dist/cockpit/index.js",
|
|
16
|
+
"@xterm/xterm": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5/+esm"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<!--
|
|
23
|
+
Console App-View embed (ADR 0057). The console loads this document into its App-View surface and
|
|
24
|
+
hands it a host element; we mount the SAME supply cockpit via the SAME ./mount.js as the
|
|
25
|
+
standalone shell — only the host and the injected endpoints differ, so the page renders
|
|
26
|
+
identically. When the console injects endpoint config via `window.__NANO_APP_VIEW__`, it wins.
|
|
27
|
+
-->
|
|
28
|
+
<main id="cockpit-root"></main>
|
|
29
|
+
<script type="module">
|
|
30
|
+
import { mountCockpit } from "./mount.js";
|
|
31
|
+
|
|
32
|
+
const cfg = window.__NANO_APP_VIEW__ ?? {};
|
|
33
|
+
mountCockpit(cfg.host ?? document.getElementById("cockpit-root"), {
|
|
34
|
+
reportUrl: cfg.reportUrl,
|
|
35
|
+
relayUrl: cfg.relayUrl,
|
|
36
|
+
hookSecret: cfg.hookSecret,
|
|
37
|
+
relayToken: cfg.relayToken,
|
|
38
|
+
relayCapability: cfg.relayCapability,
|
|
39
|
+
});
|
|
40
|
+
</script>
|
|
41
|
+
</body>
|
|
42
|
+
</html>
|