@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/app/userTasks.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Unified "Tasks" inbox read-model (issue #236) — the schema-driven pages' single source for the
|
|
2
|
+
// Tasks page, listing EVERY open native user-task escalation awaiting a human decision so an operator
|
|
3
|
+
// can resolve one in the nwf UI (Urban's `taskInbox` at /tasks is a read-only stub).
|
|
4
|
+
//
|
|
5
|
+
// The four migrated escalations (ADR 0046) are native `userTask`s with linked `.form`s. Their
|
|
6
|
+
// completable keys were only ever denormalised for the FEATURE kinds (migrations 031/032, onto
|
|
7
|
+
// `feature_runs`); the epic/PR kinds (`plan-review-decision`, `trial-merge-decision`, the PR
|
|
8
|
+
// `wait-answer`) had no app-side pointer, so the pages could not drive a completion. This module owns
|
|
9
|
+
// the `user_tasks` read-model row shape and the PURE derivation the `pollUserTasks` reconcile
|
|
10
|
+
// (app/service.ts) projects: `buildUserTaskRow` (one open task → a desired row) and
|
|
11
|
+
// `reconcileUserTasks` (the desired set vs the persisted set → the minimal upserts + deletes). The
|
|
12
|
+
// engine iteration + writes live in the poller; the decisions live here so they are unit-testable
|
|
13
|
+
// without a host, mirroring `deriveFeatureEscalationPatch`.
|
|
14
|
+
//
|
|
15
|
+
// Completion is NOT owned here — the page posts the typed form variables to the ONE canonical human
|
|
16
|
+
// completer (`completeEscalationAsHuman`, app/agentCompletion.ts) / the existing feature answer &
|
|
17
|
+
// acknowledge operations, the exact resume path the task inbox uses. This module only makes the open
|
|
18
|
+
// tasks visible; a completed task's row is removed on the next pass when the engine no longer reports
|
|
19
|
+
// it open.
|
|
20
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
21
|
+
import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT } from "./feature.ts";
|
|
22
|
+
import type { PlanReview } from "./plan.ts";
|
|
23
|
+
import type { TrialMergeAuditRow } from "./trialMerge.ts";
|
|
24
|
+
|
|
25
|
+
const now = () => new Date().toISOString();
|
|
26
|
+
|
|
27
|
+
/** The plan-review cap escalation user task (plan-fanout.bpmn) — a human directive (proceed/revise)
|
|
28
|
+
* when the adversarial review loop exhausts its budget without approval. */
|
|
29
|
+
export const PLAN_REVIEW_ELEMENT = "plan-review-decision";
|
|
30
|
+
|
|
31
|
+
/** The trial-merge escalation user task (plan-fanout.bpmn) — a human decision (proceed/rebase/abandon)
|
|
32
|
+
* when a wave's trial merge comes back red. */
|
|
33
|
+
export const TRIAL_MERGE_ELEMENT = "trial-merge-decision";
|
|
34
|
+
|
|
35
|
+
/** The PR review-loop escalation user task (convergence-loop.bpmn) — a human answer that resumes the
|
|
36
|
+
* review loop and is handed to the next round. */
|
|
37
|
+
export const PR_WAIT_ANSWER_ELEMENT = "wait-answer";
|
|
38
|
+
|
|
39
|
+
/** One row per currently-open native user-task escalation, denormalised for the Tasks page. Keyed on
|
|
40
|
+
* the completable `user_task_key` (a task is open at most once). Present iff the engine reports the
|
|
41
|
+
* task open; `pollUserTasks` deletes it once the task is gone. */
|
|
42
|
+
export interface UserTaskRow {
|
|
43
|
+
user_task_key: string;
|
|
44
|
+
element_id: string;
|
|
45
|
+
kind_label: string;
|
|
46
|
+
subject_type: string;
|
|
47
|
+
subject_key: string;
|
|
48
|
+
subject_url: string | null;
|
|
49
|
+
question: string | null;
|
|
50
|
+
process_key: string | null;
|
|
51
|
+
created_at: string;
|
|
52
|
+
updated_at: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const userTasks = (data: DataLayer) => data.table<UserTaskRow>("user_tasks", "user_task_key");
|
|
56
|
+
|
|
57
|
+
/** Human-readable label per escalation element. The set of keys is the closed set of user-task
|
|
58
|
+
* elements the Tasks inbox surfaces — an element absent from here is not an escalation and is
|
|
59
|
+
* ignored by `buildUserTaskRow`, so an arbitrary internal user task can never leak into the inbox. */
|
|
60
|
+
export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
|
|
61
|
+
[FEATURE_ESCALATION_ELEMENT]: "Feature escalation",
|
|
62
|
+
[FEATURE_BLOCKED_ELEMENT]: "Blocked feature run",
|
|
63
|
+
[PLAN_REVIEW_ELEMENT]: "Plan review",
|
|
64
|
+
[TRIAL_MERGE_ELEMENT]: "Trial merge",
|
|
65
|
+
[PR_WAIT_ANSWER_ELEMENT]: "PR review",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The denormalised context the poller has resolved for an open escalation user task. */
|
|
69
|
+
export interface UserTaskContext {
|
|
70
|
+
userTaskKey: string;
|
|
71
|
+
elementId: string;
|
|
72
|
+
subjectType: "feature" | "plan" | "pr";
|
|
73
|
+
subjectKey: string;
|
|
74
|
+
subjectUrl?: string | null;
|
|
75
|
+
question?: string | null;
|
|
76
|
+
processKey?: string | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Pure: turn one resolved open escalation task into its desired read-model row, or `null` when the
|
|
80
|
+
* element is not one of the surfaced escalation kinds (so a non-escalation user task is never listed)
|
|
81
|
+
* or the required keys are blank. `created_at`/`updated_at` default to now for a fresh row; the
|
|
82
|
+
* reconcile preserves the original `created_at` on an update. */
|
|
83
|
+
export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): UserTaskRow | null {
|
|
84
|
+
const userTaskKey = ctx.userTaskKey.trim();
|
|
85
|
+
const subjectKey = ctx.subjectKey.trim();
|
|
86
|
+
const kindLabel = USER_TASK_KIND_LABELS[ctx.elementId];
|
|
87
|
+
if (!userTaskKey || !subjectKey || !kindLabel) return null;
|
|
88
|
+
const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
|
|
89
|
+
return {
|
|
90
|
+
user_task_key: userTaskKey,
|
|
91
|
+
element_id: ctx.elementId,
|
|
92
|
+
kind_label: kindLabel,
|
|
93
|
+
subject_type: ctx.subjectType,
|
|
94
|
+
subject_key: subjectKey,
|
|
95
|
+
subject_url: ctx.subjectUrl ?? null,
|
|
96
|
+
question,
|
|
97
|
+
process_key: ctx.processKey ?? null,
|
|
98
|
+
created_at: at,
|
|
99
|
+
updated_at: at,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The minimal write plan a reconcile pass applies: rows to insert, rows to update in place (the
|
|
104
|
+
* task is still open but its denormalised context changed), and keys to delete (the task is gone). */
|
|
105
|
+
export interface UserTaskReconcile {
|
|
106
|
+
inserts: UserTaskRow[];
|
|
107
|
+
updates: UserTaskRow[];
|
|
108
|
+
deletes: string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True when the persisted row already matches the freshly-derived one on every display field, so the
|
|
112
|
+
* reconcile can skip a no-op write (an update touches `updated_at`, which would otherwise churn the
|
|
113
|
+
* row on every pass). `created_at`/`updated_at` are intentionally excluded. */
|
|
114
|
+
function sameRow(a: UserTaskRow, b: UserTaskRow): boolean {
|
|
115
|
+
return (
|
|
116
|
+
a.element_id === b.element_id &&
|
|
117
|
+
a.kind_label === b.kind_label &&
|
|
118
|
+
a.subject_type === b.subject_type &&
|
|
119
|
+
a.subject_key === b.subject_key &&
|
|
120
|
+
a.subject_url === b.subject_url &&
|
|
121
|
+
a.question === b.question &&
|
|
122
|
+
a.process_key === b.process_key
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Pure source of truth for the `pollUserTasks` reconcile: diff the DESIRED open-task rows (derived
|
|
127
|
+
* from the engine this pass) against the PERSISTED rows, returning the minimal insert/update/delete
|
|
128
|
+
* plan. A desired row not yet persisted is an insert; a persisted row whose task is still desired but
|
|
129
|
+
* whose context drifted is an update (preserving its original `created_at`); a persisted row no longer
|
|
130
|
+
* desired is a delete (its task was completed — here, via the inbox, or out-of-band). Idempotent: a
|
|
131
|
+
* steady state with no drift yields empty lists, so the poller performs zero writes. */
|
|
132
|
+
export function reconcileUserTasks(persisted: UserTaskRow[], desired: UserTaskRow[]): UserTaskReconcile {
|
|
133
|
+
const persistedByKey = new Map(persisted.map((r) => [r.user_task_key, r]));
|
|
134
|
+
const desiredByKey = new Map(desired.map((r) => [r.user_task_key, r]));
|
|
135
|
+
const inserts: UserTaskRow[] = [];
|
|
136
|
+
const updates: UserTaskRow[] = [];
|
|
137
|
+
const deletes: string[] = [];
|
|
138
|
+
|
|
139
|
+
for (const row of desired) {
|
|
140
|
+
const existing = persistedByKey.get(row.user_task_key);
|
|
141
|
+
if (!existing) {
|
|
142
|
+
inserts.push(row);
|
|
143
|
+
} else if (!sameRow(existing, row)) {
|
|
144
|
+
updates.push({ ...row, created_at: existing.created_at });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const row of persisted) {
|
|
148
|
+
if (!desiredByKey.has(row.user_task_key)) deletes.push(row.user_task_key);
|
|
149
|
+
}
|
|
150
|
+
return { inserts, updates, deletes };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Audit-table accessors + derivations used by the poller to enrich the question text ────────────
|
|
154
|
+
// These read the question/findings each escalation kind already records in a SURVIVING audit table,
|
|
155
|
+
// keyed by the subject, so the Tasks grid can show WHAT is being decided. They are display-only.
|
|
156
|
+
//
|
|
157
|
+
// NB: the bespoke `plan_escalations` / `plan_review_escalations` mirror tables were retired in
|
|
158
|
+
// migration 027 (the CONTRACT phase of the native-userTask migration), so the enrichment is derived
|
|
159
|
+
// from the canonical append-only audit logs that DID survive — `plan_reviews` (the adversarial
|
|
160
|
+
// plan-review log) and `plan_trial_merges` (the D3 trial-merge gate log) — not from a dropped mirror.
|
|
161
|
+
// The PR-loop `escalations` audit table was deliberately KEPT by 027, so `prEscalations` still reads it.
|
|
162
|
+
|
|
163
|
+
/** Pure: the findings that drove the still-open `plan-review-decision` escalation — the latest
|
|
164
|
+
* adversarial plan-review round's critique. `plan_reviews` is append-only per (epoch, round); the
|
|
165
|
+
* parked escalation is the tail of the current epoch, so the latest round by (epoch, round) carries
|
|
166
|
+
* the rejecting findings the human is being asked to overrule. `null` when there is no round yet. */
|
|
167
|
+
export function latestPlanReviewFindings(reviews: readonly PlanReview[]): string | null {
|
|
168
|
+
let latest: PlanReview | undefined;
|
|
169
|
+
for (const r of reviews) {
|
|
170
|
+
if (!latest || r.epoch > latest.epoch || (r.epoch === latest.epoch && r.round > latest.round)) latest = r;
|
|
171
|
+
}
|
|
172
|
+
return latest?.findings ?? null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Pure: the summary that drove the still-open `trial-merge-decision` escalation — the newest
|
|
176
|
+
* UNRESOLVED red trial-merge attempt. `plan_trial_merges` is append-only and supersede-on-insert
|
|
177
|
+
* marks superseded rows `resolved = 1`, so the newest unresolved `suite-failed`/`merge-conflict` row
|
|
178
|
+
* (highest `id`) is the wave awaiting the human decision. `null` when none is open. */
|
|
179
|
+
export function latestTrialMergeQuestion(audits: readonly TrialMergeAuditRow[]): string | null {
|
|
180
|
+
let latest: TrialMergeAuditRow | undefined;
|
|
181
|
+
for (const a of audits) {
|
|
182
|
+
if (a.resolved === 1) continue;
|
|
183
|
+
if (a.result !== "suite-failed" && a.result !== "merge-conflict") continue;
|
|
184
|
+
if (!latest || a.id > latest.id) latest = a;
|
|
185
|
+
}
|
|
186
|
+
return latest?.summary ?? null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** One PR review-loop escalation (001_init.sql `escalations`). `status` is open | answered; the poller
|
|
190
|
+
* reads the OPEN row's `question`. */
|
|
191
|
+
export interface PrEscalationRow {
|
|
192
|
+
id: number;
|
|
193
|
+
pr_key: string;
|
|
194
|
+
round_no: number;
|
|
195
|
+
kind: string;
|
|
196
|
+
question: string;
|
|
197
|
+
answer: string | null;
|
|
198
|
+
status: string;
|
|
199
|
+
asked_at: string;
|
|
200
|
+
answered_at: string | null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const prEscalations = (data: DataLayer) =>
|
|
204
|
+
data.table<PrEscalationRow>("escalations", "id");
|
|
205
|
+
|
|
206
|
+
/** Pure: the question for the still-open PR review-loop escalation. `escalations` is append-only and
|
|
207
|
+
* `id` is an AUTOINCREMENT PK, so when a PR has multiple `open` rows the newest (highest `id`) is the
|
|
208
|
+
* live one the human is being asked; a positional `[0]` from an unordered `find` could surface a stale
|
|
209
|
+
* row. `null` when there is no open escalation. */
|
|
210
|
+
export function latestOpenEscalationQuestion(rows: readonly PrEscalationRow[]): string | null {
|
|
211
|
+
let latest: PrEscalationRow | undefined;
|
|
212
|
+
for (const r of rows) {
|
|
213
|
+
if (r.status !== "open") continue;
|
|
214
|
+
if (!latest || r.id > latest.id) latest = r;
|
|
215
|
+
}
|
|
216
|
+
return latest?.question ?? null;
|
|
217
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- Unified "Tasks" inbox read-model (issue #236) — surface EVERY open native user-task escalation
|
|
2
|
+
-- awaiting a human decision in the nwf UI, so an operator can resolve one without leaving the app.
|
|
3
|
+
--
|
|
4
|
+
-- The four migrated escalations (ADR 0046) are native BPMN `userTask`s with linked `.form`s:
|
|
5
|
+
-- `feature-escalation` (a stuck single-issue run), `feature-blocked` (a blocked run to acknowledge),
|
|
6
|
+
-- `plan-review-decision` (a plan-review cap escalation), `trial-merge-decision` (a red trial merge),
|
|
7
|
+
-- and the PR review-loop `wait-answer`. The completable user-task keys were only ever denormalised
|
|
8
|
+
-- for the FEATURE kinds (migrations 031/032, onto `feature_runs`); the epic/PR kinds had NO app-side
|
|
9
|
+
-- pointer at all, so the only surface that listed them was Urban's read-only `taskInbox` stub.
|
|
10
|
+
--
|
|
11
|
+
-- This is the schema-driven pages' single source for the Tasks page `dataGrid`s: `pollUserTasks`
|
|
12
|
+
-- (app/service.ts) reconciles one row per currently-open escalation user task from the engine
|
|
13
|
+
-- (`searchUserTasks`), denormalising the completable `user_task_key` plus display context (the
|
|
14
|
+
-- escalation `question`, its `subject_*`, and `element_id`). A row exists iff the user task is open;
|
|
15
|
+
-- the poller deletes a row once its task is gone (answered here, via the task inbox, or out-of-band),
|
|
16
|
+
-- so `showCount` reflects live pending work. The completion affordances post the typed form variables
|
|
17
|
+
-- to the canonical human completer (`completeEscalationAsHuman` / the existing feature answer/ack
|
|
18
|
+
-- operations), the same resume path the task inbox uses — no parallel completion.
|
|
19
|
+
--
|
|
20
|
+
-- Forward-only, additive (expand): a brand-new table, no existing shape touched. Numbered after the
|
|
21
|
+
-- current highest prefix (033); the runner wraps each file in its own transaction, so this file must
|
|
22
|
+
-- NOT contain BEGIN/COMMIT.
|
|
23
|
+
CREATE TABLE user_tasks (
|
|
24
|
+
-- The completable native user-task key (the engine's `userTaskKey`) — the PK, since a user task is
|
|
25
|
+
-- open at most once and every completion affordance posts to it.
|
|
26
|
+
user_task_key TEXT PRIMARY KEY,
|
|
27
|
+
-- The BPMN `elementId` of the parked user task (one of the migrated escalation elements). Drives
|
|
28
|
+
-- which typed decision form / completion operation the page routes the answer through.
|
|
29
|
+
element_id TEXT NOT NULL,
|
|
30
|
+
-- Human-readable kind label for the grid (e.g. "Plan review", "Trial merge").
|
|
31
|
+
kind_label TEXT NOT NULL,
|
|
32
|
+
-- The domain subject the escalation belongs to: `subject_type` is feature | plan | pr; `subject_key`
|
|
33
|
+
-- is its aggregate key (feature_key / plan_key / pr_key); `subject_url` is an optional external link
|
|
34
|
+
-- (the issue/PR URL) shown as a clickable column.
|
|
35
|
+
subject_type TEXT NOT NULL,
|
|
36
|
+
subject_key TEXT NOT NULL,
|
|
37
|
+
subject_url TEXT,
|
|
38
|
+
-- The escalation question / findings the agent (or loop) raised, denormalised for display so the
|
|
39
|
+
-- operator can decide without opening the process. Best-effort; NULL when none was recorded.
|
|
40
|
+
question TEXT,
|
|
41
|
+
-- The owning process instance key, for the process-explorer link. NULL when unknown.
|
|
42
|
+
process_key TEXT,
|
|
43
|
+
created_at TEXT NOT NULL,
|
|
44
|
+
updated_at TEXT NOT NULL
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
-- The pages filter the grid by `element_id` (one grid per kind) and order by recency
|
|
48
|
+
-- (`WHERE element_id IN (...) ORDER BY updated_at DESC`). A single composite index on
|
|
49
|
+
-- `(element_id, updated_at)` serves both the equality filter and the ordered read in one structure —
|
|
50
|
+
-- no extra sort/scan — so the read stays O(matching), not O(all open tasks), as the table grows.
|
|
51
|
+
CREATE INDEX idx_user_tasks_element_updated ON user_tasks(element_id, updated_at);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# ADR 0004 — Coordinate shared contracts through a durable registry + a blackboard signal + a reconciliation pass
|
|
2
|
+
|
|
3
|
+
Status: **Proposed.**
|
|
4
|
+
Date: 2026-08-14.
|
|
5
|
+
|
|
6
|
+
> **Scope note.** A **nano-workforce-local** ADR — it governs how *this app* coordinates cross-cutting
|
|
7
|
+
> contracts across parallel/sliced agent work. Platform-wide ADRs live in
|
|
8
|
+
> `Magikcraft/nano-bpm/docs/adr` (referenced by number + repo). Continues nano-workforce's series after
|
|
9
|
+
> ADR 0001–0003.
|
|
10
|
+
|
|
11
|
+
Relates to:
|
|
12
|
+
issue **#227** (the framing + spec this ADR records the decision for),
|
|
13
|
+
issue **#223** (the concrete `NANO_PR_*` env-key synonym cleanup — this ADR makes its cascade impossible
|
|
14
|
+
to reintroduce),
|
|
15
|
+
nano-ide **#234 / #236** (the relay producer/hub wire-shape drift — the same failure mode in a wire
|
|
16
|
+
contract),
|
|
17
|
+
issues **#214** (real-entrypoint integration test) and **#217** (retro verifies acceptance) — the
|
|
18
|
+
after-the-fact verification this ADR complements with an authoring-time preventive,
|
|
19
|
+
and in this repo: `app/contracts.ts` (the registry + typed env schema + declaration-conflict
|
|
20
|
+
detection), `app/contractReconcile.ts` (the reconciliation pass), `app/blackboard.ts`
|
|
21
|
+
(the `contract` kind + coordination brief), `operations/appendBlackboard.ts`,
|
|
22
|
+
`scripts/check-contracts.ts` (the CI gate) and `scripts/reconcile-contracts.ts` (the advisory pass).
|
|
23
|
+
|
|
24
|
+
## Context
|
|
25
|
+
|
|
26
|
+
Parallel and sliced agent work keeps producing **two divergent representations of a single contract**,
|
|
27
|
+
with no mechanism that binds them at authoring time. Two live examples of the *same* failure mode:
|
|
28
|
+
|
|
29
|
+
1. **Config-key synonym** — `publicBaseUrl()` read `NANO_PR_PUBLIC_BASE_URL` and fell back to a phantom
|
|
30
|
+
`NANO_PR_BASE_URL` (introduced in the same commit, #53/2dcfb8a; nothing set it, the unit test even
|
|
31
|
+
exercised the wrong name). Two names for one value (#223). (`NANO_PR_PUBLIC_BASE_URL` was itself
|
|
32
|
+
later coalesced into the canonical `NANO_WORKFORCE_BASE_URL` in #226; both retired names are now
|
|
33
|
+
registered rejected synonyms.)
|
|
34
|
+
2. **Wire-shape drift** — a relay *producer* kept emitting the legacy `{stream, offset, chunk}` frame
|
|
35
|
+
while the *hub* had adopted an op-tagged `{op:"produce", …}` sub-protocol; the hub rejected every
|
|
36
|
+
worker terminal chunk. Every isolated slice test was green because each side tested against its own
|
|
37
|
+
fake (nano-ide #234/#236).
|
|
38
|
+
|
|
39
|
+
The through-line: **a contract (an env key, a wire shape, a shared type name, a capability-URL scheme) is
|
|
40
|
+
authored independently by parallel workers, each against a mock or a local assumption, and the divergence
|
|
41
|
+
is discovered only at runtime.** Existing issues are *after-the-fact verification* (#214, #217) or fix a
|
|
42
|
+
*single symptom* (#223). None coordinate the shared contract surface **while** siblings are authoring it,
|
|
43
|
+
nor reconcile the accumulated blackboard for duplicate/synonymous declarations.
|
|
44
|
+
|
|
45
|
+
## Decision
|
|
46
|
+
|
|
47
|
+
Coordinate shared contracts with **three complementary mechanisms**, at deliberately different lifetimes.
|
|
48
|
+
|
|
49
|
+
### 1. A durable, executable contract registry (source of truth)
|
|
50
|
+
|
|
51
|
+
`app/contracts.ts` is a committed, reviewed, first-class registry of cross-cutting contracts —
|
|
52
|
+
env/config keys, wire-frame shapes, shared exported type/interface names, and capability-URL schemes —
|
|
53
|
+
each with an **owner** + **semantics**. It is *executable where possible*:
|
|
54
|
+
|
|
55
|
+
- **Env/config keys are parsed through ONE typed schema** (`ENV_CONTRACTS` + `readEnv`/`readEnvOr`).
|
|
56
|
+
`readEnv(key)` takes a compile-time-checked `EnvKey`, so a synonymous or misspelled key is a **type
|
|
57
|
+
error**, never a silent runtime fallback. Each entry may record `rejectedSynonyms` — names we
|
|
58
|
+
deliberately retired (e.g. `NANO_PR_BASE_URL`); their reappearance in code is a **CI failure**
|
|
59
|
+
(`scripts/check-contracts.ts`). The base-URL boundary is the migrated reference: `publicBaseUrl()` now
|
|
60
|
+
reads the schema and the phantom fallback is gone, so the #223 cascade **cannot be reintroduced**.
|
|
61
|
+
- **Wire/type/capability-URL contracts** are declared alongside so ONE registry answers "does a contract
|
|
62
|
+
for X already exist?" for every category. The blackboard's `BlackboardEntry` snake_case shape and the
|
|
63
|
+
blackboard capability-URL scheme are the seed entries.
|
|
64
|
+
|
|
65
|
+
### 2. A blackboard `contract` kind (the live, in-flight signal)
|
|
66
|
+
|
|
67
|
+
`app/blackboard.ts` adds an app-recognised `contract` kind, **derived** from the shared store's kinds
|
|
68
|
+
(`APP_BLACKBOARD_KINDS = [...BLACKBOARD_KINDS, "contract"]`) so the two never drift. An agent posts a
|
|
69
|
+
`contract` entry ("I am introducing / consuming env key / wire op / type X") **as soon as it is true**,
|
|
70
|
+
so siblings in a wave see a new contract *before* they independently invent a synonym. The coordination
|
|
71
|
+
brief (`renderCoordinationBrief`) now requires: before introducing a new env key / wire field / shared
|
|
72
|
+
type, **consult the registry and the blackboard `contract` entries**; if a semantically-equivalent one
|
|
73
|
+
exists, **reuse it**; otherwise declare it in both. The registry is the durable truth; the blackboard is
|
|
74
|
+
the live signal. **Neither alone suffices.**
|
|
75
|
+
|
|
76
|
+
### 3. A de-duplication / reconciliation pass
|
|
77
|
+
|
|
78
|
+
- **Write-time.** A `contract` POST runs near-duplicate *declaration* detection
|
|
79
|
+
(`detectDeclarationConflicts`, surfaced through `operations/appendBlackboard.ts` alongside the existing
|
|
80
|
+
`file-claim` conflict reporting): it flags a **synonym** (same semantics, different name), a
|
|
81
|
+
**contradiction** (same name, different meaning), or a **rejected synonym**, so the writer reconciles
|
|
82
|
+
at authoring time.
|
|
83
|
+
- **Reconciliation pass** (`app/contractReconcile.ts`, a sibling to the L2 retro). It reads the *whole*
|
|
84
|
+
blackboard + the registry and flags synonyms, contradictions, and **mock-vs-real skew** (a contract
|
|
85
|
+
signalled in-flight that never landed in the durable registry). Advisory: it emits a report as an
|
|
86
|
+
escalation / merge candidate (`npm run reconcile:contracts`) rather than silently accumulating. The
|
|
87
|
+
mechanically-enforceable, registry-only half is the hard CI gate `npm run check:contracts`.
|
|
88
|
+
|
|
89
|
+
## Consequences
|
|
90
|
+
|
|
91
|
+
- The #223 failure mode is **categorically** closed for env keys: the synonym is a compile error and a
|
|
92
|
+
rejected synonym is a CI failure — not a runtime fallback.
|
|
93
|
+
- A new cross-cutting contract has ONE place to be declared and ONE way to be read; the coordination brief
|
|
94
|
+
routes agents through it, and the reconciliation pass catches what slips through.
|
|
95
|
+
- A CI gate (`check:contracts`) and an advisory pass (`reconcile:contracts`) make the coordination
|
|
96
|
+
observable rather than a hope.
|
|
97
|
+
|
|
98
|
+
## Open questions / follow-ups
|
|
99
|
+
|
|
100
|
+
- **Promote the `contract` kind into `@nanobpm/agentic/blackboard`.** The shared store's normaliser coerces
|
|
101
|
+
unknown kinds to `note`; the app persists `contract` by patching the row after the store's insert
|
|
102
|
+
(reusing the store's append so the idempotency logic is not duplicated). The durable home for this kind
|
|
103
|
+
is the shared package — a follow-up version bump removes the app-local patch.
|
|
104
|
+
- **Migrate the remaining env call sites** (`app/service.ts`, `app/plan.ts`, `main.ts`, …) onto
|
|
105
|
+
`readEnv`/`readEnvOr`. The registry already declares every config key and the CI gate enforces
|
|
106
|
+
declaration; routing every read through the typed schema is a mechanical follow-up.
|
|
107
|
+
- **Wire the reconciliation pass into the retro process** as an automatic cross-epic step, rather than an
|
|
108
|
+
operator-run script.
|
package/nano.app.json
CHANGED
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:
|
|
@@ -1244,6 +1268,59 @@ paths:
|
|
|
1244
1268
|
application/json:
|
|
1245
1269
|
schema:
|
|
1246
1270
|
$ref: "#/components/schemas/MessageResult"
|
|
1271
|
+
/actions/complete-user-task:
|
|
1272
|
+
post:
|
|
1273
|
+
operationId: completeUserTask
|
|
1274
|
+
summary: "Complete an open native user-task escalation from the nwf Tasks inbox (issue #236).
|
|
1275
|
+
Submits the parked task's typed `.form` variables (e.g. a plan-review `{ directive, notes }`, a
|
|
1276
|
+
trial-merge `{ action, notes }`, or a PR `{ answer }`) to the canonical human completer
|
|
1277
|
+
(completeEscalationAsHuman → completeUserTaskAttributed) — the same resume path the task inbox
|
|
1278
|
+
uses, recording who answered. The completer refuses any user task that is not one of the
|
|
1279
|
+
migrated escalation elements. The feature-run kinds keep their own operations
|
|
1280
|
+
(answer-escalation / acknowledge-blocked); this door is for the plan-review / trial-merge / PR
|
|
1281
|
+
`wait-answer` kinds whose completion is a straight typed pass-through. On success the answered
|
|
1282
|
+
task's read-model row is dropped so the Tasks grid stops offering a decision for it."
|
|
1283
|
+
requestBody:
|
|
1284
|
+
required: true
|
|
1285
|
+
content:
|
|
1286
|
+
application/json:
|
|
1287
|
+
schema:
|
|
1288
|
+
type: object
|
|
1289
|
+
additionalProperties: false
|
|
1290
|
+
required:
|
|
1291
|
+
- userTaskKey
|
|
1292
|
+
- variables
|
|
1293
|
+
properties:
|
|
1294
|
+
userTaskKey:
|
|
1295
|
+
type: string
|
|
1296
|
+
minLength: 1
|
|
1297
|
+
description: The parked escalation user-task key (user_tasks.user_task_key).
|
|
1298
|
+
variables:
|
|
1299
|
+
type: object
|
|
1300
|
+
additionalProperties: true
|
|
1301
|
+
description: The typed form variables the parked task's `.form` expects (kind-specific).
|
|
1302
|
+
operator:
|
|
1303
|
+
type: string
|
|
1304
|
+
description: Optional operator handle recorded in the attribution ledger; defaults to "operator".
|
|
1305
|
+
responses:
|
|
1306
|
+
"200":
|
|
1307
|
+
description: The escalation user task was completed and the process resumed.
|
|
1308
|
+
content:
|
|
1309
|
+
application/json:
|
|
1310
|
+
schema:
|
|
1311
|
+
$ref: "#/components/schemas/MessageResult"
|
|
1312
|
+
"400":
|
|
1313
|
+
description: A required field was missing/invalid, or the target is not an escalation task.
|
|
1314
|
+
content:
|
|
1315
|
+
application/json:
|
|
1316
|
+
schema:
|
|
1317
|
+
$ref: "#/components/schemas/MessageResult"
|
|
1318
|
+
"404":
|
|
1319
|
+
description: No open escalation user task matches the userTaskKey.
|
|
1320
|
+
content:
|
|
1321
|
+
application/json:
|
|
1322
|
+
schema:
|
|
1323
|
+
$ref: "#/components/schemas/MessageResult"
|
|
1247
1324
|
/actions/acknowledge-blocked:
|
|
1248
1325
|
post:
|
|
1249
1326
|
operationId: acknowledgeBlocked
|
|
@@ -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
|
});
|