@nanobpm/nano-workforce 0.82.0 → 0.83.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/SPEC.md +8 -5
- package/app/feature.test.ts +24 -0
- package/app/feature.ts +147 -1
- package/app/featureGateway.test.ts +195 -0
- package/app/service.test.ts +188 -1
- package/app/service.ts +130 -25
- package/app/stage.test.ts +107 -0
- package/app/stage.ts +111 -0
- package/db/migrations/007_wave_gate.sql +6 -3
- 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/feature.page.json +32 -12
- package/workers/record-wave/worker.ts +5 -3
|
@@ -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.83.0](https://github.com/nanobpm/nano-workforce/compare/v0.82.1...v0.83.0) (2026-08-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **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)
|
|
7
|
+
|
|
8
|
+
## [0.82.1](https://github.com/nanobpm/nano-workforce/compare/v0.82.0...v0.82.1) (2026-08-17)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **plan-fanout:** make the wave-merge barrier level-triggered ([#262](https://github.com/nanobpm/nano-workforce/issues/262)) ([#264](https://github.com/nanobpm/nano-workforce/issues/264)) ([ae939d8](https://github.com/nanobpm/nano-workforce/commit/ae939d8e96f136f820fcae9149c120806ddc6f1b))
|
|
14
|
+
|
|
1
15
|
# [0.82.0](https://github.com/nanobpm/nano-workforce/compare/v0.81.0...v0.82.0) (2026-08-17)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -565,11 +565,14 @@ the loop runs one parallel `implement` MI fan-out per wave:
|
|
|
565
565
|
`dependsOn`), and advances `currentWave`.
|
|
566
566
|
- **Wave-merge barrier** (`wait-wave-merged`): when a wave has a successor,
|
|
567
567
|
`record-wave` sets `plans.gate_wave` to that wave's index and the process parks at
|
|
568
|
-
the `wait-wave-merged` catch event. The poller's `
|
|
569
|
-
`wave-merged` message (correlated on `planKey`)
|
|
570
|
-
has merged** (`app/waves.ts` `waveMergeTargets`
|
|
571
|
-
`blocked`/`skipped`/keyless tasks clear vacuously)
|
|
572
|
-
|
|
568
|
+
the `wait-wave-merged` catch event. The poller's `pollWaveGatesImpl` pass is
|
|
569
|
+
**level-triggered**: it publishes the `wave-merged` message (correlated on `planKey`)
|
|
570
|
+
once **every opened PR in that wave has merged** (`app/waves.ts` `waveMergeTargets`
|
|
571
|
+
selects the PRs to wait on; `blocked`/`skipped`/keyless tasks clear vacuously) **and**
|
|
572
|
+
it observes an OPEN `wait-wave-merged` subscription for the plan — so a merge that
|
|
573
|
+
lands while the token is still upstream can't drop the signal. The poller **never**
|
|
574
|
+
clears `gate_wave`; `record-wave` owns the marker's lifecycle (re-arming it to the next
|
|
575
|
+
wave, or clearing it to NULL on the final wave). So a `dependsOn` means the dependent wave is not **implemented** until
|
|
573
576
|
its prerequisites have **landed on the base branch** — not merely opened. This lets
|
|
574
577
|
a blocking prerequisite (e.g. app scaffolding) fully converge and merge before the
|
|
575
578
|
next wave builds on it. `gate_wave` lives in `db/migrations/007_wave_gate.sql`.
|
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
|
+
});
|