@nanobpm/nano-workforce 0.133.0 → 0.134.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/CHANGELOG.md +12 -0
- package/app/deliveryGraphDispatch.ts +12 -2
- package/app/deliveryRunner.test.ts +87 -0
- package/app/deliveryRunner.ts +11 -7
- package/app/featureReadModel.test.ts +62 -17
- package/app/featureReadModel.ts +28 -9
- package/app/interEpicRegression.test.ts +3 -2
- package/app/plan.test.ts +5 -4
- package/app/plan.ts +26 -3
- package/app/planLowering.test.ts +2 -2
- package/app/plansReadModel.test.ts +38 -1
- package/app/reviewWait.ts +8 -0
- package/app/service.ts +41 -16
- package/app/stage.ts +8 -3
- package/app/terminalReaderBehaviour.test.ts +289 -0
- package/app/terminalReaderGuard.test.ts +106 -0
- package/db/migrations/080_plan_read_model_derive_terminal.sql +75 -0
- package/db/migrations/081_feature_read_model_derive_terminal.sql +65 -0
- package/openapi.yaml +39 -0
- package/operations/dispatchDeliveryGraph.test.ts +65 -0
- package/operations/dispatchDeliveryGraph.ts +52 -1
- package/operations/listActivePrs.test.ts +2 -1
- package/operations/startEpicSet.admission.integration.test.ts +3 -2
- package/operations/startPlanFanout.admission.integration.test.ts +2 -1
- package/package.json +1 -1
- package/test/trackingViews.ts +11 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- Feature-run read model: fold the ADR-0065 DERIVED terminal edge into the projection (issue #503 —
|
|
2
|
+
-- the feature_runs row of the "migrate remaining terminal-edge readers to derived_status" class).
|
|
3
|
+
--
|
|
4
|
+
-- Since ADR-0065 (`@nanobpm/urban@0.81.0`) the `instanceTracking` reconciler is a SOURCE, not a writer:
|
|
5
|
+
-- on cancel/terminate it feeds urban's instance projection and the terminal edge (`onTerminated →
|
|
6
|
+
-- abandoned`) is RECOMPUTED ON READ as `feature_runs__tracking.derived_status` — it NO LONGER writes
|
|
7
|
+
-- `abandoned` onto the base `feature_runs.status` column. `feature_runs` had NO derived reader, so a
|
|
8
|
+
-- terminated feature run's base row stayed frozen at `running`/`escalated`/`awaiting_operator` and
|
|
9
|
+
-- 076's `feature_read_model` rendered it "Implementing" (never "failed") on the Feature history grid
|
|
10
|
+
-- forever.
|
|
11
|
+
--
|
|
12
|
+
-- 076_feature_read_model_declare_once.sql authored the projection ONCE (`defineReadModel`,
|
|
13
|
+
-- app/featureReadModel.ts) and emitted each derived column VERBATIM from that declaration; it read the
|
|
14
|
+
-- base `feature_runs.status`. This migration SUPERSEDES 076's VIEW body: the declaration's `baseTable`
|
|
15
|
+
-- is now the auto-provisioned `feature_runs__tracking` derived VIEW (which re-exports `feature_runs.*`
|
|
16
|
+
-- plus a terminal-folded `derived_status`), and every status-classifying derived column below reads
|
|
17
|
+
-- `fr."derived_status"` instead of `fr."status"`. So a cancelled/terminated run renders `Done`/`failed`
|
|
18
|
+
-- with no worker write. 076 is a MERGED, IMMUTABLE migration — never edited; this is a NEW migration
|
|
19
|
+
-- superseding its VIEW body (the same pattern by which 076 superseded 073/075).
|
|
20
|
+
--
|
|
21
|
+
-- Every DERIVED column body is emitted VERBATIM from the ONE declaration
|
|
22
|
+
-- (`featureReadModel.sqlSelectFor(col, { baseAlias: "fr" })`), which ALSO drives the runtime TS via
|
|
23
|
+
-- `fnFor` — the two lowerings fall out of the same closed-DSL AST and cannot diverge. The drift guard
|
|
24
|
+
-- (app/featureReadModel.test.ts) fails if this file stops matching the declaration, and
|
|
25
|
+
-- `assertReadModelParity` proves the SQL and TS lowerings agree. SEMANTICS are unchanged from 076 EXCEPT
|
|
26
|
+
-- the status source (base transient → terminal-folded `derived_status`): `attention` still derives from
|
|
27
|
+
-- ENGINE TRUTH (an OPEN `user_tasks` row, issue #422); `stage_skipped` is still a pure function of
|
|
28
|
+
-- `converge`/`auto_merge`.
|
|
29
|
+
--
|
|
30
|
+
-- `feature_runs__tracking` is the managed VIEW urban provisions at mount (`<table>__tracking`); SQLite
|
|
31
|
+
-- does not validate a view body at CREATE time, so this migration (which runs before the runtime mount
|
|
32
|
+
-- that provisions the managed VIEW) is created fine and resolves once the managed VIEW exists. Base
|
|
33
|
+
-- columns stay aliased pass-throughs (so the static pages↔schema contract guard still sees the VIEW
|
|
34
|
+
-- columns), now sourced off `feature_runs__tracking`'s re-export of `base.*`; `feature_runs__tracking fr`
|
|
35
|
+
-- is the sole top-level FROM (the user_tasks lookups are nested EXISTS subqueries at paren depth >= 1).
|
|
36
|
+
--
|
|
37
|
+
-- Forward-only VIEW redefinition (DROP then CREATE). The runner wraps each file in its own transaction,
|
|
38
|
+
-- so this file must NOT contain BEGIN/COMMIT. Numbered after 079.
|
|
39
|
+
|
|
40
|
+
DROP VIEW IF EXISTS feature_read_model;
|
|
41
|
+
|
|
42
|
+
CREATE VIEW feature_read_model AS
|
|
43
|
+
SELECT
|
|
44
|
+
fr.feature_key AS feature_key,
|
|
45
|
+
fr.repo AS repo,
|
|
46
|
+
fr.issue_number AS issue_number,
|
|
47
|
+
fr.issue_url AS issue_url,
|
|
48
|
+
fr.title AS title,
|
|
49
|
+
fr.base_branch AS base_branch,
|
|
50
|
+
fr.status AS status,
|
|
51
|
+
fr.process_key AS process_key,
|
|
52
|
+
fr.pr_key AS pr_key,
|
|
53
|
+
fr.converge AS converge,
|
|
54
|
+
fr.auto_merge AS auto_merge,
|
|
55
|
+
fr.outcome AS outcome,
|
|
56
|
+
fr.delivery_label AS delivery_label,
|
|
57
|
+
fr.acknowledged_at AS acknowledged_at,
|
|
58
|
+
fr.created_at AS created_at,
|
|
59
|
+
fr.updated_at AS updated_at,
|
|
60
|
+
CASE WHEN COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("fr"."derived_status" = 'converging'), 0) THEN 'Converging' WHEN COALESCE((COALESCE(("fr"."pr_key" <> ''), 0) OR COALESCE(("fr"."derived_status" = 'opened'), 0)), 0) THEN 'PR open' WHEN COALESCE((COALESCE(("fr"."derived_status" = 'running'), 0) OR COALESCE(("fr"."derived_status" = 'escalated'), 0) OR COALESCE(("fr"."derived_status" = 'awaiting_operator'), 0)), 0) THEN 'Implementing' ELSE 'Requested' END AS stage,
|
|
61
|
+
CASE WHEN COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0)), 0) THEN 'ok' WHEN COALESCE(("fr"."derived_status" = 'blocked'), 0) THEN 'blocked' WHEN COALESCE((COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
|
|
62
|
+
CASE WHEN (NOT COALESCE("fr"."converge", 0)) THEN 'Converging Merging' WHEN (NOT COALESCE("fr"."auto_merge", 0)) THEN 'Merging' ELSE '' END AS stage_skipped,
|
|
63
|
+
CASE WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-blocked'), 0)), 0)) THEN 'blocked' WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-escalation'), 0)), 0)) THEN '⚠' ELSE NULL END AS attention,
|
|
64
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) AND COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket
|
|
65
|
+
FROM feature_runs__tracking fr;
|
package/openapi.yaml
CHANGED
|
@@ -1407,6 +1407,15 @@ components:
|
|
|
1407
1407
|
type: string
|
|
1408
1408
|
maxLength: 20000
|
|
1409
1409
|
description: OPTIONAL steering prompt appended to the node's job brief.
|
|
1410
|
+
timeout:
|
|
1411
|
+
type: string
|
|
1412
|
+
pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
|
|
1413
|
+
maxLength: 64
|
|
1414
|
+
description: >-
|
|
1415
|
+
OPTIONAL per-node ISO-8601 SLA timeout (#505). Overrides the run-level `nodeTimeout`
|
|
1416
|
+
(and the `PT1H` default) for THIS node's bounded-timeout → escalate boundary timer, so
|
|
1417
|
+
a legitimately-long node (e.g. a full `senior:feature` implementation) can outlast a
|
|
1418
|
+
quick gate without a spurious escalation. Absent → the run/default value.
|
|
1410
1419
|
DeliveryNodeWait:
|
|
1411
1420
|
description: >-
|
|
1412
1421
|
A `wait` node — a durable `ReadinessProbe` (ADR 0001 §2) watching an external fact. Reuses the
|
|
@@ -1496,6 +1505,14 @@ components:
|
|
|
1496
1505
|
type: object
|
|
1497
1506
|
additionalProperties: true
|
|
1498
1507
|
description: Minimal forward-declared payload stub — the concrete connector payload schema is deferred (ADR non-goal).
|
|
1508
|
+
timeout:
|
|
1509
|
+
type: string
|
|
1510
|
+
pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
|
|
1511
|
+
maxLength: 64
|
|
1512
|
+
description: >-
|
|
1513
|
+
OPTIONAL per-node ISO-8601 SLA timeout (#505). Overrides the run-level `nodeTimeout`
|
|
1514
|
+
(and the `PT1H` default) for THIS connector node's bounded-timeout → escalate boundary
|
|
1515
|
+
timer. Absent → the run/default value.
|
|
1499
1516
|
DeliveryEdge:
|
|
1500
1517
|
description: >-
|
|
1501
1518
|
A dependency edge — "`to` proceeds once fact `from` is observable" (ADR 0005 Decision 3).
|
|
@@ -1596,6 +1613,28 @@ components:
|
|
|
1596
1613
|
type: string
|
|
1597
1614
|
maxLength: 255
|
|
1598
1615
|
description: OPTIONAL idempotency key. A re-dispatch with the same key (or, when omitted, the same digest) does not double-launch. Blank/whitespace is treated as absent.
|
|
1616
|
+
nodeTimeout:
|
|
1617
|
+
type: string
|
|
1618
|
+
pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
|
|
1619
|
+
maxLength: 64
|
|
1620
|
+
description: >-
|
|
1621
|
+
OPTIONAL run-level ISO-8601 SLA timeout for `agent`/`connector` nodes (#505) — the
|
|
1622
|
+
bounded-timeout → escalate boundary bound every such node inherits unless it declares its own
|
|
1623
|
+
per-node `timeout`. Absent → the `PT1H` default. An invalid duration is rejected at submit.
|
|
1624
|
+
probeTimeout:
|
|
1625
|
+
type: string
|
|
1626
|
+
pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
|
|
1627
|
+
maxLength: 64
|
|
1628
|
+
description: >-
|
|
1629
|
+
OPTIONAL run-level ISO-8601 poll budget for `wait` gates (#505) before they escalate. Absent →
|
|
1630
|
+
the `PT30M` default. An invalid duration is rejected at submit.
|
|
1631
|
+
escalationSlaTimeout:
|
|
1632
|
+
type: string
|
|
1633
|
+
pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
|
|
1634
|
+
maxLength: 64
|
|
1635
|
+
description: >-
|
|
1636
|
+
OPTIONAL run-level ISO-8601 SLA for `human` nodes (#505) before they record an `escalated`
|
|
1637
|
+
outcome. Absent → the `P1D` default. An invalid duration is rejected at submit.
|
|
1599
1638
|
DeliveryGraphProposalBpmnRequest:
|
|
1600
1639
|
description: >-
|
|
1601
1640
|
Request the compiled BPMN of a staged delivery-graph proposal for read-only DI PREVIEW. Carries
|
|
@@ -182,4 +182,69 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
|
|
|
182
182
|
assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "expired");
|
|
183
183
|
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
test("an invalid run-level nodeTimeout duration is rejected at submit → 400, nothing launched (#505)", async () => {
|
|
187
|
+
const app = await boot();
|
|
188
|
+
assert.ok(app.api);
|
|
189
|
+
const api = app.api;
|
|
190
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
191
|
+
const res = await api.call<{ ok?: boolean; error?: string; issues?: Array<{ path: string }> }>("dispatchDeliveryGraph", {
|
|
192
|
+
body: { digest: staged.body.digest, nodeTimeout: "2 hours" },
|
|
193
|
+
});
|
|
194
|
+
// Rejected at submit — either by the edge shape-validator (openapi `pattern`) or the door's own
|
|
195
|
+
// ISO-8601 guard; both surface a 400. Nothing launches and the proposal stays staged (dispatchable).
|
|
196
|
+
assert.equal(res.status, 400);
|
|
197
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
198
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("an oversized invalid duration never bloats the 400 response — rejected with a bounded error, nothing launched (#505)", async () => {
|
|
202
|
+
const app = await boot();
|
|
203
|
+
assert.ok(app.api);
|
|
204
|
+
const api = app.api;
|
|
205
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
206
|
+
const huge = `PT${"9".repeat(5000)}X`;
|
|
207
|
+
const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
208
|
+
body: { digest: staged.body.digest, nodeTimeout: huge },
|
|
209
|
+
});
|
|
210
|
+
assert.equal(res.status, 400);
|
|
211
|
+
// The 5000-char blob is never echoed back verbatim — the edge pattern rejects it, and the door's
|
|
212
|
+
// own guard (`truncateForEcho`) caps the echo when the edge is bypassed. Either way the response
|
|
213
|
+
// stays bounded, so a malformed input can't bloat logs/response bodies.
|
|
214
|
+
assert.ok((res.body.error ?? "").length < 300, `error body should be bounded, got ${(res.body.error ?? "").length} chars`);
|
|
215
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a syntactically-valid but oversized duration is rejected at the door → 400, nothing launched (#505)", async () => {
|
|
219
|
+
const app = await boot();
|
|
220
|
+
assert.ok(app.api);
|
|
221
|
+
const api = app.api;
|
|
222
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
223
|
+
// Matches the ISO-8601 grammar but exceeds the door's MAX_DURATION_LEN (64) — the door re-enforces the
|
|
224
|
+
// openapi `maxLength: 64` so an oversized value is refused even if the edge validator is bypassed.
|
|
225
|
+
const longValid = `PT${"9".repeat(70)}H`;
|
|
226
|
+
const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
227
|
+
body: { digest: staged.body.digest, nodeTimeout: longValid },
|
|
228
|
+
});
|
|
229
|
+
assert.equal(res.status, 400);
|
|
230
|
+
assert.ok((res.body.error ?? "").length < 300, `error body should be bounded, got ${(res.body.error ?? "").length} chars`);
|
|
231
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
232
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("a valid run-level nodeTimeout override dispatches the run → 202 running (#505)", async () => {
|
|
236
|
+
const app = await boot();
|
|
237
|
+
assert.ok(app.api);
|
|
238
|
+
const api = app.api;
|
|
239
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
240
|
+
const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
241
|
+
body: { digest: staged.body.digest, nodeTimeout: "PT2H" },
|
|
242
|
+
});
|
|
243
|
+
assert.equal(res.status, 202);
|
|
244
|
+
assert.equal(res.body.ok, true);
|
|
245
|
+
assert.equal(res.body.status, "running");
|
|
246
|
+
await app.settle();
|
|
247
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
248
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
249
|
+
});
|
|
185
250
|
});
|
|
@@ -12,9 +12,39 @@
|
|
|
12
12
|
|
|
13
13
|
import { dispatchDeliveryGraphRun } from "../app/deliveryGraphDispatch.ts";
|
|
14
14
|
import { getStagedProposal, markProposalDispatched, markProposalExpired } from "../app/deliveryGraphProposals.ts";
|
|
15
|
+
import { isValidIsoDuration } from "../app/reviewWait.ts";
|
|
15
16
|
import type { DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
|
|
16
17
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
17
18
|
|
|
19
|
+
/** Cap an untrusted, rejected duration string before it is echoed into logs/response bodies. `openapi.yaml`
|
|
20
|
+
* caps these dispatch duration fields at `maxLength: 64` at the edge, and the door re-enforces that bound
|
|
21
|
+
* (see `MAX_DURATION_LEN`); this truncation is defense-in-depth for when the edge validator is bypassed,
|
|
22
|
+
* so a very large malformed value can never bloat either the logs or the response. */
|
|
23
|
+
const MAX_ECHO_LEN = 80;
|
|
24
|
+
function truncateForEcho(value: string): string {
|
|
25
|
+
return value.length > MAX_ECHO_LEN ? `${value.slice(0, MAX_ECHO_LEN)}… (${value.length} chars)` : value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Door-level cap on a duration override, mirroring the `maxLength: 64` on these fields in `openapi.yaml`.
|
|
29
|
+
* Re-enforced here so a syntactically-valid-but-oversized duration is still rejected when the edge
|
|
30
|
+
* validator is bypassed (internal calls/tests), keeping seeded process variables and error/log output bounded. */
|
|
31
|
+
const MAX_DURATION_LEN = 64;
|
|
32
|
+
|
|
33
|
+
/** Validate an OPTIONAL run-level ISO-8601 duration override off the dispatch body (#505). Blank/
|
|
34
|
+
* whitespace is treated as absent (→ the runner default). A present-but-malformed value returns
|
|
35
|
+
* `{ ok: false, invalid }` so the door can reject it at submit rather than silently deploy an
|
|
36
|
+
* uninterpretable timer. Reuses the canonical `reviewWait` grammar so accept/reject never drifts from
|
|
37
|
+
* the runner's normalise-or-default one, and enforces `MAX_DURATION_LEN` so an oversized value is
|
|
38
|
+
* rejected even if the OpenAPI `maxLength` edge check is bypassed. */
|
|
39
|
+
function validateDurationOverride(raw: unknown): { ok: true; value: string | undefined } | { ok: false; invalid: string } {
|
|
40
|
+
if (raw === undefined || raw === null) return { ok: true, value: undefined };
|
|
41
|
+
if (typeof raw !== "string") return { ok: false, invalid: String(raw) };
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
if (trimmed === "") return { ok: true, value: undefined };
|
|
44
|
+
if (trimmed.length > MAX_DURATION_LEN || !isValidIsoDuration(trimmed)) return { ok: false, invalid: trimmed };
|
|
45
|
+
return { ok: true, value: trimmed.toUpperCase() };
|
|
46
|
+
}
|
|
47
|
+
|
|
18
48
|
export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) => {
|
|
19
49
|
const digest = body && typeof body === "object" && "digest" in body && typeof body.digest === "string" ? body.digest.trim() : "";
|
|
20
50
|
if (digest === "") {
|
|
@@ -24,6 +54,27 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
|
|
|
24
54
|
const idemRaw = body && typeof body === "object" && "idempotencyKey" in body && typeof body.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
|
|
25
55
|
const idempotencyKey = idemRaw !== "" ? idemRaw : undefined;
|
|
26
56
|
|
|
57
|
+
// Run-level timeout overrides (#505) — exposed at submission, validated as ISO-8601 durations here so
|
|
58
|
+
// an invalid value is a clean 400 (never a deployed, uninterpretable timer). Absent → runner defaults.
|
|
59
|
+
const rawTimeouts: Record<"nodeTimeout" | "probeTimeout" | "escalationSlaTimeout", unknown> =
|
|
60
|
+
body && typeof body === "object"
|
|
61
|
+
? {
|
|
62
|
+
nodeTimeout: "nodeTimeout" in body ? body.nodeTimeout : undefined,
|
|
63
|
+
probeTimeout: "probeTimeout" in body ? body.probeTimeout : undefined,
|
|
64
|
+
escalationSlaTimeout: "escalationSlaTimeout" in body ? body.escalationSlaTimeout : undefined,
|
|
65
|
+
}
|
|
66
|
+
: { nodeTimeout: undefined, probeTimeout: undefined, escalationSlaTimeout: undefined };
|
|
67
|
+
const timeouts: { nodeTimeout?: string; probeTimeout?: string; escalationSlaTimeout?: string } = {};
|
|
68
|
+
for (const field of ["nodeTimeout", "probeTimeout", "escalationSlaTimeout"] as const) {
|
|
69
|
+
const parsed = validateDurationOverride(rawTimeouts[field]);
|
|
70
|
+
if (!parsed.ok) {
|
|
71
|
+
const shown = truncateForEcho(parsed.invalid);
|
|
72
|
+
app.log.warn("dispatch-delivery-graph rejected: invalid duration", { field, value: shown, invalidLength: parsed.invalid.length });
|
|
73
|
+
return { status: 400, body: { ok: false, error: `\`${field}\` must be an ISO-8601 duration (e.g. \`PT2H\`); got \`${shown}\`` } };
|
|
74
|
+
}
|
|
75
|
+
if (parsed.value !== undefined) timeouts[field] = parsed.value;
|
|
76
|
+
}
|
|
77
|
+
|
|
27
78
|
// Load the live staged proposal for this digest — refuses an unknown/expired/superseded/already-
|
|
28
79
|
// dispatched digest cleanly (no run is launched).
|
|
29
80
|
const proposal = await getStagedProposal(app.data, digest);
|
|
@@ -47,7 +98,7 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
|
|
|
47
98
|
return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
|
|
48
99
|
}
|
|
49
100
|
|
|
50
|
-
const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title });
|
|
101
|
+
const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, ...timeouts });
|
|
51
102
|
if (!dispatched.ok) {
|
|
52
103
|
app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
|
|
53
104
|
const outBody: DeliveryGraphTextResult = {
|
|
@@ -5,6 +5,7 @@ import { test } from "node:test";
|
|
|
5
5
|
import { assert, assertEquals } from "#test-assert";
|
|
6
6
|
import type { AppApi } from "@nanobpm/urban";
|
|
7
7
|
import { noopLog } from "../test/log.ts";
|
|
8
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
8
9
|
import handler from "./listActivePrs.ts";
|
|
9
10
|
|
|
10
11
|
function memApp(rows: any[], escalations: any[] = []): AppApi {
|
|
@@ -24,7 +25,7 @@ function memApp(rows: any[], escalations: any[] = []): AppApi {
|
|
|
24
25
|
},
|
|
25
26
|
};
|
|
26
27
|
};
|
|
27
|
-
return { data: { table }, log: noopLog() } as any as AppApi;
|
|
28
|
+
return { data: { table: withTrackingViews(table) }, log: noopLog() } as any as AppApi;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
function input(headers: Record<string, string> = {}) {
|
|
@@ -18,6 +18,7 @@ import { assertEquals } from "#test-assert";
|
|
|
18
18
|
import type { AppApi } from "@nanobpm/urban";
|
|
19
19
|
import { resetDefaultBranchCache } from "../app/github.ts";
|
|
20
20
|
import { noopLog } from "../test/log.ts";
|
|
21
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
21
22
|
import startEpicSet from "./startEpicSet.ts";
|
|
22
23
|
|
|
23
24
|
// ── in-memory github model (mirrors startPlanFanout.admission.integration.test.ts) ───────────────
|
|
@@ -116,7 +117,7 @@ function makeApp(seedPlans: Record<string, unknown>[] = []) {
|
|
|
116
117
|
};
|
|
117
118
|
};
|
|
118
119
|
const app = {
|
|
119
|
-
data: { table },
|
|
120
|
+
data: { table: withTrackingViews(table) },
|
|
120
121
|
engine: {
|
|
121
122
|
createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
|
|
122
123
|
started.push(req);
|
|
@@ -526,7 +527,7 @@ function makeSqliteApp(
|
|
|
526
527
|
delete: () => Promise.resolve(),
|
|
527
528
|
});
|
|
528
529
|
const app = {
|
|
529
|
-
data: { table },
|
|
530
|
+
data: { table: withTrackingViews(table) },
|
|
530
531
|
engine: { createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }) },
|
|
531
532
|
log: noopLog(),
|
|
532
533
|
} as any as AppApi;
|
|
@@ -9,6 +9,7 @@ import { assertEquals } from "#test-assert";
|
|
|
9
9
|
import type { AppApi } from "@nanobpm/urban";
|
|
10
10
|
import { resetDefaultBranchCache } from "../app/github.ts";
|
|
11
11
|
import { noopLog } from "../test/log.ts";
|
|
12
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
12
13
|
import startPlanFanout from "./startPlanFanout.ts";
|
|
13
14
|
|
|
14
15
|
// ── in-memory github model ───────────────────────────────────────────────────
|
|
@@ -112,7 +113,7 @@ function makeApp(seedPlans: Record<string, unknown>[] = []) {
|
|
|
112
113
|
};
|
|
113
114
|
};
|
|
114
115
|
const app = {
|
|
115
|
-
data: { table },
|
|
116
|
+
data: { table: withTrackingViews(table) },
|
|
116
117
|
engine: {
|
|
117
118
|
createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
|
|
118
119
|
started.push(req);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.134.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/test/trackingViews.ts
CHANGED
|
@@ -30,7 +30,17 @@ export function withTrackingViews<F extends TableFn>(base: F): F {
|
|
|
30
30
|
const statusField = baseStatusFieldFor(baseName);
|
|
31
31
|
// biome-ignore lint/suspicious/noExplicitAny: test-only projection over dynamic row shapes.
|
|
32
32
|
const project = (row: any) =>
|
|
33
|
-
row == null
|
|
33
|
+
row == null
|
|
34
|
+
? row
|
|
35
|
+
: // Honor an explicitly-seeded `derived_status` so a test can model the ADR-0065 divergence a
|
|
36
|
+
// real terminated instance produces — the base `<statusField>` frozen at its last transient
|
|
37
|
+
// while the derive edge reports the terminal (`abandoned`/`failed`/`reviewed`). When a row
|
|
38
|
+
// seeds no derived column the VIEW's `ELSE base.<statusField>` fall-through applies, so it
|
|
39
|
+
// stays byte-for-byte the pass-through the previous behaviour modelled.
|
|
40
|
+
{
|
|
41
|
+
...row,
|
|
42
|
+
[derivedColumn]: row[derivedColumn] ?? row[statusField],
|
|
43
|
+
};
|
|
34
44
|
// biome-ignore lint/suspicious/noExplicitAny: test-only Proxy over a dynamic DataLayer table.
|
|
35
45
|
return new Proxy(inner, {
|
|
36
46
|
get(target: any, prop: string) {
|