@nanobpm/nano-workforce 0.123.1 → 0.124.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 +22 -0
- package/README.md +9 -5
- package/app/deliveryGraphDeploy.test.ts +209 -0
- package/app/deliveryGraphDispatch.test.ts +143 -0
- package/app/deliveryGraphDispatch.ts +168 -0
- package/app/deliveryGraphProposals.test.ts +267 -0
- package/app/deliveryGraphProposals.ts +269 -0
- package/app/deliveryGraphRun.test.ts +6 -52
- package/app/deliveryGraphRun.ts +21 -76
- package/app/deliveryGraphText.ts +3 -3
- package/app/deliveryRunner.ts +4 -3
- package/app/featureReadModel.test.ts +80 -12
- package/app/github.test.ts +34 -0
- package/app/github.ts +12 -3
- package/app/maybeEnsureFreshHeadRun.test.ts +150 -0
- package/app/mergeEscalationQuestion.test.ts +33 -0
- package/app/mergeProtocol.test.ts +25 -0
- package/app/mergeProtocol.ts +10 -4
- package/app/pollUserTasks.test.ts +27 -0
- package/app/service.ts +92 -13
- package/app/stage.test.ts +21 -7
- package/app/stage.ts +18 -5
- package/db/migrations/075_delivery_graph_proposals.sql +48 -0
- package/db/migrations/075_feature_read_model_attention_from_user_tasks.sql +113 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
- package/docs/adr/0006-delivery-units-one-representation.md +221 -0
- package/docs/agent-guide.md +50 -58
- package/e2e/convergence-escalation.e2e.ts +10 -0
- package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
- package/e2e/retire-escalation-subsystem.e2e.ts +13 -0
- package/openapi.yaml +118 -161
- package/operations/compileDeliveryGraph.test.ts +100 -37
- package/operations/compileDeliveryGraph.ts +64 -18
- package/operations/dispatchDeliveryGraph.test.ts +171 -152
- package/operations/dispatchDeliveryGraph.ts +79 -99
- package/operations/getAgentInstructions.test.ts +10 -6
- package/operations/previewDeliveryGraph.test.ts +90 -51
- package/operations/previewDeliveryGraph.ts +45 -18
- package/package.json +3 -3
- package/pages/cockpit/mount.js +19 -12
- package/pages/delivery-graphs/mount.js +37 -137
- package/pages/delivery-graphs.page.json +50 -3
- package/resources/processes/merge-loop.bpmn +1 -1
- package/scripts/check-migrations.test.ts +9 -0
- package/scripts/check-migrations.ts +11 -1
- package/test/cockpit-embed-endpoints.test.ts +59 -36
- package/test/delivery-graphs-embed.test.ts +36 -34
- package/e2e/delivery-graph-start.e2e.ts +0 -145
- package/operations/startDeliveryGraph.integration.test.ts +0 -316
- package/operations/startDeliveryGraph.ts +0 -222
|
@@ -584,3 +584,30 @@ test("pollUserTasks (engine-first): a delivery-human task on an UNTRACKED run st
|
|
|
584
584
|
assertEquals(byKey["35002"].subject_type, "delivery");
|
|
585
585
|
assertEquals(byKey["35002"].subject_key, "dg-9"); // instance fallback — non-blank so it renders
|
|
586
586
|
});
|
|
587
|
+
|
|
588
|
+
test("pollUserTasks (typed-seam fallback): projects an inlined delivery-human task on a RUNNING run, bucketed `delivery` (issue #442)", async () => {
|
|
589
|
+
// The reduced-capability host (no raw-REST surface) discovers open tasks by scanning each active
|
|
590
|
+
// subject's instance through the typed `openUserTasks` seam. A delivery-graph `human` node parks on its
|
|
591
|
+
// RUNNING run's instance, so that instance MUST be scanned here too — else the inlined
|
|
592
|
+
// `delivery-human-task__<node>` gate is dropped on this path even though its leak guard would accept it.
|
|
593
|
+
// Guards the OTHER discovery path the engine-first sweep tests don't reach.
|
|
594
|
+
const { data, stores } = memData({
|
|
595
|
+
delivery_graph_runs: [
|
|
596
|
+
{ run_key: "delivery-graph-403eb22e", process_key: "dg-1", status: "running", title: "release runbook" },
|
|
597
|
+
{ run_key: "delivery-graph-pending", process_key: null, status: "awaiting-approval", title: "not launched yet" },
|
|
598
|
+
],
|
|
599
|
+
});
|
|
600
|
+
const engine = fakeEngine({
|
|
601
|
+
"dg-1": [{ userTaskKey: "35002", elementId: "delivery-human-task__n1" }],
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
await pollUserTasks(data, engine); // no engineRest → typed-seam fallback
|
|
605
|
+
|
|
606
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
607
|
+
assertEquals(Object.keys(byKey), ["35002"]);
|
|
608
|
+
assertEquals(byKey["35002"].element_id, "delivery-human-task__n1");
|
|
609
|
+
assertEquals(byKey["35002"].kind_label, "Delivery: human step");
|
|
610
|
+
assertEquals(byKey["35002"].subject_type, "delivery");
|
|
611
|
+
assertEquals(byKey["35002"].subject_key, "delivery-graph-403eb22e");
|
|
612
|
+
assertEquals(byKey["35002"].subject_title, "release runbook");
|
|
613
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "./conformance.ts";
|
|
29
29
|
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
30
30
|
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
31
|
+
import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
|
|
31
32
|
import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
|
|
32
33
|
import { isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
33
34
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
@@ -51,7 +52,12 @@ import {
|
|
|
51
52
|
} from "./github.ts";
|
|
52
53
|
import { pollLineage } from "./lineage.ts";
|
|
53
54
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
54
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
freshHeadRunAction,
|
|
57
|
+
headRunPresenceCount,
|
|
58
|
+
loadMergeProtocol,
|
|
59
|
+
type MergeProtocol,
|
|
60
|
+
} from "./mergeProtocol.ts";
|
|
55
61
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
56
62
|
import {
|
|
57
63
|
capabilityGates,
|
|
@@ -178,7 +184,7 @@ export const MERGE_ADMIN = ["1", "true", "on", "yes"].includes(
|
|
|
178
184
|
|
|
179
185
|
const now = () => new Date().toISOString();
|
|
180
186
|
|
|
181
|
-
interface PullRequest {
|
|
187
|
+
export interface PullRequest {
|
|
182
188
|
pr_key: string;
|
|
183
189
|
repo: string;
|
|
184
190
|
number: number;
|
|
@@ -1099,6 +1105,41 @@ async function advanceIfTerminalOutOfBand(
|
|
|
1099
1105
|
return true;
|
|
1100
1106
|
}
|
|
1101
1107
|
|
|
1108
|
+
/** Frugal-CI fresh-head-run self-heal, shared by the `"waiting"` and `"draft"` merge verdicts
|
|
1109
|
+
* (issue #454). Both verdicts feed the same {@link freshHeadRunAction} decision — when the repo's
|
|
1110
|
+
* merge protocol wants a fresh head run and this head has not been nudged yet, produce one
|
|
1111
|
+
* (mark-ready / reopen) and record the head so we fire at most once per landing attempt. Returns
|
|
1112
|
+
* `true` only when the self-heal was **actually applied** (the caller should re-poll); returns
|
|
1113
|
+
* `false` when no self-heal applies **or** the action was selected but failed (`ok === false`, e.g.
|
|
1114
|
+
* missing permission / repo policy) — so a caller that gates escalation on this (the `"draft"`
|
|
1115
|
+
* branch) falls through to the actionable escalation instead of `continue`-looping forever on a
|
|
1116
|
+
* self-heal that can never succeed. One implementation so the two verdicts can never drift (attempt
|
|
1117
|
+
* de-dupe, persistence, logging). `ensure` is injectable for tests; production uses the real
|
|
1118
|
+
* {@link ensureFreshHeadRun}. */
|
|
1119
|
+
export async function maybeEnsureFreshHeadRun(
|
|
1120
|
+
data: DataLayer,
|
|
1121
|
+
repo: string,
|
|
1122
|
+
number: number,
|
|
1123
|
+
prKey: string,
|
|
1124
|
+
protocol: MergeProtocol,
|
|
1125
|
+
verdict: "ready" | "waiting" | "conflict" | "blocked" | "draft",
|
|
1126
|
+
st: PrState,
|
|
1127
|
+
pr: PullRequest,
|
|
1128
|
+
ensure: typeof ensureFreshHeadRun = ensureFreshHeadRun,
|
|
1129
|
+
): Promise<boolean> {
|
|
1130
|
+
const action = freshHeadRunAction(protocol, verdict, headRunPresenceCount(protocol, st), st.isDraft, {
|
|
1131
|
+
headRefOid: st.headRefOid,
|
|
1132
|
+
lastActionHeadRefOid: pr.fresh_head_run_head,
|
|
1133
|
+
});
|
|
1134
|
+
if (!action) return false;
|
|
1135
|
+
const ok = await ensure(repo, number, action).catch(() => false);
|
|
1136
|
+
if (ok && st.headRefOid) {
|
|
1137
|
+
await prs(data).update(prKey, { fresh_head_run_head: st.headRefOid, updated_at: now() });
|
|
1138
|
+
}
|
|
1139
|
+
console.log(`[poller] ${verdict} -> fresh head run (${action}) ${ok ? "requested" : "skipped"} -> ${prKey}`);
|
|
1140
|
+
return ok;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1102
1143
|
/** Merge-stage poll pass (SPEC §11). Four durable waits, each keyed off the PR's `status`, are
|
|
1103
1144
|
* advanced by correlating a message — mirroring the review-ready pattern so the process owns
|
|
1104
1145
|
* the wait and this glue only signals when a GitHub condition is met:
|
|
@@ -1159,6 +1200,35 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1159
1200
|
// the PR as UNSTABLE. The same handle is reused by the frugal-CI fresh-head-run branch below.
|
|
1160
1201
|
const protocol = await loadMergeProtocol(repo, token).catch(() => null);
|
|
1161
1202
|
const verdict = classifyMergeability(st, protocol ?? undefined);
|
|
1203
|
+
if (verdict === "draft") {
|
|
1204
|
+
// A draft PR is never landable — GitHub refuses the merge outright (issue #454). Two remedies,
|
|
1205
|
+
// in order: (1) self-heal — when the repo's merge protocol has a mark-ready capability
|
|
1206
|
+
// (`freshHeadRun: "ready"`/`"ready-or-reopen"`), mark the PR ready ourselves (the frugal-CI
|
|
1207
|
+
// path), which both un-drafts it and produces the required run, then re-poll; a `"reopen"`-only
|
|
1208
|
+
// protocol has NO mark-ready capability, so `freshHeadRunAction` returns null for a draft (a
|
|
1209
|
+
// reopen can't un-draft) and this falls straight through to (2). (2) otherwise — no self-heal
|
|
1210
|
+
// applies, OR the self-heal was attempted but could not be performed (e.g. missing permission /
|
|
1211
|
+
// repo policy) — escalate with an ACTIONABLE "mark it ready" message, instead of
|
|
1212
|
+
// `continue`-looping forever on a self-heal that can never succeed or surfacing GitHub's opaque
|
|
1213
|
+
// "blocked" refusal.
|
|
1214
|
+
if (protocol) {
|
|
1215
|
+
if (await maybeEnsureFreshHeadRun(data, repo, number, prKey, protocol, verdict, st, pr)) {
|
|
1216
|
+
continue; // re-poll: the mark-ready both un-drafts the PR and produces the required run
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
// No applicable (or successful) protocol-driven self-heal → escalate so a human marks it ready.
|
|
1220
|
+
await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
|
|
1221
|
+
name: "merge-ready",
|
|
1222
|
+
correlationKey: prKey,
|
|
1223
|
+
variables: {
|
|
1224
|
+
mergeState: verdict, // "draft" → gw-mergeable default → merge-esc-conflict (draft-aware FEEL)
|
|
1225
|
+
failingChecks: st.failingChecks,
|
|
1226
|
+
failingChecksList: st.failingCheckNames.join("\n"),
|
|
1227
|
+
},
|
|
1228
|
+
});
|
|
1229
|
+
console.log(`[poller] draft (no self-heal) -> escalate mark-ready -> ${prKey}`);
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1162
1232
|
if (verdict === "waiting") {
|
|
1163
1233
|
// Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
|
|
1164
1234
|
// head run and the PR has NO required head run yet, review has converged but the last push
|
|
@@ -1170,17 +1240,7 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1170
1240
|
// `headRefOid`, so downstream merge-train PRs get a new nudge after every post-rebase
|
|
1171
1241
|
// landing attempt.
|
|
1172
1242
|
if (protocol) {
|
|
1173
|
-
|
|
1174
|
-
headRefOid: st.headRefOid,
|
|
1175
|
-
lastActionHeadRefOid: pr.fresh_head_run_head,
|
|
1176
|
-
});
|
|
1177
|
-
if (action) {
|
|
1178
|
-
const ok = await ensureFreshHeadRun(repo, number, action).catch(() => false);
|
|
1179
|
-
if (ok && st.headRefOid) {
|
|
1180
|
-
await prs(data).update(prKey, { fresh_head_run_head: st.headRefOid, updated_at: now() });
|
|
1181
|
-
}
|
|
1182
|
-
console.log(`[poller] fresh head run (${action}) ${ok ? "requested" : "skipped"} -> ${prKey}`);
|
|
1183
|
-
}
|
|
1243
|
+
await maybeEnsureFreshHeadRun(data, repo, number, prKey, protocol, verdict, st, pr);
|
|
1184
1244
|
}
|
|
1185
1245
|
continue; // GitHub still computing / checks pending
|
|
1186
1246
|
}
|
|
@@ -2245,6 +2305,19 @@ export async function pollDeliveryGraphPhase(
|
|
|
2245
2305
|
}
|
|
2246
2306
|
}
|
|
2247
2307
|
|
|
2308
|
+
/** Poll pass (ADR 0005 Decision 7): age out staged delivery-graph proposals whose TTL has elapsed by
|
|
2309
|
+
* flipping them to `expired`, so they drop out of the cockpit's staged grid rather than lingering there
|
|
2310
|
+
* only to fail dispatch. The grid filters purely on `status = 'staged'` (its datasource cannot express an
|
|
2311
|
+
* `expires_at > now` comparison), so this reconciliation sweep is what realises the proposal TTL. It is
|
|
2312
|
+
* data-only and idempotent — a proposal already terminal is left untouched. */
|
|
2313
|
+
export async function pollDeliveryProposals(data: DataLayer) {
|
|
2314
|
+
try {
|
|
2315
|
+
await sweepExpiredProposals(data);
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
console.error(`[poller] delivery graph proposals sweep: ${err}`);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2248
2321
|
export async function pollUserTasks(
|
|
2249
2322
|
data: DataLayer,
|
|
2250
2323
|
engine: EngineClient,
|
|
@@ -2381,6 +2454,11 @@ export async function pollUserTasks(
|
|
|
2381
2454
|
for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
|
|
2382
2455
|
for (const status of PR_ACTIVE_STATUSES) for (const pr of await prs(data).find({ status })) await scanInstance(pr.process_key);
|
|
2383
2456
|
for (const review of await activeConformanceReviews(data)) await scanInstance(review.process_key);
|
|
2457
|
+
// A delivery-graph `human` node parks on its RUNNING run's engine instance (an awaiting-approval run
|
|
2458
|
+
// has no instance yet — mirrors `pollDeliveryGraphPhase`). Scan it too so the inlined
|
|
2459
|
+
// `delivery-human-task__<node>` gate surfaces on this reduced-capability path exactly as it does on
|
|
2460
|
+
// the engine-first sweep — otherwise the typed-seam host silently drops every delivery human gate.
|
|
2461
|
+
for (const run of await deliveryGraphRuns(data).find({ status: "running" })) await scanInstance(run.process_key);
|
|
2384
2462
|
}
|
|
2385
2463
|
|
|
2386
2464
|
const desired = [...desiredByKey.values()];
|
|
@@ -2415,6 +2493,7 @@ export async function pollOnce(
|
|
|
2415
2493
|
await pollLineage(data);
|
|
2416
2494
|
await pollUserTasks(data, engine, engineRest);
|
|
2417
2495
|
await pollDeliveryGraphPhase(data, engine);
|
|
2496
|
+
await pollDeliveryProposals(data);
|
|
2418
2497
|
if (engineRest) {
|
|
2419
2498
|
const base = engineRest.restAddress.replace(/\/+$/, "");
|
|
2420
2499
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
package/app/stage.test.ts
CHANGED
|
@@ -68,14 +68,28 @@ test("skipped: the three converge/auto_merge cases", () => {
|
|
|
68
68
|
assertEquals(deriveStage(base({ status: "running", converge: 1, auto_merge: 1 })).skipped, "");
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
test("attention: derives from
|
|
72
|
-
// Issue #
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
assertEquals(deriveStage(base({ status: "
|
|
71
|
+
test("attention: derives from OPEN user-task engine truth (blocked, escalation, none), NOT from status", () => {
|
|
72
|
+
// Issue #422: `attention` is a pure function of whether an OPEN native user task exists for the run
|
|
73
|
+
// (the `user_tasks` inbox — the authoritative "who is waiting on a human" set), never of the sticky
|
|
74
|
+
// `status` variable. An open `feature-blocked` task → "blocked"; an open `feature-escalation` task → "⚠".
|
|
75
|
+
assertEquals(deriveStage(base({ status: "awaiting_operator", hasOpenBlockedTask: true })).attention, "blocked");
|
|
76
|
+
assertEquals(deriveStage(base({ status: "escalated", hasOpenEscalationTask: true })).attention, "⚠");
|
|
76
77
|
assertEquals(deriveStage(base({ status: "running" })).attention, null);
|
|
77
78
|
});
|
|
78
79
|
|
|
80
|
+
test("attention #422: an ANSWERED escalation (status still 'escalated' but NO open task) shows NO badge", () => {
|
|
81
|
+
// The answer-loop returns the token to `implement-task` with no status reset, so `status` reads a
|
|
82
|
+
// stale "escalated" while the escalation user task is already gone. Sourcing the badge from engine
|
|
83
|
+
// truth (no open task) clears the ⚠ — the drift the old `status`-derived badge produced.
|
|
84
|
+
assertEquals(deriveStage(base({ status: "escalated" })).attention, null);
|
|
85
|
+
assertEquals(deriveStage(base({ status: "escalated", hasOpenEscalationTask: false })).attention, null);
|
|
86
|
+
// And an escalated run WHOSE task is genuinely open still shows ⚠.
|
|
87
|
+
assertEquals(deriveStage(base({ status: "escalated", hasOpenEscalationTask: true })).attention, "⚠");
|
|
88
|
+
// Symmetrically for the blocked/operator wait.
|
|
89
|
+
assertEquals(deriveStage(base({ status: "awaiting_operator" })).attention, null);
|
|
90
|
+
assertEquals(deriveStage(base({ status: "awaiting_operator", hasOpenBlockedTask: true })).attention, "blocked");
|
|
91
|
+
});
|
|
92
|
+
|
|
79
93
|
// The three parked-status rows called out by the plan review.
|
|
80
94
|
test("escalated WITH pr_key → PR open / null", () => {
|
|
81
95
|
const d = deriveStage(base({ status: "escalated", pr_key: "o/r#5" }));
|
|
@@ -89,8 +103,8 @@ test("escalated WITHOUT pr_key → Implementing / null", () => {
|
|
|
89
103
|
assertEquals(d.state, null);
|
|
90
104
|
});
|
|
91
105
|
|
|
92
|
-
test("awaiting_operator WITHOUT pr_key → Implementing / null, attention 'blocked' when
|
|
93
|
-
const d = deriveStage(base({ status: "awaiting_operator", pr_key: null }));
|
|
106
|
+
test("awaiting_operator WITHOUT pr_key → Implementing / null, attention 'blocked' when its task is open", () => {
|
|
107
|
+
const d = deriveStage(base({ status: "awaiting_operator", pr_key: null, hasOpenBlockedTask: true }));
|
|
94
108
|
assertEquals(d.stage, "Implementing");
|
|
95
109
|
assertEquals(d.state, null);
|
|
96
110
|
assertEquals(d.attention, "blocked");
|
package/app/stage.ts
CHANGED
|
@@ -43,6 +43,14 @@ export interface StageInput {
|
|
|
43
43
|
pr_key?: string | null;
|
|
44
44
|
converge?: number | boolean | null;
|
|
45
45
|
auto_merge?: number | boolean | null;
|
|
46
|
+
/** Engine truth for the `attention` badge (issue #422): whether an OPEN native user task of each
|
|
47
|
+
* human-wait kind currently exists for this run, from the `user_tasks` inbox (`pollUserTasks`, the
|
|
48
|
+
* authoritative "who is waiting on a human" set). `attention` derives from THESE, never from the
|
|
49
|
+
* drift-prone `status` variable — so once an escalation is answered (its `user_tasks` row deleted)
|
|
50
|
+
* the badge clears immediately even while `status` still reads a stale `"escalated"`. Omitted/false
|
|
51
|
+
* ⇒ no open task ⇒ no badge. `feature_read_model` (075) mirrors this with correlated EXISTS lookups. */
|
|
52
|
+
hasOpenBlockedTask?: boolean | null;
|
|
53
|
+
hasOpenEscalationTask?: boolean | null;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
/** The derived pipeline projection for one run. `skipped` is a space-separated set of stage keys not
|
|
@@ -91,11 +99,16 @@ export function deriveStage(run: StageInput): DerivedStage {
|
|
|
91
99
|
|
|
92
100
|
// `attention`: a short badge for the active stage (the renderer colours it from `state`). This is how
|
|
93
101
|
// a parked `awaiting_operator`/`escalated` run surfaces as attention WITHOUT altering its stage.
|
|
94
|
-
// Derived from
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
102
|
+
// Derived from ENGINE TRUTH — the presence of an OPEN native user task (issue #422), NOT from the
|
|
103
|
+
// `status` variable. `status` is worker-written imperatively and goes stale on the answer-loop back
|
|
104
|
+
// into `implement-task` (the process does not reset it), so a run whose escalation was already
|
|
105
|
+
// ANSWERED still reads `status="escalated"` until its next job completes; sourcing the badge from
|
|
106
|
+
// that value made the read model lie (a resolved run flagged ⚠ on Overview). The authoritative
|
|
107
|
+
// "who is waiting on a human" set is the `user_tasks` inbox (`pollUserTasks`), which holds a row
|
|
108
|
+
// IFF the task is open and deletes it the moment it is answered — so a run shows the blocked glyph
|
|
109
|
+
// IFF an open `feature-blocked` task exists, and ⚠ IFF an open `feature-escalation` task exists.
|
|
110
|
+
// Once answered, the row is gone and the badge clears regardless of the stale `status`.
|
|
111
|
+
const attention = truthy(run.hasOpenBlockedTask) ? "blocked" : truthy(run.hasOpenEscalationTask) ? "⚠" : null;
|
|
99
112
|
|
|
100
113
|
return { stage, state, skipped: skippedKeys.join(" "), attention };
|
|
101
114
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- The `staged` delivery-graph proposal store (ADR 0005 Decision 7, issue #460). This realises
|
|
2
|
+
-- `propose → preview → approve → dispatch` as intended: the agent-facing surface ends at
|
|
3
|
+
-- propose → compile → STAGE, and a HUMAN dispatches the staged proposal from the cockpit. The old
|
|
4
|
+
-- `approvalToken` was a REPLAYABLE content digest handed back to the same caller, so any holder of
|
|
5
|
+
-- the API credential self-approved. Removing the dispatch affordance from the agent surface (there is
|
|
6
|
+
-- no `start` endpoint) dissolves that hole: the compile door persists the compiled graph HERE as a
|
|
7
|
+
-- `staged` proposal and returns only a preview + a navigational `reviewUrl` — nothing that can trigger
|
|
8
|
+
-- a run. The cockpit lists these rows, renders the preview, and dispatches the one the operator picks.
|
|
9
|
+
--
|
|
10
|
+
-- • digest (PK) — the content address of the compiled graph (`sha256(compiled.bpmn)[:12]`), the
|
|
11
|
+
-- SAME digest the runner uses for the content-addressed deploy id. It NAMES the proposal so the
|
|
12
|
+
-- agent can hand the operator an unambiguous "dispatch <digest>" and the operator dispatches
|
|
13
|
+
-- EXACTLY the digest they previewed. A re-compile of the same bytes is idempotent (same PK).
|
|
14
|
+
-- • logical_key — the LOGICAL graph identity (the graph's `name`, else the digest) used to
|
|
15
|
+
-- SUPERSEDE: staging a changed graph (new digest) for the same logical key retires the prior
|
|
16
|
+
-- staged proposal, so the cockpit shows one live proposal per logical graph, not every recompile.
|
|
17
|
+
-- • graph — the original `DeliveryGraph` JSON, retained so the cockpit dispatch action can run the
|
|
18
|
+
-- runner for the previewed digest without the agent re-submitting anything.
|
|
19
|
+
-- • preview — the rendered preview JSON (`{ diagram, sideEffects, humanNodes }`) the cockpit shows,
|
|
20
|
+
-- stamped at stage time so the list renders without recompiling.
|
|
21
|
+
-- • status — `staged` (awaiting operator review), `superseded` (replaced by a newer digest for its
|
|
22
|
+
-- logical key), `dispatched` (the operator launched it), or `expired` (aged out of its TTL before
|
|
23
|
+
-- dispatch). Only `staged` rows show in the cockpit; the poller sweeps aged-out `staged` rows to
|
|
24
|
+
-- `expired` (the grid's datasource filter is equality-only, so expiry is realised by that status
|
|
25
|
+
-- flip, not an `expires_at > now` clause).
|
|
26
|
+
-- • expires_at — the TTL horizon. Staged proposals age out of the cockpit list so a stale entry an
|
|
27
|
+
-- operator never dispatched does not linger; the poller flips an aged-out `staged` row to `expired`.
|
|
28
|
+
CREATE TABLE IF NOT EXISTS delivery_graph_proposals (
|
|
29
|
+
digest TEXT PRIMARY KEY,
|
|
30
|
+
logical_key TEXT NOT NULL,
|
|
31
|
+
title TEXT,
|
|
32
|
+
graph TEXT NOT NULL,
|
|
33
|
+
preview TEXT NOT NULL,
|
|
34
|
+
node_count INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
human_node_count INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
side_effect_count INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
side_effecting INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
status TEXT NOT NULL DEFAULT 'staged',
|
|
39
|
+
created_at TEXT NOT NULL,
|
|
40
|
+
updated_at TEXT NOT NULL,
|
|
41
|
+
expires_at TEXT NOT NULL
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
-- Supersede scans by logical_key; the cockpit list filters by status + expiry.
|
|
45
|
+
CREATE INDEX IF NOT EXISTS ix_delivery_graph_proposals_logical
|
|
46
|
+
ON delivery_graph_proposals (logical_key);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS ix_delivery_graph_proposals_status
|
|
48
|
+
ON delivery_graph_proposals (status);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
-- Feature-run `attention` badge: derive it from engine truth (an OPEN native user task), not from the
|
|
2
|
+
-- drift-prone `status` variable (issue #422 — the L1 surface closure #439 named but did not ship).
|
|
3
|
+
--
|
|
4
|
+
-- 073_feature_read_model.sql retired the WRITE-TIME display projection (L2) into this VIEW, but it
|
|
5
|
+
-- still derived `attention` from the row's own `status` column:
|
|
6
|
+
-- WHEN fr.status = 'awaiting_operator' THEN 'blocked'
|
|
7
|
+
-- WHEN fr.status = 'escalated' THEN '⚠'
|
|
8
|
+
-- `status` is a process-scope variable set imperatively by the workers on the happy path. The `feature`
|
|
9
|
+
-- process loops the answer arm (`w_answerLoop`, resolution="answer") straight back into `implement-task`
|
|
10
|
+
-- with NO reset step, so after an escalation is ANSWERED the token is ACTIVE again at `implement-task`
|
|
11
|
+
-- while `status` still reads the previous iteration's `"escalated"` until the re-running agent job
|
|
12
|
+
-- completes and overwrites it (issue #422, observed live on merlin: feature instance 31779 showing ⚠
|
|
13
|
+
-- on Overview though its escalation was resolved and it was back implementing). Deriving the badge from
|
|
14
|
+
-- that sticky value makes the read model LIE — the exact "projected state maintained imperatively at
|
|
15
|
+
-- write time" defect class #439 set out to close ("derive it, don't maintain it — No Drift Surfaces").
|
|
16
|
+
--
|
|
17
|
+
-- The authoritative "who is waiting on a human" set is NOT `status` — it is the `user_tasks` inbox
|
|
18
|
+
-- (034_user_tasks_inbox.sql): `pollUserTasks` (app/service.ts) reconciles exactly one row per CURRENTLY
|
|
19
|
+
-- OPEN escalation user task from the engine and DELETES the row the moment the task closes (answered
|
|
20
|
+
-- here, via the Tasks inbox, or out-of-band). So a run is:
|
|
21
|
+
-- * awaiting an operator (blocked glyph) IFF an open `feature-blocked` user task exists for it, and
|
|
22
|
+
-- * escalated (⚠ badge) IFF an open `feature-escalation` user task exists for it.
|
|
23
|
+
-- Deriving `attention` from that presence (engine truth) instead of `status` closes the surface: once
|
|
24
|
+
-- the escalation is answered the `user_tasks` row is gone, so ⚠ clears immediately REGARDLESS of the
|
|
25
|
+
-- stale `status`. There is no stored column and no write path any writer can leave stale — the badge is
|
|
26
|
+
-- a pure function of the live open-task set, recomputed on every read. `deriveStage` (app/stage.ts)
|
|
27
|
+
-- remains the canonical TS oracle: it now takes the same open-task signals, and
|
|
28
|
+
-- app/featureReadModel.test.ts pins the VIEW to it in lockstep over the full status × open-task matrix
|
|
29
|
+
-- (including the #422 case: status='escalated' with NO open task → attention NULL).
|
|
30
|
+
--
|
|
31
|
+
-- pollUserTasks keys these rows `subject_type='feature'`, `subject_key=<feature_key>` (app/service.ts
|
|
32
|
+
-- DEFAULT_SUBJECT_TYPE / contextFor), so the correlated match is on `fr.feature_key`. `stage` is
|
|
33
|
+
-- deliberately UNCHANGED — an escalated/awaiting_operator run maps to `Implementing`, which is correct
|
|
34
|
+
-- whether or not the flag is stale (a run back at `implement-task` IS implementing); only the attention
|
|
35
|
+
-- badge was drifting, so only it moves to engine-truth derivation.
|
|
36
|
+
--
|
|
37
|
+
-- Forward-only, non-additive to `feature_runs` (a VIEW redefinition): `DROP VIEW` then `CREATE VIEW`,
|
|
38
|
+
-- plus one idempotent `CREATE INDEX IF NOT EXISTS` on `user_tasks` to front the correlated `attention`
|
|
39
|
+
-- lookups (see the trailing index comment).
|
|
40
|
+
-- 073 is a MERGED, IMMUTABLE migration — never edited; this is a NEW migration that supersedes its VIEW
|
|
41
|
+
-- definition. `user_tasks` (034) already exists earlier in the chain, so the correlated subquery
|
|
42
|
+
-- resolves. A single plain `CREATE VIEW … SELECT … FROM feature_runs fr` (the `user_tasks` lookups are
|
|
43
|
+
-- nested EXISTS subqueries at paren depth ≥ 1, so `feature_runs fr` stays the sole top-level FROM and
|
|
44
|
+
-- every output column stays aliased — the static pages↔schema contract guard, scripts/pages-contract.
|
|
45
|
+
-- test.ts, still parses the projection). Numbered after the current highest prefix on origin/main (074).
|
|
46
|
+
-- The runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
47
|
+
|
|
48
|
+
DROP VIEW IF EXISTS feature_read_model;
|
|
49
|
+
|
|
50
|
+
CREATE VIEW feature_read_model AS
|
|
51
|
+
SELECT
|
|
52
|
+
fr.feature_key AS feature_key,
|
|
53
|
+
fr.repo AS repo,
|
|
54
|
+
fr.issue_number AS issue_number,
|
|
55
|
+
fr.issue_url AS issue_url,
|
|
56
|
+
fr.title AS title,
|
|
57
|
+
fr.base_branch AS base_branch,
|
|
58
|
+
fr.status AS status,
|
|
59
|
+
fr.process_key AS process_key,
|
|
60
|
+
fr.pr_key AS pr_key,
|
|
61
|
+
fr.converge AS converge,
|
|
62
|
+
fr.auto_merge AS auto_merge,
|
|
63
|
+
fr.outcome AS outcome,
|
|
64
|
+
fr.delivery_label AS delivery_label,
|
|
65
|
+
fr.acknowledged_at AS acknowledged_at,
|
|
66
|
+
fr.created_at AS created_at,
|
|
67
|
+
fr.updated_at AS updated_at,
|
|
68
|
+
(CASE
|
|
69
|
+
WHEN fr.status IN ('merged', 'converged', 'blocked', 'failed', 'skipped', 'abandoned') THEN 'Done'
|
|
70
|
+
WHEN fr.status = 'converging' THEN 'Converging'
|
|
71
|
+
WHEN (fr.pr_key IS NOT NULL AND fr.pr_key <> '') OR fr.status = 'opened' THEN 'PR open'
|
|
72
|
+
WHEN fr.status IN ('running', 'escalated', 'awaiting_operator') THEN 'Implementing'
|
|
73
|
+
ELSE 'Requested'
|
|
74
|
+
END) AS stage,
|
|
75
|
+
(CASE
|
|
76
|
+
WHEN fr.status IN ('merged', 'converged') THEN 'ok'
|
|
77
|
+
WHEN fr.status = 'blocked' THEN 'blocked'
|
|
78
|
+
WHEN fr.status IN ('failed', 'skipped', 'abandoned') THEN 'failed'
|
|
79
|
+
ELSE NULL
|
|
80
|
+
END) AS stage_state,
|
|
81
|
+
(CASE
|
|
82
|
+
WHEN NOT (fr.converge IS NOT NULL AND fr.converge <> 0) THEN 'Converging Merging'
|
|
83
|
+
WHEN NOT (fr.auto_merge IS NOT NULL AND fr.auto_merge <> 0) THEN 'Merging'
|
|
84
|
+
ELSE ''
|
|
85
|
+
END) AS stage_skipped,
|
|
86
|
+
(CASE
|
|
87
|
+
WHEN EXISTS (
|
|
88
|
+
SELECT 1 FROM user_tasks ut
|
|
89
|
+
WHERE ut.subject_type = 'feature' AND ut.subject_key = fr.feature_key
|
|
90
|
+
AND ut.element_id = 'feature-blocked'
|
|
91
|
+
) THEN 'blocked'
|
|
92
|
+
WHEN EXISTS (
|
|
93
|
+
SELECT 1 FROM user_tasks ut
|
|
94
|
+
WHERE ut.subject_type = 'feature' AND ut.subject_key = fr.feature_key
|
|
95
|
+
AND ut.element_id = 'feature-escalation'
|
|
96
|
+
) THEN '⚠'
|
|
97
|
+
ELSE NULL
|
|
98
|
+
END) AS attention,
|
|
99
|
+
(CASE
|
|
100
|
+
WHEN fr.status IN ('merged', 'converged', 'blocked', 'failed', 'skipped', 'abandoned') AND fr.acknowledged_at IS NOT NULL THEN 'history'
|
|
101
|
+
ELSE 'active'
|
|
102
|
+
END) AS list_bucket
|
|
103
|
+
FROM feature_runs fr;
|
|
104
|
+
|
|
105
|
+
-- Supporting index for the correlated `attention` EXISTS lookups above. Each row of
|
|
106
|
+
-- `feature_read_model` probes `user_tasks` by `(subject_type, subject_key, element_id)` (twice: once
|
|
107
|
+
-- for `feature-blocked`, once for `feature-escalation`); the only prior index (034) is on
|
|
108
|
+
-- `(element_id, updated_at)`, which does not front the equality on `subject_type`/`subject_key`, so a
|
|
109
|
+
-- page reading many `feature_runs` rows would repeat a `user_tasks` scan per row. A composite index on
|
|
110
|
+
-- the exact equality tuple turns each probe into an index seek. `IF NOT EXISTS` keeps the migration
|
|
111
|
+
-- idempotent on any DB that already carries the index.
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_user_tasks_subject_element
|
|
113
|
+
ON user_tasks(subject_type, subject_key, element_id);
|
|
@@ -160,6 +160,11 @@ shared JSON contract means either can be swapped in later without touching the a
|
|
|
160
160
|
|
|
161
161
|
### 7. Submission is propose → preview → approve → dispatch, idempotent, over the self-describing endpoint
|
|
162
162
|
|
|
163
|
+
> **Superseded — see the *Amendment (issue #460)* at the end of this section.** The `POST
|
|
164
|
+
> /actions/start/delivery-graph` agent endpoint described in the following paragraph was **never
|
|
165
|
+
> shipped and has been removed**; the agent surface ends at propose → compile → stage and dispatch is
|
|
166
|
+
> operator-only. The paragraph below is retained as the original (Proposed) decision record.
|
|
167
|
+
|
|
163
168
|
Graphs are submitted exactly as epics are today — via a **new (proposed)** `POST
|
|
164
169
|
/actions/start/delivery-graph` endpoint (paths are relative to the agent guide's `__BASE__` prefix,
|
|
165
170
|
matching the guide's style) with the JSON body, discovered via the agent guide (which already
|
|
@@ -174,6 +179,19 @@ at-least-once execution (mirroring the release workflow's `npx semantic-release`
|
|
|
174
179
|
"skip already-published" discipline — `.github/workflows/release.yml`) so a
|
|
175
180
|
resume cannot double-fire.
|
|
176
181
|
|
|
182
|
+
> **Amendment (issue #460): dispatch is operator-only.** As implemented, the "approve → dispatch"
|
|
183
|
+
> half of this decision is **not** an agent endpoint. The agent surface ends at **propose → compile →
|
|
184
|
+
> stage**: the `POST /actions/compile-delivery-graph` door validates + previews the graph and, on
|
|
185
|
+
> success, **stages** it as a proposal (a durable `delivery_graph_proposals` row, content-addressed by
|
|
186
|
+
> `digest`, superseded per logical graph + TTL-bounded), returning only a preview + a navigational
|
|
187
|
+
> `reviewUrl` — no run key, token, or PIK. A **human dispatches** the staged proposal from the cockpit's
|
|
188
|
+
> Delivery Graphs page (`POST /actions/delivery-graph/dispatch` by `digest`, an operator route). The
|
|
189
|
+
> originally-proposed agent `POST /actions/start/delivery-graph` door — where the same caller was handed
|
|
190
|
+
> a content-addressed `approvalToken` to re-submit with — was **removed**: that "approval" was a
|
|
191
|
+
> **replayable** digest returned to the approver, so any holder of the API credential self-approved.
|
|
192
|
+
> Dispatch-by-absence (there is no agent start door) closes that hole categorically; the idempotent
|
|
193
|
+
> at-most-once launch fence is retained on the operator dispatch path.
|
|
194
|
+
|
|
177
195
|
## Consequences
|
|
178
196
|
|
|
179
197
|
- nwf gains a **generic delivery-graph runner** that composes its existing primitives; the motivating
|