@nanobpm/nano-workforce 0.110.0 → 0.111.1
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/deliveryGraph.ts +46 -0
- package/app/deliveryGraphCompiler.test.ts +275 -0
- package/app/deliveryGraphCompiler.ts +487 -0
- package/app/github.test.ts +225 -1
- package/app/github.ts +124 -2
- package/app/readiness.test.ts +162 -0
- package/app/readiness.ts +165 -3
- package/app/service.ts +5 -2
- package/openapi.yaml +252 -3
- package/operations/compileDeliveryGraph.test.ts +60 -0
- package/operations/compileDeliveryGraph.ts +35 -0
- package/package.json +1 -1
- package/resources/processes/readiness-gate.bpmn +3 -0
package/app/readiness.ts
CHANGED
|
@@ -18,14 +18,28 @@
|
|
|
18
18
|
// any credential is read at execution time from the typed env-contract (`credentialEnv` names a
|
|
19
19
|
// declared {@link EnvKey}; ADR 0004 pinned decision 2) and is redacted from every log line.
|
|
20
20
|
import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts";
|
|
21
|
+
import { allCheckNames, checkConclusions, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
|
|
21
22
|
import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
|
|
22
23
|
|
|
23
24
|
/** The built-in readiness sources. `command` is the escape hatch that subsumes the long tail
|
|
24
25
|
* (`gh`, `curl`, `docker manifest inspect`, a custom probe) — adding a first-class kind later is
|
|
25
26
|
* an additive matcher, not a schema change. `capability` is the first such additive kind (#274):
|
|
26
27
|
* it resolves "which published version first carries capability C?" from the publish-provenance
|
|
27
|
-
* substrate and binds the discovered `pkg@version` back through the gate (see {@link matchCapability}).
|
|
28
|
-
|
|
28
|
+
* substrate and binds the discovered `pkg@version` back through the gate (see {@link matchCapability}).
|
|
29
|
+
* `pr` (ADR 0005 §2) is the merge-state kind: it lifts the PR-liveness/mergeability READ out of the
|
|
30
|
+
* merge loop (`app/mergeProtocol.ts` / `app/github.ts`) into a first-class probe so "watch an
|
|
31
|
+
* in-flight PR reach a declared state" is a graph edge, not logic buried in the merge-loop node
|
|
32
|
+
* body — the ACTION (landing the PR) stays in that node body; this kind only OBSERVES. */
|
|
33
|
+
export type ProbeKind = "http" | "command" | "npm" | "github-check" | "capability" | "pr";
|
|
34
|
+
|
|
35
|
+
/** The declared PR state a `pr` probe waits for (ADR 0005 §2). Each is a discovered fact about an
|
|
36
|
+
* in-flight PR, read from its live GitHub state and evaluated by {@link matchPr}:
|
|
37
|
+
* • `ready` — the PR is out of draft (the draft→ready transition is observable).
|
|
38
|
+
* • `merged` — the PR has landed; binds `mergedSha` (the merge commit) as an output.
|
|
39
|
+
* • `mergeable` — GitHub reports the PR as landable now ({@link classifyMergeability} `ready`:
|
|
40
|
+
* CLEAN/HAS_HOOKS/UNSTABLE/BEHIND — required review + checks satisfied).
|
|
41
|
+
* • `checks-green` — every head check run is complete with none failing (required checks green). */
|
|
42
|
+
export type PrCondition = "ready" | "merged" | "mergeable" | "checks-green";
|
|
29
43
|
|
|
30
44
|
/** What the gate does when the bounded wait times out (the engine timer arm fires). */
|
|
31
45
|
export type OnTimeout = "escalate" | "fail" | "continue";
|
|
@@ -33,9 +47,10 @@ export type OnTimeout = "escalate" | "fail" | "continue";
|
|
|
33
47
|
/** Backoff policy between poll attempts. */
|
|
34
48
|
export type Backoff = "fixed" | "exponential";
|
|
35
49
|
|
|
36
|
-
const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability"];
|
|
50
|
+
const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability", "pr"];
|
|
37
51
|
const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
|
|
38
52
|
const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
|
|
53
|
+
const PR_CONDITIONS: readonly PrCondition[] = ["ready", "merged", "mergeable", "checks-green"];
|
|
39
54
|
|
|
40
55
|
/** The per-kind readiness predicate. Every field is optional; each kind reads only the ones it
|
|
41
56
|
* understands and applies a sensible default when a field is absent (see the matchers below). */
|
|
@@ -66,6 +81,9 @@ export interface ProbeMatch {
|
|
|
66
81
|
* unset, the capability edge is deterministic-only. The resolved `pkg@version` and bare version
|
|
67
82
|
* are exposed to the command as `RESOLVED_ARTIFACT` / `RESOLVED_VERSION`. */
|
|
68
83
|
readonly verifyCommand?: string;
|
|
84
|
+
/** pr: the declared PR state the probe waits for (default `merged`). One of {@link PrCondition} —
|
|
85
|
+
* `ready` (out of draft), `merged`, `mergeable`, or `checks-green`. */
|
|
86
|
+
readonly prState?: PrCondition;
|
|
69
87
|
}
|
|
70
88
|
|
|
71
89
|
/** The poll cadence: how often to re-probe, how long to keep trying, and the backoff shape. */
|
|
@@ -207,6 +225,13 @@ export function parseProbe(raw: unknown): ReadinessProbe {
|
|
|
207
225
|
throw new Error("readiness probe (capability): 'match.package' is required (provenance is per-package scoped)");
|
|
208
226
|
}
|
|
209
227
|
}
|
|
228
|
+
// A pr edge whose target names no numeric PR id can never resolve — fail loudly at parse (mirroring
|
|
229
|
+
// the capability ref guard) rather than surface it as a timeout much later. `owner/repo#123`.
|
|
230
|
+
if (kind === "pr" && !parsePrTarget(target)) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`readiness probe (pr): 'target' ('${target}') must be an 'owner/repo#<number>' PR reference (e.g. 'nanobpm/nano-workforce#377')`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
210
235
|
const poll = isRecord(raw.poll) ? parsePoll(raw.poll) : undefined;
|
|
211
236
|
const credentialEnv = str(raw.credentialEnv).trim() || undefined;
|
|
212
237
|
if (credentialEnv !== undefined && !isEnvKey(credentialEnv)) {
|
|
@@ -259,9 +284,27 @@ function parseMatch(raw: Record<string, unknown>): ProbeMatch {
|
|
|
259
284
|
capabilityRef: str(raw.capabilityRef).trim() || undefined,
|
|
260
285
|
package: str(raw.package).trim() || undefined,
|
|
261
286
|
verifyCommand: str(raw.verifyCommand).trim() || undefined,
|
|
287
|
+
prState: parsePrCondition(raw.prState),
|
|
262
288
|
};
|
|
263
289
|
}
|
|
264
290
|
|
|
291
|
+
/** Narrow a raw `match.prState` to a {@link PrCondition}, throwing on a non-empty unknown value so a
|
|
292
|
+
* mistyped state (`"landed"` for `"merged"`) fails loudly at parse rather than waiting forever. An
|
|
293
|
+
* absent/blank value yields undefined — `matchPr` then applies the `merged` default. */
|
|
294
|
+
function parsePrCondition(raw: unknown): PrCondition | undefined {
|
|
295
|
+
const s = str(raw).trim();
|
|
296
|
+
if (s === "") return undefined;
|
|
297
|
+
if (!isPrCondition(s)) {
|
|
298
|
+
throw new Error(`readiness probe (pr): invalid match.prState '${s}' (expected one of ${PR_CONDITIONS.join(", ")})`);
|
|
299
|
+
}
|
|
300
|
+
return s;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isPrCondition(v: string): v is PrCondition {
|
|
304
|
+
for (const c of PR_CONDITIONS) if (c === v) return true;
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
265
308
|
function parsePoll(raw: Record<string, unknown>): ProbePoll {
|
|
266
309
|
const backoffRaw = str(raw.backoff).trim();
|
|
267
310
|
if (backoffRaw !== "" && !isBackoff(backoffRaw)) {
|
|
@@ -485,6 +528,118 @@ export function githubReleasesCommand(repo: string): string {
|
|
|
485
528
|
return `gh api --paginate --slurp ${shellQuote(`repos/${repo}/releases?per_page=100`)} -H ${shellQuote("Accept: application/vnd.github+json")}`;
|
|
486
529
|
}
|
|
487
530
|
|
|
531
|
+
// ── PR / merge-state probe (ADR 0005 §2 — lift the merge-loop READ into a first-class probe) ─────
|
|
532
|
+
|
|
533
|
+
/** A live PR observation, reduced to the fields {@link matchPr} reads. It extends the shared
|
|
534
|
+
* {@link PrState} (so `classifyMergeability` and the merge loop's liveness vocabulary are reused
|
|
535
|
+
* verbatim, never re-derived) and adds `mergedSha`, the merge commit oid a `merged` match binds
|
|
536
|
+
* downstream. Kept separate from I/O — {@link parsePrView} builds it from an already-fetched
|
|
537
|
+
* `gh pr view --json …` payload — so the matcher stays pure/unit-testable, exactly like
|
|
538
|
+
* {@link GithubRelease}. */
|
|
539
|
+
export interface PrObservation extends PrState {
|
|
540
|
+
/** The merge commit oid once landed (`gh pr view --json mergeCommit`), else null. */
|
|
541
|
+
readonly mergedSha: string | null;
|
|
542
|
+
/** Count of head checks still in flight (queued/in progress), so a `checks-green` gate never
|
|
543
|
+
* reports green while a run hasn't concluded. Derived via `pendingCheckNames`. */
|
|
544
|
+
readonly pendingChecks: number;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Parse a raw `gh pr view --json state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,mergeCommit`
|
|
548
|
+
* payload (already JSON-decoded) into a {@link PrObservation}. Reuses the SAME check-rollup readers
|
|
549
|
+
* as `fetchPrState` (`failingCheckNames`/`allCheckNames`) so a superseded/cancelled run is collapsed
|
|
550
|
+
* identically. Tolerant: a malformed/empty payload yields an all-open, no-checks observation, so a
|
|
551
|
+
* transient read degrades to "not ready" (keep waiting), never a throw. */
|
|
552
|
+
export function parsePrView(payload: unknown): PrObservation {
|
|
553
|
+
const j = isRecord(payload) ? payload : {};
|
|
554
|
+
const rollup = Array.isArray(j.statusCheckRollup) ? j.statusCheckRollup : [];
|
|
555
|
+
const names = failingCheckNames(rollup);
|
|
556
|
+
const pending = pendingCheckNames(rollup);
|
|
557
|
+
const merged = str(j.state).toUpperCase() === "MERGED" || str(j.mergedAt).trim() !== "";
|
|
558
|
+
const mergeCommit = isRecord(j.mergeCommit) ? j.mergeCommit : undefined;
|
|
559
|
+
const mergedSha = mergeCommit && str(mergeCommit.oid).trim() !== "" ? str(mergeCommit.oid).trim() : null;
|
|
560
|
+
return {
|
|
561
|
+
merged,
|
|
562
|
+
state: merged ? "merged" : str(j.state).toUpperCase() === "CLOSED" ? "closed" : "open",
|
|
563
|
+
mergeStateStatus: (str(j.mergeStateStatus) || "UNKNOWN").toUpperCase(),
|
|
564
|
+
failingChecks: names.length,
|
|
565
|
+
failingCheckNames: names,
|
|
566
|
+
totalChecks: rollup.length,
|
|
567
|
+
presentCheckNames: allCheckNames(rollup),
|
|
568
|
+
pendingCheckNames: pending,
|
|
569
|
+
checkConclusions: checkConclusions(rollup),
|
|
570
|
+
isDraft: j.isDraft === true,
|
|
571
|
+
headRefOid: str(j.headRefOid).trim() || null,
|
|
572
|
+
mergedSha,
|
|
573
|
+
pendingChecks: pending.length,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** pr readiness (ADR 0005 §2): does the observed PR satisfy the declared `match.prState`
|
|
578
|
+
* (default `merged`)? PURE — it operates on an already-fetched {@link PrObservation} and NEVER
|
|
579
|
+
* throws, so a transient/garbled read is simply "not ready yet". A `merged` match binds the merge
|
|
580
|
+
* commit as `{ mergedSha }` (mirroring the `capability` kind's `resolvedArtifact` bind) so a
|
|
581
|
+
* downstream edge can pin the exact landed commit. The merge ACTION stays in the merge-loop node
|
|
582
|
+
* body — this kind only OBSERVES. */
|
|
583
|
+
export function matchPr(match: ProbeMatch | undefined, pr: PrObservation): ProbeResult {
|
|
584
|
+
const want: PrCondition = match?.prState ?? "merged";
|
|
585
|
+
switch (want) {
|
|
586
|
+
case "ready": {
|
|
587
|
+
// draft→ready: a non-draft PR (already-merged PRs are non-draft too, so they also satisfy it).
|
|
588
|
+
const ready = !pr.isDraft;
|
|
589
|
+
return { ready, detail: `pr ${ready ? "ready (not draft)" : "still draft"}` };
|
|
590
|
+
}
|
|
591
|
+
case "merged": {
|
|
592
|
+
if (!pr.merged) return { ready: false, detail: "pr not merged yet" };
|
|
593
|
+
const bind = pr.mergedSha ? { mergedSha: pr.mergedSha } : undefined;
|
|
594
|
+
return { ready: true, detail: `pr merged${pr.mergedSha ? ` (${pr.mergedSha})` : ""}`, bind };
|
|
595
|
+
}
|
|
596
|
+
case "mergeable": {
|
|
597
|
+
const m = classifyMergeability(pr);
|
|
598
|
+
const ready = m === "ready";
|
|
599
|
+
return { ready, detail: `pr mergeability ${m}` };
|
|
600
|
+
}
|
|
601
|
+
case "checks-green": {
|
|
602
|
+
// Required checks green: at least one head run exists, none failing, AND none still in flight.
|
|
603
|
+
// A queued/in-progress run has no failing conclusion, so counting only `failingChecks` would
|
|
604
|
+
// report green while checks are still running — `pendingChecks` closes that gap. `failingChecks
|
|
605
|
+
// < 0` is token mode (checks unenumerable) — stay conservative (not ready), never falsely green.
|
|
606
|
+
const ready = pr.failingChecks === 0 && pr.pendingChecks === 0 && pr.totalChecks > 0;
|
|
607
|
+
const detail =
|
|
608
|
+
pr.totalChecks < 0
|
|
609
|
+
? "pr checks unenumerable (not ready)"
|
|
610
|
+
: pr.totalChecks === 0
|
|
611
|
+
? "pr no checks yet"
|
|
612
|
+
: pr.failingChecks > 0
|
|
613
|
+
? `pr checks ${pr.failingChecks} failing`
|
|
614
|
+
: pr.pendingChecks > 0
|
|
615
|
+
? `pr checks ${pr.pendingChecks} pending`
|
|
616
|
+
: "pr checks green";
|
|
617
|
+
return { ready, detail };
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Split an `owner/repo#123` PR reference into its repo + numeric PR number, or `null` when it
|
|
623
|
+
* carries no numeric id (so `parseProbe` can reject a never-resolvable target loudly). The `#`
|
|
624
|
+
* separator is the canonical — and only — PR handle: an `@N` form is deliberately NOT accepted, as
|
|
625
|
+
* `owner/repo@<ref>` is the repo-ref syntax used elsewhere (`parseRepoRef`), so a numeric `@N` there
|
|
626
|
+
* would ambiguously mis-parse a git ref as a PR number. Matches the OpenAPI contract + `parseProbe`
|
|
627
|
+
* error, both of which document `owner/repo#N` only. */
|
|
628
|
+
export function parsePrTarget(target: string): { repo: string; number: string } | null {
|
|
629
|
+
const t = target.trim();
|
|
630
|
+
const m = t.match(/^(.+?)#(\d+)$/);
|
|
631
|
+
if (!m) return null;
|
|
632
|
+
const repo = m[1].trim();
|
|
633
|
+
if (repo === "") return null;
|
|
634
|
+
return { repo, number: m[2] };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Build the `gh pr view` command that reads a PR's merge-state fields. `gh` reads its token from the
|
|
638
|
+
* ambient env (like `github-check`/`capability`) — no `credentialEnv`. */
|
|
639
|
+
export function prViewCommand(repo: string, number: string): string {
|
|
640
|
+
return `gh pr view ${shellQuote(number)} --repo ${shellQuote(repo)} --json ${shellQuote("state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,mergeCommit")}`;
|
|
641
|
+
}
|
|
642
|
+
|
|
488
643
|
|
|
489
644
|
// ── Single probe attempt (does I/O via the injected {@link ProbeExec}) ──────────────────────────
|
|
490
645
|
|
|
@@ -520,6 +675,13 @@ export async function probeOnce(
|
|
|
520
675
|
if (out.code !== 0) return { ready: false, detail: "capability: gh api failed (not ready)" };
|
|
521
676
|
return matchCapability(probe.match, parseReleases(parseJson(out.stdout)));
|
|
522
677
|
}
|
|
678
|
+
case "pr": {
|
|
679
|
+
const ref = parsePrTarget(probe.target);
|
|
680
|
+
if (!ref) return { ready: false, detail: "pr: unparseable target (not ready)" };
|
|
681
|
+
const out = await exec.run(prViewCommand(ref.repo, ref.number), env);
|
|
682
|
+
if (out.code !== 0) return { ready: false, detail: "pr: gh pr view failed (not ready)" };
|
|
683
|
+
return matchPr(probe.match, parsePrView(parseJson(out.stdout)));
|
|
684
|
+
}
|
|
523
685
|
}
|
|
524
686
|
}
|
|
525
687
|
|
package/app/service.ts
CHANGED
|
@@ -1150,7 +1150,11 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1150
1150
|
// (#342/#350). Reuse the `st` we just read so we don't double-fetch. This is the proven terminal
|
|
1151
1151
|
// path the whole class (#368) now shares.
|
|
1152
1152
|
if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
|
|
1153
|
-
|
|
1153
|
+
// Load the repo's merge protocol ONCE per PR iteration and pass it into the classifier so the
|
|
1154
|
+
// protocol-aware backstop (#392) can gate a red DECLARED-required check even when GitHub reports
|
|
1155
|
+
// the PR as UNSTABLE. The same handle is reused by the frugal-CI fresh-head-run branch below.
|
|
1156
|
+
const protocol = await loadMergeProtocol(repo, token).catch(() => null);
|
|
1157
|
+
const verdict = classifyMergeability(st, protocol ?? undefined);
|
|
1154
1158
|
if (verdict === "waiting") {
|
|
1155
1159
|
// Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
|
|
1156
1160
|
// head run and the PR has NO required head run yet, review has converged but the last push
|
|
@@ -1161,7 +1165,6 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1161
1165
|
// `pull_request` run once per head (mark ready / close+reopen); rebases change
|
|
1162
1166
|
// `headRefOid`, so downstream merge-train PRs get a new nudge after every post-rebase
|
|
1163
1167
|
// landing attempt.
|
|
1164
|
-
const protocol = await loadMergeProtocol(repo, token).catch(() => null);
|
|
1165
1168
|
if (protocol) {
|
|
1166
1169
|
const action = freshHeadRunAction(protocol, verdict, headRunPresenceCount(protocol, st), st.isDraft, {
|
|
1167
1170
|
headRefOid: st.headRefOid,
|
package/openapi.yaml
CHANGED
|
@@ -1217,12 +1217,12 @@ components:
|
|
|
1217
1217
|
properties:
|
|
1218
1218
|
kind:
|
|
1219
1219
|
type: string
|
|
1220
|
-
enum: [http, command, npm, github-check, capability]
|
|
1221
|
-
description: The readiness source. `command` is the escape hatch; `capability` resolves a cross-repo published-artifact edge.
|
|
1220
|
+
enum: [http, command, npm, github-check, capability, pr]
|
|
1221
|
+
description: The readiness source. `command` is the escape hatch; `capability` resolves a cross-repo published-artifact edge; `pr` watches an in-flight PR's merge state (ADR 0005 §2).
|
|
1222
1222
|
target:
|
|
1223
1223
|
type: string
|
|
1224
1224
|
minLength: 1
|
|
1225
|
-
description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`,
|
|
1225
|
+
description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, or an `owner/repo#123` PR reference for the `pr` kind).
|
|
1226
1226
|
onTimeout:
|
|
1227
1227
|
type: string
|
|
1228
1228
|
enum: [escalate, fail, continue]
|
|
@@ -1245,6 +1245,7 @@ components:
|
|
|
1245
1245
|
capabilityRef: { type: string, description: "capability: the upstream issue/PR handle the resolved version must carry." }
|
|
1246
1246
|
package: { type: string, description: "capability: the package whose releases are scanned for provenance." }
|
|
1247
1247
|
verifyCommand: { type: string, description: "capability: optional empirical verifier run once at the gate boundary." }
|
|
1248
|
+
prState: { type: string, enum: [ready, merged, mergeable, checks-green], description: "pr: the declared PR state to wait for (default merged)." }
|
|
1248
1249
|
poll:
|
|
1249
1250
|
type: object
|
|
1250
1251
|
additionalProperties: false
|
|
@@ -1497,6 +1498,221 @@ components:
|
|
|
1497
1498
|
type: string
|
|
1498
1499
|
minLength: 1
|
|
1499
1500
|
description: The dependent node's id — proceeds once `from` is observed.
|
|
1501
|
+
DeliveryCompileError:
|
|
1502
|
+
description: >-
|
|
1503
|
+
One semantic-validation or compile failure, path-qualified at the offending input
|
|
1504
|
+
(`nodes[2].kind`, `edges[1].from`, …). Mirrors a `validateDeliveryGraph` (`app/deliveryGraph.ts`)
|
|
1505
|
+
error stripped to the wire pair `{ path, message }` (the stable `code` stays server-side).
|
|
1506
|
+
type: object
|
|
1507
|
+
additionalProperties: false
|
|
1508
|
+
required:
|
|
1509
|
+
- path
|
|
1510
|
+
- message
|
|
1511
|
+
properties:
|
|
1512
|
+
path:
|
|
1513
|
+
type: string
|
|
1514
|
+
description: JSON-path pointer at the offending node/edge/fact.
|
|
1515
|
+
message:
|
|
1516
|
+
type: string
|
|
1517
|
+
description: Human-actionable description of the failure.
|
|
1518
|
+
ResolvedDeliveryNode:
|
|
1519
|
+
description: >-
|
|
1520
|
+
A normalised node in the compiled graph (ADR 0005 slice S1) — its `id`, `kind`, the
|
|
1521
|
+
deterministic BPMN `element` id it compiled to, the engine-native `calledElement` body it
|
|
1522
|
+
delegates to (Decision 2 — absent for a `human` user task), its typed `emits[]`, and the
|
|
1523
|
+
upstream node ids it `dependsOn` (sorted).
|
|
1524
|
+
type: object
|
|
1525
|
+
additionalProperties: false
|
|
1526
|
+
required:
|
|
1527
|
+
- id
|
|
1528
|
+
- kind
|
|
1529
|
+
- element
|
|
1530
|
+
- emits
|
|
1531
|
+
- dependsOn
|
|
1532
|
+
properties:
|
|
1533
|
+
id:
|
|
1534
|
+
type: string
|
|
1535
|
+
description: The author's node id (unique across the graph).
|
|
1536
|
+
kind:
|
|
1537
|
+
type: string
|
|
1538
|
+
enum: [agent, wait, human, connector]
|
|
1539
|
+
description: The node's kind from the closed allowlist (the trust boundary).
|
|
1540
|
+
element:
|
|
1541
|
+
type: string
|
|
1542
|
+
description: The deterministic BPMN element id this node compiled to (e.g. `n0`).
|
|
1543
|
+
calledElement:
|
|
1544
|
+
type: string
|
|
1545
|
+
description: >-
|
|
1546
|
+
The engine-native sub-process/call-activity target this node delegates to (Decision 2).
|
|
1547
|
+
Absent for a `human` node (a native user task, not a call activity).
|
|
1548
|
+
emits:
|
|
1549
|
+
type: array
|
|
1550
|
+
items:
|
|
1551
|
+
$ref: "#/components/schemas/DeliveryFact"
|
|
1552
|
+
description: The node's typed emitted facts (empty when it emits nothing).
|
|
1553
|
+
dependsOn:
|
|
1554
|
+
type: array
|
|
1555
|
+
items:
|
|
1556
|
+
type: string
|
|
1557
|
+
description: The ids of the upstream nodes this node depends on, sorted for determinism.
|
|
1558
|
+
ResolvedDeliveryEdge:
|
|
1559
|
+
description: >-
|
|
1560
|
+
A resolved dependency edge — the author's `from`/`to` plus the resolved upstream `fromNode`
|
|
1561
|
+
and, when the `from` was qualified (`<nodeId>.<fact>`), the referenced `fromFact`.
|
|
1562
|
+
type: object
|
|
1563
|
+
additionalProperties: false
|
|
1564
|
+
required:
|
|
1565
|
+
- from
|
|
1566
|
+
- to
|
|
1567
|
+
- fromNode
|
|
1568
|
+
properties:
|
|
1569
|
+
from:
|
|
1570
|
+
type: string
|
|
1571
|
+
description: The author's `from` endpoint verbatim (`<nodeId>` or `<nodeId>.<fact>`).
|
|
1572
|
+
to:
|
|
1573
|
+
type: string
|
|
1574
|
+
description: The dependent node's id.
|
|
1575
|
+
fromNode:
|
|
1576
|
+
type: string
|
|
1577
|
+
description: The resolved upstream node id.
|
|
1578
|
+
fromFact:
|
|
1579
|
+
type: string
|
|
1580
|
+
description: The referenced emitted fact, when the edge `from` was qualified.
|
|
1581
|
+
ResolvedDeliveryGraph:
|
|
1582
|
+
description: >-
|
|
1583
|
+
The normalised graph the compiler resolved from the input (ADR 0005 slice S1) — nodes and
|
|
1584
|
+
edges sorted deterministically so the same JSON always yields the same preview.
|
|
1585
|
+
type: object
|
|
1586
|
+
additionalProperties: false
|
|
1587
|
+
required:
|
|
1588
|
+
- nodes
|
|
1589
|
+
- edges
|
|
1590
|
+
properties:
|
|
1591
|
+
name:
|
|
1592
|
+
type: string
|
|
1593
|
+
description: The graph's optional human-readable label, echoed from the input.
|
|
1594
|
+
nodes:
|
|
1595
|
+
type: array
|
|
1596
|
+
items:
|
|
1597
|
+
$ref: "#/components/schemas/ResolvedDeliveryNode"
|
|
1598
|
+
description: The normalised nodes, sorted by id.
|
|
1599
|
+
edges:
|
|
1600
|
+
type: array
|
|
1601
|
+
items:
|
|
1602
|
+
$ref: "#/components/schemas/ResolvedDeliveryEdge"
|
|
1603
|
+
description: The resolved edges, sorted deterministically.
|
|
1604
|
+
DeliveryHumanStop:
|
|
1605
|
+
description: >-
|
|
1606
|
+
A `human` node extracted for the preview — a point where the graph STOPS for a person (or an
|
|
1607
|
+
agent answering on their behalf). Carries the instruction, the optional attached form, and the
|
|
1608
|
+
typed facts the node will emit on completion.
|
|
1609
|
+
type: object
|
|
1610
|
+
additionalProperties: false
|
|
1611
|
+
required:
|
|
1612
|
+
- nodeId
|
|
1613
|
+
- emits
|
|
1614
|
+
properties:
|
|
1615
|
+
nodeId:
|
|
1616
|
+
type: string
|
|
1617
|
+
description: The human node's id.
|
|
1618
|
+
prompt:
|
|
1619
|
+
type: string
|
|
1620
|
+
description: The instruction shown to the human/agent ("now do X"), when declared.
|
|
1621
|
+
formKey:
|
|
1622
|
+
type: string
|
|
1623
|
+
description: The explicitly-attached form key, when declared.
|
|
1624
|
+
emits:
|
|
1625
|
+
type: array
|
|
1626
|
+
items:
|
|
1627
|
+
$ref: "#/components/schemas/DeliveryFact"
|
|
1628
|
+
description: The typed facts this human node will hand forward (empty for a "click done" stop).
|
|
1629
|
+
DeliverySideEffect:
|
|
1630
|
+
description: >-
|
|
1631
|
+
A side-effecting action the compiled graph WILL perform (ADR 0005 slice S1 preview) — an
|
|
1632
|
+
`agent` job run or a `connector` outbound action. Read-only `wait` gates and `human` stops are
|
|
1633
|
+
NOT side effects (they are surfaced separately). Lets a human see "what it will do" before
|
|
1634
|
+
approving (Decision 7).
|
|
1635
|
+
type: object
|
|
1636
|
+
additionalProperties: false
|
|
1637
|
+
required:
|
|
1638
|
+
- nodeId
|
|
1639
|
+
- kind
|
|
1640
|
+
- description
|
|
1641
|
+
properties:
|
|
1642
|
+
nodeId:
|
|
1643
|
+
type: string
|
|
1644
|
+
description: The id of the node that performs the side effect.
|
|
1645
|
+
kind:
|
|
1646
|
+
type: string
|
|
1647
|
+
enum: [agent, connector]
|
|
1648
|
+
description: The side-effecting node kind.
|
|
1649
|
+
description:
|
|
1650
|
+
type: string
|
|
1651
|
+
description: Human-readable summary of the effect (e.g. "runs agent job `senior:feature`").
|
|
1652
|
+
dedupeKey:
|
|
1653
|
+
type: string
|
|
1654
|
+
description: The connector's idempotency key, when declared (at-least-once safety, Decision 7).
|
|
1655
|
+
CompileDeliveryGraphResult:
|
|
1656
|
+
description: >-
|
|
1657
|
+
A successful compile (ADR 0005 slice S1) — the PURE, side-effect-free preview a co-designing
|
|
1658
|
+
agent iterates against. Carries the compiled one-shot `bpmn` (compile-to-native artifact), a
|
|
1659
|
+
human-readable `diagram` (mermaid), the `resolved` normalised graph, and the extracted
|
|
1660
|
+
`humanNodes[]` (where it stops for a person) and `sideEffects[]` (what it will do). NOTHING is
|
|
1661
|
+
deployed — `compile` and `start` are separate doors (Decision 5/7).
|
|
1662
|
+
type: object
|
|
1663
|
+
additionalProperties: false
|
|
1664
|
+
required:
|
|
1665
|
+
- ok
|
|
1666
|
+
- diagram
|
|
1667
|
+
- bpmn
|
|
1668
|
+
- resolved
|
|
1669
|
+
- humanNodes
|
|
1670
|
+
- sideEffects
|
|
1671
|
+
properties:
|
|
1672
|
+
ok:
|
|
1673
|
+
type: boolean
|
|
1674
|
+
enum: [true]
|
|
1675
|
+
description: Discriminant — `true` for a successful compile.
|
|
1676
|
+
diagram:
|
|
1677
|
+
type: string
|
|
1678
|
+
description: A human-readable mermaid `flowchart` of the resolved graph.
|
|
1679
|
+
bpmn:
|
|
1680
|
+
type: string
|
|
1681
|
+
description: >-
|
|
1682
|
+
The compiled one-shot BPMN process definition (compile-to-native). Deterministic — the same
|
|
1683
|
+
input graph always produces byte-identical XML. Not deployed here (S4 owns deployment).
|
|
1684
|
+
resolved:
|
|
1685
|
+
$ref: "#/components/schemas/ResolvedDeliveryGraph"
|
|
1686
|
+
humanNodes:
|
|
1687
|
+
type: array
|
|
1688
|
+
items:
|
|
1689
|
+
$ref: "#/components/schemas/DeliveryHumanStop"
|
|
1690
|
+
description: The human stop-points, sorted by node id.
|
|
1691
|
+
sideEffects:
|
|
1692
|
+
type: array
|
|
1693
|
+
items:
|
|
1694
|
+
$ref: "#/components/schemas/DeliverySideEffect"
|
|
1695
|
+
description: The side-effecting actions the graph will perform, sorted by node id.
|
|
1696
|
+
CompileDeliveryGraphErrors:
|
|
1697
|
+
description: >-
|
|
1698
|
+
A rejected compile (ADR 0005 slice S1) — the graph failed shape or semantic validation. Every
|
|
1699
|
+
error is path-qualified so the co-designing agent can fix the exact offending input and
|
|
1700
|
+
re-compile. Nothing was compiled or deployed.
|
|
1701
|
+
type: object
|
|
1702
|
+
additionalProperties: false
|
|
1703
|
+
required:
|
|
1704
|
+
- ok
|
|
1705
|
+
- errors
|
|
1706
|
+
properties:
|
|
1707
|
+
ok:
|
|
1708
|
+
type: boolean
|
|
1709
|
+
enum: [false]
|
|
1710
|
+
description: Discriminant — `false` for a rejected compile.
|
|
1711
|
+
errors:
|
|
1712
|
+
type: array
|
|
1713
|
+
items:
|
|
1714
|
+
$ref: "#/components/schemas/DeliveryCompileError"
|
|
1715
|
+
description: The path-qualified validation/compile failures (at least one).
|
|
1500
1716
|
FeatureStart:
|
|
1501
1717
|
description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
|
|
1502
1718
|
by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
|
|
@@ -2246,6 +2462,39 @@ paths:
|
|
|
2246
2462
|
application/json:
|
|
2247
2463
|
schema:
|
|
2248
2464
|
$ref: "#/components/schemas/ErrorBody"
|
|
2465
|
+
/actions/compile-delivery-graph:
|
|
2466
|
+
post:
|
|
2467
|
+
operationId: compileDeliveryGraph
|
|
2468
|
+
summary: Validate + compile a delivery graph into a preview (PURE — never deploys). (ADR 0005 slice S1)
|
|
2469
|
+
description: >-
|
|
2470
|
+
The fast, safe inner loop of the delivery-graph workflow (ADR 0005 Decision 5/6). Given an
|
|
2471
|
+
agent-authored `DeliveryGraph` (the closed `agent`/`wait`/`human`/`connector` node vocabulary —
|
|
2472
|
+
the trust boundary), it runs the pure `validateDeliveryGraph` semantic check and then the
|
|
2473
|
+
deterministic, human-written compiler, returning a preview: the compiled one-shot BPMN
|
|
2474
|
+
(compile-to-native), a mermaid diagram, the resolved/normalised graph, and the extracted human
|
|
2475
|
+
stop-points and side effects. It is PURE and side-effect-free — it validates and compiles but
|
|
2476
|
+
NEVER deploys or dispatches, so an agent can call it repeatedly while iterating. Deployment is a
|
|
2477
|
+
separate door (`startDeliveryGraph`, a later slice) — there is deliberately no `dryRun` flag on
|
|
2478
|
+
the start door (Decision 5/7). A malformed graph is a 400 carrying path-qualified errors.
|
|
2479
|
+
requestBody:
|
|
2480
|
+
required: true
|
|
2481
|
+
content:
|
|
2482
|
+
application/json:
|
|
2483
|
+
schema:
|
|
2484
|
+
$ref: "#/components/schemas/DeliveryGraph"
|
|
2485
|
+
responses:
|
|
2486
|
+
"200":
|
|
2487
|
+
description: The graph validated and compiled — the pure preview (nothing deployed).
|
|
2488
|
+
content:
|
|
2489
|
+
application/json:
|
|
2490
|
+
schema:
|
|
2491
|
+
$ref: "#/components/schemas/CompileDeliveryGraphResult"
|
|
2492
|
+
"400":
|
|
2493
|
+
description: The graph failed shape or semantic validation — path-qualified errors, nothing compiled.
|
|
2494
|
+
content:
|
|
2495
|
+
application/json:
|
|
2496
|
+
schema:
|
|
2497
|
+
$ref: "#/components/schemas/CompileDeliveryGraphErrors"
|
|
2249
2498
|
/actions/start/feature:
|
|
2250
2499
|
post:
|
|
2251
2500
|
operationId: startFeature
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Tests for the POST /app/api/actions/compile-delivery-graph operation `compileDeliveryGraph`
|
|
2
|
+
// (ADR 0005 slice S1). The delegate is a thin, PURE mapping of the compiler's discriminated result
|
|
3
|
+
// onto the HTTP status: a well-formed graph → 200 { ok:true, … preview }, a malformed one → 400
|
|
4
|
+
// { ok:false, errors }. It touches no data layer and has zero side effects, so the same body compiled
|
|
5
|
+
// twice returns the identical response (callable repeatedly). These tests assert that status mapping.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
9
|
+
import { noopLog } from "../test/log.ts";
|
|
10
|
+
import handler from "./compileDeliveryGraph.ts";
|
|
11
|
+
|
|
12
|
+
const app = { log: noopLog() } as unknown as AppApi;
|
|
13
|
+
|
|
14
|
+
async function call(body: unknown) {
|
|
15
|
+
return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const GOOD = {
|
|
19
|
+
name: "runbook",
|
|
20
|
+
nodes: [
|
|
21
|
+
{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } },
|
|
22
|
+
{ id: "b", kind: "human", human: { prompt: "do X" } },
|
|
23
|
+
],
|
|
24
|
+
edges: [{ from: "a", to: "b" }],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
test("compile-delivery-graph: a well-formed graph → 200 with the pure preview", async () => {
|
|
28
|
+
const res = await call(GOOD);
|
|
29
|
+
assertEquals(res.status, 200);
|
|
30
|
+
assertEquals(res.body.ok, true);
|
|
31
|
+
assert(typeof res.body.bpmn === "string" && res.body.bpmn.length > 0);
|
|
32
|
+
assert(typeof res.body.diagram === "string" && res.body.diagram.length > 0);
|
|
33
|
+
assertEquals(res.body.resolved.nodes.length, 2);
|
|
34
|
+
assertEquals(res.body.humanNodes.length, 1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("compile-delivery-graph: a malformed graph → 400 with path-qualified errors", async () => {
|
|
38
|
+
const res = await call({
|
|
39
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
|
|
40
|
+
edges: [{ from: "a", to: "ghost" }],
|
|
41
|
+
});
|
|
42
|
+
assertEquals(res.status, 400);
|
|
43
|
+
assertEquals(res.body.ok, false);
|
|
44
|
+
assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
|
|
45
|
+
assert(res.body.errors.every((e: { path: string; message: string }) => typeof e.path === "string"));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("compile-delivery-graph: is side-effect-free — repeated calls return identical responses", async () => {
|
|
49
|
+
const a = await call(GOOD);
|
|
50
|
+
const b = await call(GOOD);
|
|
51
|
+
assertEquals(a.status, b.status);
|
|
52
|
+
assertEquals(a.body.bpmn, b.body.bpmn);
|
|
53
|
+
assertEquals(JSON.stringify(a.body.resolved), JSON.stringify(b.body.resolved));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("compile-delivery-graph: a missing/empty body → 400, never a 500", async () => {
|
|
57
|
+
const res = await call(undefined);
|
|
58
|
+
assertEquals(res.status, 400);
|
|
59
|
+
assertEquals(res.body.ok, false);
|
|
60
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// POST /app/api/actions/compile-delivery-graph → operationId `compileDeliveryGraph` (ADR 0005,
|
|
2
|
+
// slice S1). The PURE, side-effect-free compile door: the fast, safe inner loop a co-designing agent
|
|
3
|
+
// hammers while authoring a `DeliveryGraph`. It VALIDATES (the pure `validateDeliveryGraph` semantic
|
|
4
|
+
// check, run inside the compiler) and COMPILES the graph into a preview — the compiled one-shot BPMN
|
|
5
|
+
// (compile-to-native), a mermaid diagram, the resolved/normalised graph, and the extracted human
|
|
6
|
+
// stop-points + side effects — but NEVER deploys, dispatches, or mutates anything (Decision 5/6:
|
|
7
|
+
// `compile` and `start` are SEPARATE doors, and there is deliberately no `dryRun` flag on the start
|
|
8
|
+
// door). Because it has zero side effects, an agent may call it repeatedly: JSON → compile → fix.
|
|
9
|
+
//
|
|
10
|
+
// A well-formed graph is `200 { ok:true, diagram, bpmn, resolved, humanNodes, sideEffects }`; a
|
|
11
|
+
// malformed one is `400 { ok:false, errors:[{ path, message }] }`, every error path-qualified so the
|
|
12
|
+
// author can fix the exact offending input. The compiler is the single source of both truths — this
|
|
13
|
+
// delegate just maps its discriminated result onto the HTTP status.
|
|
14
|
+
|
|
15
|
+
import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
|
|
16
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
17
|
+
|
|
18
|
+
export default defineOperation("compileDeliveryGraph", async ({ body }, app) => {
|
|
19
|
+
// The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the compiler adds
|
|
20
|
+
// the SEMANTIC checks (acyclicity, edge integrity, fact resolution) the schema cannot express. A
|
|
21
|
+
// directly-invoked delegate could still pass `undefined` — the compiler reads its input as
|
|
22
|
+
// `unknown` and maps that to a clean `ok:false`, never a 500.
|
|
23
|
+
const result = compileDeliveryGraph(body);
|
|
24
|
+
if (!result.ok) {
|
|
25
|
+
app.log.warn("compile-delivery-graph rejected", { errors: result.errors.length });
|
|
26
|
+
return { status: 400, body: result };
|
|
27
|
+
}
|
|
28
|
+
app.log.info("compile-delivery-graph compiled", {
|
|
29
|
+
nodes: result.resolved.nodes.length,
|
|
30
|
+
edges: result.resolved.edges.length,
|
|
31
|
+
humanNodes: result.humanNodes.length,
|
|
32
|
+
sideEffects: result.sideEffects.length,
|
|
33
|
+
});
|
|
34
|
+
return { status: 200, body: result };
|
|
35
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.111.1",
|
|
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",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
<nano:extend name="capabilityRef" type="string" optional="true" />
|
|
23
23
|
<nano:extend name="package" type="string" optional="true" />
|
|
24
24
|
<nano:extend name="verifyCommand" type="string" optional="true" />
|
|
25
|
+
<nano:extend name="prState" type="string" optional="true" />
|
|
25
26
|
</nano:shape>
|
|
26
27
|
<nano:shape id="ReadinessProbePoll" name="Readiness probe — poll policy">
|
|
27
28
|
<nano:extend name="everyMs" type="integer" optional="true" />
|
|
@@ -46,11 +47,13 @@
|
|
|
46
47
|
<nano:extend name="ready" type="boolean" />
|
|
47
48
|
<nano:extend name="detail" type="string" optional="true" />
|
|
48
49
|
<nano:extend name="resolvedArtifact" type="string" optional="true" />
|
|
50
|
+
<nano:extend name="mergedSha" type="string" optional="true" />
|
|
49
51
|
</nano:shape>
|
|
50
52
|
<nano:shape id="ReadinessReady" name="readiness-ready message payload">
|
|
51
53
|
<nano:extend name="ready" type="boolean" />
|
|
52
54
|
<nano:extend name="detail" type="string" optional="true" />
|
|
53
55
|
<nano:extend name="resolvedArtifact" type="string" optional="true" />
|
|
56
|
+
<nano:extend name="mergedSha" type="string" optional="true" />
|
|
54
57
|
</nano:shape>
|
|
55
58
|
</nano:shapes>
|
|
56
59
|
</bpmn:extensionElements>
|