@nanobpm/nano-workforce 0.166.0 → 0.167.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/mirror-install-dispatch.yml +54 -0
- package/CHANGELOG.md +13 -0
- package/app/backfillAcknowledgedAt.test.ts +121 -0
- package/app/contracts.ts +17 -1
- package/app/delivery.ts +15 -13
- package/app/deliveryGraphReadModel.test.ts +44 -8
- package/app/deliveryGraphReadModel.ts +19 -2
- package/app/deliveryGraphRun.ts +5 -0
- package/app/epicBucket.test.ts +9 -3
- package/app/featureReadModel.ts +7 -11
- package/app/listBucket.ts +86 -0
- package/app/planReadModel.test.ts +29 -19
- package/app/planReadModel.ts +32 -26
- package/app/pollUserTasks.test.ts +165 -0
- package/app/pullRequestReadModel.test.ts +208 -0
- package/app/pullRequestReadModel.ts +76 -0
- package/app/service.ts +52 -0
- package/db/migrations/093_pull_requests_acknowledged_at.sql +30 -0
- package/db/migrations/094_pull_requests_read_model.sql +65 -0
- package/db/migrations/095_delivery_graph_acknowledged_at.sql +24 -0
- package/db/migrations/096_delivery_graph_read_model_list_bucket.sql +63 -0
- package/db/migrations/097_plan_read_model_terminal_dismiss.sql +65 -0
- package/db/migrations/098_delivery_graph_units_acknowledged_at.sql +63 -0
- package/e2e/feature-preflight.e2e.ts +3 -2
- package/e2e/feature-run.e2e.ts +60 -5
- package/nano.app.json +4 -0
- package/openapi.yaml +98 -0
- package/operations/acknowledgeDeliveryGraph.test.ts +93 -0
- package/operations/acknowledgeDeliveryGraph.ts +58 -0
- package/operations/acknowledgePr.test.ts +94 -0
- package/operations/acknowledgePr.ts +62 -0
- package/package.json +2 -2
- package/pages/delivery-graphs/library.mount.js +59 -2
- package/pages/delivery-graphs/mount.js +88 -2
- package/pages/delivery-graphs.page.json +12 -3
- package/pages/home.page.json +18 -27
- package/pages/overview.page.json +26 -17
- package/resources/processes/feature.bpmn +90 -69
- package/scripts/pages-contract.test.ts +80 -18
- package/test/delivery-graphs-ack-or-timeout.test.ts +280 -0
- package/test/delivery-graphs-library-embed.test.ts +1 -1
- package/workers/record-feature-implementing/worker.test.ts +57 -0
- package/workers/record-feature-implementing/worker.ts +31 -0
- package/workers/record-results/worker.test.ts +5 -3
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The `pull_requests_read_model` derived read model — DECLARED ONCE and compiled to BOTH backends via
|
|
2
|
+
// Urban's ADR-0065 reconciling-read-model primitive (`defineReadModel`, `@nanobpm/urban`). The
|
|
3
|
+
// exemplars are app/featureReadModel.ts / app/deliveryGraphReadModel.ts; this is the Convergence-PR
|
|
4
|
+
// twin (issue #641).
|
|
5
|
+
//
|
|
6
|
+
// WHY IT EXISTS. Before #641 the Convergence (PRs) surfaces — Overview's "Active PR convergences" and
|
|
7
|
+
// home's "Pull requests" — filtered the RAW `pull_requests` table on a hand-synced base-`status`
|
|
8
|
+
// allowlist (the 7 in-flight convergence states), so a PR dropped out of Active the instant its
|
|
9
|
+
// `status` reached a terminal state, with NO operator dismiss. That is the last-but-one base-`status`
|
|
10
|
+
// allowlist #637 set out to retire. This model gives PRs the SAME acknowledge-to-dismiss behaviour as
|
|
11
|
+
// Features/Epics/Delivery-Graphs: a terminal PR STAYS in Active until an operator dismisses it
|
|
12
|
+
// (`acknowledgePr` stamps `acknowledged_at`), then drops to History — the derived `list_bucket`
|
|
13
|
+
// (app/listBucket.ts, the ONE shared oracle) is the single activeness predicate every "Active …" grid
|
|
14
|
+
// now filters.
|
|
15
|
+
//
|
|
16
|
+
// BASE TABLE. Reads the auto-provisioned `pull_requests__tracking` derived VIEW (ADR-0065), NOT the raw
|
|
17
|
+
// `pull_requests` table: it re-exports `pull_requests.*` plus a terminal-folded `derived_status` (the
|
|
18
|
+
// `instanceTracking` reconciler's `onTerminated → abandoned` edge, recomputed on read), so a
|
|
19
|
+
// terminated PR classifies on ENGINE TRUTH — exactly as `feature_read_model` reads
|
|
20
|
+
// `feature_runs__tracking`.
|
|
21
|
+
|
|
22
|
+
import { defineReadModel, type Expr, type ReadModel } from "@nanobpm/urban";
|
|
23
|
+
import { deriveAckOpenExpr, deriveListBucketExpr } from "./listBucket.ts";
|
|
24
|
+
|
|
25
|
+
/** The base table the read model reads: the auto-provisioned `pull_requests__tracking` derived VIEW
|
|
26
|
+
* (ADR-0065), NOT the raw `pull_requests` table — so the status-classifying derivations read the
|
|
27
|
+
* terminal-folded `derived_status` and a terminated PR drops to History with no worker write. */
|
|
28
|
+
export const PULL_REQUEST_READ_MODEL_BASE_TABLE = "pull_requests__tracking";
|
|
29
|
+
|
|
30
|
+
/** The base alias the managed VIEW gives `pull_requests__tracking` — pinned so the emitted derived-
|
|
31
|
+
* column SQL (`pr."col"`) matches the migration exactly (the drift guard compares against this alias). */
|
|
32
|
+
export const PULL_REQUEST_READ_MODEL_BASE_ALIAS = "pr";
|
|
33
|
+
|
|
34
|
+
/** The effective (terminal-folded) status column the bucket/ack derivations classify on — the tracking
|
|
35
|
+
* VIEW's `derived_status`. Single source of truth for the name so the derivations can't drift from it. */
|
|
36
|
+
export const EFFECTIVE_STATUS_COLUMN = "derived_status";
|
|
37
|
+
|
|
38
|
+
/** The PR statuses that are TERMINAL for the Active/History partition (issue #641). A PR in any of
|
|
39
|
+
* these has finished its convergence/merge lifecycle: `merged` (landed), `converged` (review-only
|
|
40
|
+
* consensus, no auto-merge), `abandoned` (cancelled / out-of-band terminated — the reconciler's
|
|
41
|
+
* `onTerminated` edge), `closed` (the PR was closed on GitHub without merging), `failed` (a terminal
|
|
42
|
+
* merge/convergence failure). Distinct from `deliveryStatuses.ts`'s `TERMINAL_STATUSES` (the epic-
|
|
43
|
+
* delivery in-flight fold, which counts `converged`/`merged`/`abandoned` only): this is the FULL PR
|
|
44
|
+
* terminal tier the acknowledge-to-dismiss rule folds to History. */
|
|
45
|
+
export const PR_TERMINAL_STATUSES: readonly string[] = ["merged", "converged", "abandoned", "closed", "failed"];
|
|
46
|
+
|
|
47
|
+
/** The Active/History partition — `history` IFF the PR is terminal AND acknowledged, else `active`
|
|
48
|
+
* (live PRs + terminal-but-UNACKNOWLEDGED PRs that stay actionable until dismissed). The ONE shared
|
|
49
|
+
* oracle (app/listBucket.ts) parameterised by {@link PR_TERMINAL_STATUSES}. */
|
|
50
|
+
const listBucket: Expr = deriveListBucketExpr(EFFECTIVE_STATUS_COLUMN, PR_TERMINAL_STATUSES);
|
|
51
|
+
|
|
52
|
+
/** The operator "Dismiss" affordance flag — `1` IFF the PR is terminal AND not yet acknowledged (so the
|
|
53
|
+
* page's `showWhenField` Dismiss button renders only for a terminal-but-unacknowledged PR), else `0`. */
|
|
54
|
+
const ackOpen: Expr = deriveAckOpenExpr(EFFECTIVE_STATUS_COLUMN, PR_TERMINAL_STATUSES);
|
|
55
|
+
|
|
56
|
+
/** The keys of {@link pullRequestReadModel}'s DERIVED columns, in the order the migration emits them.
|
|
57
|
+
* Base columns are identity pass-throughs (not derivations) and are listed in the migration directly. */
|
|
58
|
+
export const PULL_REQUEST_READ_MODEL_DERIVED = ["list_bucket", "ack_open"] as const;
|
|
59
|
+
export type PullRequestReadModelDerivedColumn = (typeof PULL_REQUEST_READ_MODEL_DERIVED)[number];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The declare-once `pull_requests_read_model` derived columns. `selectBaseColumns: false` because the
|
|
63
|
+
* base columns are plain identity pass-throughs enumerated in the migration (so the static
|
|
64
|
+
* pages↔schema contract guard, which reads a VIEW's columns off an aliased select-list, sees them).
|
|
65
|
+
* Both the migration VIEW (`sqlSelectFor`, drift-guarded) and the runtime TS oracle (`fnFor`) are
|
|
66
|
+
* generated from THIS single declaration.
|
|
67
|
+
*/
|
|
68
|
+
export const pullRequestReadModel: ReadModel = defineReadModel({
|
|
69
|
+
name: "pull_requests_read_model",
|
|
70
|
+
baseTable: PULL_REQUEST_READ_MODEL_BASE_TABLE,
|
|
71
|
+
selectBaseColumns: false,
|
|
72
|
+
derive: {
|
|
73
|
+
list_bucket: listBucket,
|
|
74
|
+
ack_open: ackOpen,
|
|
75
|
+
},
|
|
76
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -198,6 +198,15 @@ export const MERGEABLE_WAIT_TIMEOUT = mergeableWaitTimeout(
|
|
|
198
198
|
* to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
|
|
199
199
|
export const REVIEW_NUDGE_MS = clampNudgeMinutes(process.env.NANO_PR_REVIEW_NUDGE_MINUTES) * 60_000;
|
|
200
200
|
|
|
201
|
+
/** Grace window (ms) before the `escalated`→`running` self-heal (below) may act on a row. The sole
|
|
202
|
+
* writer of `status="escalated"` — `record-feature-escalation` — runs on the `escalated` arm and stamps
|
|
203
|
+
* `updated_at` IMMEDIATELY BEFORE the engine creates the `feature-escalation` user task. A poll landing
|
|
204
|
+
* in that window would see `openUserTasks` report none and wrongly heal the just-raised escalation back
|
|
205
|
+
* to `running`, hiding it until a future write. Only heal rows whose escalation is older than this
|
|
206
|
+
* window, by when the user task must already be observable — sparing the in-flight transition while
|
|
207
|
+
* still reconciling genuinely-stranded rows. */
|
|
208
|
+
export const FEATURE_ESCALATION_HEAL_GRACE_MS = 60_000;
|
|
209
|
+
|
|
201
210
|
/** Whether a converged PR is automatically driven to merge (the merge-loop). Default on; set
|
|
202
211
|
* `NANO_PR_AUTO_MERGE=0` to stop at `converged` (review-only mode). */
|
|
203
212
|
export const AUTO_MERGE = !["0", "false", "off", "no"].includes(
|
|
@@ -2677,6 +2686,49 @@ export async function pollUserTasks(
|
|
|
2677
2686
|
await userTasks(data).update(user_task_key, { ...patch, updated_at: at });
|
|
2678
2687
|
}
|
|
2679
2688
|
for (const key of deletes) await userTasks(data).delete(key);
|
|
2689
|
+
|
|
2690
|
+
// ── Self-heal: `escalated` holds ONLY while parked (issue #642) ──────────────────────────────────
|
|
2691
|
+
// `record-feature-escalation` is the sole writer of `status="escalated"`, but the answer loop-back
|
|
2692
|
+
// now stamps `running` (via `record-feature-implementing`). This read-side sweep heals any row that
|
|
2693
|
+
// is ALREADY stranded at `escalated` — e.g. runs that escalated before the write-side twin shipped
|
|
2694
|
+
// (#632), or any future missed transition — by reconciling against the ENGINE's authoritative
|
|
2695
|
+
// open-escalation set. But it must NOT decide on absence from THIS pass's `desired` set:
|
|
2696
|
+
// `sweepOpenEscalationTasks` is best-effort and breaks early on a paging/transport error, so a
|
|
2697
|
+
// truncated `desired` would wrongly flip a genuinely-parked run whose task lived on an unreached page.
|
|
2698
|
+
// Confirm each escalated run POSITIVELY, per-instance, against `openUserTasks` (which pins
|
|
2699
|
+
// `state:"CREATED"`), and skip healing when that query errors — durable state is mutated only on
|
|
2700
|
+
// positive evidence that no `feature-escalation` task is open for the instance (parity with the PR
|
|
2701
|
+
// `status="escalated"` contract). Confined to rows with a known `process_key` so an untracked instance
|
|
2702
|
+
// is never guessed at.
|
|
2703
|
+
//
|
|
2704
|
+
// Presence in THIS pass's `desired` set is itself POSITIVE evidence of parking (truncation only ever
|
|
2705
|
+
// DROPS tasks, never invents one), so a run whose `feature-escalation` task was already swept is
|
|
2706
|
+
// genuinely parked — skip its per-instance `openUserTasks` RPC entirely (an avoidable N+1 on every
|
|
2707
|
+
// tick). Only a run NOT confirmed parked by the sweep falls through to the per-instance check below.
|
|
2708
|
+
const sweptParkedEscalations = new Set(
|
|
2709
|
+
desired.filter((r) => r.element_id === FEATURE_ESCALATION_ELEMENT && r.process_key).map((r) => r.process_key),
|
|
2710
|
+
);
|
|
2711
|
+
for (const run of await featureRuns(data).find({ status: "escalated" })) {
|
|
2712
|
+
if (!run.process_key) continue;
|
|
2713
|
+
if (sweptParkedEscalations.has(run.process_key)) continue; // already seen parked this pass — no RPC, no heal
|
|
2714
|
+
// A just-written escalation may not have its `feature-escalation` user task yet: the sole writer,
|
|
2715
|
+
// `record-feature-escalation`, stamps `updated_at` immediately BEFORE the engine creates the task.
|
|
2716
|
+
// Skip healing inside the grace window so this pass never races that transition and steals a fresh
|
|
2717
|
+
// escalation; a genuinely-stranded (old, or timestamp-less) row is past the window and still healed.
|
|
2718
|
+
const escalatedAt = Date.parse(run.updated_at ?? "");
|
|
2719
|
+
if (Number.isFinite(escalatedAt) && Date.now() - escalatedAt < FEATURE_ESCALATION_HEAL_GRACE_MS) continue;
|
|
2720
|
+
let openTasks: { elementId?: string }[];
|
|
2721
|
+
try {
|
|
2722
|
+
openTasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
|
|
2723
|
+
} catch (err) {
|
|
2724
|
+
// Negative evidence from a failed query is not proof the run is unparked — leave it for a later pass.
|
|
2725
|
+
console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${run.process_key}): ${err}`);
|
|
2726
|
+
continue;
|
|
2727
|
+
}
|
|
2728
|
+
if (!openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT)) {
|
|
2729
|
+
await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2680
2732
|
}
|
|
2681
2733
|
|
|
2682
2734
|
/** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- Convergence (PRs): add the `acknowledged_at` dismissal stamp + backfill currently-terminal rows
|
|
2
|
+
-- (issue #641). This is the PR half of making all four "Active …" grids behave uniformly — a row STAYS
|
|
3
|
+
-- in Active until an operator dismisses it (acknowledge-to-dismiss), then drops to History — completing
|
|
4
|
+
-- the direction set in #637 by retiring the last two base-`status` allowlists (Convergence + Delivery
|
|
5
|
+
-- Graphs). The twin stamp already exists on `feature_runs` (073) and `plans` (074).
|
|
6
|
+
--
|
|
7
|
+
-- BACKFILL IS MANDATORY (the highest-risk item). Merlin carries hundreds of already-terminal PRs (~411
|
|
8
|
+
-- merged + 35 abandoned + 5 converged at authoring). Without the backfill, repointing the Active grids
|
|
9
|
+
-- at the derived `list_bucket` (094) — which folds an UNACKNOWLEDGED terminal PR into `active` — would
|
|
10
|
+
-- dump every one of those historical PRs into Active on the next boot. So we stamp `acknowledged_at` on
|
|
11
|
+
-- every CURRENTLY-terminal row here: they load in History from day one, and only PRs that reach a
|
|
12
|
+
-- terminal state AFTER this migration require an operator dismiss (their `acknowledged_at` stays NULL
|
|
13
|
+
-- until `acknowledgePr`). Stamp = COALESCE(merged_at, converged_at, updated_at): the best available
|
|
14
|
+
-- "when it settled" timestamp, and `updated_at` is NOT NULL so the stamp is never NULL for a matched
|
|
15
|
+
-- row.
|
|
16
|
+
--
|
|
17
|
+
-- Terminal set = the PR terminal tier {merged, converged, abandoned, closed, failed}
|
|
18
|
+
-- (app/pullRequestReadModel.ts `PR_TERMINAL_STATUSES`). Classified on the base `status` (the stored
|
|
19
|
+
-- ground truth at migration time; the reconciler's `derived_status` is a read-time projection). The
|
|
20
|
+
-- `acknowledged_at IS NULL` guard keeps the backfill idempotent — re-running never re-stamps a row an
|
|
21
|
+
-- operator later dismissed with a different timestamp.
|
|
22
|
+
--
|
|
23
|
+
-- The runner wraps each file in its own transaction — no BEGIN/COMMIT here. Numbered after 092.
|
|
24
|
+
|
|
25
|
+
ALTER TABLE pull_requests ADD COLUMN acknowledged_at TEXT;
|
|
26
|
+
|
|
27
|
+
UPDATE pull_requests
|
|
28
|
+
SET acknowledged_at = COALESCE(merged_at, converged_at, updated_at)
|
|
29
|
+
WHERE acknowledged_at IS NULL
|
|
30
|
+
AND status IN ('merged', 'converged', 'abandoned', 'closed', 'failed');
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- Convergence (PRs) read model: DECLARE ONCE, compile to BOTH backends (ADR-0065, nano-ide#452) — the
|
|
2
|
+
-- `pull_requests_read_model` VIEW that gives the Convergence surfaces the SAME acknowledge-to-dismiss
|
|
3
|
+
-- Active/History partition Features/Epics/Delivery-Graphs already have (issue #641).
|
|
4
|
+
--
|
|
5
|
+
-- Before #641 the Overview "Active PR convergences" and home "Pull requests" grids filtered the RAW
|
|
6
|
+
-- `pull_requests` table on a hand-synced base-`status` allowlist (the 7 in-flight convergence states),
|
|
7
|
+
-- so a PR dropped out of Active the instant its `status` reached terminal, with NO operator dismiss —
|
|
8
|
+
-- the last-but-one base-`status` allowlist #637 set out to retire. This VIEW introduces a declared
|
|
9
|
+
-- `list_bucket` (active/history) + `ack_open` (the Dismiss affordance flag) derived from the ONE shared
|
|
10
|
+
-- oracle (app/listBucket.ts): a terminal PR STAYS in `active` until `acknowledged_at` is stamped
|
|
11
|
+
-- (`acknowledgePr`), then folds to `history`.
|
|
12
|
+
--
|
|
13
|
+
-- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
|
|
14
|
+
-- app/pullRequestReadModel.ts (`pullRequestReadModel.sqlSelectFor(col, { baseAlias: "pr" })`), which
|
|
15
|
+
-- ALSO drives the runtime TS via `fnFor` — the two lowerings fall out of the same closed-DSL AST and
|
|
16
|
+
-- cannot diverge. A drift guard (app/pullRequestReadModel.test.ts) fails if this file stops matching the
|
|
17
|
+
-- declaration, and `assertReadModelParity` proves the SQL and TS lowerings agree.
|
|
18
|
+
--
|
|
19
|
+
-- SEMANTICS. The status-classifying `list_bucket`/`ack_open` read the terminal-folded `derived_status`
|
|
20
|
+
-- off the auto-provisioned `pull_requests__tracking` derived VIEW (ADR-0065), so an out-of-band-
|
|
21
|
+
-- terminated PR classifies on ENGINE TRUTH (`abandoned`) rather than a frozen base `status`. Base
|
|
22
|
+
-- columns stay aliased identity pass-throughs (so the static pages↔schema contract guard sees the
|
|
23
|
+
-- VIEW's columns), sourced off `pull_requests__tracking`'s re-export of the base `pull_requests.*`;
|
|
24
|
+
-- `status` is exposed as the effective `COALESCE(derived_status, status)` so the pages' Status cell and
|
|
25
|
+
-- any status reader track a terminated PR. `acknowledged_at` (093) passes through so the read model can
|
|
26
|
+
-- classify on it.
|
|
27
|
+
--
|
|
28
|
+
-- Forward-only VIEW definition (DROP then CREATE). `pull_requests__tracking` is the managed VIEW urban
|
|
29
|
+
-- provisions at mount; SQLite does not validate a view body at CREATE time, so this migration (which
|
|
30
|
+
-- runs before that mount) is created fine and resolves once the managed VIEW exists. The runner wraps
|
|
31
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 093.
|
|
32
|
+
|
|
33
|
+
DROP VIEW IF EXISTS pull_requests_read_model;
|
|
34
|
+
|
|
35
|
+
CREATE VIEW pull_requests_read_model AS
|
|
36
|
+
SELECT
|
|
37
|
+
pr.pr_key AS pr_key,
|
|
38
|
+
pr.repo AS repo,
|
|
39
|
+
pr.number AS number,
|
|
40
|
+
pr.url AS url,
|
|
41
|
+
pr.title AS title,
|
|
42
|
+
COALESCE(pr.derived_status, pr.status) AS status,
|
|
43
|
+
pr.current_round AS current_round,
|
|
44
|
+
pr.process_key AS process_key,
|
|
45
|
+
pr.waiting_since AS waiting_since,
|
|
46
|
+
pr.last_review_id AS last_review_id,
|
|
47
|
+
pr.outcome AS outcome,
|
|
48
|
+
pr.created_at AS created_at,
|
|
49
|
+
pr.updated_at AS updated_at,
|
|
50
|
+
pr.converged_at AS converged_at,
|
|
51
|
+
pr.merged_at AS merged_at,
|
|
52
|
+
pr.active_worker AS active_worker,
|
|
53
|
+
pr.lease_until AS lease_until,
|
|
54
|
+
pr.last_nudge_at AS last_nudge_at,
|
|
55
|
+
pr.fresh_head_run_head AS fresh_head_run_head,
|
|
56
|
+
pr.abandon_token AS abandon_token,
|
|
57
|
+
pr.incident_key AS incident_key,
|
|
58
|
+
pr.incident_message AS incident_message,
|
|
59
|
+
pr.last_round_head AS last_round_head,
|
|
60
|
+
pr.root_request_key AS root_request_key,
|
|
61
|
+
pr.epic_phase_label AS epic_phase_label,
|
|
62
|
+
pr.acknowledged_at AS acknowledged_at,
|
|
63
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
|
|
64
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
|
|
65
|
+
FROM pull_requests__tracking pr;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- Delivery Graphs: add the `acknowledged_at` dismissal stamp + backfill currently-terminal runs (issue
|
|
2
|
+
-- #641). The Delivery-Graph half of the uniform acknowledge-to-dismiss behaviour (see 093 for the PR
|
|
3
|
+
-- half and the rationale): a terminal run STAYS in Active until an operator dismisses it, then drops to
|
|
4
|
+
-- History — retiring the `status IN ('awaiting-approval','running')` allowlist the pages filtered.
|
|
5
|
+
--
|
|
6
|
+
-- BACKFILL (mandatory, same risk as 093). Repointing the Active grids at the derived `list_bucket`
|
|
7
|
+
-- (096) folds an UNACKNOWLEDGED terminal run into `active`, so without a backfill every historical
|
|
8
|
+
-- terminal run would flood Active on boot. Stamp `acknowledged_at = updated_at` (a delivery-graph run
|
|
9
|
+
-- has no merged/converged timestamp; `updated_at` is its last-touch and is NOT NULL) on every currently-
|
|
10
|
+
-- terminal run so they load in History, and only runs that reach terminal AFTER this migration require
|
|
11
|
+
-- an operator dismiss.
|
|
12
|
+
--
|
|
13
|
+
-- Terminal set = {done, failed, abandoned} (app/deliveryGraphRun.ts `DELIVERY_GRAPH_TERMINAL_STATUSES`).
|
|
14
|
+
-- Classified on the base `status` (the stored ground truth at migration time). The `acknowledged_at IS
|
|
15
|
+
-- NULL` guard keeps the backfill idempotent.
|
|
16
|
+
--
|
|
17
|
+
-- The runner wraps each file in its own transaction — no BEGIN/COMMIT here. Numbered after 094.
|
|
18
|
+
|
|
19
|
+
ALTER TABLE delivery_graph_runs ADD COLUMN acknowledged_at TEXT;
|
|
20
|
+
|
|
21
|
+
UPDATE delivery_graph_runs
|
|
22
|
+
SET acknowledged_at = updated_at
|
|
23
|
+
WHERE acknowledged_at IS NULL
|
|
24
|
+
AND status IN ('done', 'failed', 'abandoned');
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
-- Delivery-Graph read model: fold the acknowledge-to-dismiss `list_bucket`/`ack_open` derivations into
|
|
2
|
+
-- the `delivery_graph_read_model` VIEW (issue #641). SUPERSEDES 087's VIEW body — every DERIVED column
|
|
3
|
+
-- is emitted VERBATIM from the ONE declaration in app/deliveryGraphReadModel.ts, now extended with the
|
|
4
|
+
-- shared Active/History oracle (app/listBucket.ts). 087 is a MERGED, IMMUTABLE migration — never edited;
|
|
5
|
+
-- this is a NEW migration superseding its VIEW body (the same pattern by which 081 superseded 076).
|
|
6
|
+
--
|
|
7
|
+
-- WHY. Before #641 the Overview "Active Delivery Graphs" and delivery-graphs "In-flight" grids filtered
|
|
8
|
+
-- the derived VIEW's effective `status` on a base allowlist (`status IN ('awaiting-approval','running')`)
|
|
9
|
+
-- — so a terminal run dropped out of Active the instant it settled, with NO operator dismiss (the last
|
|
10
|
+
-- base-`status` allowlist #637 set out to retire). This VIEW adds a declared `list_bucket`
|
|
11
|
+
-- (active/history) + `ack_open` (Dismiss affordance) so a terminal run STAYS in `active` until
|
|
12
|
+
-- `acknowledged_at` is stamped (`acknowledgeDeliveryGraph`), then folds to `history`, uniformly with the
|
|
13
|
+
-- other three surfaces.
|
|
14
|
+
--
|
|
15
|
+
-- Every DERIVED column below is emitted VERBATIM from `deliveryGraphReadModel.sqlSelectFor(col,
|
|
16
|
+
-- { baseAlias: "dg" })` (which ALSO drives the runtime TS via `fnFor`); the member-PR rollup DDL is
|
|
17
|
+
-- unchanged from 087 and re-emitted from `deliveryGraphPrCounts.viewDdl()`. The drift guard
|
|
18
|
+
-- (app/deliveryGraphReadModel.test.ts) fails if this file stops matching the declaration, and
|
|
19
|
+
-- `assertRollupParity`/`assertReadModelParity` prove the SQL and TS lowerings agree.
|
|
20
|
+
--
|
|
21
|
+
-- SEMANTICS unchanged from 087 EXCEPT the two new derived columns: `stage`/`stage_state` still read the
|
|
22
|
+
-- terminal-folded `derived_status`; `park_label` is the same hand-authored display column; base columns
|
|
23
|
+
-- stay aliased identity pass-throughs; `status` is the effective `COALESCE(derived_status, status)`.
|
|
24
|
+
-- `acknowledged_at` (095) now passes through so the read model can classify on it.
|
|
25
|
+
--
|
|
26
|
+
-- Forward-only VIEW redefinition (DROP then CREATE). The runner wraps each file in its own transaction,
|
|
27
|
+
-- so this file must NOT contain BEGIN/COMMIT. Numbered after 095.
|
|
28
|
+
|
|
29
|
+
DROP VIEW IF EXISTS delivery_graph_read_model;
|
|
30
|
+
DROP VIEW IF EXISTS delivery_graph_pr_counts;
|
|
31
|
+
|
|
32
|
+
CREATE VIEW IF NOT EXISTS "delivery_graph_pr_counts" AS
|
|
33
|
+
SELECT
|
|
34
|
+
"__urban_rollup_src"."root_request_key" AS "root_request_key",
|
|
35
|
+
SUM(CASE WHEN COALESCE(((NOT COALESCE(("__urban_rollup_src"."root_request_key" IS NULL), 0)) AND (NOT COALESCE(COALESCE((COALESCE(("__urban_rollup_src"."derived_status" = 'converged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'merged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'abandoned'), 0)), 0), 0))), 0) THEN 1 ELSE 0 END) AS "prs_in_flight"
|
|
36
|
+
FROM "pull_requests__tracking" "__urban_rollup_src"
|
|
37
|
+
GROUP BY "__urban_rollup_src"."root_request_key";
|
|
38
|
+
|
|
39
|
+
CREATE VIEW delivery_graph_read_model AS
|
|
40
|
+
SELECT
|
|
41
|
+
dg.run_key AS run_key,
|
|
42
|
+
COALESCE(dg.derived_status, dg.status) AS status,
|
|
43
|
+
dg.process_key AS process_key,
|
|
44
|
+
dg.process_definition_id AS process_definition_id,
|
|
45
|
+
dg.digest AS digest,
|
|
46
|
+
dg.side_effecting AS side_effecting,
|
|
47
|
+
dg.node_count AS node_count,
|
|
48
|
+
dg.human_node_count AS human_node_count,
|
|
49
|
+
dg.side_effect_count AS side_effect_count,
|
|
50
|
+
dg.title AS title,
|
|
51
|
+
dg.phase AS phase,
|
|
52
|
+
dg.phase_node_id AS phase_node_id,
|
|
53
|
+
dg.human_labels AS human_labels,
|
|
54
|
+
dg.created_at AS created_at,
|
|
55
|
+
dg.updated_at AS updated_at,
|
|
56
|
+
dg.acknowledged_at AS acknowledged_at,
|
|
57
|
+
CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'Done' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("dg"."derived_status" = 'awaiting-approval'), 0) THEN 'Requested' WHEN COALESCE((COALESCE("pc"."prs_in_flight", 0) > 0), 0) THEN 'Converging' ELSE 'Implementing' END AS stage,
|
|
58
|
+
CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'ok' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
|
|
59
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("dg"."derived_status" = 'done'), 0) OR COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) AND COALESCE(("dg"."acknowledged_at" = "dg"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
|
|
60
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("dg"."derived_status" = 'done'), 0) OR COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE(("dg"."acknowledged_at" = "dg"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open,
|
|
61
|
+
CASE WHEN dg.phase_node_id IS NOT NULL THEN dg.phase ELSE NULL END AS park_label
|
|
62
|
+
FROM delivery_graph_runs__tracking dg
|
|
63
|
+
LEFT JOIN delivery_graph_pr_counts pc ON dg.run_key = pc.root_request_key;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- Epic `plan_read_model`: fold the terminal-non-`done` dismissal arm into the Active/History partition
|
|
2
|
+
-- (issue #641, Scope C). SUPERSEDES 083's VIEW body — every DERIVED column is emitted VERBATIM from the
|
|
3
|
+
-- ONE `planReadModel` declaration (app/planReadModel.ts), now with `list_bucket`/`ack_open` classified
|
|
4
|
+
-- over the SHARED acknowledge-to-dismiss oracle (app/listBucket.ts) parameterised by the epic terminal
|
|
5
|
+
-- set {done, failed, abandoned}. 061/074/080/083 are MERGED, IMMUTABLE migrations — never edited; this
|
|
6
|
+
-- is a NEW migration superseding 083's VIEW body (the pattern by which 083 superseded 080).
|
|
7
|
+
--
|
|
8
|
+
-- WHY. Epics already stayed active-until-dismissed on the SUCCESS path (a `done` epic). But a terminal-
|
|
9
|
+
-- non-`done` epic (`failed`/`abandoned` — cancelled) fell STRAIGHT to History (the old bucket CASE's
|
|
10
|
+
-- default), skipping the operator tick-off. For true uniformity with features/PRs/delivery-graphs, the
|
|
11
|
+
-- new `list_bucket` keeps a terminal-but-UNACKNOWLEDGED epic of ANY terminal status in `active` until
|
|
12
|
+
-- dismissed, and `ack_open` extends the Dismiss affordance from `done`-only to the full terminal set (a
|
|
13
|
+
-- `failed`/`abandoned` epic's `delivery` is always non-`converging`, so it is immediately dismissable).
|
|
14
|
+
--
|
|
15
|
+
-- Every DERIVED column below is emitted VERBATIM from `planReadModel.sqlSelectFor(col, { baseAlias:
|
|
16
|
+
-- "pl" })` (which ALSO drives the runtime TS via `fnFor`, behind the app/delivery.ts adapters). The
|
|
17
|
+
-- drift guard (app/planReadModel.test.ts) fails if this file stops matching the declaration, and
|
|
18
|
+
-- `assertReadModelParity` proves the SQL and TS lowerings agree.
|
|
19
|
+
--
|
|
20
|
+
-- SEMANTICS unchanged from 083 EXCEPT `list_bucket`/`ack_open`: `delivery` still reads the base
|
|
21
|
+
-- `plans.status`; the status-classifying arms read the terminal-folded `derived_status`; the wave
|
|
22
|
+
-- columns pass through the `plan_wave_progress` lookup; the display strings (`delivery_label`,
|
|
23
|
+
-- `wave_label`) stay hand-authored. Base columns stay aliased pass-throughs; `status` is the effective
|
|
24
|
+
-- `COALESCE(derived_status, status)`. The two rollup lookups are re-joined identically.
|
|
25
|
+
--
|
|
26
|
+
-- Forward-only VIEW redefinition (DROP then CREATE). `plans__tracking` is the managed VIEW urban
|
|
27
|
+
-- provisions at mount; SQLite does not validate a view body at CREATE time, so this migration (which
|
|
28
|
+
-- runs before that mount) is created fine and resolves once the managed VIEW exists. The runner wraps
|
|
29
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 096.
|
|
30
|
+
|
|
31
|
+
DROP VIEW IF EXISTS plan_read_model;
|
|
32
|
+
|
|
33
|
+
CREATE VIEW plan_read_model AS
|
|
34
|
+
SELECT
|
|
35
|
+
pl.plan_key AS plan_key,
|
|
36
|
+
pl.repo AS repo,
|
|
37
|
+
pl.issue_number AS issue_number,
|
|
38
|
+
pl.issue_url AS issue_url,
|
|
39
|
+
pl.title AS title,
|
|
40
|
+
COALESCE(pl.derived_status, pl.status) AS status,
|
|
41
|
+
pl.task_count AS task_count,
|
|
42
|
+
pl.process_key AS process_key,
|
|
43
|
+
pl.outcome AS outcome,
|
|
44
|
+
pl.updated_at AS updated_at,
|
|
45
|
+
pl.epic_phase AS epic_phase,
|
|
46
|
+
pl.base_branch AS base_branch,
|
|
47
|
+
pl.wait_gate_label AS wait_gate_label,
|
|
48
|
+
pl.bound_artifacts AS bound_artifacts,
|
|
49
|
+
pl.promotion_pr AS promotion_pr,
|
|
50
|
+
pl.promotion_state AS promotion_state,
|
|
51
|
+
CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("pl"."status" = 'done'), 0), 0)) OR COALESCE((COALESCE("dc"."prs_opened", 0) = 0), 0)), 0) THEN NULL WHEN COALESCE((COALESCE("dc"."prs_in_flight", 0) > 0), 0) THEN 'converging' WHEN COALESCE((COALESCE("dc"."prs_merged", 0) = COALESCE("dc"."prs_opened", 0)), 0) THEN 'landed' ELSE NULL END AS delivery,
|
|
52
|
+
"wp"."wave_count" AS wave_count,
|
|
53
|
+
"wp"."current_wave" AS current_wave,
|
|
54
|
+
CASE WHEN COALESCE((COALESCE((COALESCE((COALESCE(("pl"."derived_status" = 'done'), 0) OR COALESCE(("pl"."derived_status" = 'failed'), 0) OR COALESCE(("pl"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE((CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("pl"."status" = 'done'), 0), 0)) OR COALESCE((COALESCE("dc"."prs_opened", 0) = 0), 0)), 0) THEN NULL WHEN COALESCE((COALESCE("dc"."prs_in_flight", 0) > 0), 0) THEN 'converging' WHEN COALESCE((COALESCE("dc"."prs_merged", 0) = COALESCE("dc"."prs_opened", 0)), 0) THEN 'landed' ELSE NULL END = 'converging'), 0), 0))), 0) AND COALESCE(("pl"."acknowledged_at" = "pl"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
|
|
55
|
+
CASE WHEN COALESCE((COALESCE((COALESCE((COALESCE(("pl"."derived_status" = 'done'), 0) OR COALESCE(("pl"."derived_status" = 'failed'), 0) OR COALESCE(("pl"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE((CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("pl"."status" = 'done'), 0), 0)) OR COALESCE((COALESCE("dc"."prs_opened", 0) = 0), 0)), 0) THEN NULL WHEN COALESCE((COALESCE("dc"."prs_in_flight", 0) > 0), 0) THEN 'converging' WHEN COALESCE((COALESCE("dc"."prs_merged", 0) = COALESCE("dc"."prs_opened", 0)), 0) THEN 'landed' ELSE NULL END = 'converging'), 0), 0))), 0) AND (NOT COALESCE(COALESCE(("pl"."acknowledged_at" = "pl"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open,
|
|
56
|
+
CASE
|
|
57
|
+
WHEN pl.status IS NOT 'done' OR COALESCE(dc.prs_opened, 0) = 0 THEN NULL
|
|
58
|
+
WHEN COALESCE(dc.prs_in_flight, 0) > 0 THEN dc.prs_merged || '/' || dc.prs_opened || ' slices merged, ' || dc.prs_in_flight || ' converging'
|
|
59
|
+
WHEN dc.prs_merged = dc.prs_opened THEN dc.prs_opened || '/' || dc.prs_opened || ' slices merged'
|
|
60
|
+
ELSE NULL
|
|
61
|
+
END AS delivery_label,
|
|
62
|
+
(wp.current_wave + 1) || '/' || wp.wave_count AS wave_label
|
|
63
|
+
FROM plans__tracking pl
|
|
64
|
+
LEFT JOIN plan_delivery_counts dc ON pl.plan_key = dc.plan_key
|
|
65
|
+
LEFT JOIN plan_wave_progress wp ON pl.plan_key = wp.plan_key;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
-- Delivery Graphs: carry the new `acknowledged_at` dismissal stamp (095) through the `delivery_units`
|
|
2
|
+
-- aggregate (issue #641). Migration 095 added `acknowledged_at` to the base `delivery_graph_runs`
|
|
3
|
+
-- table, but its projection onto the ADR "declare-once" `delivery_units` aggregate (088-091) predates
|
|
4
|
+
-- the column: the delivery-graph sync triggers (089) never copied it and the `delivery_graph_runs__units`
|
|
5
|
+
-- compat VIEW (091) never re-exported it. That broke the aggregate's compat-view parity invariant
|
|
6
|
+
-- (`delivery_graph_runs__units` must be byte-identical to `delivery_graph_runs`), and — since the
|
|
7
|
+
-- aggregate is the S9 unified store the feature/epic surfaces already read `acknowledged_at`/`list_bucket`
|
|
8
|
+
-- from — left the delivery-graph unit rows without the dismissal stamp the other kinds carry.
|
|
9
|
+
--
|
|
10
|
+
-- This migration supersedes the two delivery-graph WRITE triggers (INSERT/UPDATE) to also project
|
|
11
|
+
-- `NEW.acknowledged_at`, re-creates the `delivery_graph_runs__units` compat VIEW to re-export it, and
|
|
12
|
+
-- backfills the stamp onto the existing aggregate rows (095's backfill fired the OLD trigger, which
|
|
13
|
+
-- dropped `acknowledged_at`, so the aggregate must be re-synced from the base once here). The feature
|
|
14
|
+
-- and epic triggers/views already project `acknowledged_at` (089/091) and are untouched; the DELETE
|
|
15
|
+
-- trigger is column-agnostic and is left as-is.
|
|
16
|
+
--
|
|
17
|
+
-- Migrations are forward-only and immutable once merged, so 089/091 are NOT edited — the triggers/VIEW
|
|
18
|
+
-- are DROPped and re-created here (numbered after 097). The runner wraps each file in its own
|
|
19
|
+
-- transaction — no BEGIN/COMMIT.
|
|
20
|
+
|
|
21
|
+
DROP TRIGGER IF EXISTS delivery_graph_runs__du_ai;
|
|
22
|
+
CREATE TRIGGER delivery_graph_runs__du_ai AFTER INSERT ON delivery_graph_runs
|
|
23
|
+
BEGIN
|
|
24
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, created_at, updated_at)
|
|
25
|
+
VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.created_at, NEW.updated_at);
|
|
26
|
+
END;
|
|
27
|
+
|
|
28
|
+
DROP TRIGGER IF EXISTS delivery_graph_runs__du_au;
|
|
29
|
+
CREATE TRIGGER delivery_graph_runs__du_au AFTER UPDATE ON delivery_graph_runs
|
|
30
|
+
BEGIN
|
|
31
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, created_at, updated_at)
|
|
32
|
+
VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.created_at, NEW.updated_at);
|
|
33
|
+
END;
|
|
34
|
+
|
|
35
|
+
DROP VIEW IF EXISTS delivery_graph_runs__units;
|
|
36
|
+
CREATE VIEW delivery_graph_runs__units AS
|
|
37
|
+
SELECT
|
|
38
|
+
du.legacy_key AS run_key,
|
|
39
|
+
du.process_key AS process_key,
|
|
40
|
+
du.process_definition_id AS process_definition_id,
|
|
41
|
+
du.digest AS digest,
|
|
42
|
+
du.status AS status,
|
|
43
|
+
du.side_effecting AS side_effecting,
|
|
44
|
+
du.node_count AS node_count,
|
|
45
|
+
du.human_node_count AS human_node_count,
|
|
46
|
+
du.side_effect_count AS side_effect_count,
|
|
47
|
+
du.title AS title,
|
|
48
|
+
du.phase AS phase,
|
|
49
|
+
du.phase_node_id AS phase_node_id,
|
|
50
|
+
du.human_labels AS human_labels,
|
|
51
|
+
du.acknowledged_at AS acknowledged_at,
|
|
52
|
+
du.created_at AS created_at,
|
|
53
|
+
du.updated_at AS updated_at
|
|
54
|
+
FROM delivery_units du
|
|
55
|
+
WHERE du.kind = 'delivery-graph';
|
|
56
|
+
|
|
57
|
+
-- Re-sync the stamp onto the existing aggregate rows (095's backfill fired the OLD, column-dropping
|
|
58
|
+
-- trigger). Keyed by the aggregate's `legacy_key` = the run's `run_key`.
|
|
59
|
+
UPDATE delivery_units
|
|
60
|
+
SET acknowledged_at = (
|
|
61
|
+
SELECT d.acknowledged_at FROM delivery_graph_runs d WHERE d.run_key = delivery_units.legacy_key
|
|
62
|
+
)
|
|
63
|
+
WHERE kind = 'delivery-graph';
|
|
@@ -157,7 +157,8 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
157
157
|
"the green gate leads into the fan-out head",
|
|
158
158
|
);
|
|
159
159
|
assert.ok(
|
|
160
|
-
flows.includes("ensure-base-branch->
|
|
160
|
+
flows.includes("ensure-base-branch->record-feature-implementing") &&
|
|
161
|
+
flows.includes("record-feature-implementing->implement-task"),
|
|
161
162
|
`the run reaches the implement agent only after the gate (flows: ${flows.join(", ")})`,
|
|
162
163
|
);
|
|
163
164
|
// A green probe never escalates.
|
|
@@ -189,7 +190,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
189
190
|
);
|
|
190
191
|
assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
|
|
191
192
|
assert.ok(
|
|
192
|
-
flows.includes("
|
|
193
|
+
flows.includes("record-feature-implementing->implement-task"),
|
|
193
194
|
"an ungated feature reaches the implement agent",
|
|
194
195
|
);
|
|
195
196
|
} finally {
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -245,8 +245,12 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
245
245
|
|
|
246
246
|
const flows = takenFlows(app);
|
|
247
247
|
assert.ok(
|
|
248
|
-
flows.includes("w_gw_answer->
|
|
249
|
-
`answer re-dispatched the
|
|
248
|
+
flows.includes("w_gw_answer->record-feature-implementing"),
|
|
249
|
+
`answer re-dispatched through the implementing-reset task (flows: ${flows.join(", ")})`,
|
|
250
|
+
);
|
|
251
|
+
assert.ok(
|
|
252
|
+
flows.includes("record-feature-implementing->implement-task"),
|
|
253
|
+
`the reset task re-enters the same implement task (flows: ${flows.join(", ")})`,
|
|
250
254
|
);
|
|
251
255
|
assert.ok(!flows.includes("w_gw_answer->record-feature"), "the abandon (default) flow was NOT taken");
|
|
252
256
|
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
@@ -254,6 +258,56 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
254
258
|
);
|
|
255
259
|
});
|
|
256
260
|
|
|
261
|
+
test("escalate + answer: the run reads `running` (not a stale escalated) through the post-answer re-implementation (issue #642)", async () => {
|
|
262
|
+
// The write-side twin of `record-feature-escalation`: `record-feature-implementing` sits on the
|
|
263
|
+
// answer loop-back and stamps `running` BEFORE implement-task re-runs, so `feature_runs.status` no
|
|
264
|
+
// longer holds a stale `escalated` for the entire re-implementation (the #632 tear). Assert the
|
|
265
|
+
// agent observes `running` at the moment it is re-dispatched. Boots inline (not `withApp`) so the
|
|
266
|
+
// re-implementation stub can read the row through `app.db`.
|
|
267
|
+
const dbDir = mkdtempSync(join(tmpdir(), "nwf-f642-"));
|
|
268
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
|
|
269
|
+
try {
|
|
270
|
+
let calls = 0;
|
|
271
|
+
let statusDuringReimpl: string | undefined;
|
|
272
|
+
await app.engine.registerWorker("senior:feature", async () => {
|
|
273
|
+
calls += 1;
|
|
274
|
+
if (calls === 1) {
|
|
275
|
+
return { status: "escalated", question: "Which API should I use?", summary: "parked for a human" };
|
|
276
|
+
}
|
|
277
|
+
const run = await app.db
|
|
278
|
+
.table<FeatureRow>("feature_runs", "feature_key")
|
|
279
|
+
.findOne({ feature_key: "owner/repo#7" });
|
|
280
|
+
statusDuringReimpl = run?.status;
|
|
281
|
+
return { status: "opened", pr: "owner/repo#642", summary: "resumed and opened" };
|
|
282
|
+
});
|
|
283
|
+
const featureKey = "owner/repo#7";
|
|
284
|
+
const started = await app.api?.call("startFeature", { body: { issue: featureKey, baseBranch: "epic/e2e" } });
|
|
285
|
+
assert.equal(started?.status, 202, "startFeature accepted the issue");
|
|
286
|
+
await app.settle();
|
|
287
|
+
|
|
288
|
+
const parked = await featureRow(app, featureKey);
|
|
289
|
+
assert.equal(parked.status, "escalated", "the run parks at escalated while awaiting the answer");
|
|
290
|
+
assert.ok(parked.process_key, "the parked run carries its engine process-instance key");
|
|
291
|
+
|
|
292
|
+
const tasks = await app.engine.searchUserTasks({ processInstanceKey: parked.process_key! });
|
|
293
|
+
const task = tasks.find((t) => t.elementId === "feature-escalation") as InboxTask | undefined;
|
|
294
|
+
assert.ok(task?.userTaskKey, "the feature escalation parked a completable native user task");
|
|
295
|
+
|
|
296
|
+
await app.engine.completeUserTask(task!.userTaskKey, { resolution: "answer", answer: "use v2" });
|
|
297
|
+
await app.settle();
|
|
298
|
+
|
|
299
|
+
assert.equal(
|
|
300
|
+
statusDuringReimpl,
|
|
301
|
+
"running",
|
|
302
|
+
"the reset task stamped `running` before implement-task re-ran — no stale escalated",
|
|
303
|
+
);
|
|
304
|
+
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
305
|
+
} finally {
|
|
306
|
+
await app.stop();
|
|
307
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
257
311
|
test("escalate + abandon: abandoning routes to record-feature (default flow)", async () => {
|
|
258
312
|
await withApp(
|
|
259
313
|
{
|
|
@@ -273,7 +327,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
273
327
|
flows.includes("w_gw_answer->record-feature"),
|
|
274
328
|
`abandon routed to record-feature (flows: ${flows.join(", ")})`,
|
|
275
329
|
);
|
|
276
|
-
assert.ok(!flows.includes("w_gw_answer->
|
|
330
|
+
assert.ok(!flows.includes("w_gw_answer->record-feature-implementing"), "the answer loop was NOT taken");
|
|
277
331
|
},
|
|
278
332
|
);
|
|
279
333
|
});
|
|
@@ -322,8 +376,9 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
322
376
|
|
|
323
377
|
const flows = takenFlows(app);
|
|
324
378
|
assert.ok(
|
|
325
|
-
flows.includes("w_gw_answer->
|
|
326
|
-
|
|
379
|
+
flows.includes("w_gw_answer->record-feature-implementing") &&
|
|
380
|
+
flows.includes("record-feature-implementing->implement-task"),
|
|
381
|
+
`the answer re-dispatched the same implement task through the reset (flows: ${flows.join(", ")})`,
|
|
327
382
|
);
|
|
328
383
|
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
329
384
|
|
package/nano.app.json
CHANGED
|
@@ -235,6 +235,10 @@
|
|
|
235
235
|
{
|
|
236
236
|
"taskType": "pr.delivery-connector",
|
|
237
237
|
"handler": "workers/delivery-connector/worker.ts"
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
"taskType": "pr.record-feature-implementing",
|
|
241
|
+
"handler": "workers/record-feature-implementing/worker.ts"
|
|
238
242
|
}
|
|
239
243
|
],
|
|
240
244
|
"externalTaskTypes": [
|