@nanobpm/nano-workforce 0.71.0 → 0.72.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/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +14 -0
- package/app/blackboard.test.ts +75 -1
- package/app/blackboard.ts +144 -14
- package/app/contractReconcile.test.ts +94 -0
- package/app/contractReconcile.ts +134 -0
- package/app/contracts.test.ts +111 -0
- package/app/contracts.ts +446 -0
- package/docs/adr/0004-shared-contract-coordination.md +108 -0
- package/openapi.yaml +24 -0
- package/operations/appendBlackboard.ts +29 -9
- package/operations/blackboard.test.ts +49 -0
- package/package.json +3 -1
- package/pages/tasks.page.json +228 -172
- package/scripts/check-contracts.test.ts +58 -0
- package/scripts/check-contracts.ts +151 -0
- package/scripts/reconcile-contracts.test.ts +38 -0
- package/scripts/reconcile-contracts.ts +104 -0
package/openapi.yaml
CHANGED
|
@@ -864,6 +864,30 @@ components:
|
|
|
864
864
|
type: string
|
|
865
865
|
created_at:
|
|
866
866
|
type: string
|
|
867
|
+
contractConflicts:
|
|
868
|
+
type: array
|
|
869
|
+
description: >-
|
|
870
|
+
Near-duplicate contract-DECLARATION conflicts on a `contract` POST (issue #227): a
|
|
871
|
+
synonym, contradiction, or rejected synonym vs. the durable contract registry. Advisory —
|
|
872
|
+
surfaced so the writer reconciles a divergent contract at authoring time; never a lock.
|
|
873
|
+
items:
|
|
874
|
+
type: object
|
|
875
|
+
additionalProperties: false
|
|
876
|
+
required:
|
|
877
|
+
- kind
|
|
878
|
+
- proposedName
|
|
879
|
+
- existingName
|
|
880
|
+
- detail
|
|
881
|
+
properties:
|
|
882
|
+
kind:
|
|
883
|
+
type: string
|
|
884
|
+
enum: [synonym, contradiction, rejected-synonym]
|
|
885
|
+
proposedName:
|
|
886
|
+
type: string
|
|
887
|
+
existingName:
|
|
888
|
+
type: string
|
|
889
|
+
detail:
|
|
890
|
+
type: string
|
|
867
891
|
AbandonStatus:
|
|
868
892
|
type: object
|
|
869
893
|
required:
|
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
// POST → append one entry: { author_task?, kind?, files?, body, wave?, dedupe_key? }. Idempotent
|
|
8
8
|
// on (plan, dedupe_key). Returns { id, inserted, conflicts } — `conflicts` lists prior
|
|
9
9
|
// sibling `file-claim`s on the same file(s) (advisory first-writer-wins; never a lock).
|
|
10
|
+
// A `contract` POST additionally returns `contractConflicts` (near-duplicate declaration
|
|
11
|
+
// conflicts vs. the durable registry, #227); the field is absent for other kinds, per the
|
|
12
|
+
// OpenAPI schema's optional property.
|
|
10
13
|
|
|
11
14
|
import {
|
|
12
15
|
appendEntry,
|
|
16
|
+
detectContractDeclarationConflicts,
|
|
13
17
|
detectFileClaimConflicts,
|
|
14
|
-
|
|
18
|
+
normalizeAppKind,
|
|
15
19
|
planKeyForToken,
|
|
16
20
|
} from "../app/blackboard.ts";
|
|
17
21
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
@@ -28,40 +32,56 @@ export default defineOperation("appendBlackboard", async ({ req, body }, app) =>
|
|
|
28
32
|
const b = body ?? {};
|
|
29
33
|
const text = typeof b.body === "string" ? b.body.trim() : "";
|
|
30
34
|
if (!text) return { status: 400, body: { error: "'body' (the note text) is required" } };
|
|
31
|
-
const kind =
|
|
35
|
+
const kind = normalizeAppKind(b.kind);
|
|
32
36
|
const files = Array.isArray(b.files) ? b.files.map(String) : [];
|
|
33
37
|
// Normalize once (trim + default to "system") so the value we send to appendEntry matches the
|
|
34
38
|
// value we send to detectFileClaimConflicts. Otherwise an omitted/blank author_task is stored as
|
|
35
39
|
// "system" but conflict detection sees "", and the caller's own prior "system" claims are wrongly
|
|
36
40
|
// reported as sibling conflicts.
|
|
37
41
|
const author_task = (typeof b.author_task === "string" ? b.author_task.trim() : "") || "system";
|
|
42
|
+
// Trim before it becomes the idempotency key: `dedupe_key` backs a unique index (and is now also
|
|
43
|
+
// parsed for the `<category>:<name>` contract ref), so accidental leading/trailing whitespace would
|
|
44
|
+
// otherwise slip past dedupe and create near-identical entries. A blank-after-trim key is no key.
|
|
45
|
+
const dedupe_key = typeof b.dedupe_key === "string" ? b.dedupe_key.trim() || undefined : undefined;
|
|
38
46
|
const res = await appendEntry(app.data, planKey, {
|
|
39
47
|
author_task,
|
|
40
48
|
kind,
|
|
41
49
|
files,
|
|
42
50
|
body: text,
|
|
43
51
|
wave: typeof b.wave === "number" ? b.wave : null,
|
|
44
|
-
dedupe_key
|
|
52
|
+
dedupe_key,
|
|
45
53
|
});
|
|
46
|
-
// Advisory conflict-of-intent
|
|
47
|
-
// the append and filtered to claims strictly before ours (id < res.id),
|
|
48
|
-
// decided by insertion order
|
|
49
|
-
// own just-written row is never reported. Never blocks the append
|
|
54
|
+
// Advisory conflict-of-intent. For a `file-claim`, surface prior sibling claims on the same
|
|
55
|
+
// file(s) — computed AFTER the append and filtered to claims strictly before ours (id < res.id),
|
|
56
|
+
// so first-writer-wins is decided by insertion order (a sibling that raced a claim in between is
|
|
57
|
+
// still caught, and our own just-written row is never reported). Never blocks the append.
|
|
50
58
|
const conflicts = kind === "file-claim"
|
|
51
59
|
? await detectFileClaimConflicts(app.data, planKey, {
|
|
52
60
|
author_task,
|
|
53
61
|
files,
|
|
54
|
-
beforeId:
|
|
62
|
+
beforeId: res.id,
|
|
55
63
|
})
|
|
56
64
|
: [];
|
|
65
|
+
// For a `contract`, surface near-duplicate DECLARATION conflicts (a synonym/contradiction/rejected
|
|
66
|
+
// synonym vs. the durable registry) so a writer reconciles a divergent contract at authoring time
|
|
67
|
+
// (#227). Advisory — the agent decides how to react.
|
|
68
|
+
const contractConflicts = kind === "contract"
|
|
69
|
+
? detectContractDeclarationConflicts({ dedupe_key, body: text })
|
|
70
|
+
: [];
|
|
57
71
|
app.log.info("blackboard entry appended", {
|
|
58
72
|
planKey,
|
|
59
73
|
kind,
|
|
60
74
|
inserted: res.inserted,
|
|
61
75
|
conflicts: conflicts.length,
|
|
76
|
+
contractConflicts: contractConflicts.length,
|
|
62
77
|
});
|
|
63
78
|
return {
|
|
64
79
|
status: res.inserted ? 201 : 200,
|
|
65
|
-
|
|
80
|
+
// `contractConflicts` is only meaningful on a `contract` POST and is optional in the schema, so
|
|
81
|
+
// omit it entirely for other kinds rather than emitting an always-empty array (keeps the response
|
|
82
|
+
// shape aligned with the OpenAPI contract, which does not require the field).
|
|
83
|
+
body: kind === "contract"
|
|
84
|
+
? { id: res.id, inserted: res.inserted, conflicts, contractConflicts }
|
|
85
|
+
: { id: res.id, inserted: res.inserted, conflicts },
|
|
66
86
|
};
|
|
67
87
|
});
|
|
@@ -100,6 +100,18 @@ test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", async
|
|
|
100
100
|
assertEquals(n, 1);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
test("POST dedupe_key is trimmed → a whitespace-padded retry still dedupes to one row (#227)", async () => {
|
|
104
|
+
const { app, db } = memApp();
|
|
105
|
+
await seedPlan(app, "o/r#1", "tok");
|
|
106
|
+
assertEquals((await call(app, "POST", { token: "tok" }, { author_task: "t", body: "claim", dedupe_key: "t:claim:1" })).status, 201);
|
|
107
|
+
// A retry whose key only differs by leading/trailing whitespace must NOT slip past dedupe.
|
|
108
|
+
const padded = await call(app, "POST", { token: "tok" }, { author_task: "t", body: "claim", dedupe_key: " t:claim:1 " });
|
|
109
|
+
assertEquals(padded.status, 200);
|
|
110
|
+
assertEquals(padded.body.inserted, false);
|
|
111
|
+
const [{ n }] = db.all<{ n: number }>("SELECT COUNT(*) AS n FROM agentic_blackboard WHERE scope = ?", ["o/r#1"]);
|
|
112
|
+
assertEquals(n, 1);
|
|
113
|
+
});
|
|
114
|
+
|
|
103
115
|
test("GET ?since returns only newer entries", async () => {
|
|
104
116
|
const { app } = memApp();
|
|
105
117
|
await seedPlan(app, "o/r#1", "tok");
|
|
@@ -175,4 +187,41 @@ test("POST a non-file-claim carries no conflicts", async () => {
|
|
|
175
187
|
await seedPlan(app, "o/r#1", "tok");
|
|
176
188
|
const res = await call(app, "POST", { token: "tok" }, { author_task: "t", kind: "note", body: "fyi" });
|
|
177
189
|
assertEquals(res.body.conflicts, []);
|
|
190
|
+
// `contractConflicts` is optional in the schema and only meaningful on a `contract` POST — a
|
|
191
|
+
// non-`contract` response omits it entirely rather than emitting an always-empty array (#229).
|
|
192
|
+
assertEquals("contractConflicts" in res.body, false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("POST kind='contract' persists the contract kind and round-trips through GET (#227)", async () => {
|
|
196
|
+
const { app } = memApp();
|
|
197
|
+
await seedPlan(app, "o/r#1", "tok");
|
|
198
|
+
const post = await call(app, "POST", { token: "tok" }, {
|
|
199
|
+
author_task: "task-a",
|
|
200
|
+
kind: "contract",
|
|
201
|
+
dedupe_key: "env:NANO_WIDGET_TIMEOUT",
|
|
202
|
+
body: "introducing env key NANO_WIDGET_TIMEOUT — app/widget.ts — widget request timeout in ms",
|
|
203
|
+
});
|
|
204
|
+
assertEquals(post.status, 201);
|
|
205
|
+
// No existing contract matches, so no declaration conflicts.
|
|
206
|
+
assertEquals(post.body.contractConflicts, []);
|
|
207
|
+
|
|
208
|
+
const get = await call(app, "GET", { token: "tok" });
|
|
209
|
+
assertEquals(get.body.entries.length, 1);
|
|
210
|
+
// The store's normaliser would coerce an unknown kind to 'note'; the adapter restores 'contract'.
|
|
211
|
+
assertEquals(get.body.entries[0].kind, "contract");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("POST kind='contract' reintroducing a rejected synonym surfaces a declaration conflict (#223/#227)", async () => {
|
|
215
|
+
const { app } = memApp();
|
|
216
|
+
await seedPlan(app, "o/r#1", "tok");
|
|
217
|
+
const post = await call(app, "POST", { token: "tok" }, {
|
|
218
|
+
author_task: "task-b",
|
|
219
|
+
kind: "contract",
|
|
220
|
+
dedupe_key: "env:NANO_PR_BASE_URL",
|
|
221
|
+
body: "base url for the app",
|
|
222
|
+
});
|
|
223
|
+
assertEquals(post.status, 201);
|
|
224
|
+
assertEquals(post.body.contractConflicts.length >= 1, true);
|
|
225
|
+
assertEquals(post.body.contractConflicts[0].kind, "rejected-synonym");
|
|
226
|
+
assertEquals(post.body.contractConflicts[0].existingName, "NANO_WORKFORCE_BASE_URL");
|
|
178
227
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.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",
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
"pretypecheck": "urban gen",
|
|
38
38
|
"check:prompts": "node --experimental-strip-types scripts/check-agent-prompts.ts",
|
|
39
39
|
"check:migrations": "node --experimental-strip-types scripts/check-migrations.ts",
|
|
40
|
+
"check:contracts": "node --experimental-strip-types scripts/check-contracts.ts",
|
|
41
|
+
"reconcile:contracts": "node --experimental-strip-types scripts/reconcile-contracts.ts",
|
|
40
42
|
"gen": "urban gen",
|
|
41
43
|
"gen:check": "urban gen --check",
|
|
42
44
|
"layout": "node --experimental-strip-types scripts/layout-bpmn.ts",
|