@nanobpm/nano-workforce 0.108.0 → 0.110.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/agentSkill.ts +54 -0
- package/app/contracts.ts +9 -0
- package/app/deliveryGraph.test.ts +357 -0
- package/app/deliveryGraph.ts +463 -0
- package/app/resolveApiBase.test.ts +43 -0
- package/app/resolveApiBase.ts +28 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +221 -0
- package/openapi.yaml +298 -0
- package/operations/getAgentInstructions.ts +2 -16
- package/operations/getAgentSkill.test.ts +72 -0
- package/operations/getAgentSkill.ts +36 -0
- package/package.json +1 -1
- package/pages/home.page.json +0 -14
- package/pages/overview.page.json +14 -0
- package/skills/README.md +50 -0
- package/skills/nano-workforce/SKILL.md +126 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# ADR 0005 — Agent-authored delivery graphs (data-over-a-closed-vocabulary, human-in-the-loop)
|
|
2
|
+
|
|
3
|
+
Status: **Proposed.**
|
|
4
|
+
Date: 2026-08-20.
|
|
5
|
+
|
|
6
|
+
> **Scope note.** This is a **nano-workforce-local** ADR — it governs how *this app* runs
|
|
7
|
+
> heterogeneous cross-repo delivery work that mixes automated and human steps. Platform-wide ADRs live
|
|
8
|
+
> in `Magikcraft/nano-bpm/docs/adr` (referenced by number + repo, e.g. "nano-bpm ADR 0051").
|
|
9
|
+
> nano-workforce's own series continues here after ADR 0004.
|
|
10
|
+
|
|
11
|
+
Relates to:
|
|
12
|
+
nano-workforce **ADR 0001** (cross-repo epics + the generic `ReadinessProbe` wait-gate — this ADR
|
|
13
|
+
generalizes §4's deferred "release DAG" and its §2 gate into an arbitrary graph),
|
|
14
|
+
nano-workforce **ADR 0002** (escalations are user tasks + forms — the human-node machinery this ADR
|
|
15
|
+
promotes from *exception* to *scheduled node*),
|
|
16
|
+
nano-workforce **ADR 0003** (epic base-branch admission — the guardrail an `agent`/merge node inherits),
|
|
17
|
+
nano-workforce **ADR 0004** (shared-contract coordination — the discipline a cross-repo edge rides on),
|
|
18
|
+
nano-bpm **ADR 0026** (Urban human surfaces + `taskInbox` — where human nodes render),
|
|
19
|
+
nano-bpm **ADR 0046** (agent-as-worker vs agent-in-the-node — why an agent can answer a human node's
|
|
20
|
+
form, and why a node's *body* can itself be an agent),
|
|
21
|
+
nano-bpm **ADR 0051** (nano-workforce — the crew orchestrator whose `plan-fanout` interpreter is the
|
|
22
|
+
prior art this ADR widens),
|
|
23
|
+
nano-bpm **ADR 0056** (the Nano agentic protocol — the live-steering plane, complementary to this
|
|
24
|
+
durable lane),
|
|
25
|
+
nano-bpm **ADR 0059** (the app-hosted OpenAPI hook surface these graphs are submitted and signalled
|
|
26
|
+
through),
|
|
27
|
+
and issues **#263** (capability edges / publish provenance — the emit-vs-poll dual this ADR resolves),
|
|
28
|
+
**#289** (capability edge wired into dispatch — the `readiness-gate` call-activity pattern reused here),
|
|
29
|
+
**#242** (the pre-plan classifier — the *derive-by-default* front-end deferred here).
|
|
30
|
+
|
|
31
|
+
## Context
|
|
32
|
+
|
|
33
|
+
nano-workforce today runs exactly two shapes of work, each as a **static BPMN process interpreting a
|
|
34
|
+
data graph**: `convergence-loop.bpmn` (one PR) and `plan-fanout.bpmn` (an epic — a
|
|
35
|
+
`RecordPlanTask[] + dependsOn[]` DAG fanned out over multi-instance, with waves, trial-merges, and
|
|
36
|
+
escalations). Both are **specialised to one node shape**: "an agent implements a slice → opens a PR."
|
|
37
|
+
|
|
38
|
+
But real delivery in this ecosystem is a **heterogeneous, cross-repo, partly-human graph**. A concrete
|
|
39
|
+
case from one session:
|
|
40
|
+
|
|
41
|
+
1. PR **#A** must merge in repo 1.
|
|
42
|
+
2. *then* draft PR **#B** in repo 2 can be taken out of draft and merged.
|
|
43
|
+
3. *then* a **human** must do a **manual OTP publish** and **set up OIDC trusted publishing** — no
|
|
44
|
+
automation can cross this step.
|
|
45
|
+
4. *then* PR **#C** in repo 3 can consume the just-published version.
|
|
46
|
+
|
|
47
|
+
This is a dependency graph whose **nodes are a mix of automated tasks, in-flight-PR/merge watches, and
|
|
48
|
+
human actions**, and whose **edges span repos**. nwf has every *primitive* this needs, but no way to
|
|
49
|
+
*compose* them into one arbitrary graph:
|
|
50
|
+
|
|
51
|
+
- **Automated execution** — agent job types + the supervisor/worker fleet.
|
|
52
|
+
- **"Watch the world"** — the `ReadinessProbe` gate (ADR 0001 §2): durable, bounded (timeout →
|
|
53
|
+
escalate), resumable, with `http`/`command`/`npm`/`github-check`/`capability` kinds.
|
|
54
|
+
- **Cross-repo release edges** — capability edges + publish provenance (#263/#274), wired into dispatch
|
|
55
|
+
as a `readiness-gate` call activity (#289).
|
|
56
|
+
- **Human decision points** — user tasks + forms (ADR 0002), answerable by a human **or** an agent,
|
|
57
|
+
with SLA nudges.
|
|
58
|
+
- **Visible phase across a graph** — derived `epic_phase` (#261) + the cockpit/overview.
|
|
59
|
+
|
|
60
|
+
The gap is purely **composition**: a way to feed an *arbitrary* graph of these node kinds — including
|
|
61
|
+
human nodes as **scheduled stops that hand a value forward** — into one runner. ADR 0001 §4 sketched a
|
|
62
|
+
narrow "release DAG" and deferred it; the manual-publish-in-the-middle case is the general form that
|
|
63
|
+
finally justifies building it.
|
|
64
|
+
|
|
65
|
+
The obvious-but-wrong answer is "let an agent design a one-shot process definition per graph" — i.e.
|
|
66
|
+
generate BPMN (or process-builder code) with an LLM and deploy it. That fails on two axes at once:
|
|
67
|
+
**reliability** (an LLM emitting deployable BPMN/builder-code is authoring an artifact that must compile
|
|
68
|
+
and deploy before you learn it's wrong) and **trust** (agents deploying arbitrary executable process
|
|
69
|
+
definitions — or worse, arbitrary code — into your engine is an unbounded surface: any job type, any
|
|
70
|
+
service task, any script). Both objections point at the same fix: **the agent must never author the
|
|
71
|
+
executable artifact.**
|
|
72
|
+
|
|
73
|
+
## Decision
|
|
74
|
+
|
|
75
|
+
### 1. The contract is a validated JSON graph over a *closed* node vocabulary — never an agent-authored executable artifact
|
|
76
|
+
|
|
77
|
+
A delivery graph is submitted as **data**: a JSON DAG whose nodes each name a `kind` from a **fixed
|
|
78
|
+
allowlist** and whose edges name **facts** (below). This JSON — not BPMN, not builder-code — is the
|
|
79
|
+
durable, agent-facing contract, expressed as a **nano-app-schema** type and **published through the
|
|
80
|
+
agent guide** (`GET /app/api/agent`) so a co-designing agent reads exactly what it may build. Ingest
|
|
81
|
+
**validates** against the schema and rejects with **actionable** errors, closing the same
|
|
82
|
+
author→validate→fix loop the capability edges use.
|
|
83
|
+
|
|
84
|
+
This is decisive on both failing axes:
|
|
85
|
+
|
|
86
|
+
- **Reliability** — LLMs emit validated JSON reliably; deployable BPMN/builder-code they do not. The
|
|
87
|
+
whole "co-design → submit" UX depends on the artifact being cheap to validate and safe to produce.
|
|
88
|
+
- **Trust** — a graph can only compose allowlisted `kind`s; it **cannot** express an arbitrary service
|
|
89
|
+
task or run arbitrary code. Safe by construction, exactly as `plan-fanout`'s data model is today.
|
|
90
|
+
|
|
91
|
+
### 2. Node vocabulary — four kinds, each delegating to an engine-native body
|
|
92
|
+
|
|
93
|
+
The closed set (extensible only by a deliberate ADR/PR, never by graph authors):
|
|
94
|
+
|
|
95
|
+
- **`agent`** — a worker executes an agent job type (the existing fan-out body).
|
|
96
|
+
- **`wait`** — a `ReadinessProbe` (ADR 0001 §2), watching an external fact: `github-check`, `npm`,
|
|
97
|
+
`capability` (#263), `http`, `command`, and a new **`pr`/merge-state** kind (draft→ready→merged,
|
|
98
|
+
required checks, mergeable) lifted out of `mergeProtocol.ts` into a first-class probe kind.
|
|
99
|
+
- **`human`** — a scheduled user task + form (§4).
|
|
100
|
+
- **`connector`** — an automated, side-effecting outbound action (the connector I/O surface).
|
|
101
|
+
|
|
102
|
+
Crucially, **execution stays engine-native**: each node kind is a real, already-deployed
|
|
103
|
+
sub-process / call activity (`readiness-gate`, a user task, the implementation task, a connector
|
|
104
|
+
invocation). The graph layer owns **scheduling** (which nodes' edges are satisfied → dispatch), not a
|
|
105
|
+
re-implementation of execution.
|
|
106
|
+
|
|
107
|
+
### 3. Edges are *facts*, discovered — not declared values (extends ADR 0001 §4)
|
|
108
|
+
|
|
109
|
+
Every edge means *"B proceeds once fact X about A is observable."* The fact vocabulary is uniform —
|
|
110
|
+
*PR merged*, *check green*, *version on npm carrying capability C*, *human confirmed done*,
|
|
111
|
+
*connector action acknowledged* — and the satisfying state is **discovered**, never pre-declared
|
|
112
|
+
(ADR 0001 §4's discover-don't-declare, generalized from "capability published" to the whole graph). An
|
|
113
|
+
edge is therefore *always* a `wait`-shaped observation; a plain `dependsOn` between two internal nodes
|
|
114
|
+
is the degenerate "wait for the upstream node's completion fact."
|
|
115
|
+
|
|
116
|
+
### 4. Human nodes are first-class scheduled user tasks that can *emit* a typed fact
|
|
117
|
+
|
|
118
|
+
A `human` node is ADR 0002's user-task+form machinery promoted from **exception** (something broke) to
|
|
119
|
+
**scheduled node** (a planned stop). It surfaces *"now do X"* on the app's own **Tasks** page/inbox, blocks its
|
|
120
|
+
dependents, is **answerable by a human or an agent** (ADR 0046), and is **SLA-bounded** so it nags and
|
|
121
|
+
cannot silently wedge the graph.
|
|
122
|
+
|
|
123
|
+
- **It can hand a value forward.** A human node's form captures a **typed output** that **late-binds
|
|
124
|
+
downstream** — e.g. the manual-publish node emits `resolvedArtifact` (`@nanobpm/urban@0.54.0`), which
|
|
125
|
+
a downstream `capability`/`npm` edge binds and pins. A "click done" gate is the degenerate case that
|
|
126
|
+
emits nothing. This is the **emit-side** of #263's emit-vs-poll dual — and a *human* emitter is the
|
|
127
|
+
same shape as an automated one, which is what unifies human and automated steps in one graph.
|
|
128
|
+
- **Form resolution is specific-else-generic:** (1) an explicit `formKey` on the node; else (2) a form
|
|
129
|
+
**selected** by node category; else (3) a **generic** fallback form that still captures a typed
|
|
130
|
+
emitted fact (so *every* human node can emit downstream even with no bespoke form). Forms are
|
|
131
|
+
preferably **attached at authoring time** (deterministic, visible in preview); a **runtime
|
|
132
|
+
agent-form-router is a gated exception** — it fires only when a node activates with no resolvable
|
|
133
|
+
form, never in every human node's critical path (same "deterministic default, agent judgment as the
|
|
134
|
+
escape hatch" grain as the capability probe's empirical verifier).
|
|
135
|
+
|
|
136
|
+
### 5. A trusted, deterministic compiler turns the JSON into something the engine runs — exposed as an agent tool
|
|
137
|
+
|
|
138
|
+
The *only* thing that turns graph-data into an executable is a **deterministic, human-written, tested
|
|
139
|
+
compiler**. It is exposed to the co-designing agent as a **tool** — `validate/compile(JSON) →
|
|
140
|
+
{ ok, diagram, errors }` — which doubles as the **preview/dry-run**: the agent iterates JSON → tool →
|
|
141
|
+
fix, and the rendered graph is what a human approves before anything runs. Because the compiler only
|
|
142
|
+
ever instantiates allowlisted node kinds, it inherits Decision 1's trust bound.
|
|
143
|
+
|
|
144
|
+
### 6. Execution strategy is swappable behind the JSON contract; the axis is static-vs-dynamic topology
|
|
145
|
+
|
|
146
|
+
The compiler MAY target either execution strategy, and because the JSON is the contract, the choice is
|
|
147
|
+
an implementation detail the agent never sees:
|
|
148
|
+
|
|
149
|
+
- **Compile-to-native** — emit a **one-shot native BPMN definition** (native parallel/event gateways do
|
|
150
|
+
the scheduling; you get a real diagram for free) and deploy it. **Best when the graph is known at
|
|
151
|
+
authoring time.**
|
|
152
|
+
- **Interpret** — feed the JSON to one generic deployed process that evaluates the ready-set at
|
|
153
|
+
runtime. **Best when the graph is discovered or mutated at runtime**, and the strategy that makes
|
|
154
|
+
**mid-flight amendment** tractable (edit a variable, not migrate a deployed definition).
|
|
155
|
+
|
|
156
|
+
The discriminator is **author-time-static vs runtime-dynamic topology**. Delivery runbooks (the
|
|
157
|
+
motivating case — a *pre-known* release choreography) are static → **start with compile-to-native**.
|
|
158
|
+
`plan-fanout` stays **interpret** (its graph is agent-discovered and its waves adapt to results). The
|
|
159
|
+
shared JSON contract means either can be swapped in later without touching the agent UX.
|
|
160
|
+
|
|
161
|
+
### 7. Submission is propose → preview → approve → dispatch, idempotent, over the self-describing endpoint
|
|
162
|
+
|
|
163
|
+
Graphs are submitted exactly as epics are today — via a **new (proposed)** `POST
|
|
164
|
+
/actions/start/delivery-graph` endpoint (paths are relative to the agent guide's `__BASE__` prefix,
|
|
165
|
+
matching the guide's style) with the JSON body, discovered via the agent guide (which already
|
|
166
|
+
documents `POST /actions/start/plan-fanout` and `POST /actions/complete-user-task`). This endpoint
|
|
167
|
+
does not yet exist in `openapi.yaml` — it is introduced by this Proposed ADR. Three ways in, **one validated contract**: agent-ergonomic
|
|
168
|
+
(co-design → POST), raw REST, and a **UI JSON-paste fallback**. Because these graphs *merge PRs and
|
|
169
|
+
publish packages*, submission **defaults to propose-preview-approve** — the resolved graph (what it will
|
|
170
|
+
do, where it stops for humans) is rendered and a human approves before dispatch; that approval is itself
|
|
171
|
+
just the first human node. Submission carries an **idempotency key** so a re-POST cannot double-launch,
|
|
172
|
+
and every **side-effecting node (`connector`, merge, publish) carries a dedupe key** and tolerates
|
|
173
|
+
at-least-once execution (mirroring the release workflow's `npx semantic-release`
|
|
174
|
+
"skip already-published" discipline — `.github/workflows/release.yml`) so a
|
|
175
|
+
resume cannot double-fire.
|
|
176
|
+
|
|
177
|
+
## Consequences
|
|
178
|
+
|
|
179
|
+
- nwf gains a **generic delivery-graph runner** that composes its existing primitives; the motivating
|
|
180
|
+
human-in-the-middle cross-repo case (merge → un-draft → manual publish+OIDC → consume) becomes a
|
|
181
|
+
single submitted graph rather than hand-carried human coordination.
|
|
182
|
+
- **#263's deferred "publish node emits"** is subsumed: an emitting node (human *or* automated) that
|
|
183
|
+
hands a version to a downstream edge is Decision 4 + Decision 3.
|
|
184
|
+
- The **connector I/O surface** finds its orchestration home: a connector is just an automated emitting
|
|
185
|
+
node kind (Decision 2).
|
|
186
|
+
- New surface to own: the JSON graph schema, the deterministic compiler, and the `pr`/merge-state probe
|
|
187
|
+
kind. All bounded — the schema is validated at ingest, the compiler is deterministic and tested, and
|
|
188
|
+
every node inherits the ADR 0001 §2 timeout+escalation bound, so a malformed or hanging graph cannot
|
|
189
|
+
stall silently.
|
|
190
|
+
- The **agent never deploys executable artifacts**; the trust boundary is the closed vocabulary + the
|
|
191
|
+
human-written compiler, not agent output.
|
|
192
|
+
- Amendment is cheap **only** under the interpret strategy; compile-to-native graphs are amended by
|
|
193
|
+
cancel + resubmit (an accepted cost for pre-known runbooks).
|
|
194
|
+
|
|
195
|
+
## Non-goals / deferred
|
|
196
|
+
|
|
197
|
+
- **Derive-by-default graph authoring.** Populating edges automatically from intake (the #242
|
|
198
|
+
classifier facet, or PR-dependency inference) — this ADR ships the **declared** graph + the runner;
|
|
199
|
+
derivation is sugar layered on later.
|
|
200
|
+
- **Runtime-dynamic delivery graphs.** The first cut is compile-to-native for **static** runbooks; the
|
|
201
|
+
interpret strategy for graphs whose shape changes at runtime is kept behind the same contract but not
|
|
202
|
+
built now.
|
|
203
|
+
- **Mid-flight amendment of a running graph** beyond cancel+resubmit.
|
|
204
|
+
- **A second workflow engine.** Scheduling composes the nano engine's native constructs (or a thin
|
|
205
|
+
ready-set loop); execution is always engine-native sub-processes. Do not re-implement durability,
|
|
206
|
+
timers, or receive-tasks.
|
|
207
|
+
- **Non-npm emit facts** (OCI/github-release) and behavioural edges beyond the `command` escape hatch —
|
|
208
|
+
added when a real case lands.
|
|
209
|
+
|
|
210
|
+
## Open questions
|
|
211
|
+
|
|
212
|
+
- **Compiler target for the first cut** — confirm compile-to-native (diagram + native scheduling) vs a
|
|
213
|
+
minimal interpreter reusing `plan-fanout` patterns; the ADR leans native for static runbooks.
|
|
214
|
+
- **`pr`/merge-state probe kind boundary** — how much of `mergeProtocol.ts` (required checks, mergeable,
|
|
215
|
+
base guards) becomes probe-matcher config vs stays in the merge-loop node body.
|
|
216
|
+
- **Emitted-fact typing** — the schema for a node's typed output (version string, URL, artifact id) and
|
|
217
|
+
how downstream edges reference it (`from: <nodeId>.<fact>`), so binds are validated not stringly.
|
|
218
|
+
- **Definition lifecycle under compile-to-native** — naming, versioning, and GC of one-shot deployed
|
|
219
|
+
process definitions so the engine doesn't accumulate them.
|
|
220
|
+
- **Approval granularity** — one approval of the whole graph at submit, vs re-approval when a human node
|
|
221
|
+
amends the un-started tail (if amendment is ever allowed).
|
package/openapi.yaml
CHANGED
|
@@ -821,6 +821,38 @@ components:
|
|
|
821
821
|
instructions:
|
|
822
822
|
type: string
|
|
823
823
|
description: The full operator guide as markdown.
|
|
824
|
+
AgentSkill:
|
|
825
|
+
type: object
|
|
826
|
+
description: The portable Nano Workforce operator skill (SKILL.md) — a thin bootstrap an agent
|
|
827
|
+
runtime loads on demand. It resolves which instance to drive and then fetches the live
|
|
828
|
+
operator guide. The `skill` markdown has any embedded example keyed to this instance's
|
|
829
|
+
`baseUrl`.
|
|
830
|
+
additionalProperties: false
|
|
831
|
+
required:
|
|
832
|
+
- format
|
|
833
|
+
- appVersion
|
|
834
|
+
- generatedAt
|
|
835
|
+
- baseUrl
|
|
836
|
+
- skill
|
|
837
|
+
properties:
|
|
838
|
+
format:
|
|
839
|
+
type: string
|
|
840
|
+
description: The `skill` media format. Always "markdown".
|
|
841
|
+
enum:
|
|
842
|
+
- markdown
|
|
843
|
+
appVersion:
|
|
844
|
+
type: string
|
|
845
|
+
nullable: true
|
|
846
|
+
description: The running app version this skill was served from (null when unreadable).
|
|
847
|
+
generatedAt:
|
|
848
|
+
type: string
|
|
849
|
+
description: When this response was rendered (ISO-8601).
|
|
850
|
+
baseUrl:
|
|
851
|
+
type: string
|
|
852
|
+
description: The app control-API base the skill was fetched from (e.g. https://host/app/api).
|
|
853
|
+
skill:
|
|
854
|
+
type: string
|
|
855
|
+
description: The full operator skill (SKILL.md) as markdown, including its YAML frontmatter.
|
|
824
856
|
SubmitResult:
|
|
825
857
|
type: object
|
|
826
858
|
required:
|
|
@@ -1221,6 +1253,250 @@ components:
|
|
|
1221
1253
|
everyMs: { type: integer, description: Interval between poll attempts (ms). }
|
|
1222
1254
|
timeoutMs: { type: integer, description: Bounded budget (ms) before the gate escalates. }
|
|
1223
1255
|
backoff: { type: string, enum: [fixed, exponential], description: Backoff shape between attempts. }
|
|
1256
|
+
DeliveryGraph:
|
|
1257
|
+
description: >-
|
|
1258
|
+
An agent-authored delivery graph (ADR 0005) — the SINGLE agent-facing artifact for a
|
|
1259
|
+
heterogeneous, partly-human, cross-repo delivery runbook. It is DATA, never an executable
|
|
1260
|
+
artifact: a JSON DAG whose nodes each name a `kind` from a CLOSED allowlist
|
|
1261
|
+
(`agent`/`wait`/`human`/`connector` — Decision 1/2, the trust boundary) and whose `edges`
|
|
1262
|
+
name DISCOVERED facts (Decision 3). Ingest validates the SHAPE here and the SEMANTICS
|
|
1263
|
+
(acyclicity, edge integrity, fact resolution) in the pure `validateDeliveryGraph`
|
|
1264
|
+
(`app/deliveryGraph.ts`). This slice (S0) defines the vocabulary + validation surface ONLY —
|
|
1265
|
+
no compiler, dispatch, or execution (those land in later slices).
|
|
1266
|
+
type: object
|
|
1267
|
+
additionalProperties: false
|
|
1268
|
+
required:
|
|
1269
|
+
- nodes
|
|
1270
|
+
properties:
|
|
1271
|
+
name:
|
|
1272
|
+
type: string
|
|
1273
|
+
maxLength: 255
|
|
1274
|
+
description: OPTIONAL human-readable label for the graph (shown in the rendered preview).
|
|
1275
|
+
nodes:
|
|
1276
|
+
type: array
|
|
1277
|
+
minItems: 1
|
|
1278
|
+
maxItems: 256
|
|
1279
|
+
items:
|
|
1280
|
+
$ref: "#/components/schemas/DeliveryNode"
|
|
1281
|
+
description: >-
|
|
1282
|
+
The graph's nodes. Each carries a unique `id` and a `kind` from the closed allowlist,
|
|
1283
|
+
plus its per-kind config and its typed `emits[]` declaration. Node ids must be unique
|
|
1284
|
+
across the graph (enforced by `validateDeliveryGraph`).
|
|
1285
|
+
edges:
|
|
1286
|
+
type: array
|
|
1287
|
+
maxItems: 1024
|
|
1288
|
+
items:
|
|
1289
|
+
$ref: "#/components/schemas/DeliveryEdge"
|
|
1290
|
+
description: >-
|
|
1291
|
+
The dependency edges — the graph's discovered-fact topology (Decision 3). Each edge means
|
|
1292
|
+
"`to` proceeds once fact `from` about the upstream node is observable". `from` is either a
|
|
1293
|
+
bare `<nodeId>` (the degenerate "wait for the upstream node's completion" fact) or a
|
|
1294
|
+
qualified `<nodeId>.<fact>` referencing one of that node's declared `emits`. Omit/`[]` for
|
|
1295
|
+
a set of independent (root) nodes. The edge set must be a DAG.
|
|
1296
|
+
DeliveryNode:
|
|
1297
|
+
description: >-
|
|
1298
|
+
One node in a delivery graph. A discriminated union on `kind` over the CLOSED allowlist; the
|
|
1299
|
+
matching per-kind config object (`agent`/`wait`/`connector`) is REQUIRED and names the
|
|
1300
|
+
engine-native body the node delegates to (Decision 2 — the graph schedules, it does not
|
|
1301
|
+
re-implement execution). The `human` config is the sole exception — it is OPTIONAL (a bare
|
|
1302
|
+
`human` node resolves to a generic emit-capturing form fallback in S3).
|
|
1303
|
+
oneOf:
|
|
1304
|
+
- $ref: "#/components/schemas/DeliveryNodeAgent"
|
|
1305
|
+
- $ref: "#/components/schemas/DeliveryNodeWait"
|
|
1306
|
+
- $ref: "#/components/schemas/DeliveryNodeHuman"
|
|
1307
|
+
- $ref: "#/components/schemas/DeliveryNodeConnector"
|
|
1308
|
+
discriminator:
|
|
1309
|
+
propertyName: kind
|
|
1310
|
+
mapping:
|
|
1311
|
+
agent: "#/components/schemas/DeliveryNodeAgent"
|
|
1312
|
+
wait: "#/components/schemas/DeliveryNodeWait"
|
|
1313
|
+
human: "#/components/schemas/DeliveryNodeHuman"
|
|
1314
|
+
connector: "#/components/schemas/DeliveryNodeConnector"
|
|
1315
|
+
DeliveryFact:
|
|
1316
|
+
description: >-
|
|
1317
|
+
A typed output a node declares it will EMIT (ADR 0005 Decision 3/4 — emitted-fact typing).
|
|
1318
|
+
A downstream edge references it as `from: "<nodeId>.<fact>"`, so a bind is validated against
|
|
1319
|
+
this declaration, not stringly. A "click done" human node or a pass-through node declares no
|
|
1320
|
+
facts (`emits` absent/empty) — the degenerate no-emit case.
|
|
1321
|
+
type: object
|
|
1322
|
+
additionalProperties: false
|
|
1323
|
+
required:
|
|
1324
|
+
- name
|
|
1325
|
+
- type
|
|
1326
|
+
properties:
|
|
1327
|
+
name:
|
|
1328
|
+
type: string
|
|
1329
|
+
minLength: 1
|
|
1330
|
+
maxLength: 128
|
|
1331
|
+
pattern: '^[A-Za-z_][A-Za-z0-9_]*$'
|
|
1332
|
+
description: The fact's identifier, referenced downstream as `<nodeId>.<name>`. Must be unique within the node.
|
|
1333
|
+
type:
|
|
1334
|
+
type: string
|
|
1335
|
+
enum: [string, number, boolean, artifact, version, url]
|
|
1336
|
+
description: >-
|
|
1337
|
+
The fact's declared type. `artifact` is a `pkg@version` handle, `version` a bare version
|
|
1338
|
+
string, `url` a location — mirrors the values `capability`/`pr` probes late-bind.
|
|
1339
|
+
description:
|
|
1340
|
+
type: string
|
|
1341
|
+
maxLength: 512
|
|
1342
|
+
description: OPTIONAL human note describing what the fact carries.
|
|
1343
|
+
DeliveryNodeCommon:
|
|
1344
|
+
type: object
|
|
1345
|
+
properties:
|
|
1346
|
+
id:
|
|
1347
|
+
type: string
|
|
1348
|
+
minLength: 1
|
|
1349
|
+
maxLength: 128
|
|
1350
|
+
pattern: '^[A-Za-z_][A-Za-z0-9_.-]*$'
|
|
1351
|
+
description: The node's identifier, unique within the graph and referenced by edges.
|
|
1352
|
+
emits:
|
|
1353
|
+
type: array
|
|
1354
|
+
maxItems: 32
|
|
1355
|
+
items:
|
|
1356
|
+
$ref: "#/components/schemas/DeliveryFact"
|
|
1357
|
+
description: >-
|
|
1358
|
+
The typed facts this node hands forward when it completes (Decision 3/4). Absent/empty for
|
|
1359
|
+
a node that emits nothing. Downstream edges bind these via `from: "<nodeId>.<fact>"`.
|
|
1360
|
+
DeliveryNodeAgent:
|
|
1361
|
+
description: >-
|
|
1362
|
+
An `agent` node — a worker executes an agent job type (the existing fan-out body). Bounded
|
|
1363
|
+
(timeout → escalate) and resumable like every node.
|
|
1364
|
+
allOf:
|
|
1365
|
+
- $ref: "#/components/schemas/DeliveryNodeCommon"
|
|
1366
|
+
- type: object
|
|
1367
|
+
additionalProperties: false
|
|
1368
|
+
required:
|
|
1369
|
+
- id
|
|
1370
|
+
- kind
|
|
1371
|
+
- agent
|
|
1372
|
+
properties:
|
|
1373
|
+
id: { type: string }
|
|
1374
|
+
kind: { type: string, enum: [agent] }
|
|
1375
|
+
emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
|
|
1376
|
+
agent:
|
|
1377
|
+
type: object
|
|
1378
|
+
additionalProperties: false
|
|
1379
|
+
required:
|
|
1380
|
+
- jobType
|
|
1381
|
+
properties:
|
|
1382
|
+
jobType:
|
|
1383
|
+
type: string
|
|
1384
|
+
minLength: 1
|
|
1385
|
+
description: The agent job type a worker executes for this node (e.g. `senior:feature`).
|
|
1386
|
+
prompt:
|
|
1387
|
+
type: string
|
|
1388
|
+
maxLength: 20000
|
|
1389
|
+
description: OPTIONAL steering prompt appended to the node's job brief.
|
|
1390
|
+
DeliveryNodeWait:
|
|
1391
|
+
description: >-
|
|
1392
|
+
A `wait` node — a durable `ReadinessProbe` (ADR 0001 §2) watching an external fact. Reuses the
|
|
1393
|
+
existing `ReadinessProbe` shape verbatim (Decision 3 — never a second wait loop); the `pr`
|
|
1394
|
+
merge-state kind is added to that shape by slice S2 and flows in here automatically.
|
|
1395
|
+
allOf:
|
|
1396
|
+
- $ref: "#/components/schemas/DeliveryNodeCommon"
|
|
1397
|
+
- type: object
|
|
1398
|
+
additionalProperties: false
|
|
1399
|
+
required:
|
|
1400
|
+
- id
|
|
1401
|
+
- kind
|
|
1402
|
+
- wait
|
|
1403
|
+
properties:
|
|
1404
|
+
id: { type: string }
|
|
1405
|
+
kind: { type: string, enum: [wait] }
|
|
1406
|
+
emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
|
|
1407
|
+
wait:
|
|
1408
|
+
$ref: "#/components/schemas/ReadinessProbe"
|
|
1409
|
+
DeliveryNodeHuman:
|
|
1410
|
+
description: >-
|
|
1411
|
+
A `human` node — a scheduled user task + form (ADR 0002 machinery promoted from exception to
|
|
1412
|
+
node, Decision 4). Surfaces "now do X" on the Tasks inbox, blocks dependents, is answerable by
|
|
1413
|
+
a human OR an agent, is SLA-bounded, and can EMIT a typed fact its form captures.
|
|
1414
|
+
allOf:
|
|
1415
|
+
- $ref: "#/components/schemas/DeliveryNodeCommon"
|
|
1416
|
+
- type: object
|
|
1417
|
+
additionalProperties: false
|
|
1418
|
+
required:
|
|
1419
|
+
- id
|
|
1420
|
+
- kind
|
|
1421
|
+
properties:
|
|
1422
|
+
id: { type: string }
|
|
1423
|
+
kind: { type: string, enum: [human] }
|
|
1424
|
+
emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
|
|
1425
|
+
human:
|
|
1426
|
+
type: object
|
|
1427
|
+
additionalProperties: false
|
|
1428
|
+
description: >-
|
|
1429
|
+
OPTIONAL human-node config. `formKey` explicitly attaches a form (else a form is
|
|
1430
|
+
selected by node category, else a generic emit-capturing fallback — resolved in S3).
|
|
1431
|
+
The node's typed output is declared via the node-level `emits[]`.
|
|
1432
|
+
properties:
|
|
1433
|
+
formKey:
|
|
1434
|
+
type: string
|
|
1435
|
+
minLength: 1
|
|
1436
|
+
description: OPTIONAL explicit form to attach at authoring time (specific-else-generic resolution, S3).
|
|
1437
|
+
prompt:
|
|
1438
|
+
type: string
|
|
1439
|
+
maxLength: 20000
|
|
1440
|
+
description: OPTIONAL instruction shown to the human/agent completing the task ("now do X").
|
|
1441
|
+
DeliveryNodeConnector:
|
|
1442
|
+
description: >-
|
|
1443
|
+
A `connector` node — an automated, side-effecting outbound action (the connector I/O surface).
|
|
1444
|
+
Side-effecting, so it carries a `dedupeKey` and tolerates at-least-once execution. The
|
|
1445
|
+
`payload` schema is a minimal forward-declared stub in this slice (ADR 0005 non-goal — the
|
|
1446
|
+
concrete connector I/O lands later).
|
|
1447
|
+
allOf:
|
|
1448
|
+
- $ref: "#/components/schemas/DeliveryNodeCommon"
|
|
1449
|
+
- type: object
|
|
1450
|
+
additionalProperties: false
|
|
1451
|
+
required:
|
|
1452
|
+
- id
|
|
1453
|
+
- kind
|
|
1454
|
+
- connector
|
|
1455
|
+
properties:
|
|
1456
|
+
id: { type: string }
|
|
1457
|
+
kind: { type: string, enum: [connector] }
|
|
1458
|
+
emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
|
|
1459
|
+
connector:
|
|
1460
|
+
type: object
|
|
1461
|
+
additionalProperties: false
|
|
1462
|
+
required:
|
|
1463
|
+
- target
|
|
1464
|
+
properties:
|
|
1465
|
+
target:
|
|
1466
|
+
type: string
|
|
1467
|
+
minLength: 1
|
|
1468
|
+
description: The connector action target (forward-declared — the concrete scheme lands in a later slice).
|
|
1469
|
+
dedupeKey:
|
|
1470
|
+
type: string
|
|
1471
|
+
minLength: 1
|
|
1472
|
+
description: >-
|
|
1473
|
+
OPTIONAL idempotency key so an at-least-once resume cannot double-fire this
|
|
1474
|
+
side-effecting node (ADR 0005 Decision 7). Author-supplied or graph-derived.
|
|
1475
|
+
payload:
|
|
1476
|
+
type: object
|
|
1477
|
+
additionalProperties: true
|
|
1478
|
+
description: Minimal forward-declared payload stub — the concrete connector payload schema is deferred (ADR non-goal).
|
|
1479
|
+
DeliveryEdge:
|
|
1480
|
+
description: >-
|
|
1481
|
+
A dependency edge — "`to` proceeds once fact `from` is observable" (ADR 0005 Decision 3).
|
|
1482
|
+
`from` is either a bare `<nodeId>` (wait for the upstream node's completion fact) or a
|
|
1483
|
+
qualified `<nodeId>.<fact>` referencing a declared `emits` fact of that node. Both endpoints
|
|
1484
|
+
must resolve to a node in the graph, the referenced fact must be declared, and the whole edge
|
|
1485
|
+
set must be a DAG — all enforced by `validateDeliveryGraph`.
|
|
1486
|
+
type: object
|
|
1487
|
+
additionalProperties: false
|
|
1488
|
+
required:
|
|
1489
|
+
- from
|
|
1490
|
+
- to
|
|
1491
|
+
properties:
|
|
1492
|
+
from:
|
|
1493
|
+
type: string
|
|
1494
|
+
minLength: 1
|
|
1495
|
+
description: The upstream endpoint — `<nodeId>` (completion) or `<nodeId>.<fact>` (a declared emitted fact).
|
|
1496
|
+
to:
|
|
1497
|
+
type: string
|
|
1498
|
+
minLength: 1
|
|
1499
|
+
description: The dependent node's id — proceeds once `from` is observed.
|
|
1224
1500
|
FeatureStart:
|
|
1225
1501
|
description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
|
|
1226
1502
|
by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
|
|
@@ -1861,6 +2137,28 @@ paths:
|
|
|
1861
2137
|
application/json:
|
|
1862
2138
|
schema:
|
|
1863
2139
|
$ref: "#/components/schemas/ErrorBody"
|
|
2140
|
+
/agent/skill:
|
|
2141
|
+
get:
|
|
2142
|
+
operationId: getAgentSkill
|
|
2143
|
+
summary: The portable operator skill (markdown SKILL.md) an agent runtime loads on demand — a
|
|
2144
|
+
thin bootstrap that resolves which instance to drive, then fetches the live operator guide
|
|
2145
|
+
(GET /agent). Surfaced from the Overview tab's "Agent Instructions" prompt.
|
|
2146
|
+
security:
|
|
2147
|
+
- hookSecret: []
|
|
2148
|
+
- {}
|
|
2149
|
+
responses:
|
|
2150
|
+
"200":
|
|
2151
|
+
description: The operator skill, with any example keyed to this instance.
|
|
2152
|
+
content:
|
|
2153
|
+
application/json:
|
|
2154
|
+
schema:
|
|
2155
|
+
$ref: "#/components/schemas/AgentSkill"
|
|
2156
|
+
"401":
|
|
2157
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
2158
|
+
content:
|
|
2159
|
+
application/json:
|
|
2160
|
+
schema:
|
|
2161
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
1864
2162
|
/actions/start/convergence-loop:
|
|
1865
2163
|
post:
|
|
1866
2164
|
operationId: startConvergenceLoop
|
|
@@ -13,32 +13,18 @@
|
|
|
13
13
|
// NANO_PR_WEBHOOK_SECRET is set (the runtime does not enforce OpenAPI `security`).
|
|
14
14
|
|
|
15
15
|
import { renderAgentGuide, resolveEngineBase } from "../app/agentGuide.ts";
|
|
16
|
+
import { resolveApiBase } from "../app/resolveApiBase.ts";
|
|
16
17
|
import { buildVersionInfo, envVar } from "../app/version.ts";
|
|
17
18
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
18
19
|
|
|
19
20
|
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
-
* Reconstruct the app control-API base the caller reached us on (e.g. "https://host/app/api"), so
|
|
23
|
-
* the guide's example commands are copy-pasteable. Honour reverse-proxy forwarding headers; fall
|
|
24
|
-
* back to a localhost default when the Host header is absent (e.g. a raw unit-test request).
|
|
25
|
-
*/
|
|
26
|
-
function resolveApiBase(req: { path: string; headers: Headers }): string {
|
|
27
|
-
const rawProto = (req.headers.get("x-forwarded-proto") ?? "http").split(",")[0].trim().toLowerCase();
|
|
28
|
-
// x-forwarded-proto is user-controlled behind some proxies; only trust http/https.
|
|
29
|
-
const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
|
|
30
|
-
const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim();
|
|
31
|
-
// The op is mounted at "<base>/agent"; strip the trailing segment to recover the base path.
|
|
32
|
-
const basePath = req.path.replace(/\/agent\/?$/, "") || "/app/api";
|
|
33
|
-
return host ? `${proto}://${host}${basePath}` : `http://localhost:3000${basePath}`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
22
|
export default defineOperation("getAgentInstructions", ({ req }, app) => {
|
|
37
23
|
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
38
24
|
app.log.warn("getAgentInstructions rejected: missing/invalid shared secret");
|
|
39
25
|
return { status: 401, body: { error: "unauthorized" } };
|
|
40
26
|
}
|
|
41
|
-
const baseUrl = resolveApiBase(req);
|
|
27
|
+
const baseUrl = resolveApiBase(req, "agent");
|
|
42
28
|
return {
|
|
43
29
|
status: 200,
|
|
44
30
|
body: {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Tests for GET /app/api/agent/skill → operation `getAgentSkill` (ADR 0058 OpenAPI surface).
|
|
2
|
+
// The SKILL.md markdown is served as the `skill` field with any example keyed to the request's
|
|
3
|
+
// control-API base. Mirrors the getAgentInstructions test's request shape and shared-secret guard.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assert, assertEquals } from "#test-assert";
|
|
6
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
7
|
+
import { noopLog } from "../test/log.ts";
|
|
8
|
+
import handler from "./getAgentSkill.ts";
|
|
9
|
+
|
|
10
|
+
const app = { log: noopLog() } as any as AppApi;
|
|
11
|
+
|
|
12
|
+
function input(headers: Record<string, string> = {}, path = "/app/api/agent/skill") {
|
|
13
|
+
return {
|
|
14
|
+
req: {
|
|
15
|
+
method: "GET",
|
|
16
|
+
path,
|
|
17
|
+
query: new URLSearchParams(),
|
|
18
|
+
headers: new Headers(headers),
|
|
19
|
+
text: async () => "",
|
|
20
|
+
} as any,
|
|
21
|
+
params: {},
|
|
22
|
+
query: {},
|
|
23
|
+
body: undefined,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("returns 200 with the SKILL.md markdown and metadata", async () => {
|
|
28
|
+
const r = (await handler(input(), app)) as any;
|
|
29
|
+
assertEquals(r.status, 200);
|
|
30
|
+
assertEquals(r.body.format, "markdown");
|
|
31
|
+
assert("appVersion" in r.body); // nullable, but always present
|
|
32
|
+
assert(typeof r.body.generatedAt === "string" && r.body.generatedAt.length > 0);
|
|
33
|
+
assert(typeof r.body.baseUrl === "string" && r.body.baseUrl.length > 0);
|
|
34
|
+
assert(typeof r.body.skill === "string" && r.body.skill.length > 200);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("the skill is the portable bootstrap: frontmatter + fetch-the-live-guide", async () => {
|
|
38
|
+
const md = ((await handler(input(), app)) as any).body.skill as string;
|
|
39
|
+
assert(md.includes("name: nano-workforce"), "carries the skill frontmatter");
|
|
40
|
+
assert(md.includes("/agent"), "bootstraps by fetching the live operator guide");
|
|
41
|
+
assert(md.includes("Confirm which instance") || md.includes("confirm"), "tells the agent to confirm the target instance");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("baseUrl is keyed to the request's control-API base; no placeholder leaks", async () => {
|
|
45
|
+
const forwarded = input({ host: "wf.example.com", "x-forwarded-proto": "https" });
|
|
46
|
+
const body = (await handler(forwarded, app)) as any;
|
|
47
|
+
assertEquals(body.body.baseUrl, "https://wf.example.com/app/api");
|
|
48
|
+
assert(!body.body.skill.includes("__BASE__"), "no unsubstituted __BASE__ placeholder");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("x-forwarded-proto is restricted to http/https", async () => {
|
|
52
|
+
const spoofed = input({ host: "wf.example.com", "x-forwarded-proto": "javascript" });
|
|
53
|
+
const body = (await handler(spoofed, app)) as any;
|
|
54
|
+
assertEquals(body.body.baseUrl, "http://wf.example.com/app/api", "unsafe scheme falls back to http");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
|
|
58
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
59
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
60
|
+
try {
|
|
61
|
+
// SECRET is bound at import time, so import a cache-busted copy to observe the guard.
|
|
62
|
+
const mod = await import(`./getAgentSkill.ts?guard=${Date.now()}`);
|
|
63
|
+
const guarded = mod.default as typeof handler;
|
|
64
|
+
const bad = (await guarded(input(), app)) as any;
|
|
65
|
+
assertEquals(bad.status, 401);
|
|
66
|
+
const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
|
|
67
|
+
assertEquals(ok.status, 200);
|
|
68
|
+
} finally {
|
|
69
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
70
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
71
|
+
}
|
|
72
|
+
});
|