@nanobpm/nano-workforce 0.70.2 → 0.72.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/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +14 -0
- package/SPEC.md +7 -2
- package/app/agentCompletion.test.ts +69 -0
- package/app/agentCompletion.ts +78 -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/app/instance-tracking.test.ts +44 -1
- package/app/pollUserTasks.test.ts +151 -0
- package/app/service.ts +229 -2
- package/app/trialMerge.ts +5 -0
- package/app/userTasks.test.ts +208 -0
- package/app/userTasks.ts +217 -0
- package/db/migrations/034_user_tasks_inbox.sql +51 -0
- package/docs/adr/0004-shared-contract-coordination.md +108 -0
- package/nano.app.json +2 -1
- package/openapi.yaml +77 -0
- package/operations/appendBlackboard.ts +29 -9
- package/operations/blackboard.test.ts +49 -0
- package/operations/completeUserTask.test.ts +146 -0
- package/operations/completeUserTask.ts +72 -0
- package/package.json +3 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/pages/feature.page.json +1 -0
- package/pages/home.page.json +4 -0
- package/pages/overview.page.json +1 -0
- package/pages/tasks.page.json +297 -0
- 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/.github/workflows/ci.yml
CHANGED
|
@@ -54,6 +54,13 @@ jobs:
|
|
|
54
54
|
- name: Check migration prefixes (no collisions)
|
|
55
55
|
run: npm run check:migrations
|
|
56
56
|
|
|
57
|
+
# Contract-registry gate (issue #227): every config-family env key must be declared in the ONE
|
|
58
|
+
# typed schema (app/contracts.ts), no retired synonym (e.g. NANO_PR_BASE_URL, #223) may reappear
|
|
59
|
+
# in code, and the registry must reconcile against itself (no synonyms/contradictions). This is
|
|
60
|
+
# what makes a duplicate/synonymous contract a build failure instead of a silent runtime fallback.
|
|
61
|
+
- name: Check contract registry (no synonyms / undeclared keys)
|
|
62
|
+
run: npm run check:contracts
|
|
63
|
+
|
|
57
64
|
# Runs the full *.test.ts suite under Node's built-in test runner (node:test), which strips
|
|
58
65
|
# TypeScript types on the fly (Node >= 22.6) — no build step.
|
|
59
66
|
- name: Test (Node)
|
package/AGENTS.md
CHANGED
|
@@ -59,6 +59,29 @@ Before starting planned work, check for an existing issue or PR. If one is
|
|
|
59
59
|
already in progress, stop and flag it with a link. Otherwise create and claim an
|
|
60
60
|
issue before writing code.
|
|
61
61
|
|
|
62
|
+
## Shared contracts: one registry, one typed env schema (issue #227, ADR 0004)
|
|
63
|
+
|
|
64
|
+
Parallel/sliced work keeps producing **two divergent representations of one contract** — an env-key
|
|
65
|
+
synonym (the canonical `NANO_WORKFORCE_BASE_URL` vs. retired names like `NANO_PR_PUBLIC_BASE_URL`/its
|
|
66
|
+
phantom `NANO_PR_BASE_URL` fallback, #226/#223), a wire-shape drift (nano-ide #234), two type names
|
|
67
|
+
for one shape — each authored against a mock, discovered only at runtime. Prevent it at authoring
|
|
68
|
+
time:
|
|
69
|
+
|
|
70
|
+
- **Consult the durable registry FIRST — `app/contracts.ts`.** Before introducing a new **env/config
|
|
71
|
+
key**, **wire-frame shape**, **shared exported type**, or **capability-URL scheme**, check the
|
|
72
|
+
registry. If a semantically-equivalent contract exists, **reuse it**; otherwise declare it there
|
|
73
|
+
(owner + semantics per entry).
|
|
74
|
+
- **Env keys go through the ONE typed schema** (`ENV_CONTRACTS` + `readEnv`/`readEnvOr`). Every
|
|
75
|
+
config-family key (`NANO_*`, `NANOBPMN_*`, `CAMUNDA_*`, `PR_REVIEW_*`) MUST be declared; a synonym or
|
|
76
|
+
an undeclared key is a **CI failure** (`npm run check:contracts`). A **retired synonym** (e.g.
|
|
77
|
+
`NANO_PR_BASE_URL`) reappearing in code is a hard failure — never reintroduce a phantom fallback.
|
|
78
|
+
- **Signal in-flight on the blackboard.** When introducing/consuming a cross-cutting contract, POST a
|
|
79
|
+
`kind:"contract"` entry (`dedupe_key` as `<category>:<name>`, e.g. `env:NANO_X`) so siblings see it
|
|
80
|
+
before they reinvent it. The write-time guard reports near-duplicate-declaration `contractConflicts`.
|
|
81
|
+
- **Reconcile.** `npm run reconcile:contracts` reads the whole blackboard + registry and reports
|
|
82
|
+
synonyms / contradictions / mock-vs-real skew (advisory). `npm run check:contracts` is the hard,
|
|
83
|
+
registry-only gate (also in CI).
|
|
84
|
+
|
|
62
85
|
## BPMN: author the semantic model, generate the diagram
|
|
63
86
|
|
|
64
87
|
**The `.bpmn` files under `resources/processes/` are hand-authored semantic
|
|
@@ -226,11 +249,12 @@ npm run check # urban check (manifest va
|
|
|
226
249
|
npm run layout:check # BPMN diagram freshness (no drift)
|
|
227
250
|
npm run check:prompts # agent-prompt template resolution
|
|
228
251
|
npm run check:migrations # migration prefixes (no collisions)
|
|
252
|
+
npm run check:contracts # contract registry (no synonyms / undeclared env keys)
|
|
229
253
|
npm test # unit tests (node --test)
|
|
230
254
|
```
|
|
231
255
|
|
|
232
256
|
CI (`.github/workflows/ci.yml`) gates lint, typecheck, `urban check`, `layout:check`,
|
|
233
|
-
the prompt check, the migration-prefix check, and the Node test suite. Run `npm run layout <file.bpmn>` after
|
|
257
|
+
the prompt check, the migration-prefix check, the contract-registry check, and the Node test suite. Run `npm run layout <file.bpmn>` after
|
|
234
258
|
any BPMN flow change and commit the regenerated diagram — the `layout:check`
|
|
235
259
|
gate fails the build otherwise.
|
|
236
260
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.72.0](https://github.com/nanobpm/nano-workforce/compare/v0.71.0...v0.72.0) (2026-08-15)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* coordinate shared contracts via a durable registry + blackboard signal + reconciliation pass ([#229](https://github.com/nanobpm/nano-workforce/issues/229)) ([2c250b5](https://github.com/nanobpm/nano-workforce/commit/2c250b51596e1a062c4e09c6aef638010abea793)), closes [#223](https://github.com/nanobpm/nano-workforce/issues/223) [#234](https://github.com/nanobpm/nano-workforce/issues/234) [214/#217](https://github.com/nanobpm/nano-workforce/issues/217) [#223](https://github.com/nanobpm/nano-workforce/issues/223) [#227](https://github.com/nanobpm/nano-workforce/issues/227) [#227](https://github.com/nanobpm/nano-workforce/issues/227)
|
|
7
|
+
|
|
8
|
+
# [0.71.0](https://github.com/nanobpm/nano-workforce/compare/v0.70.2...v0.71.0) (2026-08-15)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add a Tasks page to resolve native user-task escalations in the UI ([#238](https://github.com/nanobpm/nano-workforce/issues/238)) ([4dcd1a1](https://github.com/nanobpm/nano-workforce/commit/4dcd1a1ca1756bec45997ce619ed92bff31d03d6)), closes [210/#220](https://github.com/nanobpm/nano-workforce/issues/220)
|
|
14
|
+
|
|
1
15
|
## [0.70.2](https://github.com/nanobpm/nano-workforce/compare/v0.70.1...v0.70.2) (2026-08-15)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -264,8 +264,12 @@ generic Urban **page runtime** (`@nanobpm/app`, ADR 0042) — no hand-written SP
|
|
|
264
264
|
The page defines status-filtered tabs (active vs. history), a submit form, a
|
|
265
265
|
per-row **Cancel** action, and an expandable detail with the round/escalation
|
|
266
266
|
child grids and a lazily-loaded transcript. The round/escalation grids are
|
|
267
|
-
read-only audit
|
|
268
|
-
|
|
267
|
+
read-only audit. Open native user-task escalations are additionally resolved
|
|
268
|
+
app-side from the **Tasks** page (`pages/tasks.page.json`, issue #236) — a nav
|
|
269
|
+
tab whose per-kind `dataGrid`s list every open escalation (feature / plan-review
|
|
270
|
+
/ trial-merge / PR review / blocked-run) off the `user_tasks` read-model and
|
|
271
|
+
submit the typed decision to the canonical human completer — so an operator no
|
|
272
|
+
longer depends on Urban's read-only `taskInbox` stub at `/tasks`.
|
|
269
273
|
|
|
270
274
|
The app-specific business-logic endpoints are **OpenAPI operations** mounted
|
|
271
275
|
under `api.base` (`/app/api`), each implemented by a delegate module in
|
|
@@ -282,6 +286,7 @@ The full, authoritative contract is `openapi.yaml` (Swagger UI at
|
|
|
282
286
|
| `POST` | `/app/api/actions/start/convergence-loop` | parse the PR ref → create the aggregate + start the process (the ONE submit door — page + external callers) |
|
|
283
287
|
| `POST` | `/app/api/actions/start/plan-fanout` | parse the issue ref → start a plan fan-out run (the ONE plan door) |
|
|
284
288
|
| `POST` | `/app/api/actions/message` (`escalation-answered`) | answer an open merge-loop escalation → publish `escalation-answered` (the four #156 escalation kinds are native user tasks answered via the task inbox) |
|
|
289
|
+
| `POST` | `/app/api/actions/complete-user-task` | complete an open native user-task escalation from the Tasks page (plan-review / trial-merge / PR `wait-answer`) → `completeEscalationAsHuman` (the same resume path the task inbox uses) |
|
|
285
290
|
| `GET`/`POST` | `/app/api/hooks/blackboard` | per-plan coordination blackboard (capability-token side-channel) |
|
|
286
291
|
| `GET` | `/app/api/hooks/abandon` | cooperative abandon check (per-PR capability token) |
|
|
287
292
|
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
latestCompletion,
|
|
22
22
|
revertAgentCompletion,
|
|
23
23
|
type TaskCompletion,
|
|
24
|
+
validateEscalationVariables,
|
|
24
25
|
} from "./agentCompletion.ts";
|
|
25
26
|
|
|
26
27
|
/** A minimal in-memory `Table<T>`: AUTOINCREMENT ids on insert, structural `find`, `get`, `update`. */
|
|
@@ -386,3 +387,71 @@ test("latestCompletion returns the newest row by id regardless of insertion orde
|
|
|
386
387
|
assertEquals(newest.id, 3, "the highest-id row for the key wins, not the first found");
|
|
387
388
|
assertEquals(stores.task_completions.rows[0].id, 3, "the backing array is not reordered");
|
|
388
389
|
});
|
|
390
|
+
|
|
391
|
+
// --- Form-contract enforcement (issue #236 review advisory): a completion must satisfy the linked
|
|
392
|
+
// `.form`'s required-field + select allowed-value contract BEFORE the engine resumes, so a missing or
|
|
393
|
+
// invalid decision can never park the process in an invalid state. Derived from the canonical `.form`,
|
|
394
|
+
// exercised through BOTH completers so agent and human paths reject invalid input identically.
|
|
395
|
+
|
|
396
|
+
test("completer rejects a completion missing a required form field (no engine resume, no ledger row)", async () => {
|
|
397
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
398
|
+
const data = memData(stores);
|
|
399
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-1", elementId: "wait-answer" }]);
|
|
400
|
+
|
|
401
|
+
const r = await completeEscalationAsHuman(data, engine, {
|
|
402
|
+
userTaskKey: "ut-1",
|
|
403
|
+
operatorId: "alice",
|
|
404
|
+
variables: { answer: " " }, // required, but blank
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
assertEquals(r.ok, false);
|
|
408
|
+
assert(String(r.reason).includes("answer"), "the reason names the missing required field");
|
|
409
|
+
assertEquals(completed.length, 0, "an invalid completion never resumes the process");
|
|
410
|
+
assertEquals(stores.task_completions.rows.length, 0, "and no attribution row is written");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("completer rejects a select value outside the form's allowed set", async () => {
|
|
414
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
415
|
+
const data = memData(stores);
|
|
416
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-2", elementId: "trial-merge-decision" }]);
|
|
417
|
+
|
|
418
|
+
const r = await completeEscalationAsAgent(data, engine, {
|
|
419
|
+
userTaskKey: "ut-2",
|
|
420
|
+
agentId: "bot",
|
|
421
|
+
variables: { action: "explode" }, // not one of proceed/rebase/abandon
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
assertEquals(r.ok, false);
|
|
425
|
+
assert(String(r.reason).includes("action"), "the reason names the invalid select field");
|
|
426
|
+
assertEquals(completed.length, 0, "an out-of-range decision never resumes the process");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("completer accepts variables that satisfy the form contract (required present + allowed value)", async () => {
|
|
430
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
431
|
+
const data = memData(stores);
|
|
432
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-3", elementId: "plan-review-decision" }]);
|
|
433
|
+
|
|
434
|
+
const r = await completeEscalationAsHuman(data, engine, {
|
|
435
|
+
userTaskKey: "ut-3",
|
|
436
|
+
operatorId: "alice",
|
|
437
|
+
variables: { directive: "revise", notes: "narrow scope" },
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
assertEquals(r.ok, true);
|
|
441
|
+
assertEquals(completed.length, 1, "a contract-valid completion resumes the process");
|
|
442
|
+
assertEquals(completed[0].variables, { directive: "revise", notes: "narrow scope" });
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("validateEscalationVariables derives its contract from the canonical .form files", async () => {
|
|
446
|
+
// wait-answer -> pr-escalation.form (answer required)
|
|
447
|
+
assertEquals(validateEscalationVariables("wait-answer", { answer: "ok" }), null);
|
|
448
|
+
assert(validateEscalationVariables("wait-answer", {}) !== null);
|
|
449
|
+
// trial-merge-decision -> action required, allowed proceed/rebase/abandon
|
|
450
|
+
assertEquals(validateEscalationVariables("trial-merge-decision", { action: "abandon" }), null);
|
|
451
|
+
assert(validateEscalationVariables("trial-merge-decision", { action: "nope" }) !== null);
|
|
452
|
+
// plan-review-decision -> directive required, allowed proceed/revise
|
|
453
|
+
assertEquals(validateEscalationVariables("plan-review-decision", { directive: "proceed" }), null);
|
|
454
|
+
assert(validateEscalationVariables("plan-review-decision", { directive: "" }) !== null);
|
|
455
|
+
// an element with no linked form contract is not enforced
|
|
456
|
+
assertEquals(validateEscalationVariables("some-other-task", { whatever: 1 }), null);
|
|
457
|
+
});
|
package/app/agentCompletion.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// SAME `completeUserTaskAttributed` (as `human`), so there is exactly one implementation of "complete
|
|
21
21
|
// an escalation user task" — the agent path is an extension of it, not a parallel copy.
|
|
22
22
|
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
23
24
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
24
25
|
|
|
25
26
|
const now = () => new Date().toISOString();
|
|
@@ -69,6 +70,77 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
69
70
|
"wait-answer", // PR review-loop escalation (convergence-loop.bpmn, U3)
|
|
70
71
|
]);
|
|
71
72
|
|
|
73
|
+
/** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
|
|
74
|
+
* BPMN `zeebe:formDefinition formId`). Kept beside `ESCALATION_TASK_ELEMENTS` so the completer
|
|
75
|
+
* validates against the SAME `.form` the task inbox renders — one contract, no second field list. */
|
|
76
|
+
const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
77
|
+
"feature-escalation": "feature-escalation",
|
|
78
|
+
"plan-review-decision": "plan-review-decision",
|
|
79
|
+
"trial-merge-decision": "trial-merge-decision",
|
|
80
|
+
"wait-answer": "pr-escalation",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
interface FormContract {
|
|
84
|
+
/** Field keys marked `validate.required` in the `.form`. */
|
|
85
|
+
required: string[];
|
|
86
|
+
/** `select` field key → its allowed `values`. */
|
|
87
|
+
allowed: Record<string, string[]>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const formContractCache = new Map<string, FormContract>();
|
|
91
|
+
|
|
92
|
+
/** Derive a `.form`'s required-field + select allowed-value contract, cached per formId. The `.form`
|
|
93
|
+
* files (`resources/forms/*.form`, deployed via nano.app.json) are the CANONICAL contract, so this
|
|
94
|
+
* reads them rather than re-encoding the field lists — no drift surface. */
|
|
95
|
+
function formContract(formId: string): FormContract {
|
|
96
|
+
const cached = formContractCache.get(formId);
|
|
97
|
+
if (cached) return cached;
|
|
98
|
+
const raw: {
|
|
99
|
+
components?: { key?: string; validate?: { required?: boolean }; values?: { value?: string }[] }[];
|
|
100
|
+
} = JSON.parse(readFileSync(new URL(`../resources/forms/${formId}.form`, import.meta.url), "utf8"));
|
|
101
|
+
const required: string[] = [];
|
|
102
|
+
const allowed: Record<string, string[]> = {};
|
|
103
|
+
for (const c of raw.components ?? []) {
|
|
104
|
+
if (!c.key) continue;
|
|
105
|
+
if (c.validate?.required) required.push(c.key);
|
|
106
|
+
if (c.values?.length) {
|
|
107
|
+
allowed[c.key] = c.values.map((v) => v.value ?? "").filter((v) => v !== "");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const contract: FormContract = { required, allowed };
|
|
111
|
+
formContractCache.set(formId, contract);
|
|
112
|
+
return contract;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Validate completion `variables` against the escalation's `.form` contract (required fields present
|
|
116
|
+
* + `select` values within the allowed set), so a completion can never resume the process with a
|
|
117
|
+
* missing/invalid decision (e.g. a `wait-answer` with no `answer`, or a `trial-merge-decision` with
|
|
118
|
+
* an `action` outside proceed/rebase/abandon). Returns a human-readable reason on violation, or
|
|
119
|
+
* `null` when the variables satisfy the contract. Derived from the canonical `.form` — the same
|
|
120
|
+
* contract the task inbox renders — so both the agent and human completers reject invalid input the
|
|
121
|
+
* exact same way, with one implementation. An element with no linked form contract is not enforced. */
|
|
122
|
+
export function validateEscalationVariables(
|
|
123
|
+
elementId: string,
|
|
124
|
+
variables: Record<string, unknown>,
|
|
125
|
+
): string | null {
|
|
126
|
+
const formId = ESCALATION_FORM_BY_ELEMENT[elementId];
|
|
127
|
+
if (!formId) return null;
|
|
128
|
+
const { required, allowed } = formContract(formId);
|
|
129
|
+
for (const key of required) {
|
|
130
|
+
const v = variables[key];
|
|
131
|
+
if (v === undefined || v === null || (typeof v === "string" && v.trim() === "")) {
|
|
132
|
+
return `${elementId}: "${key}" is required`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const [key, values] of Object.entries(allowed)) {
|
|
136
|
+
const v = variables[key];
|
|
137
|
+
if (v !== undefined && v !== null && !values.includes(String(v))) {
|
|
138
|
+
return `${elementId}: "${key}" must be one of ${values.join(", ")}`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
72
144
|
/** The canonical attributed completer. Records an attribution row in `task_completions` (reversible
|
|
73
145
|
* iff the actor is an agent) and THEN completes the user task with the exact typed `variables` — so
|
|
74
146
|
* the ledger row can never be lost by a resume that fires before the write. If the engine
|
|
@@ -183,6 +255,9 @@ export async function completeEscalationAsAgent(
|
|
|
183
255
|
const resolved = await resolveEscalationTask(engine, userTaskKey);
|
|
184
256
|
if (!resolved.ok) return resolved;
|
|
185
257
|
|
|
258
|
+
const invalid = validateEscalationVariables(resolved.elementId, input.variables);
|
|
259
|
+
if (invalid) return { ok: false, reason: invalid };
|
|
260
|
+
|
|
186
261
|
const { completionId } = await completeUserTaskAttributed(
|
|
187
262
|
data,
|
|
188
263
|
engine,
|
|
@@ -212,6 +287,9 @@ export async function completeEscalationAsHuman(
|
|
|
212
287
|
const resolved = await resolveEscalationTask(engine, userTaskKey);
|
|
213
288
|
if (!resolved.ok) return resolved;
|
|
214
289
|
|
|
290
|
+
const invalid = validateEscalationVariables(resolved.elementId, input.variables);
|
|
291
|
+
if (invalid) return { ok: false, reason: invalid };
|
|
292
|
+
|
|
215
293
|
const { completionId } = await completeUserTaskAttributed(
|
|
216
294
|
data,
|
|
217
295
|
engine,
|
package/app/blackboard.test.ts
CHANGED
|
@@ -8,20 +8,49 @@ import { test } from "node:test";
|
|
|
8
8
|
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
9
9
|
import { memBlackboardData } from "../test/blackboardDb.ts";
|
|
10
10
|
import {
|
|
11
|
+
APP_BLACKBOARD_KINDS,
|
|
11
12
|
appendEntry,
|
|
12
13
|
blackboardUrl,
|
|
14
|
+
detectContractDeclarationConflicts,
|
|
13
15
|
detectFileClaimConflicts,
|
|
14
16
|
isUniqueViolation,
|
|
15
17
|
mintBlackboardToken,
|
|
18
|
+
normalizeAppKind,
|
|
16
19
|
normalizeKind,
|
|
20
|
+
parseContractRef,
|
|
17
21
|
planKeyForToken,
|
|
18
22
|
planKeyForTokenSync,
|
|
19
23
|
publicBaseUrl,
|
|
20
24
|
readBlackboard,
|
|
21
25
|
readBlackboardPage,
|
|
22
26
|
renderCoordinationBrief,
|
|
27
|
+
toSafeRowId,
|
|
23
28
|
} from "./blackboard.ts";
|
|
24
29
|
|
|
30
|
+
test("toSafeRowId: passes through safe numbers/bigints, throws above MAX_SAFE_INTEGER (PR #229)", () => {
|
|
31
|
+
assertEquals(toSafeRowId(42), 42);
|
|
32
|
+
assertEquals(toSafeRowId(0), 0);
|
|
33
|
+
// A bigint within the safe range narrows to an identical number.
|
|
34
|
+
assertEquals(toSafeRowId(9007199254740991n), Number.MAX_SAFE_INTEGER);
|
|
35
|
+
// Beyond 2^53 a Number(...) coercion would silently lose precision as a `since` cursor — fail loud.
|
|
36
|
+
let threw = false;
|
|
37
|
+
try {
|
|
38
|
+
toSafeRowId(9007199254740993n);
|
|
39
|
+
} catch {
|
|
40
|
+
threw = true;
|
|
41
|
+
}
|
|
42
|
+
assert(threw, "a bigint id above MAX_SAFE_INTEGER must throw, not silently lose precision");
|
|
43
|
+
// The number branch guards the same boundary: a driver-returned non-safe-integer must also fail
|
|
44
|
+
// loud rather than pass through as a lossy cursor (#229).
|
|
45
|
+
let threwNum = false;
|
|
46
|
+
try {
|
|
47
|
+
toSafeRowId(Number.MAX_SAFE_INTEGER + 2);
|
|
48
|
+
} catch {
|
|
49
|
+
threwNum = true;
|
|
50
|
+
}
|
|
51
|
+
assert(threwNum, "a number id that is not a safe integer must throw, not silently pass through");
|
|
52
|
+
});
|
|
53
|
+
|
|
25
54
|
test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
|
|
26
55
|
const a = mintBlackboardToken();
|
|
27
56
|
const b = mintBlackboardToken();
|
|
@@ -36,7 +65,7 @@ test("publicBaseUrl: honours the env override and trims a trailing slash", () =>
|
|
|
36
65
|
});
|
|
37
66
|
|
|
38
67
|
test("publicBaseUrl: a blank/whitespace override falls back instead of yielding a bad URL", () => {
|
|
39
|
-
// Explicit override args bypass the `
|
|
68
|
+
// Explicit override args bypass the `NANO_WORKFORCE_BASE_URL` default, so this test
|
|
40
69
|
// needs no env manipulation — the env-read path is covered by the dedicated test below.
|
|
41
70
|
assertEquals(publicBaseUrl(""), "http://localhost:3000");
|
|
42
71
|
assertEquals(publicBaseUrl(" "), "http://localhost:3000");
|
|
@@ -71,6 +100,30 @@ test("normalizeKind: valid passes through, anything else becomes note", () => {
|
|
|
71
100
|
assertEquals(normalizeKind(undefined), "note");
|
|
72
101
|
});
|
|
73
102
|
|
|
103
|
+
test("normalizeAppKind: passes contract through (the store's own normaliser would coerce it to note)", () => {
|
|
104
|
+
assertEquals(normalizeAppKind("contract"), "contract");
|
|
105
|
+
assertEquals(normalizeAppKind("file-claim"), "file-claim");
|
|
106
|
+
assertEquals(normalizeAppKind("bogus"), "note");
|
|
107
|
+
assert(APP_BLACKBOARD_KINDS.includes("contract"), "contract is an app-recognised kind");
|
|
108
|
+
assert(APP_BLACKBOARD_KINDS.includes("note"), "app kinds are a superset of the store's kinds");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("parseContractRef: parses the <category>:<name> dedupe_key convention", () => {
|
|
112
|
+
assertEquals(parseContractRef("env:NANO_X"), { category: "env", name: "NANO_X" });
|
|
113
|
+
assertEquals(parseContractRef("type:BlackboardEntry"), { category: "type", name: "BlackboardEntry" });
|
|
114
|
+
assertEquals(parseContractRef("bogus:X"), undefined);
|
|
115
|
+
assertEquals(parseContractRef("nocolon"), undefined);
|
|
116
|
+
assertEquals(parseContractRef(undefined), undefined);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("detectContractDeclarationConflicts: flags a retired synonym; clean for a genuinely new key", () => {
|
|
120
|
+
const rejected = detectContractDeclarationConflicts({ dedupe_key: "env:NANO_PR_BASE_URL", body: "base url" });
|
|
121
|
+
assertEquals(rejected[0]?.kind, "rejected-synonym");
|
|
122
|
+
assertEquals(detectContractDeclarationConflicts({ dedupe_key: "env:NANO_TOTALLY_NEW", body: "an unrelated brand new thing about caching" }), []);
|
|
123
|
+
// No convention → nothing to reconcile against.
|
|
124
|
+
assertEquals(detectContractDeclarationConflicts({ body: "freeform" }), []);
|
|
125
|
+
});
|
|
126
|
+
|
|
74
127
|
test("renderCoordinationBrief: leads with a separator and teaches the protocol + URL", () => {
|
|
75
128
|
const url = "https://h/app/api/hooks/blackboard?token=abc";
|
|
76
129
|
const brief = renderCoordinationBrief(url);
|
|
@@ -89,6 +142,13 @@ test("renderCoordinationBrief: leads with a separator and teaches the protocol +
|
|
|
89
142
|
// Learnings: teaches reading prior gotchas and posting a reusable `learning`.
|
|
90
143
|
assertStringIncludes(brief, "learning");
|
|
91
144
|
assertStringIncludes(brief, "Share what you learn");
|
|
145
|
+
// Contracts (#227): teaches consulting the registry + blackboard and posting a `contract` entry.
|
|
146
|
+
assertStringIncludes(brief, "contract");
|
|
147
|
+
assertStringIncludes(brief, "app/contracts.ts");
|
|
148
|
+
assertStringIncludes(brief, "kind\":\"contract\"");
|
|
149
|
+
// The contract-POST write-time guard surfaces overlaps via `contractConflicts` (a `file-claim`
|
|
150
|
+
// POST uses the separate `conflicts` array); the brief must name the right field.
|
|
151
|
+
assertStringIncludes(brief, "contractConflicts");
|
|
92
152
|
});
|
|
93
153
|
|
|
94
154
|
test("planKeyForToken: resolves a token to its plan, undefined otherwise (async + sync agree)", async () => {
|
|
@@ -166,6 +226,20 @@ test("appendEntry: a blank body is rejected", async () => {
|
|
|
166
226
|
assert(threw, "blank body must throw");
|
|
167
227
|
});
|
|
168
228
|
|
|
229
|
+
test("appendEntry: a contract append patches the kind even when it collapses onto an existing row (deterministic round-trip)", async () => {
|
|
230
|
+
const { data } = memBlackboardData();
|
|
231
|
+
// A prior append under this dedupe_key stored kind `note` (the store's default for an unknown kind).
|
|
232
|
+
const first = await appendEntry(data, "p", { author_task: "t", body: "seed", kind: "note", dedupe_key: "env:NANO_X" });
|
|
233
|
+
assertEquals(first.inserted, true);
|
|
234
|
+
// A later `contract` append with the SAME dedupe_key is a no-op insert (`inserted: false`) — but it
|
|
235
|
+
// must still leave the row readable as `contract`, not lingering as `note`.
|
|
236
|
+
const again = await appendEntry(data, "p", { author_task: "t", body: "seed", kind: "contract", dedupe_key: "env:NANO_X" });
|
|
237
|
+
assertEquals(again.inserted, false, "same dedupe_key is a no-op insert");
|
|
238
|
+
const entries = await readBlackboard(data, "p");
|
|
239
|
+
assertEquals(entries.length, 1);
|
|
240
|
+
assertEquals(entries[0].kind, "contract", "the contract kind is patched deterministically regardless of res.inserted");
|
|
241
|
+
});
|
|
242
|
+
|
|
169
243
|
test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
|
|
170
244
|
const { data } = memBlackboardData();
|
|
171
245
|
await appendEntry(data, "p", { body: "one" });
|
package/app/blackboard.ts
CHANGED
|
@@ -28,8 +28,20 @@
|
|
|
28
28
|
// handle (`data.source().db`) — the same physical database the record gateway (`data.table`) uses,
|
|
29
29
|
// so the HTTP hook and the agentic channel share one connection and one table.
|
|
30
30
|
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
BLACKBOARD_TABLE,
|
|
33
|
+
BlackboardStore,
|
|
34
|
+
normalizeKind as normalizeStoreKind,
|
|
35
|
+
type SqliteDb,
|
|
36
|
+
BLACKBOARD_KINDS as STORE_KINDS,
|
|
37
|
+
} from "@nanobpm/agentic/blackboard";
|
|
32
38
|
import type { DataLayer } from "@nanobpm/urban";
|
|
39
|
+
import {
|
|
40
|
+
type ContractCategory,
|
|
41
|
+
type DeclarationConflict,
|
|
42
|
+
detectDeclarationConflicts,
|
|
43
|
+
readEnvOr,
|
|
44
|
+
} from "./contracts.ts";
|
|
33
45
|
|
|
34
46
|
// Re-export the storage vocabulary from the shared package so there is ONE canonical definition of
|
|
35
47
|
// the kinds, the kind-normaliser, and the unique-violation predicate — the app never keeps a
|
|
@@ -37,6 +49,23 @@ import type { DataLayer } from "@nanobpm/urban";
|
|
|
37
49
|
export type { BlackboardKind } from "@nanobpm/agentic/blackboard";
|
|
38
50
|
export { BLACKBOARD_KINDS, isUniqueViolation, normalizeKind } from "@nanobpm/agentic/blackboard";
|
|
39
51
|
|
|
52
|
+
|
|
53
|
+
/** The app-recognised blackboard kinds: the shared store's kinds PLUS `contract` — the live,
|
|
54
|
+
* in-flight coordination signal introduced by issue #227 ("I am introducing / consuming contract
|
|
55
|
+
* X"). It is DERIVED from the store's set (spread, never re-typed), so the two never drift; `contract`
|
|
56
|
+
* is the only app-local addition, until the shared package promotes it. The durable source of truth
|
|
57
|
+
* for a contract is the registry (`app/contracts.ts`); this kind is the real-time heads-up that lets
|
|
58
|
+
* siblings in a wave see a new contract before they independently invent a synonym. */
|
|
59
|
+
export const APP_BLACKBOARD_KINDS = [...STORE_KINDS, "contract"] as const;
|
|
60
|
+
export type AppBlackboardKind = (typeof APP_BLACKBOARD_KINDS)[number];
|
|
61
|
+
|
|
62
|
+
/** The app-side kind normaliser: passes `contract` through (the store's own normaliser would coerce
|
|
63
|
+
* it to `note`), and defers to the shared normaliser for every other value so unknown kinds still
|
|
64
|
+
* default to `note`. ONE place decides the app's kind vocabulary. */
|
|
65
|
+
export function normalizeAppKind(kind: unknown): AppBlackboardKind {
|
|
66
|
+
return kind === "contract" ? "contract" : normalizeStoreKind(kind);
|
|
67
|
+
}
|
|
68
|
+
|
|
40
69
|
/** The parsed, agent-facing view of an entry (files decoded to an array). Snake_case is the
|
|
41
70
|
* app/HTTP-hook boundary contract every existing caller and agent already consumes. */
|
|
42
71
|
export interface BlackboardEntry {
|
|
@@ -69,15 +98,17 @@ export function mintBlackboardToken(): string {
|
|
|
69
98
|
}
|
|
70
99
|
|
|
71
100
|
/** The externally-reachable base URL agents use to reach this app. Must resolve from WHEREVER the
|
|
72
|
-
* agent runs (co-located or remote/containerised), so it is configured, never hardcoded.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
101
|
+
* agent runs (co-located or remote/containerised), so it is configured, never hardcoded. Read
|
|
102
|
+
* through the ONE typed env schema ({@link readEnvOr} over `NANO_WORKFORCE_BASE_URL`) — there is no
|
|
103
|
+
* second name for this value. The retired synonyms `NANO_PR_PUBLIC_BASE_URL` (coalesced into the
|
|
104
|
+
* canonical name in #226) and the phantom `NANO_PR_BASE_URL` fallback (introduced in #53, cleaned
|
|
105
|
+
* up per #223) are recorded as rejected synonyms in `app/contracts.ts`, so neither can be
|
|
106
|
+
* reintroduced as a silent runtime fallback. */
|
|
107
|
+
export function publicBaseUrl(base: string = readEnvOr("NANO_WORKFORCE_BASE_URL")): string {
|
|
108
|
+
// Skip a blank/whitespace value so an explicitly-set-but-empty NANO_WORKFORCE_BASE_URL can't yield
|
|
109
|
+
// a malformed capability URL; fall back to the schema-declared default.
|
|
110
|
+
const resolved = base.trim() || readEnvOr("NANO_WORKFORCE_BASE_URL");
|
|
111
|
+
return resolved.replace(/\/+$/, "");
|
|
81
112
|
}
|
|
82
113
|
|
|
83
114
|
/** The capability URL for a plan's blackboard: the token rides the query string, so the agent can
|
|
@@ -122,7 +153,9 @@ from re-discovering it the hard way.
|
|
|
122
153
|
|
|
123
154
|
\`kind\` is one of: \`file-claim\` (you now edit a file outside your original slice),
|
|
124
155
|
\`constraint-change\` (you discovered a constraint that changes another task's direction),
|
|
125
|
-
\`scope-change\` (your contract/scope shifted), \`
|
|
156
|
+
\`scope-change\` (your contract/scope shifted), \`contract\` (you are introducing/consuming a shared
|
|
157
|
+
env key / wire shape / type / capability-URL scheme — see below), \`learning\` (see below), or
|
|
158
|
+
\`note\`. Set
|
|
126
159
|
\`author_task\` to your task id. If a retry might make you re-POST the same fact, include a stable
|
|
127
160
|
\`"dedupe_key"\` so it collapses to one entry.
|
|
128
161
|
|
|
@@ -139,6 +172,29 @@ one entry across retries and siblings:
|
|
|
139
172
|
|
|
140
173
|
A \`learning\` is advisory and never blocks anyone; it is knowledge for the fleet, not a claim on a file.
|
|
141
174
|
|
|
175
|
+
**Coordinate SHARED CONTRACTS before you invent one (issue #227).** A *contract* is anything two
|
|
176
|
+
slices must agree on but that each of you could author independently against a mock: an **env/config
|
|
177
|
+
key**, a **wire-frame shape** (a message/relay payload), a **shared exported type/interface name**, or
|
|
178
|
+
a **capability-URL scheme**. Divergent copies of one contract (an env-key synonym, a producer/hub
|
|
179
|
+
wire-shape drift) are only discovered at runtime, after both sides are green against their own mock.
|
|
180
|
+
Prevent it:
|
|
181
|
+
|
|
182
|
+
- **Consult the durable contract registry FIRST** (\`app/contracts.ts\` — env keys go through the ONE
|
|
183
|
+
typed \`ENV_CONTRACTS\` schema; wire/type/capability-URL contracts are declared alongside). If a
|
|
184
|
+
semantically-equivalent contract already exists, **reuse it** (import the shared type, read the
|
|
185
|
+
existing env key) — never author a synonym. A retired synonym (e.g. \`NANO_PR_BASE_URL\`) is a hard
|
|
186
|
+
CI failure, not a fallback.
|
|
187
|
+
- **Consult the blackboard \`contract\` entries** below for what siblings are introducing *right now*.
|
|
188
|
+
- **When you introduce OR consume a cross-cutting contract, POST a \`contract\` entry** so siblings see
|
|
189
|
+
it before they reinvent it, and add the durable declaration to \`app/contracts.ts\`:
|
|
190
|
+
|
|
191
|
+
curl -s -X POST "${url}" -H 'content-type: application/json' \\
|
|
192
|
+
-d '{"author_task":"<your-task-id>","kind":"contract","dedupe_key":"env:NANO_X","body":"introducing env key NANO_X — <owner> — <semantics + default>"}'
|
|
193
|
+
|
|
194
|
+
The write-time guard reports a \`contractConflicts\` array on a \`contract\` POST when your declaration looks
|
|
195
|
+
like a synonym of, or contradicts, an existing contract — reconcile before proceeding. (A \`file-claim\`
|
|
196
|
+
POST reports its own overlaps in a separate \`conflicts\` array — see below.)
|
|
197
|
+
|
|
142
198
|
**Stay in sync while you work (this matters most while siblings run in parallel).** The GET
|
|
143
199
|
response includes a \`"cursor"\`. Re-read incrementally — before you start each new file, and at
|
|
144
200
|
least every few minutes on long tasks — passing the last cursor back as \`since\` so you fetch only
|
|
@@ -280,15 +336,46 @@ export async function detectFileClaimConflicts(
|
|
|
280
336
|
}));
|
|
281
337
|
}
|
|
282
338
|
|
|
339
|
+
/** Narrow a SQLite row id (`number | bigint`) to a JS number, failing loudly if it exceeds the
|
|
340
|
+
* safe-integer range. Blackboard rowids are monotonic and stay far below 2^53 in practice, but the
|
|
341
|
+
* driver types the id as `number | bigint`; coercing a huge bigint via `Number(...)` would silently
|
|
342
|
+
* lose precision and corrupt the id where it is used as a `since` cursor. We narrow ONCE here so the
|
|
343
|
+
* union never escapes `appendEntry` and no caller re-coerces. Both branches guard the safe-integer
|
|
344
|
+
* boundary: a `bigint` above `MAX_SAFE_INTEGER`, or a driver-returned `number` that is somehow not a
|
|
345
|
+
* safe integer, both throw rather than silently yield a lossy cursor. */
|
|
346
|
+
export function toSafeRowId(id: number | bigint): number {
|
|
347
|
+
if (typeof id === "bigint") {
|
|
348
|
+
if (id > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`blackboard row id ${id} exceeds Number.MAX_SAFE_INTEGER; unsafe to narrow to a JS number cursor`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
return Number(id);
|
|
354
|
+
}
|
|
355
|
+
if (!Number.isSafeInteger(id)) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`blackboard row id ${id} is not a safe integer; unsafe to use as a JS number cursor`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
return id;
|
|
361
|
+
}
|
|
362
|
+
|
|
283
363
|
/** Append an entry, idempotently. A blank `body` is rejected. When a `dedupe_key` is supplied and
|
|
284
364
|
* an entry already exists for it on this plan, the write is a no-op and the existing id is
|
|
285
|
-
* returned (`inserted: false`) — so an engine job retry re-POSTing the same fact never duplicates.
|
|
365
|
+
* returned (`inserted: false`) — so an engine job retry re-POSTing the same fact never duplicates.
|
|
366
|
+
*
|
|
367
|
+
* The `contract` kind (issue #227) is the app-local addition: the shared store's normaliser would
|
|
368
|
+
* coerce it to `note`, so after the store's insert (which owns all the idempotency/dedupe semantics)
|
|
369
|
+
* we patch the ONE row's kind column back to `contract`. We reuse the store's insert rather than
|
|
370
|
+
* hand-writing a parallel one, so there is no drift in the append logic — only the kind vocabulary is
|
|
371
|
+
* app-extended. */
|
|
286
372
|
export async function appendEntry(
|
|
287
373
|
data: DataLayer,
|
|
288
374
|
planKey: string,
|
|
289
375
|
input: BlackboardInput,
|
|
290
|
-
): Promise<{ inserted: boolean; id: number
|
|
291
|
-
|
|
376
|
+
): Promise<{ inserted: boolean; id: number }> {
|
|
377
|
+
const appKind = normalizeAppKind(input.kind);
|
|
378
|
+
const res = storeFor(data).append(planKey, {
|
|
292
379
|
authorTask: input.author_task,
|
|
293
380
|
kind: input.kind,
|
|
294
381
|
files: input.files,
|
|
@@ -296,4 +383,47 @@ export async function appendEntry(
|
|
|
296
383
|
wave: input.wave,
|
|
297
384
|
dedupeKey: input.dedupe_key,
|
|
298
385
|
});
|
|
386
|
+
if (appKind === "contract") {
|
|
387
|
+
// The store defaulted `kind` to `note` (its normaliser doesn't know `contract`); restore it on
|
|
388
|
+
// the resolved row. Patch on EVERY `contract` append — not only when `res.inserted` — so an
|
|
389
|
+
// idempotent retry (or a race) that resolves to an existing id (`inserted: false`), or a row
|
|
390
|
+
// first inserted under a different kind for the same `dedupe_key`, still reads back as `contract`
|
|
391
|
+
// rather than lingering as `note`. The UPDATE is idempotent on an already-`contract` row.
|
|
392
|
+
// Bind `res.id` as-is (it is `number | bigint`) — coercing a bigint id via `Number(...)` could
|
|
393
|
+
// lose precision above `Number.MAX_SAFE_INTEGER` and patch the wrong row; SQLite binds bigint natively.
|
|
394
|
+
data.source().db.run(`UPDATE ${BLACKBOARD_TABLE} SET kind = 'contract' WHERE id = ?`, [res.id]);
|
|
395
|
+
}
|
|
396
|
+
return { inserted: res.inserted, id: toSafeRowId(res.id) };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Parse a `contract` entry's `dedupe_key` convention `<category>:<name>` (e.g. `env:NANO_X`,
|
|
400
|
+
* `type:BlackboardEntry`). Returns the category+name when it parses, else undefined. */
|
|
401
|
+
export function parseContractRef(
|
|
402
|
+
dedupeKey: string | undefined,
|
|
403
|
+
): { category: ContractCategory; name: string } | undefined {
|
|
404
|
+
if (!dedupeKey) return undefined;
|
|
405
|
+
const idx = dedupeKey.indexOf(":");
|
|
406
|
+
if (idx <= 0) return undefined;
|
|
407
|
+
const category = dedupeKey.slice(0, idx).trim();
|
|
408
|
+
const name = dedupeKey.slice(idx + 1).trim();
|
|
409
|
+
if (!name) return undefined;
|
|
410
|
+
if (category === "env" || category === "wire" || category === "type" || category === "capability-url") {
|
|
411
|
+
return { category, name };
|
|
412
|
+
}
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Write-time near-duplicate DECLARATION detection for a `contract` POST (issue #227, AC4). Extends
|
|
417
|
+
* the store's conflict reporting (`file-claim`) to the declaration surface: given a proposed contract
|
|
418
|
+
* (its `<category>:<name>` from `dedupe_key`, its semantics from `body`), report any registry entry
|
|
419
|
+
* that is a synonym (same thing, different name), a contradiction (same name, different meaning), or a
|
|
420
|
+
* rejected synonym (a retired name). Advisory — surfaced to the writer, never a lock. Returns `[]`
|
|
421
|
+
* when the entry doesn't carry the `<category>:<name>` convention (nothing to reconcile against). */
|
|
422
|
+
export function detectContractDeclarationConflicts(opts: {
|
|
423
|
+
dedupe_key?: string;
|
|
424
|
+
body: string;
|
|
425
|
+
}): DeclarationConflict[] {
|
|
426
|
+
const ref = parseContractRef(opts.dedupe_key);
|
|
427
|
+
if (!ref) return [];
|
|
428
|
+
return detectDeclarationConflicts({ category: ref.category, name: ref.name, semantics: opts.body });
|
|
299
429
|
}
|