@nanobpm/nano-workforce 0.160.0 → 0.162.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 +12 -0
- package/app/agentGuide.test.ts +115 -0
- package/app/agentGuide.ts +109 -0
- package/app/deliveryGraphStage.ts +108 -0
- package/app/sequenceIssues.test.ts +186 -0
- package/app/sequenceIssues.ts +236 -0
- package/docs/agent-guide.md +11 -0
- package/docs/mcp-runbook.md +16 -0
- package/e2e/addressable-guide.e2e.ts +89 -0
- package/e2e/sequenceIssues.e2e.ts +94 -0
- package/openapi.yaml +296 -0
- package/operations/compileDeliveryGraph.ts +21 -59
- package/operations/getAgentGuide.test.ts +100 -0
- package/operations/getAgentGuide.ts +92 -0
- package/operations/sequenceIssues.test.ts +101 -0
- package/operations/sequenceIssues.ts +55 -0
- package/package.json +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Tests for the POST /app/api/actions/start/sequence-issues operation `sequenceIssues` (epic
|
|
2
|
+
// nano-workforce#605, S4/#610). The intent-shaped door GENERATES the canonical delivery graph and
|
|
3
|
+
// STAGES it through the SAME compile+stage flow as `compileDeliveryGraph` — the response carries only
|
|
4
|
+
// a preview + a navigational `reviewUrl` (no dispatch handle); dispatch stays an operator action.
|
|
5
|
+
// Invalid input is a 400 with `issues[{path,message}]` and nothing is staged.
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assert, assertEquals } from "#test-assert";
|
|
11
|
+
import type { AppApi, DataLayer } from "@nanobpm/urban";
|
|
12
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
13
|
+
import { deliveryGraphProposals, listStagedProposals } from "../app/deliveryGraphProposals.ts";
|
|
14
|
+
import { noopLog } from "../test/log.ts";
|
|
15
|
+
import handler from "./sequenceIssues.ts";
|
|
16
|
+
|
|
17
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
18
|
+
|
|
19
|
+
async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-seqissues-"));
|
|
21
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
22
|
+
try {
|
|
23
|
+
const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
|
|
24
|
+
await fn(edge, app.db);
|
|
25
|
+
} finally {
|
|
26
|
+
await app.stop?.();
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function call(app: AppApi, body: unknown, headers: Record<string, string> = {}) {
|
|
32
|
+
const req = { path: "/app/api/actions/start/sequence-issues", headers: new Headers(headers) };
|
|
33
|
+
return (await handler({ req: req as any, params: {}, query: {}, body } as any, app)) as any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("sequence-issues: a valid intent → 200 ready, staged as a proposal, with the preview", async () => {
|
|
37
|
+
await withApp(async (app, data) => {
|
|
38
|
+
const res = await call(app, { behind: "acme/repo#100", issues: ["acme/repo#1", "acme/repo#2"] });
|
|
39
|
+
assertEquals(res.status, 200);
|
|
40
|
+
assertEquals(res.body.status, "ready");
|
|
41
|
+
assert(typeof res.body.digest === "string" && res.body.digest.length > 0);
|
|
42
|
+
assert(typeof res.body.preview === "object" && res.body.preview !== null);
|
|
43
|
+
assert(typeof res.body.preview.diagram === "string" && res.body.preview.diagram.length > 0);
|
|
44
|
+
// Staged through the SAME path as compileDeliveryGraph — visible immediately (read-after-write).
|
|
45
|
+
const row = await deliveryGraphProposals(data).get(res.body.digest);
|
|
46
|
+
assert(row, "the generated graph is staged for operator dispatch");
|
|
47
|
+
assertEquals(row?.status, "staged");
|
|
48
|
+
const live = await listStagedProposals(data);
|
|
49
|
+
assert(live.some((p) => p.digest === res.body.digest), "the staged digest is listed");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("sequence-issues: the response exposes NO dispatch handle — the door stages, never dispatches", async () => {
|
|
54
|
+
await withApp(async (app) => {
|
|
55
|
+
const res = await call(app, { issues: ["acme/repo#1"] });
|
|
56
|
+
assertEquals(res.status, 200);
|
|
57
|
+
const keys = Object.keys(res.body);
|
|
58
|
+
for (const forbidden of ["runKey", "token", "approvalToken", "processInstanceKey", "processKey", "dispatchUrl"]) {
|
|
59
|
+
assert(!keys.includes(forbidden), `response must not carry a dispatch handle (${forbidden})`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("sequence-issues: an identical intent re-stages the SAME digest (idempotent), not a duplicate", async () => {
|
|
65
|
+
await withApp(async (app, data) => {
|
|
66
|
+
const a = await call(app, { issues: ["acme/repo#1", "acme/repo#2"] });
|
|
67
|
+
const b = await call(app, { issues: ["acme/repo#1", "acme/repo#2"] });
|
|
68
|
+
assertEquals(a.body.digest, b.body.digest);
|
|
69
|
+
const live = await listStagedProposals(data);
|
|
70
|
+
assertEquals(live.filter((p) => p.digest === a.body.digest).length, 1);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("sequence-issues: empty issues → 400 with issues[{path,message}], nothing staged", async () => {
|
|
75
|
+
await withApp(async (app, data) => {
|
|
76
|
+
const res = await call(app, { issues: [] });
|
|
77
|
+
assertEquals(res.status, 400);
|
|
78
|
+
assert(Array.isArray(res.body.issues) && res.body.issues.length > 0);
|
|
79
|
+
for (const iss of res.body.issues) {
|
|
80
|
+
assert(typeof iss.path === "string" && typeof iss.message === "string");
|
|
81
|
+
}
|
|
82
|
+
assertEquals((await listStagedProposals(data)).length, 0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("sequence-issues: an unparseable issue ref → 400 at the offending path, nothing staged", async () => {
|
|
87
|
+
await withApp(async (app, data) => {
|
|
88
|
+
const res = await call(app, { issues: ["acme/repo#1", "garbage"] });
|
|
89
|
+
assertEquals(res.status, 400);
|
|
90
|
+
assert(res.body.issues.some((i: any) => i.path === "issues[1]"));
|
|
91
|
+
assertEquals((await listStagedProposals(data)).length, 0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("sequence-issues: a missing body folds into the same 400 contract (not a 500)", async () => {
|
|
96
|
+
await withApp(async (app) => {
|
|
97
|
+
const res = await call(app, undefined);
|
|
98
|
+
assertEquals(res.status, 400);
|
|
99
|
+
assert(Array.isArray(res.body.issues) && res.body.issues.length > 0);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// POST /app/api/actions/start/sequence-issues → operationId `sequenceIssues` (epic
|
|
2
|
+
// nano-workforce#605, S4/#610). An INTENT-SHAPED door: instead of making an agent hand-author the
|
|
3
|
+
// full canonical node/edge JSON for "implement issue → converge → merge" (§9.4 — 13 nodes + 12 edges
|
|
4
|
+
// for four gated issues, in the evidence session), it takes the high-level intent
|
|
5
|
+
// `{ behind?, issues[] }` and GENERATES that canonical delivery graph, then hands it to the SAME
|
|
6
|
+
// compile+stage flow the raw `compileDeliveryGraph` door uses. It STAGES for operator review and
|
|
7
|
+
// returns a navigational `reviewUrl` and NOTHING that can trigger a run — dispatch stays an
|
|
8
|
+
// operator-only cockpit action (ADR 0005 Decision 7 / issue #460). No new runner, no second staging
|
|
9
|
+
// path: the generator only produces the `DeliveryGraph` (`buildSequenceGraph`) and delegates.
|
|
10
|
+
//
|
|
11
|
+
// Invalid input (empty `issues`, an unparseable `owner/repo#N` ref, an unknown target/probe per the
|
|
12
|
+
// S3 vocabulary) is a 400 carrying the uniform `issues[{path,message}]` contract; nothing is staged.
|
|
13
|
+
import { compileAndStageDeliveryGraph } from "../app/deliveryGraphStage.ts";
|
|
14
|
+
import { resolvePublicOrigin } from "../app/resolveApiBase.ts";
|
|
15
|
+
import { buildSequenceGraph } from "../app/sequenceIssues.ts";
|
|
16
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
17
|
+
|
|
18
|
+
export default defineOperation("sequenceIssues", async ({ body, req }, app) => {
|
|
19
|
+
// Build the canonical graph from the intent. Input validation (shape, ref format, vocabulary drift)
|
|
20
|
+
// lives in the pure `buildSequenceGraph` and returns the uniform `issues[{path,message}]` contract —
|
|
21
|
+
// a directly-invoked delegate passing `undefined` folds into the same clean rejection.
|
|
22
|
+
const built = buildSequenceGraph(body);
|
|
23
|
+
if (!built.ok) {
|
|
24
|
+
app.log.warn("sequence-issues rejected", { issues: built.issues.length });
|
|
25
|
+
return { status: 400, body: { error: "invalid sequenceIssues intent", issues: built.issues } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Hand the CONSTRUCTED graph to the shared compile+stage flow — one compiler, one staging path,
|
|
29
|
+
// idempotency/digest inherited (AGENTS.md "no drift surfaces"). `reviewUrl` keys to the origin this
|
|
30
|
+
// request arrived on (tunnel/proxy prefix), not the static deployment-wide base (#577).
|
|
31
|
+
const staged = await compileAndStageDeliveryGraph(
|
|
32
|
+
app.data,
|
|
33
|
+
built.graph,
|
|
34
|
+
JSON.stringify(built.graph),
|
|
35
|
+
resolvePublicOrigin(req),
|
|
36
|
+
);
|
|
37
|
+
if (!staged.ok) {
|
|
38
|
+
// A generated graph is well-formed by construction; a compile failure here is a generator defect,
|
|
39
|
+
// surfaced through the SAME `issues[{path,message}]` contract (mapped from the compiler's
|
|
40
|
+
// path-qualified `errors`) rather than a 500.
|
|
41
|
+
app.log.error("sequence-issues compile failed on a generated graph", { errors: staged.body.errors.length });
|
|
42
|
+
return {
|
|
43
|
+
status: 400,
|
|
44
|
+
body: { error: "generated delivery graph failed to compile", issues: staged.body.errors },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
app.log.info("sequence-issues staged", {
|
|
49
|
+
digest: staged.digest,
|
|
50
|
+
nodes: staged.nodeCount,
|
|
51
|
+
humanNodes: staged.humanNodeCount,
|
|
52
|
+
sideEffects: staged.sideEffectCount,
|
|
53
|
+
});
|
|
54
|
+
return { status: staged.status, body: staged.body };
|
|
55
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.162.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",
|