@nanobpm/nano-workforce 0.65.0 → 0.67.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/AGENTS.md +19 -7
- package/CHANGELOG.md +14 -0
- package/SPEC.md +12 -3
- package/app/agentic/channel.test.ts +118 -1
- package/app/agentic/channel.ts +80 -6
- package/main.ts +24 -8
- package/package.json +1 -1
- package/resources/processes/plan-fanout.bpmn +58 -0
- package/workers/record-plan/worker.ts +5 -14
- package/workers/record-trial-merge/worker.ts +6 -14
- package/workers/record-wave/worker.ts +6 -19
package/AGENTS.md
CHANGED
|
@@ -142,13 +142,25 @@ instead of rediscovering them:
|
|
|
142
142
|
per-child isolation in the testkit). Only vars read by an in-subprocess gateway
|
|
143
143
|
need this — output-only vars (e.g. `summary`, `pr`) are fine.
|
|
144
144
|
|
|
145
|
-
## Data envelopes are scalar-only
|
|
146
|
-
|
|
147
|
-
`nano:dataEnvelope`
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
145
|
+
## Data envelopes: message payloads are scalar-only; worker I/O shapes support arrays
|
|
146
|
+
|
|
147
|
+
Two different `nano:dataEnvelope` uses have different rules — don't conflate them:
|
|
148
|
+
|
|
149
|
+
- **Message payloads are scalar-only.** A `nano:dataEnvelope` that crosses the
|
|
150
|
+
engine's message correlation supports only **scalar** `nano:extend` types
|
|
151
|
+
(`string`, `integer`, `datetime`, `boolean`) — **not arrays**. When a message
|
|
152
|
+
must carry a list (e.g. failing check names), join it to a scalar (e.g.
|
|
153
|
+
`\n`-separated) in the publisher before it crosses the envelope, and split it on
|
|
154
|
+
the far side if needed.
|
|
155
|
+
- **Worker job-I/O shapes support `list="true"` arrays.** A service task's
|
|
156
|
+
`io.nanobpm.dataEnvelope.in`/`.out` shape is codegen/typing-only (no runtime
|
|
157
|
+
filtering), so `nano:extend … list="true"` (scalar arrays, e.g. `dependsOn`)
|
|
158
|
+
and `nano:reference … list="true"` (object arrays) are supported end-to-end and
|
|
159
|
+
derive to `T[]` in the generated types (per #211 — e.g.
|
|
160
|
+
`RecordPlanIn.tasks: RecordPlanTask[]`, `RecordWaveIn.waveResults`). Prefer
|
|
161
|
+
deriving a worker's array inputs from the model this way over a hand-typed
|
|
162
|
+
`interface In`; don't "fix" a modelled `list="true"` array back to a joined
|
|
163
|
+
scalar.
|
|
152
164
|
|
|
153
165
|
## Urban page runtime: rendering primitives are not JS-truthy
|
|
154
166
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.67.0](https://github.com/nanobpm/nano-workforce/compare/v0.66.0...v0.67.0) (2026-08-14)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **agentic:** local-first visibility — on by default, security opt-in (hub) ([#218](https://github.com/nanobpm/nano-workforce/issues/218)) ([ffef1d9](https://github.com/nanobpm/nano-workforce/commit/ffef1d9819679cb6995ad2d2825bce5c6cabccb5)), closes [jwulf/c8ctl-plugin-nano#38](https://github.com/jwulf/c8ctl-plugin-nano/issues/38)
|
|
7
|
+
|
|
8
|
+
# [0.66.0](https://github.com/nanobpm/nano-workforce/compare/v0.65.0...v0.66.0) (2026-08-14)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* derive record-plan/record-wave/record-trial-merge array inputs from the model ([#212](https://github.com/nanobpm/nano-workforce/issues/212)) ([95f1694](https://github.com/nanobpm/nano-workforce/commit/95f16944918b99107e784eed5ab69a5962365ec4)), closes [#211](https://github.com/nanobpm/nano-workforce/issues/211) [#211](https://github.com/nanobpm/nano-workforce/issues/211) [#211](https://github.com/nanobpm/nano-workforce/issues/211) [#211](https://github.com/nanobpm/nano-workforce/issues/211)
|
|
14
|
+
|
|
1
15
|
# [0.65.0](https://github.com/nanobpm/nano-workforce/compare/v0.64.0...v0.65.0) (2026-08-13)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -488,9 +488,18 @@ Start(issue) → plan → record-plan → implement (parallel MI) → record-res
|
|
|
488
488
|
it raises a non-retryable `NO_WORK_DISPATCHED` incident instead of completing
|
|
489
489
|
green — a no-op run must not masquerade as success (issue #86).
|
|
490
490
|
|
|
491
|
-
**
|
|
492
|
-
|
|
493
|
-
`
|
|
491
|
+
**App-worker payloads are typed from the model.** Each `pr.*` service task
|
|
492
|
+
(`record-plan`, `record-wave`, `record-trial-merge`, `select-wave`,
|
|
493
|
+
`record-plan-review`, `resolve-trial-attention`, `record-results`, …) carries an
|
|
494
|
+
`io.nanobpm.dataEnvelope.in` shape whose `nano:shapes` express the `tasks`/`results`
|
|
495
|
+
lists directly: worker job-I/O envelopes support `list="true"` arrays — both
|
|
496
|
+
`nano:extend … list="true"` scalar arrays (e.g. `dependsOn`, `conflicts`) and
|
|
497
|
+
`nano:reference … list="true"` object arrays (e.g. `RecordPlanIn.tasks`,
|
|
498
|
+
`RecordWaveIn.waveResults`) — which derive to `T[]` in the generated task types
|
|
499
|
+
(per #211). This job-I/O shape is codegen/typing-only (no runtime filtering); the
|
|
500
|
+
`scalar-only` constraint applies only to **message payload** envelopes that cross
|
|
501
|
+
correlation at runtime, not to these worker in/out shapes. The **agent** tasks
|
|
502
|
+
(`senior:*`) still self-type `job.variables` inline (like `finalize`).
|
|
494
503
|
|
|
495
504
|
**Domain model** (`db/migrations/004_planning.sql`): `plans` (one row per issue) +
|
|
496
505
|
`plan_tasks` (one row per slice, tracking its `status`/`pr_key`/`summary`).
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
// hub is visible via `inspect()`, families mount/tear-down through the seam, and shutdown is clean.
|
|
6
6
|
import { type AddressInfo, createServer, type Server } from "node:http";
|
|
7
7
|
import { test } from "node:test";
|
|
8
|
+
import { createLogger } from "@nanobpm/urban/runtime";
|
|
8
9
|
import { WebSocket } from "ws";
|
|
9
10
|
import { assert, assertEquals } from "#test-assert";
|
|
10
11
|
import { noopLog } from "../../test/log.ts";
|
|
11
|
-
import { type AgenticChannelHandle, mountAgenticChannel } from "./channel.ts";
|
|
12
|
+
import { type AgenticChannelHandle, LOCAL_AGENTIC_TOKEN, mountAgenticChannel } from "./channel.ts";
|
|
12
13
|
import { type AgenticContext, AgenticFamilyRegistry } from "./registry.ts";
|
|
13
14
|
|
|
14
15
|
const SECRET = "test-agentic-secret";
|
|
@@ -242,3 +243,119 @@ test("a missing secret is refused (never mount an open channel)", async (t) => {
|
|
|
242
243
|
assert(threw, "mountAgenticChannel must reject an empty secret");
|
|
243
244
|
assertEquals(port > 0, true);
|
|
244
245
|
});
|
|
246
|
+
|
|
247
|
+
test("LOCAL mode (secure:false): the well-known token upgrades with NO credential", async (t) => {
|
|
248
|
+
const { server, port } = await startHttp();
|
|
249
|
+
// Local-first default-on: no secret, no credential — a `nano work` worker appears live with the
|
|
250
|
+
// well-known localhost token alone (security opt-in).
|
|
251
|
+
const channel = await mountAgenticChannel({
|
|
252
|
+
server,
|
|
253
|
+
secret: "",
|
|
254
|
+
secure: false,
|
|
255
|
+
data: undefined,
|
|
256
|
+
log: noopLog(),
|
|
257
|
+
});
|
|
258
|
+
t.after(async () => {
|
|
259
|
+
await channel.teardown();
|
|
260
|
+
await closeServer(server);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const ws = await connect(port, `?token=${LOCAL_AGENTIC_TOKEN}`);
|
|
264
|
+
assertEquals(ws.readyState, WebSocket.OPEN);
|
|
265
|
+
assertEquals(channel.hub.connectionCount, 1);
|
|
266
|
+
assertEquals(channel.inspect().mode, "local");
|
|
267
|
+
ws.close();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("LOCAL mode still rejects a wrong token (4401)", async (t) => {
|
|
271
|
+
const { server, port } = await startHttp();
|
|
272
|
+
const channel = await mountAgenticChannel({
|
|
273
|
+
server,
|
|
274
|
+
secret: "",
|
|
275
|
+
secure: false,
|
|
276
|
+
data: undefined,
|
|
277
|
+
log: noopLog(),
|
|
278
|
+
});
|
|
279
|
+
t.after(async () => {
|
|
280
|
+
await channel.teardown();
|
|
281
|
+
await closeServer(server);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const closedCode = await rejectionCode(port, "?token=not-the-local-token");
|
|
285
|
+
assertEquals(closedCode, 4401);
|
|
286
|
+
assertEquals(channel.hub.connectionCount, 0);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
/** A capturing `Logger`: records every `(level, msg)` pair the sink receives. */
|
|
290
|
+
function capturingLog(): { log: ReturnType<typeof noopLog>; records: Array<{ level: string; msg: string }> } {
|
|
291
|
+
const records: Array<{ level: string; msg: string }> = [];
|
|
292
|
+
const log = createLogger((level: string, msg: string) => {
|
|
293
|
+
records.push({ level, msg });
|
|
294
|
+
});
|
|
295
|
+
return { log, records };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
test("LOCAL mode warns when the server is bound to a non-loopback interface", async (t) => {
|
|
299
|
+
const server = createServer((_req, res) => res.end());
|
|
300
|
+
await new Promise<void>((resolve) => server.listen(0, "0.0.0.0", resolve));
|
|
301
|
+
const { log, records } = capturingLog();
|
|
302
|
+
const channel = await mountAgenticChannel({
|
|
303
|
+
server,
|
|
304
|
+
secret: "",
|
|
305
|
+
secure: false,
|
|
306
|
+
data: undefined,
|
|
307
|
+
log,
|
|
308
|
+
});
|
|
309
|
+
t.after(async () => {
|
|
310
|
+
await channel.teardown();
|
|
311
|
+
await closeServer(server);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const warned = records.some((r) => r.level === "warn" && r.msg.includes("not bound to loopback"));
|
|
315
|
+
assert(warned, "LOCAL mode on a non-loopback bind must warn that the well-known token is exposed");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("LOCAL mode warns when the server bind address is unverifiable (not listening)", async (t) => {
|
|
319
|
+
const { server } = await startHttp();
|
|
320
|
+
// Simulate a server whose bind cannot be verified (e.g. mounted before `listen` resolves):
|
|
321
|
+
// `address()` returns null, so the LOCAL exposure check cannot confirm a loopback-only bind.
|
|
322
|
+
const realAddress = server.address.bind(server);
|
|
323
|
+
server.address = () => null;
|
|
324
|
+
const { log, records } = capturingLog();
|
|
325
|
+
const channel = await mountAgenticChannel({
|
|
326
|
+
server,
|
|
327
|
+
secret: "",
|
|
328
|
+
secure: false,
|
|
329
|
+
data: undefined,
|
|
330
|
+
log,
|
|
331
|
+
});
|
|
332
|
+
t.after(async () => {
|
|
333
|
+
server.address = realAddress;
|
|
334
|
+
await channel.teardown();
|
|
335
|
+
await closeServer(server);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const warned = records.some(
|
|
339
|
+
(r) => r.level === "warn" && r.msg.includes("bind address could not be verified"),
|
|
340
|
+
);
|
|
341
|
+
assert(warned, "LOCAL mode on an unbound server must warn that the well-known token is unverifiable");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("LOCAL mode does NOT warn when the server is bound to loopback", async (t) => {
|
|
345
|
+
const { server } = await startHttp();
|
|
346
|
+
const { log, records } = capturingLog();
|
|
347
|
+
const channel = await mountAgenticChannel({
|
|
348
|
+
server,
|
|
349
|
+
secret: "",
|
|
350
|
+
secure: false,
|
|
351
|
+
data: undefined,
|
|
352
|
+
log,
|
|
353
|
+
});
|
|
354
|
+
t.after(async () => {
|
|
355
|
+
await channel.teardown();
|
|
356
|
+
await closeServer(server);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const warned = records.some((r) => r.level === "warn" && r.msg.includes("not bound to loopback"));
|
|
360
|
+
assert(!warned, "a loopback-bound LOCAL channel is the expected safe case and must not warn");
|
|
361
|
+
});
|
package/app/agentic/channel.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
|
|
15
15
|
// is untouched; advisory semantics preserved (a family never gates a BPMN sequence flow).
|
|
16
16
|
import type { Server } from "node:http";
|
|
17
|
+
import type { AddressInfo } from "node:net";
|
|
17
18
|
import {
|
|
18
19
|
AgenticHub,
|
|
19
20
|
sharedSecretAuthenticator,
|
|
@@ -26,11 +27,47 @@ import { AgenticFamilyRegistry } from "./registry.ts";
|
|
|
26
27
|
/** The path the agentic channel is served on, on the app's own port. */
|
|
27
28
|
export const AGENTIC_PATH = "/agentic";
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The well-known identity token used in LOCAL mode (security opt-in). Nano is local-first: on a
|
|
32
|
+
* developer's own machine the visibility channel is on by default with no credential friction, so
|
|
33
|
+
* the hub and the `nano work` worker agree on this constant, well-known localhost token. It is NOT
|
|
34
|
+
* a secret — it only gates same-machine dev traffic. In secure mode (`secure: true` + a real
|
|
35
|
+
* `NANO_AGENTIC_SECRET`) this constant is never used and a real ADR 0028 verifier applies. Keep in
|
|
36
|
+
* lock-step with the worker constant in jwulf/c8ctl-plugin-nano (`c8ctl-plugin.js` LOCAL_AGENTIC_TOKEN).
|
|
37
|
+
*/
|
|
38
|
+
export const LOCAL_AGENTIC_TOKEN = "nano-local";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* True if `addr` (from {@link Server.address}) is a loopback / same-machine bind — the safety
|
|
42
|
+
* assumption LOCAL mode relies on. A string address is a UNIX domain socket / named pipe (same-host
|
|
43
|
+
* only) and is treated as safe; a TCP bind is loopback only for `127.0.0.0/8` or `::1`. A wildcard
|
|
44
|
+
* bind (`0.0.0.0` / `::`) or any specific public interface is NOT loopback, so the well-known
|
|
45
|
+
* {@link LOCAL_AGENTIC_TOKEN} would be reachable off-box. `null` (an unbound / not-yet-listening
|
|
46
|
+
* server) is NOT treated as safe — the bind is unverifiable, so callers must handle it explicitly
|
|
47
|
+
* rather than silently skipping the exposure check.
|
|
48
|
+
*/
|
|
49
|
+
function isLoopbackBind(addr: string | AddressInfo | null): boolean {
|
|
50
|
+
if (addr === null) return false;
|
|
51
|
+
if (typeof addr === "string") return true;
|
|
52
|
+
const host = addr.address;
|
|
53
|
+
return host === "::1" || host === "::ffff:127.0.0.1" || host.startsWith("127.");
|
|
54
|
+
}
|
|
55
|
+
|
|
29
56
|
export interface MountAgenticChannelOptions {
|
|
30
57
|
/** The app's own `node:http` server (share its port; `app.httpServer` narrowed to `Server`). */
|
|
31
58
|
readonly server: Server;
|
|
32
|
-
/** The shared-secret ADR 0028 identity token every valid peer must present as `?token=…`.
|
|
59
|
+
/** The shared-secret ADR 0028 identity token every valid peer must present as `?token=…`. In LOCAL
|
|
60
|
+
* mode (`secure: false`) this may be empty — the hub substitutes {@link LOCAL_AGENTIC_TOKEN}. */
|
|
33
61
|
readonly secret: string;
|
|
62
|
+
/**
|
|
63
|
+
* Security mode. Nano is local-first, so this defaults to `true` (strict) at the library level to
|
|
64
|
+
* keep the fail-closed contract for any caller that doesn't opt in — but `main.ts` passes
|
|
65
|
+
* `secure: false` whenever no `NANO_AGENTIC_SECRET` is configured, mounting an on-by-default LOCAL
|
|
66
|
+
* channel: a well-known localhost token ({@link LOCAL_AGENTIC_TOKEN}) and NO required capability
|
|
67
|
+
* credential. Set `secure: true` (with a real secret) to require an ADR 0028 identity token AND a
|
|
68
|
+
* capability credential on every upgrade.
|
|
69
|
+
*/
|
|
70
|
+
readonly secure?: boolean;
|
|
34
71
|
/** The app's SQLite data layer, threaded to family modules (may be absent when data isn't mounted). */
|
|
35
72
|
readonly data: DataLayer | undefined;
|
|
36
73
|
/** A structured logger for lifecycle lines. */
|
|
@@ -67,21 +104,56 @@ async function discoverRegistry(log: Logger): Promise<AgenticFamilyRegistry> {
|
|
|
67
104
|
export async function mountAgenticChannel(
|
|
68
105
|
opts: MountAgenticChannelOptions,
|
|
69
106
|
): Promise<AgenticChannelHandle> {
|
|
70
|
-
const { server,
|
|
71
|
-
|
|
107
|
+
const { server, data, log } = opts;
|
|
108
|
+
// Local-first: `secure` defaults to true at the library level (fail-closed for callers that don't
|
|
109
|
+
// opt in), but `main.ts` passes `secure: false` for the on-by-default LOCAL channel. In LOCAL mode
|
|
110
|
+
// an empty secret is fine — we substitute the well-known localhost token and drop the credential
|
|
111
|
+
// requirement so a `nano work` worker appears live with zero configuration.
|
|
112
|
+
const secure = opts.secure ?? true;
|
|
113
|
+
const secret = opts.secret || (secure ? "" : LOCAL_AGENTIC_TOKEN);
|
|
114
|
+
if (secure && !secret) {
|
|
115
|
+
throw new Error("mountAgenticChannel (secure mode) requires a non-empty identity secret");
|
|
116
|
+
}
|
|
72
117
|
|
|
73
118
|
const transport = new WebSocketChannelTransport({ server, path: AGENTIC_PATH });
|
|
74
119
|
const hub = new AgenticHub({
|
|
75
120
|
transport,
|
|
76
|
-
//
|
|
77
|
-
// is rejected (4401 / 4403). Swap in a real ADR 0028 verifier later by passing an
|
|
78
|
-
|
|
121
|
+
// Secure mode: a valid identity token PLUS a required capability credential upgrades; either
|
|
122
|
+
// missing/invalid is rejected (4401 / 4403). Swap in a real ADR 0028 verifier later by passing an
|
|
123
|
+
// Authenticator. LOCAL mode: token-only (the well-known localhost token), no credential required.
|
|
124
|
+
authenticator: sharedSecretAuthenticator({ secret, requireCredential: secure }),
|
|
79
125
|
onError: (err, connectionId) =>
|
|
80
126
|
log.warn("agentic hub error", { connectionId, err: String(err) }),
|
|
81
127
|
});
|
|
82
128
|
// Share the app's port: the transport rode the existing server, so it is already listening.
|
|
83
129
|
await transport.ready();
|
|
84
130
|
|
|
131
|
+
// LOCAL mode gates only on the well-known localhost token, so it is safe ONLY while the server is
|
|
132
|
+
// bound to loopback. The channel rides the app's server and does not own its bind address, so it
|
|
133
|
+
// cannot enforce this — but if the server is exposed on a wildcard/public interface, the token is
|
|
134
|
+
// reachable off-box; warn loudly so an operator either binds to loopback or switches to secure mode.
|
|
135
|
+
// A `null` address (server not listening yet) is unverifiable — warn rather than silently skipping
|
|
136
|
+
// the exposure check, since the bind could later resolve to a public interface.
|
|
137
|
+
if (!secure) {
|
|
138
|
+
const addr = server.address();
|
|
139
|
+
if (addr === null) {
|
|
140
|
+
log.warn(
|
|
141
|
+
"agentic channel is in LOCAL mode but the server bind address could not be verified " +
|
|
142
|
+
"(the server is not listening yet) — the well-known LOCAL_AGENTIC_TOKEN cannot be " +
|
|
143
|
+
"confirmed loopback-only. Mount the channel after the server is listening, set " +
|
|
144
|
+
"NANO_AGENTIC_SECRET for secure mode, or bind the server to 127.0.0.1.",
|
|
145
|
+
{ mode: "local", bind: null },
|
|
146
|
+
);
|
|
147
|
+
} else if (!isLoopbackBind(addr)) {
|
|
148
|
+
log.warn(
|
|
149
|
+
"agentic channel is in LOCAL mode but the server is not bound to loopback — the well-known " +
|
|
150
|
+
"LOCAL_AGENTIC_TOKEN is reachable from other hosts. Set NANO_AGENTIC_SECRET for secure " +
|
|
151
|
+
"mode, or bind the server to 127.0.0.1.",
|
|
152
|
+
{ mode: "local", bind: typeof addr === "object" ? addr.address : String(addr) },
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
85
157
|
// If discovery or any family mount throws, the transport + hub are already live: tear down whatever
|
|
86
158
|
// mounted (in reverse) and close the hub before rethrowing, so a failed boot never strands upgrade
|
|
87
159
|
// handlers or half-open connections.
|
|
@@ -97,6 +169,7 @@ export async function mountAgenticChannel(
|
|
|
97
169
|
|
|
98
170
|
log.info("agentic channel mounted", {
|
|
99
171
|
path: AGENTIC_PATH,
|
|
172
|
+
mode: secure ? "secure" : "local",
|
|
100
173
|
families: registry.names(),
|
|
101
174
|
});
|
|
102
175
|
|
|
@@ -108,6 +181,7 @@ export async function mountAgenticChannel(
|
|
|
108
181
|
inspect() {
|
|
109
182
|
return {
|
|
110
183
|
path: AGENTIC_PATH,
|
|
184
|
+
mode: secure ? "secure" : "local",
|
|
111
185
|
families: registry.names(),
|
|
112
186
|
connections: hub.connectionCount,
|
|
113
187
|
address: hub.address,
|
package/main.ts
CHANGED
|
@@ -46,24 +46,40 @@ const app = await runFromEnv({ engine, host, port: PORT, handleSignals: false })
|
|
|
46
46
|
// Agentic visibility channel (ADR 0056, epic #142). Ride the app's OWN HTTP server so the channel
|
|
47
47
|
// shares the app port (no sidecar). This is the ONLY main.ts wiring for the whole epic — sibling
|
|
48
48
|
// slices (H1/H3/H4) extend it by dropping a family module under `app/agentic/families/`, never here.
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
49
|
+
//
|
|
50
|
+
// Local-first (security opt-in): Nano is designed for local use, so the channel is ON BY DEFAULT.
|
|
51
|
+
// - No secret configured -> LOCAL mode: well-known localhost token, no credential required, so a
|
|
52
|
+
// `nano work` worker appears live with zero configuration.
|
|
53
|
+
// - `NANO_AGENTIC_SECRET` (or `NANO_PR_WEBHOOK_SECRET`) set -> SECURE mode: ADR 0028 identity token
|
|
54
|
+
// + capability credential required on every upgrade.
|
|
55
|
+
// - `NANO_AGENTIC=off` (or 0/false/no) -> disabled entirely.
|
|
56
|
+
// `app.httpServer` is a `node:http` Server once started (undefined on hosts that don't surface one,
|
|
57
|
+
// e.g. Deno).
|
|
52
58
|
let agentic: AgenticChannelHandle | undefined;
|
|
53
59
|
const agenticSecret = envVar("NANO_AGENTIC_SECRET") ?? envVar("NANO_PR_WEBHOOK_SECRET");
|
|
60
|
+
const agenticDisabled = /^(0|off|false|no)$/i.test(envVar("NANO_AGENTIC") ?? "");
|
|
54
61
|
const httpServer = app.httpServer;
|
|
55
62
|
if (httpServer instanceof Server) {
|
|
56
|
-
if (
|
|
63
|
+
if (agenticDisabled) {
|
|
64
|
+
app.log.info("agentic channel disabled (NANO_AGENTIC=off)");
|
|
65
|
+
} else {
|
|
66
|
+
const secure = Boolean(agenticSecret);
|
|
57
67
|
agentic = await mountAgenticChannel({
|
|
58
68
|
server: httpServer,
|
|
59
|
-
secret: agenticSecret,
|
|
69
|
+
secret: agenticSecret ?? "",
|
|
70
|
+
secure,
|
|
60
71
|
data: app.data,
|
|
61
72
|
log: app.log,
|
|
62
73
|
});
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
if (!secure) {
|
|
75
|
+
app.log.info(
|
|
76
|
+
"agentic channel mounted in LOCAL mode (on by default, token-only — a well-known localhost " +
|
|
77
|
+
"token, no capability credential). Set NANO_AGENTIC_SECRET for secure mode, or " +
|
|
78
|
+
"NANO_AGENTIC=off to disable.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
65
81
|
}
|
|
66
|
-
} else if (
|
|
82
|
+
} else if (!agenticDisabled) {
|
|
67
83
|
app.log.warn("agentic channel not mounted: app.httpServer is not a node:http Server on this host");
|
|
68
84
|
}
|
|
69
85
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.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",
|
|
@@ -12,6 +12,55 @@
|
|
|
12
12
|
<nano:extend name="repo" type="string" />
|
|
13
13
|
<nano:extend name="baseBranch" type="string" />
|
|
14
14
|
</nano:shape>
|
|
15
|
+
<nano:shape id="RecordPlanTask" name="Record plan — raw planner task">
|
|
16
|
+
<nano:extend name="id" type="string" optional="true" />
|
|
17
|
+
<nano:extend name="title" type="string" optional="true" />
|
|
18
|
+
<nano:extend name="prompt" type="string" optional="true" />
|
|
19
|
+
<nano:extend name="dependsOn" type="string" list="true" optional="true" />
|
|
20
|
+
</nano:shape>
|
|
21
|
+
<nano:shape id="RecordPlanIn" name="Record plan — input">
|
|
22
|
+
<nano:extend name="planKey" type="string" />
|
|
23
|
+
<nano:reference name="tasks" ref="RecordPlanTask" list="true" />
|
|
24
|
+
<nano:extend name="note" type="string" optional="true" />
|
|
25
|
+
</nano:shape>
|
|
26
|
+
<nano:shape id="RecordWaveTaskRef" name="Record wave — task ref">
|
|
27
|
+
<nano:extend name="id" type="string" optional="true" />
|
|
28
|
+
</nano:shape>
|
|
29
|
+
<nano:shape id="RecordWaveDelta" name="Record wave — task delta">
|
|
30
|
+
<nano:extend name="contractChange" type="string" optional="true" />
|
|
31
|
+
<nano:extend name="newlyTouches" type="string" list="true" optional="true" />
|
|
32
|
+
<nano:extend name="affectsTasks" type="string" list="true" optional="true" />
|
|
33
|
+
<nano:extend name="constraint" type="string" optional="true" />
|
|
34
|
+
</nano:shape>
|
|
35
|
+
<nano:shape id="RecordWaveResult" name="Record wave — agent result">
|
|
36
|
+
<nano:extend name="status" type="string" optional="true" />
|
|
37
|
+
<nano:extend name="summary" type="string" optional="true" />
|
|
38
|
+
<nano:extend name="pr" type="string" optional="true" />
|
|
39
|
+
<nano:extend name="delta" type="RecordWaveDelta" optional="true" />
|
|
40
|
+
</nano:shape>
|
|
41
|
+
<nano:shape id="RecordWaveIn" name="Record wave — input">
|
|
42
|
+
<nano:extend name="planKey" type="string" />
|
|
43
|
+
<nano:extend name="currentWave" type="integer" />
|
|
44
|
+
<nano:extend name="waveCount" type="integer" />
|
|
45
|
+
<nano:reference name="waveTasks" ref="RecordWaveTaskRef" list="true" />
|
|
46
|
+
<nano:reference name="waveResults" ref="RecordWaveResult" list="true" />
|
|
47
|
+
</nano:shape>
|
|
48
|
+
<nano:shape id="RecordTrialMergeHead" name="Record trial merge — open head">
|
|
49
|
+
<nano:extend name="repo" type="string" />
|
|
50
|
+
<nano:extend name="prNumber" type="integer" />
|
|
51
|
+
<nano:extend name="headRef" type="string" optional="true" />
|
|
52
|
+
<nano:extend name="headSha" type="string" optional="true" />
|
|
53
|
+
</nano:shape>
|
|
54
|
+
<nano:shape id="RecordTrialMergeIn" name="Record trial merge — input">
|
|
55
|
+
<nano:extend name="planKey" type="string" />
|
|
56
|
+
<nano:extend name="currentWave" type="integer" optional="true" />
|
|
57
|
+
<nano:extend name="trialMergeWave" type="integer" optional="true" />
|
|
58
|
+
<nano:reference name="waveOpenHeads" ref="RecordTrialMergeHead" list="true" />
|
|
59
|
+
<nano:extend name="result" type="string" optional="true" />
|
|
60
|
+
<nano:extend name="conflicts" type="string" list="true" optional="true" />
|
|
61
|
+
<nano:extend name="failing" type="string" list="true" optional="true" />
|
|
62
|
+
<nano:extend name="summary" type="string" optional="true" />
|
|
63
|
+
</nano:shape>
|
|
15
64
|
<nano:shape id="RecordPlanReviewIn" name="Record plan review — input">
|
|
16
65
|
<nano:extend name="planKey" type="string" />
|
|
17
66
|
<nano:extend name="approved" type="boolean" optional="true" />
|
|
@@ -68,6 +117,9 @@
|
|
|
68
117
|
<bpmn:serviceTask id="record-plan" name="Record plan & levelize">
|
|
69
118
|
<bpmn:extensionElements>
|
|
70
119
|
<zeebe:taskDefinition type="pr.record-plan" />
|
|
120
|
+
<zeebe:properties>
|
|
121
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordPlanIn" />
|
|
122
|
+
</zeebe:properties>
|
|
71
123
|
</bpmn:extensionElements>
|
|
72
124
|
<bpmn:incoming>f_toRecordPlan</bpmn:incoming>
|
|
73
125
|
<bpmn:outgoing>f_toReviewPlan</bpmn:outgoing>
|
|
@@ -215,6 +267,9 @@
|
|
|
215
267
|
<bpmn:serviceTask id="record-wave" name="Record wave & hand off">
|
|
216
268
|
<bpmn:extensionElements>
|
|
217
269
|
<zeebe:taskDefinition type="pr.record-wave" />
|
|
270
|
+
<zeebe:properties>
|
|
271
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordWaveIn" />
|
|
272
|
+
</zeebe:properties>
|
|
218
273
|
</bpmn:extensionElements>
|
|
219
274
|
<bpmn:incoming>f_toRecordWave</bpmn:incoming>
|
|
220
275
|
<bpmn:outgoing>f_toTrialNeeded</bpmn:outgoing>
|
|
@@ -246,6 +301,9 @@
|
|
|
246
301
|
<bpmn:serviceTask id="record-trial-merge" name="Record trial merge">
|
|
247
302
|
<bpmn:extensionElements>
|
|
248
303
|
<zeebe:taskDefinition type="pr.record-trial-merge" />
|
|
304
|
+
<zeebe:properties>
|
|
305
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordTrialMergeIn" />
|
|
306
|
+
</zeebe:properties>
|
|
249
307
|
</bpmn:extensionElements>
|
|
250
308
|
<bpmn:incoming>f_toRecordTrialMerge</bpmn:incoming>
|
|
251
309
|
<bpmn:outgoing>f_toGwTrial</bpmn:outgoing>
|
|
@@ -18,21 +18,12 @@
|
|
|
18
18
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
19
19
|
import { planTaskDeps, planTasks } from "../../app/plan.ts";
|
|
20
20
|
import { computeWaves, WaveError, type WaveTask } from "../../app/waves.ts";
|
|
21
|
+
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
dependsOn?: unknown;
|
|
27
|
-
}
|
|
28
|
-
// NOTE (issue #211): this worker reads an ARRAY input (`tasks: RawTask[]`), which the scalar-only
|
|
29
|
-
// `nano:dataEnvelope` cannot express, so it keeps a hand-written `interface In` pending a decision
|
|
30
|
-
// on extending the envelope schema to arrays. See the PR body's "array inputs" escalation.
|
|
31
|
-
interface In extends Record<string, unknown> {
|
|
32
|
-
planKey: string;
|
|
33
|
-
tasks?: RawTask[];
|
|
34
|
-
note?: string;
|
|
35
|
-
}
|
|
23
|
+
// Input typed off the model data envelope (`RecordPlanIn` in plan-fanout.bpmn) — ADR 0040. The
|
|
24
|
+
// `tasks[]` array field is a `nano:reference` to the `RecordPlanTask` shape (`list="true"`), so it
|
|
25
|
+
// resolves to `RecordPlanTask[]` with no hand-written interface.
|
|
26
|
+
type In = WorkerInputs["pr.record-plan"];
|
|
36
27
|
interface NormalTask {
|
|
37
28
|
id: string;
|
|
38
29
|
title: string;
|
|
@@ -8,21 +8,13 @@ import {
|
|
|
8
8
|
trialMergeDecision,
|
|
9
9
|
trialMergeTaskId,
|
|
10
10
|
} from "../../app/trialMerge.ts";
|
|
11
|
+
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
11
12
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
planKey: string;
|
|
18
|
-
currentWave?: unknown;
|
|
19
|
-
trialMergeWave?: unknown;
|
|
20
|
-
waveOpenHeads?: unknown;
|
|
21
|
-
result?: unknown;
|
|
22
|
-
conflicts?: unknown;
|
|
23
|
-
failing?: unknown;
|
|
24
|
-
summary?: unknown;
|
|
25
|
-
}
|
|
13
|
+
// Input typed off the model data envelope (`RecordTrialMergeIn` in plan-fanout.bpmn) — ADR 0040.
|
|
14
|
+
// The `waveOpenHeads[]` array field is a `nano:reference` to the `RecordTrialMergeHead` shape, and
|
|
15
|
+
// `conflicts[]` / `failing[]` are scalar `list="true"` extends, so all three resolve from the model
|
|
16
|
+
// with no hand-written interface.
|
|
17
|
+
type In = WorkerInputs["pr.record-trial-merge"];
|
|
26
18
|
interface Out extends Record<string, unknown> {
|
|
27
19
|
trialMergeRed: boolean;
|
|
28
20
|
question?: string;
|
|
@@ -28,26 +28,13 @@ import {
|
|
|
28
28
|
import { parsePr, submitPr } from "../../app/service.ts";
|
|
29
29
|
import { parseTaskDelta, readTaskDeltas, recordTaskDelta } from "../../app/taskDelta.ts";
|
|
30
30
|
import { shouldRunTrialMerge, type TrialMergeHead } from "../../app/trialMerge.ts";
|
|
31
|
+
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
interface WaveTaskIn {
|
|
39
|
-
id?: unknown;
|
|
40
|
-
}
|
|
41
|
-
// NOTE (issue #211): this worker reads ARRAY inputs (`waveTasks[]`, `waveResults[]`), which the
|
|
42
|
-
// scalar-only `nano:dataEnvelope` cannot express, so it keeps a hand-written `interface In` pending
|
|
43
|
-
// a decision on extending the envelope schema to arrays. See the PR body's "array inputs" escalation.
|
|
44
|
-
interface In extends Record<string, unknown> {
|
|
45
|
-
planKey: string;
|
|
46
|
-
currentWave: number;
|
|
47
|
-
waveCount: number;
|
|
48
|
-
waveTasks?: WaveTaskIn[];
|
|
49
|
-
waveResults?: Result[];
|
|
50
|
-
}
|
|
33
|
+
// Input typed off the model data envelope (`RecordWaveIn` in plan-fanout.bpmn) — ADR 0040. The
|
|
34
|
+
// `waveTasks[]` / `waveResults[]` array fields are `nano:reference`s to the `RecordWaveTaskRef` /
|
|
35
|
+
// `RecordWaveResult` shapes (`list="true"`), so they resolve to `<Shape>[]` with no hand-written
|
|
36
|
+
// interface.
|
|
37
|
+
type In = WorkerInputs["pr.record-wave"];
|
|
51
38
|
interface Out extends Record<string, unknown> {
|
|
52
39
|
currentWave: number;
|
|
53
40
|
hasMoreWaves: boolean;
|