@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/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
- normalizeKind,
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 = normalizeKind(b.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: typeof b.dedupe_key === "string" ? b.dedupe_key : undefined,
52
+ dedupe_key,
45
53
  });
46
- // Advisory conflict-of-intent: surface prior sibling claims on the same file(s). Computed AFTER
47
- // the append and filtered to claims strictly before ours (id < res.id), so first-writer-wins is
48
- // decided by insertion order a sibling that raced a claim in between is still caught, and our
49
- // own just-written row is never reported. Never blocks the append — the agent decides how to react.
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: Number(res.id),
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
- body: { id: Number(res.id), inserted: res.inserted, conflicts },
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.71.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",