@nanobpm/nano-workforce 0.82.1 → 0.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/pr-title-lint.yml +39 -0
- package/AGENTS.md +10 -0
- package/CHANGELOG.md +14 -0
- package/app/feature.test.ts +24 -0
- package/app/feature.ts +147 -1
- package/app/featureGateway.test.ts +195 -0
- package/app/service.ts +16 -1
- package/app/stage.test.ts +107 -0
- package/app/stage.ts +111 -0
- package/db/migrations/039_feature_pipeline_stage.sql +29 -0
- package/main.ts +5 -1
- package/openapi.yaml +47 -0
- package/operations/acknowledgeDone.test.ts +108 -0
- package/operations/acknowledgeDone.ts +53 -0
- package/package.json +2 -2
- package/pages/epic-detail.page.json +16 -31
- package/pages/feature.page.json +32 -12
- package/scripts/pages-contract.test.ts +22 -7
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: PR title lint
|
|
2
|
+
|
|
3
|
+
# Squash-merge uses the PR title as the commit subject on `main`, and
|
|
4
|
+
# semantic-release (Angular preset) only cuts a release for `feat:`/`fix:`/`perf:`.
|
|
5
|
+
# A non-conventional title therefore lands on `main` and is silently skipped by
|
|
6
|
+
# the release job — so gate the title against the Conventional Commits grammar.
|
|
7
|
+
on:
|
|
8
|
+
pull_request:
|
|
9
|
+
types: [opened, edited, synchronize, reopened]
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
pull-requests: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
lint-title:
|
|
16
|
+
name: Conventional PR title
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: amannn/action-semantic-pull-request@v5
|
|
20
|
+
env:
|
|
21
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
22
|
+
with:
|
|
23
|
+
# Keep in lockstep with the semantic-release commit-analyzer preset.
|
|
24
|
+
types: |
|
|
25
|
+
feat
|
|
26
|
+
fix
|
|
27
|
+
perf
|
|
28
|
+
build
|
|
29
|
+
chore
|
|
30
|
+
ci
|
|
31
|
+
docs
|
|
32
|
+
refactor
|
|
33
|
+
revert
|
|
34
|
+
style
|
|
35
|
+
test
|
|
36
|
+
requireScope: false
|
|
37
|
+
subjectPattern: ^(?![A-Z]).+$
|
|
38
|
+
subjectPatternError: |
|
|
39
|
+
The subject "{subject}" must start with a lowercase letter.
|
package/AGENTS.md
CHANGED
|
@@ -308,6 +308,16 @@ agents:
|
|
|
308
308
|
DCO check.
|
|
309
309
|
- **Conventional Commits.** `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`,
|
|
310
310
|
`test:`, imperative mood. Review-comment fix-ups are `chore:`, not `fix:`.
|
|
311
|
+
- **PR titles must be Conventional too — they become the release trigger.** PRs
|
|
312
|
+
land on `main` via **squash merge**, so the **PR title is the commit subject**
|
|
313
|
+
semantic-release analyses. Only `feat:` (minor) and `fix:`/`perf:` (patch) cut a
|
|
314
|
+
release; any other type — or a non-conventional title like `Redesign …` or
|
|
315
|
+
`Foundation: …` — lands on `main` and is **silently skipped** by the release job
|
|
316
|
+
(no version, no changelog, no deploy). A user-facing feature **must** be titled
|
|
317
|
+
`feat:`. The `PR title lint` workflow (`.github/workflows/pr-title-lint.yml`)
|
|
318
|
+
enforces this; if a non-conventional title ever slips through, push one empty
|
|
319
|
+
releasable commit (`git commit --allow-empty -s -m "feat(scope): …"`) to release
|
|
320
|
+
the accumulated changes.
|
|
311
321
|
- **Feature work in a worktree** off `origin/main`, one branch per change; open a
|
|
312
322
|
PR and reference the closing issue (`Closes #NN`).
|
|
313
323
|
- **Never `git push --force` on `main`;** use `--force-with-lease` on feature
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.84.0](https://github.com/nanobpm/nano-workforce/compare/v0.83.0...v0.84.0) (2026-08-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **ui:** render narrative epic-detail sections with urban prose renderer ([#270](https://github.com/nanobpm/nano-workforce/issues/270)) ([#271](https://github.com/nanobpm/nano-workforce/issues/271)) ([38c67a4](https://github.com/nanobpm/nano-workforce/commit/38c67a479693730b9a79f3a7b0f88ef574d3a456)), closes [nano-ide#274](https://github.com/nano-ide/issues/274) [#87](https://github.com/nanobpm/nano-workforce/issues/87) [274/#275](https://github.com/nanobpm/nano-workforce/issues/275)
|
|
7
|
+
|
|
8
|
+
# [0.83.0](https://github.com/nanobpm/nano-workforce/compare/v0.82.1...v0.83.0) (2026-08-17)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **feature-view:** release intent-first pipeline view with stage chips ([0342edb](https://github.com/nanobpm/nano-workforce/commit/0342edbf67372450d6c499c1d39dc042e8dda342)), closes [#267](https://github.com/nanobpm/nano-workforce/issues/267) [#266](https://github.com/nanobpm/nano-workforce/issues/266)
|
|
14
|
+
|
|
1
15
|
## [0.82.1](https://github.com/nanobpm/nano-workforce/compare/v0.82.0...v0.82.1) (2026-08-17)
|
|
2
16
|
|
|
3
17
|
|
package/app/feature.test.ts
CHANGED
|
@@ -239,6 +239,30 @@ test("startFeature: a settled run is restarted in place (status reset, pr/outcom
|
|
|
239
239
|
assertEquals(row.process_key, "PI-2");
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
+
test("startFeature: an in-place restart clears a stale acknowledged_at (re-earn the tick-off)", async () => {
|
|
243
|
+
const stores = {
|
|
244
|
+
feature_runs: {
|
|
245
|
+
rows: [
|
|
246
|
+
{
|
|
247
|
+
feature_key: "owner/repo#42",
|
|
248
|
+
status: "merged",
|
|
249
|
+
process_key: "PI-OLD",
|
|
250
|
+
pr_key: "owner/repo#100",
|
|
251
|
+
acknowledged_at: "2024-01-01T00:00:00Z",
|
|
252
|
+
converge: 1,
|
|
253
|
+
auto_merge: 1,
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
key: "feature_key",
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-3" }) } as any;
|
|
260
|
+
await startFeature(memData(stores), engine, PARSED, "main", true, true);
|
|
261
|
+
const row = stores.feature_runs.rows[0];
|
|
262
|
+
assertEquals(row.status, "running");
|
|
263
|
+
assertEquals(row.acknowledged_at, null);
|
|
264
|
+
});
|
|
265
|
+
|
|
242
266
|
// Issue #248: the human-readable identity for the feature grids. Every start persists a non-blank
|
|
243
267
|
// `title` — the fetched issue title when available, else the `owner/repo#N` key — on BOTH the insert
|
|
244
268
|
// (new run) and update (in-place restart) paths, so the title-led grid never renders a blank cell.
|
package/app/feature.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
18
18
|
import { coalesceTitle, fetchIssueTitle } from "./github.ts";
|
|
19
19
|
import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
|
|
20
|
+
import { deriveListBucket, deriveStage } from "./stage.ts";
|
|
20
21
|
|
|
21
22
|
/** The BPMN process this module drives (resources/processes/feature.bpmn). */
|
|
22
23
|
export const FEATURE_PROCESS_ID = "feature";
|
|
@@ -70,6 +71,25 @@ export interface FeatureRun {
|
|
|
70
71
|
* (`record-blocked-ack` / the acknowledge operation) and, as a self-heal, by `pollFeatureBlocked`
|
|
71
72
|
* when a previously-observed task is completed out-of-band (see `deriveFeatureBlockedPatch`). */
|
|
72
73
|
blocked_user_task_key: string | null;
|
|
74
|
+
/** Timestamp an operator dismissed a TERMINAL run (§5, `acknowledge-done`); NULL until then. When
|
|
75
|
+
* set on a terminal row, `list_bucket` flips from 'active' to 'history'. Projection surface. */
|
|
76
|
+
acknowledged_at: string | null;
|
|
77
|
+
/** Projection maintained by the feature_runs gateway (like `delivery_label`): the canonical pipeline
|
|
78
|
+
* stage key from `deriveStage` (Requested|Implementing|PR open|Converging|Merging|Done). The page's
|
|
79
|
+
* pipeline column binds `activeField` to it. NULL only on legacy rows before `backfillFeatureStages`. */
|
|
80
|
+
stage: string | null;
|
|
81
|
+
/** Gateway projection: the active stage's render state from `deriveStage` (`ok`|`failed`|`blocked`|
|
|
82
|
+
* NULL). The page binds `stateField` to it; NULL means in-progress (renderer shows `active`). */
|
|
83
|
+
stage_state: string | null;
|
|
84
|
+
/** Gateway projection: space-separated set of stage keys NOT in this row's path from `deriveStage`
|
|
85
|
+
* (derived from `converge`/`auto_merge`). The page binds `notInPathField` to it. */
|
|
86
|
+
stage_skipped: string | null;
|
|
87
|
+
/** Gateway projection: a short attention badge (`blocked` / `⚠`) for the active stage, or NULL, from
|
|
88
|
+
* `deriveStage`. The page binds `badgeField` to it. */
|
|
89
|
+
attention: string | null;
|
|
90
|
+
/** Gateway projection: the Active/History partition label ('active'|'history'), 'history' iff a
|
|
91
|
+
* terminal row has been acknowledged. The page's tabs filter on it with flat `in` clauses (§5). */
|
|
92
|
+
list_bucket: string | null;
|
|
73
93
|
created_at: string;
|
|
74
94
|
updated_at: string;
|
|
75
95
|
}
|
|
@@ -251,7 +271,128 @@ export function deriveFeatureBlockedPatch(
|
|
|
251
271
|
return Object.keys(patch).length > 0 ? patch : null;
|
|
252
272
|
}
|
|
253
273
|
|
|
254
|
-
|
|
274
|
+
/** The feature_runs fields the projection reads. A patch touching none of these cannot change the
|
|
275
|
+
* derived `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`, so the gateway can skip the
|
|
276
|
+
* read-back+reproject for it (see the `update` proxy). Kept adjacent to `projectFeatureRun` so the two
|
|
277
|
+
* stay in lockstep — every field `projectFeatureRun` reads MUST appear here. */
|
|
278
|
+
const PROJECTION_INPUT_KEYS: readonly (keyof FeatureRun)[] = [
|
|
279
|
+
"status",
|
|
280
|
+
"pr_key",
|
|
281
|
+
"converge",
|
|
282
|
+
"auto_merge",
|
|
283
|
+
"escalation_question",
|
|
284
|
+
"escalation_user_task_key",
|
|
285
|
+
"blocked_user_task_key",
|
|
286
|
+
"acknowledged_at",
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
/** The feature_runs fields the projection WRITES. Included in the reproject trigger so a caller who
|
|
290
|
+
* writes a derived column directly (e.g. `stage`/`list_bucket`) can never bypass derivation: the
|
|
291
|
+
* gateway re-reads, recomputes, and OVERRIDES the raw value with the canonical derived one, keeping
|
|
292
|
+
* "the gateway is the one projection source" a true invariant. Must mirror `projectFeatureRun`'s keys. */
|
|
293
|
+
const PROJECTION_OUTPUT_KEYS: readonly (keyof FeatureRun)[] = [
|
|
294
|
+
"stage",
|
|
295
|
+
"stage_state",
|
|
296
|
+
"stage_skipped",
|
|
297
|
+
"attention",
|
|
298
|
+
"list_bucket",
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
/** True when a patch changes at least one field the projection derives from OR one it writes — i.e. the
|
|
302
|
+
* projection must be recomputed. A patch touching only projection-irrelevant fields (e.g. `updated_at`)
|
|
303
|
+
* leaves the stored projection correct, since the gateway is the sole write path (see `featureRuns`); a
|
|
304
|
+
* patch that writes a derived column directly still forces a reproject so derivation can't be bypassed. */
|
|
305
|
+
function patchAffectsProjection(patch: Partial<FeatureRun>): boolean {
|
|
306
|
+
return PROJECTION_INPUT_KEYS.some((k) => k in patch) || PROJECTION_OUTPUT_KEYS.some((k) => k in patch);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Compute the write-time projection columns for a fully-merged feature_runs row. Centralised so the
|
|
310
|
+
* gateway is the ONE place `deriveStage` / `deriveListBucket` are applied — the page, SQL, pollers and
|
|
311
|
+
* workers never re-derive the mapping (AGENTS.md "derivation over duplication"). */
|
|
312
|
+
function projectFeatureRun(row: Partial<FeatureRun>): Partial<FeatureRun> {
|
|
313
|
+
if (!row.status) return {};
|
|
314
|
+
const { stage, state, skipped, attention } = deriveStage({
|
|
315
|
+
status: row.status,
|
|
316
|
+
pr_key: row.pr_key ?? null,
|
|
317
|
+
converge: row.converge ?? null,
|
|
318
|
+
auto_merge: row.auto_merge ?? null,
|
|
319
|
+
escalation_question: row.escalation_question ?? null,
|
|
320
|
+
escalation_user_task_key: row.escalation_user_task_key ?? null,
|
|
321
|
+
blocked_user_task_key: row.blocked_user_task_key ?? null,
|
|
322
|
+
});
|
|
323
|
+
return {
|
|
324
|
+
stage,
|
|
325
|
+
stage_state: state,
|
|
326
|
+
stage_skipped: skipped,
|
|
327
|
+
attention,
|
|
328
|
+
list_bucket: deriveListBucket(row.status, row.acknowledged_at ?? null),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The feature_runs record gateway (keyed on `feature_key`). Wrapped in a thin projecting proxy so the
|
|
333
|
+
* derived pipeline columns (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) CANNOT be
|
|
334
|
+
* missed by any writer: `insert` and `update` merge the incoming values over the current stored row,
|
|
335
|
+
* then recompute the projection from that post-write field set and write it alongside — exactly the
|
|
336
|
+
* `delivery_label`-style write-time projection, but hoisted to the single gateway so the many scattered
|
|
337
|
+
* status writers (startFeature, the service pollers/reconcilers, the acknowledge operations, and the
|
|
338
|
+
* feature workers) all stay UNCHANGED and automatically get a correct, fresh projection. `update`
|
|
339
|
+
* skips the read-back+reproject for a patch that touches no projection input (e.g. an `updated_at`-only
|
|
340
|
+
* poller write), avoiding a needless `get` roundtrip — the stored projection is already correct since
|
|
341
|
+
* this gateway is the sole write path. Every other method delegates straight through. This is the sole
|
|
342
|
+
* runtime/app-layer WRITE path to feature_runs (no app-code raw SQL, no other `data.table("feature_runs")`
|
|
343
|
+
* mutation — read-only direct reads in e2e tests, and forward-only data migrations such as
|
|
344
|
+
* `db/migrations/036_backfill_titles.sql`, notwithstanding), so
|
|
345
|
+
* `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket` are
|
|
346
|
+
* always populated and correct for every row and every transition. */
|
|
347
|
+
export const featureRuns = (data: DataLayer) => {
|
|
348
|
+
const table = data.table<FeatureRun>("feature_runs", "feature_key");
|
|
349
|
+
return new Proxy(table, {
|
|
350
|
+
get(target, prop) {
|
|
351
|
+
if (prop === "insert") {
|
|
352
|
+
return (row: Partial<FeatureRun>) => target.insert({ ...row, ...projectFeatureRun(row) });
|
|
353
|
+
}
|
|
354
|
+
if (prop === "update") {
|
|
355
|
+
return async (id: unknown, patch: Partial<FeatureRun>) => {
|
|
356
|
+
// Only re-read + reproject when the patch changes a projection input. A projection-irrelevant
|
|
357
|
+
// patch (e.g. an `updated_at`-only poller write) leaves the stored projection correct — the
|
|
358
|
+
// gateway is the sole write path — so skip the extra `get` roundtrip and delegate straight.
|
|
359
|
+
if (!patchAffectsProjection(patch)) return target.update(id, patch);
|
|
360
|
+
const existing = await target.get(id);
|
|
361
|
+
const merged: Partial<FeatureRun> = { ...existing, ...patch };
|
|
362
|
+
return target.update(id, { ...patch, ...projectFeatureRun(merged) });
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
// Delegate every other method straight through. Bind functions to the real target so the
|
|
366
|
+
// gateway's private class fields (`#src`) resolve — a Proxy `this` would not carry them.
|
|
367
|
+
const value = Reflect.get(target, prop, target);
|
|
368
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/** Re-project every feature_runs row through the gateway so rows written before migration 039 (whose
|
|
374
|
+
* projection columns are NULL) get correct `stage`/`stage_state`/`stage_skipped`/`attention`/
|
|
375
|
+
* `list_bucket` values. Idempotent and safe to re-run: it re-derives from each row's own stored fields,
|
|
376
|
+
* so a second pass is a no-op. Runs once at boot (pollOnce) — the gateway keeps every future write
|
|
377
|
+
* fresh, so this only needs to catch legacy rows once. */
|
|
378
|
+
export async function backfillFeatureStages(data: DataLayer): Promise<number> {
|
|
379
|
+
const table = featureRuns(data);
|
|
380
|
+
const rows = await table.all();
|
|
381
|
+
let stamped = 0;
|
|
382
|
+
for (const row of rows) {
|
|
383
|
+
// Only touch rows the projection has never reached — a legacy pre-039 row whose `stage` column is
|
|
384
|
+
// still NULL. The gateway keeps every write fresh, so an already-projected row needs no re-write;
|
|
385
|
+
// skipping them avoids a full-table rewrite on every boot and keeps `stamped` an honest count of
|
|
386
|
+
// rows actually backfilled (not the total row count).
|
|
387
|
+
if (row.stage != null) continue;
|
|
388
|
+
// Re-derive the projection from the legacy row's own stored fields and write it. (An empty patch
|
|
389
|
+
// would now short-circuit the projecting proxy — it only reprojects on a projection-input change —
|
|
390
|
+
// so backfill projects explicitly rather than relying on an empty-patch reproject.)
|
|
391
|
+
await table.update(row.feature_key, projectFeatureRun(row));
|
|
392
|
+
stamped++;
|
|
393
|
+
}
|
|
394
|
+
return stamped;
|
|
395
|
+
}
|
|
255
396
|
|
|
256
397
|
/** The deterministic task id for a single-issue run — the implementation agent branches
|
|
257
398
|
* `feat/<task.id>` (see resources/prompts/feature.md), so it MUST be derivable from the issue alone
|
|
@@ -307,6 +448,11 @@ export async function startFeature(
|
|
|
307
448
|
auto_merge: autoMerge ? 1 : 0,
|
|
308
449
|
outcome: null,
|
|
309
450
|
delivery_label: null,
|
|
451
|
+
// Clear the operator tick-off so a re-dispatched run is NOT silently dropped into History when
|
|
452
|
+
// it next settles: a stale `acknowledged_at` from the prior terminal run would make
|
|
453
|
+
// `deriveListBucket` flip the row to 'history' the moment it completes again, skipping the
|
|
454
|
+
// intended operator dismissal. A fresh run must re-earn its tick-off.
|
|
455
|
+
acknowledged_at: null,
|
|
310
456
|
escalation_question: null,
|
|
311
457
|
escalation_user_task_key: null,
|
|
312
458
|
blocked_user_task_key: null,
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Tests for the feature_runs gateway's write-time pipeline projection (issue #254 §1/§3) and the
|
|
2
|
+
// one-shot backfill. The gateway wraps the plain table so EVERY writer — no matter which module —
|
|
3
|
+
// automatically gets a fresh `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`
|
|
4
|
+
// projection, without passing them: the single write path is the only place `deriveStage` /
|
|
5
|
+
// `deriveListBucket` are applied.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
9
|
+
import { backfillFeatureStages, featureRuns } from "./feature.ts";
|
|
10
|
+
|
|
11
|
+
// An in-memory record gateway with the same semantics the real Table exposes (get/all/find/insert/
|
|
12
|
+
// update). The featureRuns proxy wraps whatever data.table returns, so this exercises the real proxy.
|
|
13
|
+
function memData(): { data: DataLayer; rows: any[] } {
|
|
14
|
+
const rows: any[] = [];
|
|
15
|
+
function tbl(_name: string, pk = "id") {
|
|
16
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
17
|
+
return {
|
|
18
|
+
async all() {
|
|
19
|
+
return rows.slice();
|
|
20
|
+
},
|
|
21
|
+
async get(id: any) {
|
|
22
|
+
return rows.find((r) => r[pk] === id);
|
|
23
|
+
},
|
|
24
|
+
async find(where: any = {}) {
|
|
25
|
+
return rows.filter((r) => match(r, where));
|
|
26
|
+
},
|
|
27
|
+
async insert(row: any) {
|
|
28
|
+
rows.push({ ...row });
|
|
29
|
+
return row[pk];
|
|
30
|
+
},
|
|
31
|
+
async update(id: any, patch: any) {
|
|
32
|
+
const r = rows.find((row) => row[pk] === id);
|
|
33
|
+
if (r) Object.assign(r, patch);
|
|
34
|
+
return r ? 1 : 0;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
39
|
+
return { data, rows };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("update with only {status:'opened'} projects PR open / null / active without the caller passing them", async () => {
|
|
43
|
+
const { data, rows } = memData();
|
|
44
|
+
rows.push({ feature_key: "o/r#1", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
|
|
45
|
+
await featureRuns(data).update("o/r#1", { status: "opened" });
|
|
46
|
+
assertEquals(rows[0].stage, "PR open");
|
|
47
|
+
assertEquals(rows[0].stage_state, null);
|
|
48
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("update {status:'failed'} projects Done / failed", async () => {
|
|
52
|
+
const { data, rows } = memData();
|
|
53
|
+
rows.push({ feature_key: "o/r#2", status: "running", converge: 1, auto_merge: 1 });
|
|
54
|
+
await featureRuns(data).update("o/r#2", { status: "failed" });
|
|
55
|
+
assertEquals(rows[0].stage, "Done");
|
|
56
|
+
assertEquals(rows[0].stage_state, "failed");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("update {status:'blocked'} projects Done / blocked", async () => {
|
|
60
|
+
const { data, rows } = memData();
|
|
61
|
+
rows.push({ feature_key: "o/r#3", status: "running", converge: 1, auto_merge: 1 });
|
|
62
|
+
await featureRuns(data).update("o/r#3", { status: "blocked" });
|
|
63
|
+
assertEquals(rows[0].stage, "Done");
|
|
64
|
+
assertEquals(rows[0].stage_state, "blocked");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("update {status:'escalated'} on a row with no pr_key projects Implementing / null", async () => {
|
|
68
|
+
const { data, rows } = memData();
|
|
69
|
+
rows.push({ feature_key: "o/r#4", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
|
|
70
|
+
await featureRuns(data).update("o/r#4", { status: "escalated" });
|
|
71
|
+
assertEquals(rows[0].stage, "Implementing");
|
|
72
|
+
assertEquals(rows[0].stage_state, null);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a run with converge=false projects stage_skipped containing Converging and Merging", async () => {
|
|
76
|
+
const { data, rows } = memData();
|
|
77
|
+
rows.push({ feature_key: "o/r#5", status: "running", converge: 0, auto_merge: 0 });
|
|
78
|
+
await featureRuns(data).update("o/r#5", { status: "running" });
|
|
79
|
+
assert(rows[0].stage_skipped.includes("Converging"));
|
|
80
|
+
assert(rows[0].stage_skipped.includes("Merging"));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("insert projects the pipeline columns from status", async () => {
|
|
84
|
+
const { data, rows } = memData();
|
|
85
|
+
await featureRuns(data).insert({ feature_key: "o/r#6", status: "running", converge: 1, auto_merge: 1 } as any);
|
|
86
|
+
assertEquals(rows[0].stage, "Implementing");
|
|
87
|
+
assertEquals(rows[0].stage_state, null);
|
|
88
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("setting acknowledged_at on a terminal row flips list_bucket to 'history'", async () => {
|
|
92
|
+
const { data, rows } = memData();
|
|
93
|
+
rows.push({ feature_key: "o/r#7", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
|
|
94
|
+
// Terminal but unacknowledged → still Active.
|
|
95
|
+
await featureRuns(data).update("o/r#7", { status: "merged" });
|
|
96
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
97
|
+
// Acknowledge → History.
|
|
98
|
+
await featureRuns(data).update("o/r#7", { acknowledged_at: "2024-01-01T00:00:00Z" });
|
|
99
|
+
assertEquals(rows[0].list_bucket, "history");
|
|
100
|
+
assertEquals(rows[0].stage, "Done");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("update touching only projection-irrelevant fields skips the read-back and reproject", async () => {
|
|
104
|
+
const { data, rows } = memData();
|
|
105
|
+
rows.push({
|
|
106
|
+
feature_key: "o/r#skip",
|
|
107
|
+
status: "merged",
|
|
108
|
+
converge: 1,
|
|
109
|
+
auto_merge: 1,
|
|
110
|
+
acknowledged_at: null,
|
|
111
|
+
stage: "Done",
|
|
112
|
+
stage_state: "ok",
|
|
113
|
+
stage_skipped: "",
|
|
114
|
+
attention: null,
|
|
115
|
+
list_bucket: "active",
|
|
116
|
+
});
|
|
117
|
+
// Count get() calls to prove the projection-irrelevant path does no read-back roundtrip.
|
|
118
|
+
let gets = 0;
|
|
119
|
+
const raw = (data as any).table;
|
|
120
|
+
(data as any).table = (n: string, pk?: string) => {
|
|
121
|
+
const t = raw(n, pk);
|
|
122
|
+
const origGet = t.get;
|
|
123
|
+
t.get = async (id: any) => {
|
|
124
|
+
gets++;
|
|
125
|
+
return origGet.call(t, id);
|
|
126
|
+
};
|
|
127
|
+
return t;
|
|
128
|
+
};
|
|
129
|
+
await featureRuns(data).update("o/r#skip", { updated_at: "2024-06-01T00:00:00Z" });
|
|
130
|
+
assertEquals(gets, 0);
|
|
131
|
+
assertEquals(rows[0].updated_at, "2024-06-01T00:00:00Z");
|
|
132
|
+
// Untouched projection stays as stored.
|
|
133
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
134
|
+
|
|
135
|
+
// A projection-input change (acknowledged_at) DOES read back and reproject.
|
|
136
|
+
await featureRuns(data).update("o/r#skip", { acknowledged_at: "2024-06-01T00:00:00Z" });
|
|
137
|
+
assertEquals(gets, 1);
|
|
138
|
+
assertEquals(rows[0].list_bucket, "history");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a direct write of a projection output field forces reprojection and overrides the raw value", async () => {
|
|
142
|
+
const { data, rows } = memData();
|
|
143
|
+
rows.push({
|
|
144
|
+
feature_key: "o/r#bypass",
|
|
145
|
+
status: "running",
|
|
146
|
+
pr_key: null,
|
|
147
|
+
converge: 1,
|
|
148
|
+
auto_merge: 1,
|
|
149
|
+
stage: "Implementing",
|
|
150
|
+
stage_state: null,
|
|
151
|
+
list_bucket: "active",
|
|
152
|
+
});
|
|
153
|
+
// A caller tries to bypass derivation by writing the derived column directly. The gateway must NOT
|
|
154
|
+
// persist the raw value: it re-reads, recomputes from the merged inputs, and overrides it.
|
|
155
|
+
await featureRuns(data).update("o/r#bypass", { stage: "Done", list_bucket: "history" });
|
|
156
|
+
assertEquals(rows[0].stage, "Implementing");
|
|
157
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("backfillFeatureStages stamps legacy terminal and live rows with helper-derived values", async () => {
|
|
161
|
+
const { data, rows } = memData();
|
|
162
|
+
// Legacy rows written before migration 039 → projection columns absent/NULL.
|
|
163
|
+
rows.push({ feature_key: "o/r#8", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: "2024-01-01T00:00:00Z" });
|
|
164
|
+
rows.push({ feature_key: "o/r#9", status: "running", pr_key: null, converge: 0, auto_merge: 0 });
|
|
165
|
+
|
|
166
|
+
const stamped = await backfillFeatureStages(data);
|
|
167
|
+
assertEquals(stamped, 2);
|
|
168
|
+
|
|
169
|
+
const terminal = rows.find((r) => r.feature_key === "o/r#8");
|
|
170
|
+
assertEquals(terminal.stage, "Done");
|
|
171
|
+
assertEquals(terminal.stage_state, "ok");
|
|
172
|
+
assertEquals(terminal.list_bucket, "history");
|
|
173
|
+
|
|
174
|
+
const live = rows.find((r) => r.feature_key === "o/r#9");
|
|
175
|
+
assertEquals(live.stage, "Implementing");
|
|
176
|
+
assertEquals(live.stage_state, null);
|
|
177
|
+
assertEquals(live.stage_skipped, "Converging Merging");
|
|
178
|
+
assertEquals(live.list_bucket, "active");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("backfillFeatureStages skips already-projected rows and counts only rows it stamps", async () => {
|
|
182
|
+
const { data, rows } = memData();
|
|
183
|
+
// One legacy row (no projection) + one already-projected row (gateway kept it fresh).
|
|
184
|
+
rows.push({ feature_key: "o/r#legacy", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
|
|
185
|
+
rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", updated_at: "0" });
|
|
186
|
+
|
|
187
|
+
const stamped = await backfillFeatureStages(data);
|
|
188
|
+
// Only the legacy row is stamped; the already-projected row is skipped.
|
|
189
|
+
assertEquals(stamped, 1);
|
|
190
|
+
const legacy = rows.find((r) => r.feature_key === "o/r#legacy");
|
|
191
|
+
assertEquals(legacy.stage, "Done");
|
|
192
|
+
// The already-projected row was not re-written (its sentinel updated_at is untouched).
|
|
193
|
+
const fresh = rows.find((r) => r.feature_key === "o/r#fresh");
|
|
194
|
+
assertEquals(fresh.updated_at, "0");
|
|
195
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
|
12
12
|
import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
13
13
|
import { agentSlaTimeout } from "./agentSla.ts";
|
|
14
14
|
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
15
|
-
import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
|
|
15
|
+
import { backfillFeatureStages, deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
|
|
16
16
|
import {
|
|
17
17
|
classifyMergeability,
|
|
18
18
|
coalesceTitle,
|
|
@@ -1647,12 +1647,27 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1647
1647
|
* The wave-merge barrier is now level-triggered and probes the engine's message-subscription state
|
|
1648
1648
|
* over the same raw-REST search surface, so it runs only when `engineRest` is supplied (as in
|
|
1649
1649
|
* production — `main.ts` always passes it). */
|
|
1650
|
+
/** One-shot guard so the feature-stage backfill (`backfillFeatureStages`) runs at most once per
|
|
1651
|
+
* process, on the first `pollOnce`. Idempotent regardless, but there is no need to re-scan every row
|
|
1652
|
+
* on every poll. */
|
|
1653
|
+
let featureStagesBackfilled = false;
|
|
1654
|
+
|
|
1650
1655
|
export async function pollOnce(
|
|
1651
1656
|
data: DataLayer,
|
|
1652
1657
|
engine: EngineClient,
|
|
1653
1658
|
token: string,
|
|
1654
1659
|
engineRest?: { restAddress: string; token?: string },
|
|
1655
1660
|
) {
|
|
1661
|
+
// One-shot: re-project any pre-#254 feature_runs rows whose pipeline columns are still NULL. The
|
|
1662
|
+
// gateway keeps every future write fresh, so this only needs to run once per process and is safe to
|
|
1663
|
+
// re-run (it re-derives from each row's own stored fields).
|
|
1664
|
+
if (!featureStagesBackfilled) {
|
|
1665
|
+
// Only arm the one-shot guard AFTER a successful backfill: setting it first would swallow a
|
|
1666
|
+
// transient failure (e.g. a DB blip) and leave legacy rows unprojected forever, since every later
|
|
1667
|
+
// pass would skip. On a throw the guard stays false and the next `pollOnce` retries.
|
|
1668
|
+
await backfillFeatureStages(data);
|
|
1669
|
+
featureStagesBackfilled = true;
|
|
1670
|
+
}
|
|
1656
1671
|
await pollReviews(data, engine, token);
|
|
1657
1672
|
await pollMerges(data, engine, token);
|
|
1658
1673
|
await pollDelivery(data);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Unit tests for the canonical feature-run pipeline stage model (issue #254 §1). `deriveStage` is the
|
|
2
|
+
// ONE source of truth the feature_runs gateway projects onto the stored pipeline columns, so the
|
|
3
|
+
// mapping must be TOTAL and DETERMINISTIC over every FEATURE_RUN_STATUS and emit the urban 0.53.0
|
|
4
|
+
// `kind:"pipeline"` renderer's EXACT vocabulary (state `ok|failed|blocked|null`).
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
7
|
+
import { FEATURE_RUN_STATUSES } from "./feature.ts";
|
|
8
|
+
import { deriveListBucket, deriveStage, type StageInput } from "./stage.ts";
|
|
9
|
+
|
|
10
|
+
const base = (over: Partial<StageInput> & { status: string }): StageInput => ({
|
|
11
|
+
pr_key: null,
|
|
12
|
+
converge: 1,
|
|
13
|
+
auto_merge: 1,
|
|
14
|
+
escalation_question: null,
|
|
15
|
+
escalation_user_task_key: null,
|
|
16
|
+
blocked_user_task_key: null,
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("deriveStage is TOTAL: every one of the 11 statuses maps to a defined stage and state", () => {
|
|
21
|
+
assertEquals(FEATURE_RUN_STATUSES.length, 11);
|
|
22
|
+
for (const status of FEATURE_RUN_STATUSES) {
|
|
23
|
+
const d = deriveStage(base({ status }));
|
|
24
|
+
assert(d.stage !== undefined, `stage undefined for ${status}`);
|
|
25
|
+
assert(
|
|
26
|
+
["Requested", "Implementing", "PR open", "Converging", "Merging", "Done"].includes(d.stage),
|
|
27
|
+
`stage out of range for ${status}: ${d.stage}`,
|
|
28
|
+
);
|
|
29
|
+
// state is one of the renderer's exact values (null allowed)
|
|
30
|
+
assert([null, "ok", "failed", "blocked"].includes(d.state), `state out of range for ${status}: ${d.state}`);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("status -> stage mapping (each row)", () => {
|
|
35
|
+
// Terminal tier → Done.
|
|
36
|
+
for (const status of ["merged", "converged", "blocked", "failed", "skipped", "abandoned"]) {
|
|
37
|
+
assertEquals(deriveStage(base({ status })).stage, "Done", status);
|
|
38
|
+
}
|
|
39
|
+
// Live/parked tier.
|
|
40
|
+
assertEquals(deriveStage(base({ status: "converging" })).stage, "Converging");
|
|
41
|
+
assertEquals(deriveStage(base({ status: "opened" })).stage, "PR open");
|
|
42
|
+
assertEquals(deriveStage(base({ status: "running", pr_key: "o/r#9" })).stage, "PR open");
|
|
43
|
+
assertEquals(deriveStage(base({ status: "running" })).stage, "Implementing");
|
|
44
|
+
// The pre-start/created initial state (unknown non-terminal, no pr_key) → Requested.
|
|
45
|
+
assertEquals(deriveStage(base({ status: "created" })).stage, "Requested");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("state: the three non-null values plus the null in-progress case", () => {
|
|
49
|
+
assertEquals(deriveStage(base({ status: "merged" })).state, "ok");
|
|
50
|
+
assertEquals(deriveStage(base({ status: "converged" })).state, "ok");
|
|
51
|
+
assertEquals(deriveStage(base({ status: "failed" })).state, "failed");
|
|
52
|
+
assertEquals(deriveStage(base({ status: "skipped" })).state, "failed");
|
|
53
|
+
assertEquals(deriveStage(base({ status: "abandoned" })).state, "failed");
|
|
54
|
+
assertEquals(deriveStage(base({ status: "blocked" })).state, "blocked");
|
|
55
|
+
// Every non-terminal status → null (in-progress).
|
|
56
|
+
for (const status of ["running", "opened", "converging", "escalated", "awaiting_operator"]) {
|
|
57
|
+
assertEquals(deriveStage(base({ status })).state, null, status);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("state emits 'failed' (not 'fail') so the renderer does not degrade a failure to active", () => {
|
|
62
|
+
assertEquals(deriveStage(base({ status: "failed" })).state, "failed");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("skipped: the three converge/auto_merge cases", () => {
|
|
66
|
+
// converge off → skip both.
|
|
67
|
+
assertEquals(deriveStage(base({ status: "running", converge: 0, auto_merge: 0 })).skipped, "Converging Merging");
|
|
68
|
+
// converge on, auto_merge off → skip Merging only.
|
|
69
|
+
assertEquals(deriveStage(base({ status: "running", converge: 1, auto_merge: 0 })).skipped, "Merging");
|
|
70
|
+
// both on → empty.
|
|
71
|
+
assertEquals(deriveStage(base({ status: "running", converge: 1, auto_merge: 1 })).skipped, "");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("attention: blocked, escalation, none", () => {
|
|
75
|
+
assertEquals(deriveStage(base({ status: "awaiting_operator", blocked_user_task_key: "ut-1" })).attention, "blocked");
|
|
76
|
+
assertEquals(deriveStage(base({ status: "escalated", escalation_user_task_key: "ut-2" })).attention, "⚠");
|
|
77
|
+
assertEquals(deriveStage(base({ status: "escalated", escalation_question: "which base?" })).attention, "⚠");
|
|
78
|
+
assertEquals(deriveStage(base({ status: "running" })).attention, null);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// The three parked-status rows called out by the plan review.
|
|
82
|
+
test("escalated WITH pr_key → PR open / null", () => {
|
|
83
|
+
const d = deriveStage(base({ status: "escalated", pr_key: "o/r#5" }));
|
|
84
|
+
assertEquals(d.stage, "PR open");
|
|
85
|
+
assertEquals(d.state, null);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("escalated WITHOUT pr_key → Implementing / null", () => {
|
|
89
|
+
const d = deriveStage(base({ status: "escalated", pr_key: null }));
|
|
90
|
+
assertEquals(d.stage, "Implementing");
|
|
91
|
+
assertEquals(d.state, null);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("awaiting_operator WITHOUT pr_key → Implementing / null, attention 'blocked' when parked", () => {
|
|
95
|
+
const d = deriveStage(base({ status: "awaiting_operator", pr_key: null, blocked_user_task_key: "ut-3" }));
|
|
96
|
+
assertEquals(d.stage, "Implementing");
|
|
97
|
+
assertEquals(d.state, null);
|
|
98
|
+
assertEquals(d.attention, "blocked");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("deriveListBucket: history iff terminal AND acknowledged, else active", () => {
|
|
102
|
+
assertEquals(deriveListBucket("merged", null), "active");
|
|
103
|
+
assertEquals(deriveListBucket("merged", "2024-01-01T00:00:00Z"), "history");
|
|
104
|
+
// A non-terminal status is always active, even if (spuriously) acknowledged.
|
|
105
|
+
assertEquals(deriveListBucket("running", "2024-01-01T00:00:00Z"), "active");
|
|
106
|
+
assertEquals(deriveListBucket("blocked", "2024-01-01T00:00:00Z"), "history");
|
|
107
|
+
});
|
package/app/stage.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Canonical feature-run pipeline stage model (issue #254 §1) — the ONE source of truth for the
|
|
2
|
+
// derived pipeline surface the Feature view renders. Mirrors the single-source-of-truth style of
|
|
3
|
+
// `deriveDelivery` (app/delivery.ts) and `classifyEscalation` (app/escalationTaxonomy.ts): a PURE,
|
|
4
|
+
// read-only function with no data access. The feature_runs gateway (app/feature.ts) projects its
|
|
5
|
+
// output onto the stored `stage`/`stage_state`/`stage_skipped`/`attention` columns at write time,
|
|
6
|
+
// exactly as `delivery_label` is projected — so the declarative dataGrid page consumes ready, stored
|
|
7
|
+
// columns (bound by `{"field":…}`) and never has to call TS or express OR/null in its flat filter DSL.
|
|
8
|
+
//
|
|
9
|
+
// The mapping is TOTAL and DETERMINISTIC over all 11 FEATURE_RUN_STATUSES, computed from ONLY the
|
|
10
|
+
// fields stored on the row — never a "previous"/"underlying" stage, because a FeatureRun stores only
|
|
11
|
+
// its CURRENT status (any prior stage was overwritten on transition). Do NOT duplicate this mapping
|
|
12
|
+
// anywhere (not in SQL, not in the page, not in each poller/worker): every writer flows through the
|
|
13
|
+
// gateway, which is the single caller.
|
|
14
|
+
|
|
15
|
+
/** The canonical pipeline stage keys, in path order. `Merging` is a path/visual stage the renderer
|
|
16
|
+
* fills as upcoming — no status maps to it as the ACTIVE stage (intentional). */
|
|
17
|
+
export const STAGE_KEYS = ["Requested", "Implementing", "PR open", "Converging", "Merging", "Done"] as const;
|
|
18
|
+
export type StageKey = (typeof STAGE_KEYS)[number];
|
|
19
|
+
|
|
20
|
+
/** The active stage's render state, in the urban 0.53.0 `kind:"pipeline"` column's EXACT vocabulary:
|
|
21
|
+
* `ok` (Done ✓ success), `failed` (Done ✕ failure), `blocked` (blocked glyph), or `null` (in-progress
|
|
22
|
+
* → the renderer treats it as `active`). Any OTHER string silently degrades to `active` in the
|
|
23
|
+
* renderer, so a failed run MUST emit `'failed'` (not `'fail'`) to render as a failure. */
|
|
24
|
+
export type StageState = "ok" | "failed" | "blocked" | null;
|
|
25
|
+
|
|
26
|
+
/** The 6 TRULY-terminal statuses that map to the `Done` stage. Distinct from
|
|
27
|
+
* `FEATURE_TERMINAL_STATUSES` (app/feature.ts), which is the redispatch-settled set and also counts
|
|
28
|
+
* `opened`/`converging` as terminal — those are LIVE pipeline stages (`PR open`/`Converging`), NOT
|
|
29
|
+
* Done, so this list must stay separate. Also the basis of the `list_bucket` history partition. */
|
|
30
|
+
export const STAGE_DONE_STATUSES: readonly string[] = [
|
|
31
|
+
"merged",
|
|
32
|
+
"converged",
|
|
33
|
+
"blocked",
|
|
34
|
+
"failed",
|
|
35
|
+
"skipped",
|
|
36
|
+
"abandoned",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** The subset of a FeatureRun `deriveStage` reads. FeatureRun (app/feature.ts) structurally satisfies
|
|
40
|
+
* this; keeping the input structural avoids a stage.ts ↔ feature.ts import cycle. */
|
|
41
|
+
export interface StageInput {
|
|
42
|
+
status: string;
|
|
43
|
+
pr_key?: string | null;
|
|
44
|
+
converge?: number | boolean | null;
|
|
45
|
+
auto_merge?: number | boolean | null;
|
|
46
|
+
escalation_question?: string | null;
|
|
47
|
+
escalation_user_task_key?: string | null;
|
|
48
|
+
blocked_user_task_key?: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The derived pipeline projection for one run. `skipped` is a space-separated set of stage keys not
|
|
52
|
+
* in this row's path (bound to the renderer's `notInPathField`). */
|
|
53
|
+
export interface DerivedStage {
|
|
54
|
+
stage: StageKey;
|
|
55
|
+
state: StageState;
|
|
56
|
+
skipped: string;
|
|
57
|
+
attention: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const truthy = (v: number | boolean | null | undefined): boolean => v === true || (typeof v === "number" && v !== 0);
|
|
61
|
+
|
|
62
|
+
/** Derive the canonical pipeline stage, its render state, its not-in-path set, and its attention badge
|
|
63
|
+
* for one feature run. Pure and read-only — TOTAL over all 11 statuses (never returns undefined). */
|
|
64
|
+
export function deriveStage(run: StageInput): DerivedStage {
|
|
65
|
+
const { status } = run;
|
|
66
|
+
|
|
67
|
+
// TERMINAL tier — the 6 truly-terminal statuses collapse to Done. Unconditional: terminal `blocked`
|
|
68
|
+
// is the issue §1 'Done ✕' row (state `blocked`), NOT Implementing.
|
|
69
|
+
let stage: StageKey;
|
|
70
|
+
let state: StageState;
|
|
71
|
+
if (STAGE_DONE_STATUSES.includes(status)) {
|
|
72
|
+
stage = "Done";
|
|
73
|
+
state =
|
|
74
|
+
status === "merged" || status === "converged"
|
|
75
|
+
? "ok"
|
|
76
|
+
: status === "blocked"
|
|
77
|
+
? "blocked"
|
|
78
|
+
: "failed"; // failed / skipped / abandoned
|
|
79
|
+
} else {
|
|
80
|
+
// LIVE/PARKED tier — one shared rule for every non-terminal status. `escalated`/`awaiting_operator`
|
|
81
|
+
// are parked but their stored fields still describe WHERE in the pipeline they stalled, so they run
|
|
82
|
+
// through the same rule as a live run; their attention comes from the badge (below), not the stage.
|
|
83
|
+
if (status === "converging") stage = "Converging";
|
|
84
|
+
else if ((run.pr_key ?? "") !== "" || status === "opened") stage = "PR open";
|
|
85
|
+
else if (status === "running" || status === "escalated" || status === "awaiting_operator") stage = "Implementing";
|
|
86
|
+
else stage = "Requested";
|
|
87
|
+
state = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// `skipped`: stages not in this row's path, purely from converge/auto_merge.
|
|
91
|
+
const converge = truthy(run.converge);
|
|
92
|
+
const autoMerge = truthy(run.auto_merge);
|
|
93
|
+
const skippedKeys: StageKey[] = !converge ? ["Converging", "Merging"] : !autoMerge ? ["Merging"] : [];
|
|
94
|
+
|
|
95
|
+
// `attention`: a short badge for the active stage (the renderer colours it from `state`). This is how
|
|
96
|
+
// a parked `awaiting_operator`/`escalated` run surfaces as attention WITHOUT altering its stage.
|
|
97
|
+
const attention = run.blocked_user_task_key
|
|
98
|
+
? "blocked"
|
|
99
|
+
: run.escalation_user_task_key || run.escalation_question
|
|
100
|
+
? "⚠"
|
|
101
|
+
: null;
|
|
102
|
+
|
|
103
|
+
return { stage, state, skipped: skippedKeys.join(" "), attention };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The Active/History partition label (§5), maintained at write time so the flat-DSL page tabs filter
|
|
107
|
+
* on a stored `list_bucket` column with only `in` clauses. `history` iff the row is in a truly-terminal
|
|
108
|
+
* status AND acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). */
|
|
109
|
+
export function deriveListBucket(status: string, acknowledgedAt: string | null | undefined): "active" | "history" {
|
|
110
|
+
return STAGE_DONE_STATUSES.includes(status) && acknowledgedAt != null ? "history" : "active";
|
|
111
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- 039_feature_pipeline_stage.sql — issue #254 §1/§5: reify the feature-run pipeline stage and the
|
|
2
|
+
-- Active/History tick-off partition as derived, write-time-projected columns, so the declarative
|
|
3
|
+
-- Feature view can render an intent-first pipeline track and a Done tick-off with only stored
|
|
4
|
+
-- `{"field":…}` bindings and flat `in` filters (the dataGrid page DSL has no OR / IS NULL / TS
|
|
5
|
+
-- callback). These mirror the existing `delivery_label` / `epic_phase` display projections: each is
|
|
6
|
+
-- maintained by the feature_runs gateway (app/feature.ts) from the pure `deriveStage` helper
|
|
7
|
+
-- (app/stage.ts) on every write — never hand-derived in SQL, the page, or a poller.
|
|
8
|
+
--
|
|
9
|
+
-- Columns:
|
|
10
|
+
-- • acknowledged_at — NULL until an operator dismisses a terminal run (§5, acknowledge-done).
|
|
11
|
+
-- • stage — deriveStage(...).stage: Requested|Implementing|PR open|Converging|Merging|Done
|
|
12
|
+
-- (the page's pipeline column binds `activeField` to it).
|
|
13
|
+
-- • stage_state — deriveStage(...).state: ok|failed|blocked|NULL (bound to `stateField`).
|
|
14
|
+
-- • stage_skipped — deriveStage(...).skipped: space-separated not-in-path stage keys (`notInPathField`).
|
|
15
|
+
-- • attention — deriveStage(...).attention: short badge text or NULL (`badgeField`).
|
|
16
|
+
-- • list_bucket — 'active' | 'history': history iff terminal AND acknowledged, else active
|
|
17
|
+
-- (Active = live + terminal-but-unacknowledged; History = acknowledged terminals).
|
|
18
|
+
--
|
|
19
|
+
-- Forward-only, additive (expand): all nullable with no default, so pre-#254 rows grandfather in as
|
|
20
|
+
-- NULL and never gate control flow. `backfillFeatureStages` (app/feature.ts) stamps legacy rows once
|
|
21
|
+
-- at boot, and the gateway keeps every future write fresh. Numbered after the current highest prefix
|
|
22
|
+
-- on origin/main (038); the runner wraps each file in its own transaction, so this file must NOT
|
|
23
|
+
-- contain BEGIN/COMMIT.
|
|
24
|
+
ALTER TABLE feature_runs ADD COLUMN acknowledged_at TEXT;
|
|
25
|
+
ALTER TABLE feature_runs ADD COLUMN stage TEXT;
|
|
26
|
+
ALTER TABLE feature_runs ADD COLUMN stage_state TEXT;
|
|
27
|
+
ALTER TABLE feature_runs ADD COLUMN stage_skipped TEXT;
|
|
28
|
+
ALTER TABLE feature_runs ADD COLUMN attention TEXT;
|
|
29
|
+
ALTER TABLE feature_runs ADD COLUMN list_bucket TEXT;
|
package/main.ts
CHANGED
|
@@ -96,7 +96,11 @@ async function pollLoop(): Promise<void> {
|
|
|
96
96
|
}
|
|
97
97
|
if (!shuttingDown) pollTimer = setTimeout(() => void pollLoop(), POLL_MS);
|
|
98
98
|
}
|
|
99
|
-
|
|
99
|
+
// Run the first pass immediately at boot (not after POLL_MS) so the one-shot feature-stage backfill
|
|
100
|
+
// runs before the UI is relied upon — the Feature Runs grid/tabs filter on the stored `list_bucket`
|
|
101
|
+
// projection, which is NULL on legacy rows until `backfillFeatureStages()` runs inside `pollOnce()`.
|
|
102
|
+
// Deferring the first pass would leave those rows missing from Active/History for up to POLL_MS.
|
|
103
|
+
if (app.data) void pollLoop();
|
|
100
104
|
|
|
101
105
|
async function drainAndExit(): Promise<void> {
|
|
102
106
|
if (shuttingDown) return;
|
package/openapi.yaml
CHANGED
|
@@ -1501,6 +1501,53 @@ paths:
|
|
|
1501
1501
|
application/json:
|
|
1502
1502
|
schema:
|
|
1503
1503
|
$ref: "#/components/schemas/MessageResult"
|
|
1504
|
+
/actions/acknowledge-done:
|
|
1505
|
+
post:
|
|
1506
|
+
operationId: acknowledgeDone
|
|
1507
|
+
summary: "Tick off a TERMINAL feature run (issue #254 §5). Stamps `acknowledged_at` on the run so
|
|
1508
|
+
the gateway recomputes its `list_bucket` to 'history', dropping the finished run out of the
|
|
1509
|
+
primary Active list into History. The Done twin of acknowledge-blocked, but a terminal run is
|
|
1510
|
+
not parked at a user task, so this completes no user task and only writes the row. Keyed on the
|
|
1511
|
+
run's `feature_key`; idempotent-safe (re-acknowledging keeps it in History)."
|
|
1512
|
+
requestBody:
|
|
1513
|
+
required: true
|
|
1514
|
+
content:
|
|
1515
|
+
application/json:
|
|
1516
|
+
schema:
|
|
1517
|
+
type: object
|
|
1518
|
+
additionalProperties: false
|
|
1519
|
+
required:
|
|
1520
|
+
- feature_key
|
|
1521
|
+
properties:
|
|
1522
|
+
feature_key:
|
|
1523
|
+
type: string
|
|
1524
|
+
minLength: 1
|
|
1525
|
+
description: The feature run's key (feature_runs.feature_key, `<owner>/<repo>#<n>`).
|
|
1526
|
+
responses:
|
|
1527
|
+
"200":
|
|
1528
|
+
description: The terminal run was acknowledged and moved to History.
|
|
1529
|
+
content:
|
|
1530
|
+
application/json:
|
|
1531
|
+
schema:
|
|
1532
|
+
$ref: "#/components/schemas/MessageResult"
|
|
1533
|
+
"400":
|
|
1534
|
+
description: A required field (feature_key) was missing or invalid.
|
|
1535
|
+
content:
|
|
1536
|
+
application/json:
|
|
1537
|
+
schema:
|
|
1538
|
+
$ref: "#/components/schemas/MessageResult"
|
|
1539
|
+
"404":
|
|
1540
|
+
description: No feature run matches the feature_key.
|
|
1541
|
+
content:
|
|
1542
|
+
application/json:
|
|
1543
|
+
schema:
|
|
1544
|
+
$ref: "#/components/schemas/MessageResult"
|
|
1545
|
+
"409":
|
|
1546
|
+
description: The feature run is not terminal, so it cannot be ticked off yet.
|
|
1547
|
+
content:
|
|
1548
|
+
application/json:
|
|
1549
|
+
schema:
|
|
1550
|
+
$ref: "#/components/schemas/MessageResult"
|
|
1504
1551
|
/hooks/agent-complete:
|
|
1505
1552
|
post:
|
|
1506
1553
|
operationId: agentCompleteEscalation
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Tests for the POST /app/api/actions/acknowledge-done operation `acknowledgeDone` (issue #254 §5).
|
|
2
|
+
// The nwf UI's "tick off" affordance for a TERMINAL feature run: it stamps `acknowledged_at` via the
|
|
3
|
+
// feature_runs gateway, which recomputes `list_bucket` to 'history', dropping the run from Active into
|
|
4
|
+
// History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is not parked). Mirrors
|
|
5
|
+
// the acknowledge-blocked twin's shape.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
8
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
9
|
+
import { featureRuns } from "../app/feature.ts";
|
|
10
|
+
import { noopLog } from "../test/log.ts";
|
|
11
|
+
import handler from "./acknowledgeDone.ts";
|
|
12
|
+
|
|
13
|
+
// An in-memory data layer wired through the REAL featureRuns gateway proxy, so the test exercises the
|
|
14
|
+
// gateway's list_bucket projection exactly as production does.
|
|
15
|
+
function memApp(seed: any[]): { app: AppApi; rows: any[] } {
|
|
16
|
+
const stores: Record<string, any[]> = { feature_runs: seed };
|
|
17
|
+
function tbl(name: string, pk = "id") {
|
|
18
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
19
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
20
|
+
return {
|
|
21
|
+
async all() {
|
|
22
|
+
return rows.slice();
|
|
23
|
+
},
|
|
24
|
+
async get(id: any) {
|
|
25
|
+
return rows.find((r) => r[pk] === id);
|
|
26
|
+
},
|
|
27
|
+
async find(where: any = {}) {
|
|
28
|
+
return rows.filter((r) => match(r, where));
|
|
29
|
+
},
|
|
30
|
+
async insert(row: any) {
|
|
31
|
+
rows.push({ ...row });
|
|
32
|
+
return row[pk];
|
|
33
|
+
},
|
|
34
|
+
async update(id: any, patch: any) {
|
|
35
|
+
const r = rows.find((row) => row[pk] === id);
|
|
36
|
+
if (r) Object.assign(r, patch);
|
|
37
|
+
return r ? 1 : 0;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const app = {
|
|
42
|
+
data: { table: (n: string, pk?: string) => tbl(n, pk) },
|
|
43
|
+
log: noopLog(),
|
|
44
|
+
} as any as AppApi;
|
|
45
|
+
return { app, rows: stores.feature_runs };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function call(app: AppApi, body: unknown) {
|
|
49
|
+
return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test("acknowledge-done: stamps acknowledged_at and flips list_bucket to 'history' on a terminal row", async () => {
|
|
53
|
+
const { app, rows } = memApp([{ feature_key: "o/r#1", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
54
|
+
// Seed the projection as the gateway would have on the last write (terminal, unacknowledged → active).
|
|
55
|
+
await featureRuns(app.data).update("o/r#1", { status: "merged" });
|
|
56
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
57
|
+
|
|
58
|
+
const res = await call(app, { feature_key: "o/r#1" });
|
|
59
|
+
|
|
60
|
+
assertEquals(res.status, 200);
|
|
61
|
+
assertEquals(res.body.ok, true);
|
|
62
|
+
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
63
|
+
assertEquals(rows[0].list_bucket, "history");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("acknowledge-done: idempotent-safe — re-acknowledging keeps the row in History", async () => {
|
|
67
|
+
const { app, rows } = memApp([{ feature_key: "o/r#2", status: "failed", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
68
|
+
const first = await call(app, { feature_key: "o/r#2" });
|
|
69
|
+
assertEquals(first.status, 200);
|
|
70
|
+
assertEquals(rows[0].list_bucket, "history");
|
|
71
|
+
const firstStamp = rows[0].acknowledged_at;
|
|
72
|
+
// Re-acknowledge — still 200, still history.
|
|
73
|
+
const second = await call(app, { feature_key: "o/r#2" });
|
|
74
|
+
assertEquals(second.status, 200);
|
|
75
|
+
assertEquals(rows[0].list_bucket, "history");
|
|
76
|
+
assertEquals(typeof firstStamp, "string");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("acknowledge-done: a missing feature_key → 400", async () => {
|
|
80
|
+
const { app } = memApp([]);
|
|
81
|
+
const res = await call(app, {});
|
|
82
|
+
assertEquals(res.status, 400);
|
|
83
|
+
assertEquals(res.body.ok, false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("acknowledge-done: no such feature run → 404", async () => {
|
|
87
|
+
const { app } = memApp([]);
|
|
88
|
+
const res = await call(app, { feature_key: "o/r#gone" });
|
|
89
|
+
assertEquals(res.status, 404);
|
|
90
|
+
assertEquals(res.body.ok, false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("acknowledge-done: a non-terminal run → 409, no acknowledged_at stamped", async () => {
|
|
94
|
+
const { app, rows } = memApp([{ feature_key: "o/r#live", status: "running", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
95
|
+
await featureRuns(app.data).update("o/r#live", { status: "running" });
|
|
96
|
+
const res = await call(app, { feature_key: "o/r#live" });
|
|
97
|
+
assertEquals(res.status, 409);
|
|
98
|
+
assertEquals(res.body.ok, false);
|
|
99
|
+
assertEquals(rows[0].acknowledged_at, null);
|
|
100
|
+
assertEquals(rows[0].list_bucket, "active");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("acknowledge-done: a converging (redispatch-terminal but live) run → 409", async () => {
|
|
104
|
+
const { app, rows } = memApp([{ feature_key: "o/r#conv", status: "converging", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
105
|
+
const res = await call(app, { feature_key: "o/r#conv" });
|
|
106
|
+
assertEquals(res.status, 409);
|
|
107
|
+
assertEquals(rows[0].acknowledged_at, null);
|
|
108
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// POST /app/api/actions/acknowledge-done → operationId `acknowledgeDone` (issue #254 §5).
|
|
2
|
+
// The nwf UI's "tick off" affordance for a TERMINAL feature run: an operator dismisses a finished
|
|
3
|
+
// run (Done ✓ / Done ✕) directly from the Feature / Overview pages so it drops out of the primary
|
|
4
|
+
// Active list into History. It is the DONE twin of `acknowledgeBlocked` — but a terminal run is NOT
|
|
5
|
+
// parked at a user task, so this op does NOT complete a user task and touches no engine/ledger: it
|
|
6
|
+
// simply stamps `acknowledged_at` on the row via the feature_runs gateway. It rejects (409) a run that
|
|
7
|
+
// is not yet truly terminal, so it can never pre-seed the tick-off on a still-live run.
|
|
8
|
+
//
|
|
9
|
+
// The gateway (app/feature.ts) recomputes `list_bucket` on that write — a terminal row with
|
|
10
|
+
// `acknowledged_at` set flips to 'history' — so this op NEVER hand-sets `list_bucket` (or any other
|
|
11
|
+
// projection). Keyed on the row's `feature_key`. Idempotent-safe: re-acknowledging simply re-stamps
|
|
12
|
+
// the timestamp and keeps the row in History.
|
|
13
|
+
|
|
14
|
+
import { featureRuns } from "../app/feature.ts";
|
|
15
|
+
import { STAGE_DONE_STATUSES } from "../app/stage.ts";
|
|
16
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
17
|
+
|
|
18
|
+
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
19
|
+
|
|
20
|
+
export default defineOperation("acknowledgeDone", async ({ body }, app) => {
|
|
21
|
+
if (!body || typeof body !== "object") {
|
|
22
|
+
app.log.warn("acknowledge-done rejected: missing request body");
|
|
23
|
+
return { status: 400, body: { ok: false, error: "feature_key is required" } };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const featureKey = str(body.feature_key);
|
|
27
|
+
if (!featureKey) return { status: 400, body: { ok: false, error: "feature_key is required" } };
|
|
28
|
+
|
|
29
|
+
const runs = featureRuns(app.data);
|
|
30
|
+
const run = await runs.get(featureKey);
|
|
31
|
+
if (!run) {
|
|
32
|
+
app.log.warn("acknowledge-done: no such feature run", { featureKey });
|
|
33
|
+
return { status: 404, body: { ok: false, error: "no such feature run" } };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Guard: only a TRULY-terminal run (a `Done`-stage status — the same set `deriveListBucket` moves to
|
|
37
|
+
// History) may be ticked off. Acknowledging a still-live run (e.g. `running`/`opened`/`converging`)
|
|
38
|
+
// would pre-seed `acknowledged_at`, so the moment it later settles `deriveListBucket` would drop it
|
|
39
|
+
// straight into History, skipping the operator tick-off this op exists to require.
|
|
40
|
+
if (!STAGE_DONE_STATUSES.includes(run.status)) {
|
|
41
|
+
app.log.warn("acknowledge-done rejected: run is not terminal", { featureKey, status: run.status });
|
|
42
|
+
return { status: 409, body: { ok: false, error: "feature run is not terminal" } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Stamp the dismissal. The gateway recomputes `list_bucket` from the merged row (→ 'history' for a
|
|
46
|
+
// terminal run), so we never hand-set it here. Idempotent: re-acknowledging re-stamps and stays in
|
|
47
|
+
// History.
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
await runs.update(featureKey, { acknowledged_at: now, updated_at: now });
|
|
50
|
+
|
|
51
|
+
app.log.info("operator ticked off feature run", { featureKey });
|
|
52
|
+
return { status: 200, body: { ok: true, message: "acknowledged" } };
|
|
53
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.84.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",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@nanobpm/agentic": "^0.1.0",
|
|
56
|
-
"@nanobpm/urban": "^0.
|
|
56
|
+
"@nanobpm/urban": "^0.54.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@biomejs/biome": "^2.4.11",
|
|
@@ -137,38 +137,36 @@
|
|
|
137
137
|
}
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
|
-
"type": "
|
|
140
|
+
"type": "prose",
|
|
141
141
|
"id": "plan-reviews",
|
|
142
142
|
"props": {
|
|
143
143
|
"title": "Plan review trace",
|
|
144
144
|
"refreshMs": 5000,
|
|
145
145
|
"collapsible": true,
|
|
146
146
|
"defaultCollapsed": true,
|
|
147
|
+
"measure": 80,
|
|
148
|
+
"empty": "No plan reviews recorded yet.",
|
|
147
149
|
"data": {
|
|
148
150
|
"kind": "datasource",
|
|
149
151
|
"source": "app",
|
|
150
152
|
"table": "plan_reviews",
|
|
151
|
-
"orderBy": { "field": "
|
|
153
|
+
"orderBy": { "field": "created_at", "dir": "asc" },
|
|
152
154
|
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
153
155
|
},
|
|
154
|
-
"
|
|
155
|
-
|
|
156
|
-
{ "field": "epoch", "header": "Epoch" },
|
|
157
|
-
{ "field": "approved", "header": "Approved? (1/0)" },
|
|
158
|
-
{ "field": "findings", "header": "Reviewer findings" },
|
|
159
|
-
{ "field": "created_at", "header": "Recorded" }
|
|
160
|
-
]
|
|
156
|
+
"header": "Round {{round}} · epoch {{epoch}} · approved {{approved}} · {{created_at}}",
|
|
157
|
+
"body": "findings"
|
|
161
158
|
}
|
|
162
159
|
},
|
|
163
160
|
{
|
|
164
|
-
"type": "
|
|
161
|
+
"type": "prose",
|
|
165
162
|
"id": "plan-review-escalations",
|
|
166
163
|
"props": {
|
|
167
164
|
"title": "Plan-review escalations",
|
|
168
|
-
"rowKey": "id",
|
|
169
165
|
"refreshMs": 5000,
|
|
170
166
|
"collapsible": true,
|
|
171
167
|
"defaultCollapsed": true,
|
|
168
|
+
"measure": 80,
|
|
169
|
+
"empty": "No plan-review escalations.",
|
|
172
170
|
"data": {
|
|
173
171
|
"kind": "datasource",
|
|
174
172
|
"source": "app",
|
|
@@ -176,16 +174,8 @@
|
|
|
176
174
|
"orderBy": { "field": "id", "dir": "desc" },
|
|
177
175
|
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
178
176
|
},
|
|
179
|
-
"
|
|
180
|
-
|
|
181
|
-
{ "field": "round", "header": "Round" },
|
|
182
|
-
{ "field": "findings", "header": "Findings" },
|
|
183
|
-
{ "field": "status", "header": "Status" },
|
|
184
|
-
{ "field": "directive", "header": "Directive" },
|
|
185
|
-
{ "field": "note", "header": "Human note" },
|
|
186
|
-
{ "field": "asked_at", "header": "Asked" },
|
|
187
|
-
{ "field": "answered_at", "header": "Answered" }
|
|
188
|
-
]
|
|
177
|
+
"header": "Round {{round}} · epoch {{epoch}} · {{status}} · directive {{directive}} · asked {{asked_at}} · answered {{answered_at}} · note {{note}}",
|
|
178
|
+
"body": "findings"
|
|
189
179
|
}
|
|
190
180
|
},
|
|
191
181
|
{
|
|
@@ -214,14 +204,15 @@
|
|
|
214
204
|
}
|
|
215
205
|
},
|
|
216
206
|
{
|
|
217
|
-
"type": "
|
|
207
|
+
"type": "prose",
|
|
218
208
|
"id": "coordination-notes",
|
|
219
209
|
"props": {
|
|
220
210
|
"title": "Coordination notes",
|
|
221
|
-
"rowKey": "id",
|
|
222
211
|
"refreshMs": 5000,
|
|
223
212
|
"collapsible": true,
|
|
224
213
|
"defaultCollapsed": true,
|
|
214
|
+
"measure": 80,
|
|
215
|
+
"empty": "No coordination notes posted yet.",
|
|
225
216
|
"data": {
|
|
226
217
|
"kind": "datasource",
|
|
227
218
|
"source": "app",
|
|
@@ -229,14 +220,8 @@
|
|
|
229
220
|
"orderBy": { "field": "id", "dir": "asc" },
|
|
230
221
|
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
231
222
|
},
|
|
232
|
-
"
|
|
233
|
-
|
|
234
|
-
{ "field": "author_task", "header": "Agent" },
|
|
235
|
-
{ "field": "kind", "header": "Kind" },
|
|
236
|
-
{ "field": "files", "header": "Files" },
|
|
237
|
-
{ "field": "body", "header": "Note" },
|
|
238
|
-
{ "field": "created_at", "header": "Posted" }
|
|
239
|
-
]
|
|
223
|
+
"header": "Wave {{wave}} · {{author_task}} · {{kind}} · files {{files}} · {{created_at}}",
|
|
224
|
+
"body": "body"
|
|
240
225
|
}
|
|
241
226
|
},
|
|
242
227
|
{
|
package/pages/feature.page.json
CHANGED
|
@@ -63,23 +63,33 @@
|
|
|
63
63
|
"source": "app",
|
|
64
64
|
"table": "feature_runs",
|
|
65
65
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
66
|
-
"filter": [{ "field": "
|
|
66
|
+
"filter": [{ "field": "list_bucket", "in": ["active"] }]
|
|
67
67
|
},
|
|
68
68
|
"tabs": [
|
|
69
|
-
{ "label": "Active", "filter": [{ "field": "
|
|
70
|
-
{ "label": "History", "filter": [{ "field": "
|
|
69
|
+
{ "label": "Active", "filter": [{ "field": "list_bucket", "in": ["active"] }] },
|
|
70
|
+
{ "label": "History", "filter": [{ "field": "list_bucket", "in": ["history"] }] },
|
|
71
71
|
{ "label": "All", "filter": [] }
|
|
72
72
|
],
|
|
73
73
|
"columns": [
|
|
74
|
-
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "feature_key", "truncate": true, "width": "
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
74
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "feature_key", "truncate": true, "width": "30%", "linkField": "issue_url" },
|
|
75
|
+
{
|
|
76
|
+
"field": "stage",
|
|
77
|
+
"header": "Pipeline",
|
|
78
|
+
"kind": "pipeline",
|
|
79
|
+
"stages": [
|
|
80
|
+
{ "key": "Requested", "label": "Requested" },
|
|
81
|
+
{ "key": "Implementing", "label": "Implementing" },
|
|
82
|
+
{ "key": "PR open", "label": "PR open" },
|
|
83
|
+
{ "key": "Converging", "label": "Converging" },
|
|
84
|
+
{ "key": "Merging", "label": "Merging" },
|
|
85
|
+
{ "key": "Done", "label": "Done" }
|
|
86
|
+
],
|
|
87
|
+
"activeField": "stage",
|
|
88
|
+
"stateField": "stage_state",
|
|
89
|
+
"badgeField": "attention",
|
|
90
|
+
"notInPathField": "stage_skipped",
|
|
91
|
+
"locus": { "field": "pr_key", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } }
|
|
92
|
+
},
|
|
83
93
|
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
84
94
|
],
|
|
85
95
|
"rowActions": [
|
|
@@ -100,11 +110,21 @@
|
|
|
100
110
|
"path": "/app/api/actions/acknowledge-blocked",
|
|
101
111
|
"body": { "userTaskKey": "{{row.blocked_user_task_key}}" }
|
|
102
112
|
}
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"label": "Dismiss",
|
|
116
|
+
"confirm": "Tick off this finished run? It acknowledges the run as done and files it under History.",
|
|
117
|
+
"showWhenField": "stage_state",
|
|
118
|
+
"action": {
|
|
119
|
+
"path": "/app/api/actions/acknowledge-done",
|
|
120
|
+
"body": { "feature_key": "{{row.feature_key}}" }
|
|
121
|
+
}
|
|
103
122
|
}
|
|
104
123
|
],
|
|
105
124
|
"detail": {
|
|
106
125
|
"fields": [
|
|
107
126
|
{ "field": "escalation_question", "label": "Escalation question" },
|
|
127
|
+
{ "field": "base_branch", "label": "Base branch" },
|
|
108
128
|
{ "field": "outcome", "label": "Outcome" },
|
|
109
129
|
{ "field": "delivery_label", "label": "Delivery" }
|
|
110
130
|
],
|
|
@@ -112,6 +112,12 @@ function filterFields(filter: Json): string[] {
|
|
|
112
112
|
return filter.map((f: Json) => f?.field).filter(Boolean);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// Pull `{{field}}` interpolation names out of a prose renderer's header template (nano-ide#274).
|
|
116
|
+
function templateFields(tpl: Json): string[] {
|
|
117
|
+
if (typeof tpl !== "string") return [];
|
|
118
|
+
return [...tpl.matchAll(/\{\{([^{}]+)\}\}/g)].map((m) => m[1].trim()).filter(Boolean);
|
|
119
|
+
}
|
|
120
|
+
|
|
115
121
|
function collectRefs(page: string, node: Json, out: Ref[]): void {
|
|
116
122
|
if (Array.isArray(node)) {
|
|
117
123
|
for (const v of node) collectRefs(page, v, out);
|
|
@@ -120,10 +126,17 @@ function collectRefs(page: string, node: Json, out: Ref[]): void {
|
|
|
120
126
|
if (!node || typeof node !== "object") return;
|
|
121
127
|
|
|
122
128
|
// Top-level datasource grid: the datasource lives at `node.data`, while `columns`, `rowKey`,
|
|
123
|
-
// `filter`/`tabs`, and `detail` are siblings on the same `node` (the grid props).
|
|
129
|
+
// `filter`/`tabs`, and `detail` are siblings on the same `node` (the grid props). A `prose`
|
|
130
|
+
// renderer (nano-ide#274) binds the same `node.data` but has no `columns`: its displayed content
|
|
131
|
+
// is the header template's `{{field}}` refs plus the single `body` field, so fold those in as
|
|
132
|
+
// "columns" so the field-existence + surface guards below apply to prose sections too.
|
|
124
133
|
const data = node.data;
|
|
125
134
|
if (data && data.kind === "datasource" && typeof data.table === "string") {
|
|
126
|
-
const columns: string[] =
|
|
135
|
+
const columns: string[] = [
|
|
136
|
+
...(node.columns ?? []).map((c: Json) => c.field),
|
|
137
|
+
...templateFields(node.header),
|
|
138
|
+
...(typeof node.body === "string" ? [node.body] : []),
|
|
139
|
+
].filter(Boolean);
|
|
127
140
|
// Every reference that resolves to a column on this table — the runtime 400s on any of them if
|
|
128
141
|
// it names a column the migrations never created, so all must be guarded, not just displayed
|
|
129
142
|
// columns. `detail.fields`/`detail.linkField` render columns of the same top-level row.
|
|
@@ -215,17 +228,19 @@ test("issue #87: plan_reviews is surfaced on the per-epic detail page", async ()
|
|
|
215
228
|
const onEpicDetail = refs.some(
|
|
216
229
|
(r) => r.page === "epic-detail.page.json" && r.table === "plan_reviews",
|
|
217
230
|
);
|
|
218
|
-
assert(onEpicDetail, "epic-detail.page.json must bind
|
|
231
|
+
assert(onEpicDetail, "epic-detail.page.json must bind the plan-review trace to plan_reviews");
|
|
219
232
|
|
|
220
|
-
// The trace is only useful with the verdict + critique
|
|
221
|
-
// visibly displayed `columns` (not `fields`, which also holds binding refs like orderBy.field)
|
|
222
|
-
//
|
|
233
|
+
// The trace is only useful with the verdict + critique surfaced, so pin them. Assert against the
|
|
234
|
+
// visibly displayed `columns` (not `fields`, which also holds binding refs like orderBy.field) —
|
|
235
|
+
// for the `prose` renderer (nano-ide#274) these are the header template's `{{round}}`/`{{approved}}`
|
|
236
|
+
// refs plus the `findings` body — so a field silently dropped from the UI can't pass by being
|
|
237
|
+
// referenced elsewhere.
|
|
223
238
|
const required = ["round", "approved", "findings"];
|
|
224
239
|
for (const r of refs.filter((x) => x.table === "plan_reviews")) {
|
|
225
240
|
for (const col of required) {
|
|
226
241
|
assert(
|
|
227
242
|
r.columns.includes(col),
|
|
228
|
-
`${r.page}: plan_reviews
|
|
243
|
+
`${r.page}: plan_reviews trace must surface the "${col}" field`,
|
|
229
244
|
);
|
|
230
245
|
}
|
|
231
246
|
}
|