@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/contracts.ts
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// nano-workforce — the durable contract registry (issue #227, ADR 0004).
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM this exists to kill: parallel/sliced agent work keeps producing *two divergent
|
|
4
|
+
// representations of one contract* — an env-key synonym (the canonical `NANO_WORKFORCE_BASE_URL`
|
|
5
|
+
// vs. retired names like `NANO_PR_PUBLIC_BASE_URL`/`NANO_PR_BASE_URL`, #226/#223), a wire-shape
|
|
6
|
+
// drift (a producer emitting a legacy frame the hub no longer accepts, nano-ide #234), two type
|
|
7
|
+
// names for one shape — each authored independently against a mock, with the divergence only
|
|
8
|
+
// discovered at runtime.
|
|
9
|
+
//
|
|
10
|
+
// THE FIX: a first-class, *committed and executable* source of truth for every cross-cutting
|
|
11
|
+
// contract. Declare-once entries (env/config keys, wire-frame shapes, shared exported type/interface
|
|
12
|
+
// names, capability-URL schemes) with an owner + semantics per entry. Because the registry is code:
|
|
13
|
+
// - env/config keys are parsed through ONE typed schema ({@link ENV_CONTRACTS} + {@link readEnv}),
|
|
14
|
+
// so a duplicate or synonymous key is a compile/lint failure (`scripts/check-contracts.ts`),
|
|
15
|
+
// never a silent runtime fallback — the #223 cascade cannot be reintroduced;
|
|
16
|
+
// - a rejected synonym (an old name we deliberately retired) is recorded here, so its reappearance
|
|
17
|
+
// in code is a hard CI failure, not a phantom fallback;
|
|
18
|
+
// - the reconciliation pass (`app/contractReconcile.ts`) reads this registry alongside the whole
|
|
19
|
+
// blackboard and flags synonyms / contradictions / mock-vs-real skew.
|
|
20
|
+
//
|
|
21
|
+
// The registry is the DURABLE truth; the blackboard `contract` kind (app/blackboard.ts) is the
|
|
22
|
+
// LIVE, in-flight signal ("I am introducing / consuming contract X") so siblings in a wave see a new
|
|
23
|
+
// contract *before* they independently invent a synonym. Neither alone suffices.
|
|
24
|
+
|
|
25
|
+
/** The kinds of cross-cutting contract the registry coordinates. */
|
|
26
|
+
export type ContractCategory = "env" | "wire" | "type" | "capability-url";
|
|
27
|
+
|
|
28
|
+
/** Fields shared by every contract entry: a stable name, an owning subsystem, and human semantics. */
|
|
29
|
+
interface ContractBase {
|
|
30
|
+
/** The canonical name (env var, wire op/frame, exported type, or URL scheme id). */
|
|
31
|
+
readonly name: string;
|
|
32
|
+
/** The subsystem/module that owns this contract (where its canonical definition lives). */
|
|
33
|
+
readonly owner: string;
|
|
34
|
+
/** What the contract means — enough for a sibling to decide "is mine the same as this?". */
|
|
35
|
+
readonly semantics: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An env/config key. Parsed through the one typed schema; `default` documents the fallback. */
|
|
39
|
+
export interface EnvContract extends ContractBase {
|
|
40
|
+
readonly category: "env";
|
|
41
|
+
/** The documented default when the key is unset/blank (omit for a required secret). */
|
|
42
|
+
readonly default?: string;
|
|
43
|
+
/** Names we DELIBERATELY retired for this value. Their reappearance in code is a CI failure — a
|
|
44
|
+
* retired synonym must never come back as a silent fallback (the #223 failure mode). */
|
|
45
|
+
readonly rejectedSynonyms?: readonly string[];
|
|
46
|
+
/** True for a secret/credential whose value must never be logged or defaulted. */
|
|
47
|
+
readonly secret?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A wire-frame shape that crosses a process/transport boundary (e.g. a relay control frame). */
|
|
51
|
+
export interface WireContract extends ContractBase {
|
|
52
|
+
readonly category: "wire";
|
|
53
|
+
/** A concise, executable-where-possible description of the frame shape both sides must share. */
|
|
54
|
+
readonly shape: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A shared exported type/interface name that more than one slice depends on. */
|
|
58
|
+
export interface TypeContract extends ContractBase {
|
|
59
|
+
readonly category: "type";
|
|
60
|
+
/** The module the canonical definition is exported from — the ONE import both sides must use. */
|
|
61
|
+
readonly module: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A capability-URL scheme: how a token-scoped side-channel URL is assembled. */
|
|
65
|
+
export interface CapabilityUrlContract extends ContractBase {
|
|
66
|
+
readonly category: "capability-url";
|
|
67
|
+
/** The URL template (documented), e.g. `<base>/app/api/hooks/blackboard?token=<token>`. */
|
|
68
|
+
readonly scheme: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type Contract = EnvContract | WireContract | TypeContract | CapabilityUrlContract;
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------------------------
|
|
74
|
+
// The one typed env schema. EVERY config-family env key MUST be declared here; the CI check
|
|
75
|
+
// (`scripts/check-contracts.ts`) fails the build if code reads a config key that is not declared,
|
|
76
|
+
// or reads a rejected synonym. This makes the schema the single source of truth for config keys.
|
|
77
|
+
// ---------------------------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export const ENV_CONTRACTS = {
|
|
80
|
+
NANO_WORKFORCE_BASE_URL: {
|
|
81
|
+
category: "env",
|
|
82
|
+
name: "NANO_WORKFORCE_BASE_URL",
|
|
83
|
+
owner: "app/blackboard.ts",
|
|
84
|
+
semantics:
|
|
85
|
+
"Externally-reachable base URL agents use to reach this app (must resolve from wherever the agent runs). Drives every plan's blackboard capability URL.",
|
|
86
|
+
default: "http://localhost:3000",
|
|
87
|
+
// Retired synonyms, recorded so their reintroduction is a CI failure rather than a silent second
|
|
88
|
+
// name for one value: `NANO_PR_PUBLIC_BASE_URL` was coalesced into this canonical name in #226;
|
|
89
|
+
// `NANO_PR_BASE_URL` was a phantom fallback introduced in #53 (2dcfb8a) and cleaned up per #223.
|
|
90
|
+
rejectedSynonyms: ["NANO_PR_PUBLIC_BASE_URL", "NANO_PR_BASE_URL"],
|
|
91
|
+
},
|
|
92
|
+
NANO_PR_POLL_MS: {
|
|
93
|
+
category: "env",
|
|
94
|
+
name: "NANO_PR_POLL_MS",
|
|
95
|
+
owner: "main.ts",
|
|
96
|
+
semantics: "Poller cadence in milliseconds for the self-scheduling reconciliation loop.",
|
|
97
|
+
default: "60000",
|
|
98
|
+
},
|
|
99
|
+
NANO_PR_MAX_ROUNDS: {
|
|
100
|
+
category: "env",
|
|
101
|
+
name: "NANO_PR_MAX_ROUNDS",
|
|
102
|
+
owner: "app/service.ts",
|
|
103
|
+
semantics: "Maximum review rounds before a PR escalates.",
|
|
104
|
+
default: "20",
|
|
105
|
+
},
|
|
106
|
+
NANO_PR_MAX_CI_FIX_ROUNDS: {
|
|
107
|
+
category: "env",
|
|
108
|
+
name: "NANO_PR_MAX_CI_FIX_ROUNDS",
|
|
109
|
+
owner: "app/service.ts",
|
|
110
|
+
semantics: "Maximum CI-fix attempts per PR.",
|
|
111
|
+
default: "3",
|
|
112
|
+
},
|
|
113
|
+
NANO_PR_MAX_REBASE_ROUNDS: {
|
|
114
|
+
category: "env",
|
|
115
|
+
name: "NANO_PR_MAX_REBASE_ROUNDS",
|
|
116
|
+
owner: "app/service.ts",
|
|
117
|
+
semantics: "Maximum rebase attempts per PR.",
|
|
118
|
+
default: "3",
|
|
119
|
+
},
|
|
120
|
+
NANO_PR_REVIEW_WAIT_TIMEOUT: {
|
|
121
|
+
category: "env",
|
|
122
|
+
name: "NANO_PR_REVIEW_WAIT_TIMEOUT",
|
|
123
|
+
owner: "app/service.ts",
|
|
124
|
+
semantics: "How long to wait for a review before nudging/escalating (FEEL/ISO-8601 duration).",
|
|
125
|
+
},
|
|
126
|
+
NANO_PR_REVIEW_NUDGE_MINUTES: {
|
|
127
|
+
category: "env",
|
|
128
|
+
name: "NANO_PR_REVIEW_NUDGE_MINUTES",
|
|
129
|
+
owner: "app/service.ts",
|
|
130
|
+
semantics: "Minutes between review nudges.",
|
|
131
|
+
},
|
|
132
|
+
NANO_PR_AUTO_MERGE: {
|
|
133
|
+
category: "env",
|
|
134
|
+
name: "NANO_PR_AUTO_MERGE",
|
|
135
|
+
owner: "app/service.ts",
|
|
136
|
+
semantics: "Whether the app auto-merges a converged PR (1/0).",
|
|
137
|
+
default: "1",
|
|
138
|
+
},
|
|
139
|
+
NANO_PR_MERGE_METHOD: {
|
|
140
|
+
category: "env",
|
|
141
|
+
name: "NANO_PR_MERGE_METHOD",
|
|
142
|
+
owner: "app/service.ts",
|
|
143
|
+
semantics: "Merge method for auto-merge (squash|merge|rebase).",
|
|
144
|
+
default: "squash",
|
|
145
|
+
},
|
|
146
|
+
NANO_PR_MERGE_ADMIN: {
|
|
147
|
+
category: "env",
|
|
148
|
+
name: "NANO_PR_MERGE_ADMIN",
|
|
149
|
+
owner: "app/service.ts",
|
|
150
|
+
semantics: "Whether to merge with admin override (1/0).",
|
|
151
|
+
default: "0",
|
|
152
|
+
},
|
|
153
|
+
NANO_PR_GITHUB_TRANSPORT: {
|
|
154
|
+
category: "env",
|
|
155
|
+
name: "NANO_PR_GITHUB_TRANSPORT",
|
|
156
|
+
owner: "app/github.ts",
|
|
157
|
+
semantics: "GitHub transport selector (auto|cli|rest).",
|
|
158
|
+
default: "auto",
|
|
159
|
+
},
|
|
160
|
+
NANO_PR_WEBHOOK_SECRET: {
|
|
161
|
+
category: "env",
|
|
162
|
+
name: "NANO_PR_WEBHOOK_SECRET",
|
|
163
|
+
owner: "operations/*.ts (agentic HTTP hooks)",
|
|
164
|
+
semantics:
|
|
165
|
+
"Shared secret authenticating the agentic supply HTTP-hook operations (getAgenticSupply / getAgentInstructions / listActivePrs / getVersion / agentCompleteEscalation / revertEscalationCompletion). Also the fallback secret for NANO_AGENTIC_SECRET.",
|
|
166
|
+
secret: true,
|
|
167
|
+
},
|
|
168
|
+
NANO_AGENTIC_SECRET: {
|
|
169
|
+
category: "env",
|
|
170
|
+
name: "NANO_AGENTIC_SECRET",
|
|
171
|
+
owner: "main.ts",
|
|
172
|
+
semantics:
|
|
173
|
+
"Secret authenticating the agentic supply endpoint; falls back to NANO_PR_WEBHOOK_SECRET when unset.",
|
|
174
|
+
secret: true,
|
|
175
|
+
},
|
|
176
|
+
NANO_AGENTIC: {
|
|
177
|
+
category: "env",
|
|
178
|
+
name: "NANO_AGENTIC",
|
|
179
|
+
owner: "main.ts",
|
|
180
|
+
semantics:
|
|
181
|
+
"Feature flag for the agentic supply endpoint; a value of 0/off/false/no disables it (enabled when unset).",
|
|
182
|
+
},
|
|
183
|
+
NANO_WORKFORCE_GIT_SHA: {
|
|
184
|
+
category: "env",
|
|
185
|
+
name: "NANO_WORKFORCE_GIT_SHA",
|
|
186
|
+
owner: "app/version.ts",
|
|
187
|
+
semantics:
|
|
188
|
+
"Explicit git SHA override for version reporting on deploys shipped without a .git directory; version derivation reads .git when unset.",
|
|
189
|
+
},
|
|
190
|
+
NANO_AUTO_RETRO: {
|
|
191
|
+
category: "env",
|
|
192
|
+
name: "NANO_AUTO_RETRO",
|
|
193
|
+
owner: "app/retro.ts",
|
|
194
|
+
semantics: "Opt-out toggle for the epic retrospective stage (0/false disables).",
|
|
195
|
+
default: "1",
|
|
196
|
+
},
|
|
197
|
+
NANO_ESCALATION_SLA_TIMEOUT: {
|
|
198
|
+
category: "env",
|
|
199
|
+
name: "NANO_ESCALATION_SLA_TIMEOUT",
|
|
200
|
+
owner: "app/plan.ts",
|
|
201
|
+
semantics: "SLA timeout for an escalation user task (FEEL/ISO-8601 duration).",
|
|
202
|
+
},
|
|
203
|
+
NANO_PR_AGENT_SLA_TIMEOUT: {
|
|
204
|
+
category: "env",
|
|
205
|
+
name: "NANO_PR_AGENT_SLA_TIMEOUT",
|
|
206
|
+
owner: "app/service.ts",
|
|
207
|
+
semantics:
|
|
208
|
+
"SLA timeout for an agent (service) task before its boundary timer fires and the PR escalates for human attention (ISO-8601 duration). A malformed value falls back to the default.",
|
|
209
|
+
default: "PT2H",
|
|
210
|
+
},
|
|
211
|
+
NANO_APP_DB_URL: {
|
|
212
|
+
category: "env",
|
|
213
|
+
name: "NANO_APP_DB_URL",
|
|
214
|
+
owner: "nano.app.json / DataLayer",
|
|
215
|
+
semantics: "Connection URL for the app SQLite DataLayer.",
|
|
216
|
+
},
|
|
217
|
+
NANOBPMN_BASE_URL: {
|
|
218
|
+
category: "env",
|
|
219
|
+
name: "NANOBPMN_BASE_URL",
|
|
220
|
+
owner: "app/agentGuide.ts",
|
|
221
|
+
semantics: "Base URL of the nanobpmn engine REST API (used to derive CAMUNDA_REST_ADDRESS).",
|
|
222
|
+
default: "http://localhost:8080",
|
|
223
|
+
},
|
|
224
|
+
PR_REVIEW_PORT: {
|
|
225
|
+
category: "env",
|
|
226
|
+
name: "PR_REVIEW_PORT",
|
|
227
|
+
owner: "main.ts",
|
|
228
|
+
semantics: "TCP port the app HTTP server binds.",
|
|
229
|
+
default: "3000",
|
|
230
|
+
},
|
|
231
|
+
GITHUB_TOKEN: {
|
|
232
|
+
category: "env",
|
|
233
|
+
name: "GITHUB_TOKEN",
|
|
234
|
+
owner: "app/github.ts",
|
|
235
|
+
semantics: "GitHub API credential the app uses for all GitHub calls.",
|
|
236
|
+
secret: true,
|
|
237
|
+
},
|
|
238
|
+
CAMUNDA_REST_ADDRESS: {
|
|
239
|
+
category: "env",
|
|
240
|
+
name: "CAMUNDA_REST_ADDRESS",
|
|
241
|
+
owner: "app/agentGuide.ts",
|
|
242
|
+
semantics: "Explicit REST address of the engine (overrides NANOBPMN_BASE_URL derivation).",
|
|
243
|
+
},
|
|
244
|
+
CAMUNDA_TOKEN: {
|
|
245
|
+
category: "env",
|
|
246
|
+
name: "CAMUNDA_TOKEN",
|
|
247
|
+
owner: "app/agentGuide.ts",
|
|
248
|
+
semantics: "Bearer token for the engine REST API.",
|
|
249
|
+
secret: true,
|
|
250
|
+
},
|
|
251
|
+
CAMUNDA_TRANSPORT: {
|
|
252
|
+
category: "env",
|
|
253
|
+
name: "CAMUNDA_TRANSPORT",
|
|
254
|
+
owner: "app/agentGuide.ts",
|
|
255
|
+
semantics: "Engine transport selector.",
|
|
256
|
+
},
|
|
257
|
+
} as const satisfies Record<string, EnvContract>;
|
|
258
|
+
|
|
259
|
+
/** The set of declared config-key names — the single typed vocabulary of env keys. */
|
|
260
|
+
export type EnvKey = keyof typeof ENV_CONTRACTS;
|
|
261
|
+
|
|
262
|
+
/** Every declared env contract, widened to {@link EnvContract} (assignment-widening — no `as`), so
|
|
263
|
+
* callers can read the optional `default`/`rejectedSynonyms`/`secret` fields on any entry. */
|
|
264
|
+
export function envContracts(): EnvContract[] {
|
|
265
|
+
const list: EnvContract[] = Object.values(ENV_CONTRACTS);
|
|
266
|
+
return list;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The declared entry for a key, widened to {@link EnvContract} so callers can read `default`/
|
|
270
|
+
* `rejectedSynonyms` (the `as const satisfies` above keeps each entry's narrow literal type, on
|
|
271
|
+
* which those optional fields don't exist for every member). Assignment-widening — no `as` cast. */
|
|
272
|
+
export function envContract(key: EnvKey): EnvContract {
|
|
273
|
+
const entry: EnvContract = ENV_CONTRACTS[key];
|
|
274
|
+
return entry;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Read a declared env key through the one schema. `key` is a compile-time-checked {@link EnvKey},
|
|
278
|
+
* so a typo or a synonymous key (e.g. the retired `NANO_PR_BASE_URL`) is a TYPE error here — it can
|
|
279
|
+
* never resolve to a silent runtime fallback. Returns the trimmed value, or `undefined` when unset
|
|
280
|
+
* or blank/whitespace (so an explicitly-empty key never yields a malformed value). */
|
|
281
|
+
export function readEnv(
|
|
282
|
+
key: EnvKey,
|
|
283
|
+
env: Record<string, string | undefined> = process.env,
|
|
284
|
+
): string | undefined {
|
|
285
|
+
const trimmed = env[key]?.trim();
|
|
286
|
+
return trimmed ? trimmed : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Read a declared env key, falling back to its registered `default` (then to `fallback`) when
|
|
290
|
+
* unset/blank. Keeps the default in ONE place — the registry entry — not scattered at call sites. */
|
|
291
|
+
export function readEnvOr(
|
|
292
|
+
key: EnvKey,
|
|
293
|
+
fallback = "",
|
|
294
|
+
env: Record<string, string | undefined> = process.env,
|
|
295
|
+
): string {
|
|
296
|
+
return readEnv(key, env) ?? envContract(key).default ?? fallback;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------------------------
|
|
300
|
+
// The non-env contracts. These are the shared shapes/types/schemes that parallel slices must
|
|
301
|
+
// converge on. Kept alongside the env schema so ONE registry answers "does a contract for X
|
|
302
|
+
// already exist?" for every category.
|
|
303
|
+
// ---------------------------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
export const WIRE_CONTRACTS = {
|
|
306
|
+
"relay.produce": {
|
|
307
|
+
category: "wire",
|
|
308
|
+
name: "relay.produce",
|
|
309
|
+
owner: "@nanobpm/agentic/relay",
|
|
310
|
+
semantics:
|
|
311
|
+
"Op-tagged relay control frame a worker terminal chunk producer emits and the hub consumes. The op-tagged shape superseded the legacy positional `{stream, offset, chunk}` frame (nano-ide #234/#236); a producer must emit the op-tagged shape or the hub rejects it as `malformed relay message payload`.",
|
|
312
|
+
shape: '{ op: "produce", incarnation: number, stream: string, offset: number, chunk: string }',
|
|
313
|
+
},
|
|
314
|
+
} as const satisfies Record<string, WireContract>;
|
|
315
|
+
|
|
316
|
+
export const TYPE_CONTRACTS = {
|
|
317
|
+
BlackboardEntry: {
|
|
318
|
+
category: "type",
|
|
319
|
+
name: "BlackboardEntry",
|
|
320
|
+
owner: "app/blackboard.ts",
|
|
321
|
+
semantics:
|
|
322
|
+
"The snake_case, agent-facing view of a blackboard entry — the HTTP-hook boundary shape every caller and agent consumes. Both the read and write halves import this ONE definition.",
|
|
323
|
+
module: "app/blackboard.ts",
|
|
324
|
+
},
|
|
325
|
+
} as const satisfies Record<string, TypeContract>;
|
|
326
|
+
|
|
327
|
+
export const CAPABILITY_URL_CONTRACTS = {
|
|
328
|
+
blackboard: {
|
|
329
|
+
category: "capability-url",
|
|
330
|
+
name: "blackboard",
|
|
331
|
+
owner: "app/blackboard.ts",
|
|
332
|
+
semantics:
|
|
333
|
+
"Per-plan blackboard side-channel. The per-plan token IS the credential; it rides the query string so the agent GET/POSTs the exact string it was handed with no header assembly. The base is `NANO_WORKFORCE_BASE_URL` (one env contract), never hardcoded.",
|
|
334
|
+
scheme: "<NANO_WORKFORCE_BASE_URL>/app/api/hooks/blackboard?token=<token>",
|
|
335
|
+
},
|
|
336
|
+
} as const satisfies Record<string, CapabilityUrlContract>;
|
|
337
|
+
|
|
338
|
+
/** Every contract, across all categories — the flat list the reconciliation pass and CI check walk. */
|
|
339
|
+
export function allContracts(): Contract[] {
|
|
340
|
+
return [
|
|
341
|
+
...Object.values(ENV_CONTRACTS),
|
|
342
|
+
...Object.values(WIRE_CONTRACTS),
|
|
343
|
+
...Object.values(TYPE_CONTRACTS),
|
|
344
|
+
...Object.values(CAPABILITY_URL_CONTRACTS),
|
|
345
|
+
];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** All names deliberately retired as synonyms of a live env contract, mapped to the canonical name
|
|
349
|
+
* that replaced them. A read of any synonym is a CI failure — the #223 phantom-fallback failure
|
|
350
|
+
* mode, guarded categorically. */
|
|
351
|
+
export function rejectedEnvSynonyms(): Map<string, string> {
|
|
352
|
+
const out = new Map<string, string>();
|
|
353
|
+
for (const c of envContracts()) {
|
|
354
|
+
for (const syn of c.rejectedSynonyms ?? []) out.set(syn, c.name);
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------------------------
|
|
360
|
+
// Near-duplicate DECLARATION detection — the write-time / declare-time guard. Given a proposed new
|
|
361
|
+
// contract, is there an EXISTING one that is the same thing under a different name (a synonym), or a
|
|
362
|
+
// contradicting one (same name, different semantics/owner)? Surfaced to the writer so a duplicate is
|
|
363
|
+
// caught at authoring time, not at runtime.
|
|
364
|
+
// ---------------------------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
/** A near-duplicate finding between a proposed declaration and an existing registry contract. */
|
|
367
|
+
export interface DeclarationConflict {
|
|
368
|
+
/** `synonym`: same category + equivalent semantics under a different name (two names, one thing).
|
|
369
|
+
* `contradiction`: same name but different semantics/owner (one name, two meanings).
|
|
370
|
+
* `rejected-synonym`: the proposed name is a retired synonym of a live env contract. */
|
|
371
|
+
readonly kind: "synonym" | "contradiction" | "rejected-synonym";
|
|
372
|
+
readonly proposedName: string;
|
|
373
|
+
readonly existingName: string;
|
|
374
|
+
readonly detail: string;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Normalise free-text semantics to a comparable token bag: lowercased, punctuation stripped,
|
|
378
|
+
* short stopwords dropped. Deliberately crude — it only needs to catch "two names for one value". */
|
|
379
|
+
function semanticTokens(text: string): Set<string> {
|
|
380
|
+
const STOP = new Set([
|
|
381
|
+
"the", "a", "an", "of", "to", "for", "and", "or", "is", "it", "this", "that", "with",
|
|
382
|
+
"on", "in", "at", "by", "as", "per", "its", "so", "one", "value", "used", "use",
|
|
383
|
+
]);
|
|
384
|
+
return new Set(
|
|
385
|
+
text
|
|
386
|
+
.toLowerCase()
|
|
387
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
388
|
+
.split(" ")
|
|
389
|
+
.filter((w) => w.length > 2 && !STOP.has(w)),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Jaccard overlap of two token bags (0..1). */
|
|
394
|
+
function overlap(a: Set<string>, b: Set<string>): number {
|
|
395
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
396
|
+
let inter = 0;
|
|
397
|
+
for (const w of a) if (b.has(w)) inter++;
|
|
398
|
+
return inter / (a.size + b.size - inter);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The semantics-overlap threshold above which two DIFFERENTLY-named contracts of the same category
|
|
402
|
+
* are flagged as probable synonyms. Tuned to be advisory (surface for a human/agent decision), not a
|
|
403
|
+
* hard gate. */
|
|
404
|
+
export const SYNONYM_THRESHOLD = 0.6;
|
|
405
|
+
|
|
406
|
+
/** Detect near-duplicate declarations of `proposed` against `existing` (defaults to the registry).
|
|
407
|
+
* Returns every conflict found so a writer sees synonyms AND contradictions AND rejected synonyms. */
|
|
408
|
+
export function detectDeclarationConflicts(
|
|
409
|
+
proposed: { category: ContractCategory; name: string; semantics: string },
|
|
410
|
+
existing: Contract[] = allContracts(),
|
|
411
|
+
): DeclarationConflict[] {
|
|
412
|
+
const out: DeclarationConflict[] = [];
|
|
413
|
+
const rejected = rejectedEnvSynonyms();
|
|
414
|
+
if (proposed.category === "env" && rejected.has(proposed.name)) {
|
|
415
|
+
out.push({
|
|
416
|
+
kind: "rejected-synonym",
|
|
417
|
+
proposedName: proposed.name,
|
|
418
|
+
existingName: rejected.get(proposed.name) ?? "",
|
|
419
|
+
detail: `'${proposed.name}' is a retired synonym of '${rejected.get(proposed.name)}'; reuse the canonical key, do not reintroduce the fallback.`,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const proposedTokens = semanticTokens(proposed.semantics);
|
|
423
|
+
for (const c of existing) {
|
|
424
|
+
if (c.name === proposed.name) {
|
|
425
|
+
if (c.category === proposed.category && overlap(proposedTokens, semanticTokens(c.semantics)) < SYNONYM_THRESHOLD) {
|
|
426
|
+
out.push({
|
|
427
|
+
kind: "contradiction",
|
|
428
|
+
proposedName: proposed.name,
|
|
429
|
+
existingName: c.name,
|
|
430
|
+
detail: `'${proposed.name}' already exists (owner ${c.owner}) with different semantics; reconcile before redeclaring.`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (c.category !== proposed.category) continue;
|
|
436
|
+
if (overlap(proposedTokens, semanticTokens(c.semantics)) >= SYNONYM_THRESHOLD) {
|
|
437
|
+
out.push({
|
|
438
|
+
kind: "synonym",
|
|
439
|
+
proposedName: proposed.name,
|
|
440
|
+
existingName: c.name,
|
|
441
|
+
detail: `'${proposed.name}' looks semantically equivalent to existing '${c.name}' (owner ${c.owner}); reuse it instead of authoring a synonym.`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import { assert, assertEquals } from "#test-assert";
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
|
-
import { TERMINAL_STATUSES } from "./service.ts";
|
|
10
|
+
import { PR_ACTIVE_STATUSES, PLAN_ACTIVE_STATUSES, FEATURE_ACTIVE_STATUSES, TERMINAL_STATUSES } from "./service.ts";
|
|
11
11
|
import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
|
|
12
|
+
import { FEATURE_TERMINAL_STATUSES } from "./feature.ts";
|
|
12
13
|
|
|
13
14
|
interface Binding {
|
|
14
15
|
table: string;
|
|
@@ -73,3 +74,45 @@ test("instanceTracking: plans activeStatuses covers every in-flight status", asy
|
|
|
73
74
|
const b = bindingFor(await bindings(), "plans");
|
|
74
75
|
assertEquals([...(b.activeStatuses ?? [])].sort(), [...inFlight].sort());
|
|
75
76
|
});
|
|
77
|
+
|
|
78
|
+
// The app-side `pollUserTasks` scan constant is DERIVED from the manifest at load time (no hand-kept
|
|
79
|
+
// duplicate), so it must match the manifest binding exactly — this closes the drift surface Copilot
|
|
80
|
+
// flagged (a second hard-coded list that could silently diverge from the reconciler's activeStatuses).
|
|
81
|
+
test("PR_ACTIVE_STATUSES is derived from the manifest binding (no drift)", async () => {
|
|
82
|
+
const b = bindingFor(await bindings(), "pull_requests");
|
|
83
|
+
assertEquals([...PR_ACTIVE_STATUSES].sort(), [...(b.activeStatuses ?? [])].sort());
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Same guard for the plan scan: `pollUserTasks` no longer hard-codes ["planning","dispatched"] but
|
|
87
|
+
// derives PLAN_ACTIVE_STATUSES from the manifest, so the plan-escalation scan can't drift from the
|
|
88
|
+
// reconciler's activeStatuses any more than the PR scan can.
|
|
89
|
+
test("PLAN_ACTIVE_STATUSES is derived from the manifest binding (no drift)", async () => {
|
|
90
|
+
const b = bindingFor(await bindings(), "plans");
|
|
91
|
+
assertEquals([...PLAN_ACTIVE_STATUSES].sort(), [...(b.activeStatuses ?? [])].sort());
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("instanceTracking: feature_runs activeStatuses excludes every terminal status", async () => {
|
|
95
|
+
const b = bindingFor(await bindings(), "feature_runs");
|
|
96
|
+
for (const terminal of FEATURE_TERMINAL_STATUSES) {
|
|
97
|
+
assert(!b.activeStatuses?.includes(terminal), `terminal status "${terminal}" must not be active`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Every instance-alive feature status must be reconcilable: a run parked at `feature-blocked`
|
|
102
|
+
// (awaiting_operator) or `feature-escalation` (escalated) — or still running — keeps a live engine
|
|
103
|
+
// instance, so onTerminated must be able to flip it to `abandoned` if that instance terminates.
|
|
104
|
+
// Notably includes `awaiting_operator`: without it a run that terminates while blocked strands at
|
|
105
|
+
// `awaiting_operator` forever and blocks re-dispatch (the drift Copilot flagged on #238).
|
|
106
|
+
test("instanceTracking: feature_runs activeStatuses covers every in-flight status", async () => {
|
|
107
|
+
const inFlight = ["running", "escalated", "awaiting_operator"];
|
|
108
|
+
const b = bindingFor(await bindings(), "feature_runs");
|
|
109
|
+
assertEquals([...(b.activeStatuses ?? [])].sort(), [...inFlight].sort());
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Same no-drift guard for the feature scan: `pollUserTasks` no longer hard-codes
|
|
113
|
+
// ["running","escalated","awaiting_operator"] but derives FEATURE_ACTIVE_STATUSES from the manifest,
|
|
114
|
+
// so the feature-escalation scan can't drift from the reconciler's activeStatuses.
|
|
115
|
+
test("FEATURE_ACTIVE_STATUSES is derived from the manifest binding (no drift)", async () => {
|
|
116
|
+
const b = bindingFor(await bindings(), "feature_runs");
|
|
117
|
+
assertEquals([...FEATURE_ACTIVE_STATUSES].sort(), [...(b.activeStatuses ?? [])].sort());
|
|
118
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Integration test for `pollUserTasks` (issue #236) — the reconcile that projects the engine's
|
|
2
|
+
// currently-open native user-task escalations onto the unified `user_tasks` read-model the Tasks page
|
|
3
|
+
// reads. It generalises the two feature pollers across every subject: feature runs (denormalised
|
|
4
|
+
// keys), in-flight plans (`plan-review-decision` / `trial-merge-decision`), and in-flight PRs
|
|
5
|
+
// (`wait-answer`). A completed task's row is removed on the next pass so `showCount` tracks live work.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
8
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
9
|
+
import { pollUserTasks } from "./service.ts";
|
|
10
|
+
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: in-memory table double, mirrors featureEscalation.test.ts
|
|
12
|
+
function memData(seed: Record<string, any[]> = {}): { data: DataLayer; stores: Record<string, any[]> } {
|
|
13
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
14
|
+
const stores: Record<string, any[]> = {};
|
|
15
|
+
for (const [k, v] of Object.entries(seed)) stores[k] = v.map((r) => ({ ...r }));
|
|
16
|
+
function tbl(name: string, pk = "id") {
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
18
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
19
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
20
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
21
|
+
return {
|
|
22
|
+
async all() {
|
|
23
|
+
return rows.slice();
|
|
24
|
+
},
|
|
25
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
26
|
+
async get(id: any) {
|
|
27
|
+
return rows.find((r) => r[pk] === id);
|
|
28
|
+
},
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
30
|
+
async find(where: any = {}) {
|
|
31
|
+
return rows.filter((r) => match(r, where));
|
|
32
|
+
},
|
|
33
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
34
|
+
async insert(r: any) {
|
|
35
|
+
rows.push({ ...r });
|
|
36
|
+
return r[pk];
|
|
37
|
+
},
|
|
38
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
39
|
+
async update(id: any, patch: any) {
|
|
40
|
+
const r = rows.find((row) => row[pk] === id);
|
|
41
|
+
if (r) Object.assign(r, patch);
|
|
42
|
+
},
|
|
43
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
44
|
+
async delete(id: any) {
|
|
45
|
+
const i = rows.findIndex((r) => r[pk] === id);
|
|
46
|
+
if (i >= 0) rows.splice(i, 1);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as unknown as DataLayer;
|
|
51
|
+
return { data, stores };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A fake engine whose open user tasks are keyed by processInstanceKey (the only field the poller
|
|
55
|
+
* queries on for plan / PR instances). */
|
|
56
|
+
function fakeEngine(byInstance: Record<string, { userTaskKey: string; elementId?: string }[]>): EngineClient {
|
|
57
|
+
return {
|
|
58
|
+
searchUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
59
|
+
Promise.resolve(filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : []),
|
|
60
|
+
} as unknown as EngineClient;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait escalations into user_tasks", async () => {
|
|
64
|
+
const { data, stores } = memData({
|
|
65
|
+
feature_runs: [
|
|
66
|
+
{
|
|
67
|
+
feature_key: "o/r#10",
|
|
68
|
+
status: "escalated",
|
|
69
|
+
process_key: "fp-10",
|
|
70
|
+
issue_url: "https://github.com/o/r/issues/10",
|
|
71
|
+
escalation_user_task_key: "ut-feat",
|
|
72
|
+
escalation_question: "which framework?",
|
|
73
|
+
blocked_user_task_key: null,
|
|
74
|
+
delivery_label: null,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
plans: [
|
|
78
|
+
{ plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20" },
|
|
79
|
+
{ plan_key: "o/r#21", status: "done", process_key: "pp-21", issue_url: "https://github.com/o/r/issues/21" },
|
|
80
|
+
],
|
|
81
|
+
plan_reviews: [
|
|
82
|
+
{ plan_key: "o/r#20", epoch: 0, round: 0, approved: 0, findings: "scope was fine", created_at: "2025-01-01T00:00:00.000Z" },
|
|
83
|
+
{ plan_key: "o/r#20", epoch: 0, round: 1, approved: 0, findings: "scope too broad", created_at: "2025-01-02T00:00:00.000Z" },
|
|
84
|
+
],
|
|
85
|
+
plan_trial_merges: [
|
|
86
|
+
{ id: 1, plan_key: "o/r#20", wave: 0, result: "suite-failed", summary: "wave 0 red", resolved: 0 },
|
|
87
|
+
],
|
|
88
|
+
pull_requests: [
|
|
89
|
+
{ pr_key: "o/r#30", status: "escalated", process_key: "rp-30", url: "https://github.com/o/r/pull/30" },
|
|
90
|
+
],
|
|
91
|
+
escalations: [{ id: 1, pr_key: "o/r#30", status: "open", question: "conflicting reviews" }],
|
|
92
|
+
});
|
|
93
|
+
const engine = fakeEngine({
|
|
94
|
+
"pp-20": [
|
|
95
|
+
{ userTaskKey: "ut-plan", elementId: "plan-review-decision" },
|
|
96
|
+
{ userTaskKey: "ut-trial", elementId: "trial-merge-decision" },
|
|
97
|
+
],
|
|
98
|
+
"pp-21": [{ userTaskKey: "ut-terminal", elementId: "plan-review-decision" }],
|
|
99
|
+
"rp-30": [{ userTaskKey: "ut-pr", elementId: "wait-answer" }],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await pollUserTasks(data, engine);
|
|
103
|
+
|
|
104
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
105
|
+
assertEquals(Object.keys(byKey).sort(), ["ut-feat", "ut-plan", "ut-pr", "ut-trial"]);
|
|
106
|
+
assertEquals(byKey["ut-feat"].kind_label, "Feature escalation");
|
|
107
|
+
assertEquals(byKey["ut-feat"].question, "which framework?");
|
|
108
|
+
assertEquals(byKey["ut-plan"].kind_label, "Plan review");
|
|
109
|
+
assertEquals(byKey["ut-plan"].question, "scope too broad");
|
|
110
|
+
assertEquals(byKey["ut-trial"].kind_label, "Trial merge");
|
|
111
|
+
assertEquals(byKey["ut-trial"].question, "wave 0 red");
|
|
112
|
+
assertEquals(byKey["ut-pr"].kind_label, "PR review");
|
|
113
|
+
assertEquals(byKey["ut-pr"].subject_type, "pr");
|
|
114
|
+
assertEquals(byKey["ut-pr"].question, "conflicting reviews");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("pollUserTasks: removes a row once its task is no longer open (completed / out-of-band)", async () => {
|
|
118
|
+
const { data, stores } = memData({
|
|
119
|
+
user_tasks: [
|
|
120
|
+
{
|
|
121
|
+
user_task_key: "ut-old",
|
|
122
|
+
element_id: "wait-answer",
|
|
123
|
+
kind_label: "PR review",
|
|
124
|
+
subject_type: "pr",
|
|
125
|
+
subject_key: "o/r#30",
|
|
126
|
+
subject_url: null,
|
|
127
|
+
question: null,
|
|
128
|
+
process_key: "rp-30",
|
|
129
|
+
created_at: "2025-01-01T00:00:00.000Z",
|
|
130
|
+
updated_at: "2025-01-01T00:00:00.000Z",
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
pull_requests: [{ pr_key: "o/r#30", status: "converging", process_key: "rp-30", url: "https://github.com/o/r/pull/30" }],
|
|
134
|
+
});
|
|
135
|
+
const engine = fakeEngine({ "rp-30": [] });
|
|
136
|
+
|
|
137
|
+
await pollUserTasks(data, engine);
|
|
138
|
+
|
|
139
|
+
assertEquals(stores.user_tasks, []);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("pollUserTasks: skips terminal plans and PRs without a process key", async () => {
|
|
143
|
+
const { data, stores } = memData({
|
|
144
|
+
plans: [{ plan_key: "o/r#40", status: "planning", process_key: null, issue_url: "https://github.com/o/r/issues/40" }],
|
|
145
|
+
});
|
|
146
|
+
const engine = fakeEngine({});
|
|
147
|
+
|
|
148
|
+
await pollUserTasks(data, engine);
|
|
149
|
+
|
|
150
|
+
assertEquals(stores.user_tasks ?? [], []);
|
|
151
|
+
});
|