@nanobpm/nano-workforce 0.118.2 → 0.119.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.119.0](https://github.com/nanobpm/nano-workforce/compare/v0.118.2...v0.119.0) (2026-08-22)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **epic-detail:** wave visualization + task→representation links ([#411](https://github.com/nanobpm/nano-workforce/issues/411)) ([#434](https://github.com/nanobpm/nano-workforce/issues/434)) ([cff11fe](https://github.com/nanobpm/nano-workforce/commit/cff11febc30c1fbe08ea385ef5cf36fae0ff2fe7)), closes [nano-ide#424](https://github.com/nano-ide/issues/424)
|
|
7
|
+
|
|
1
8
|
## [0.118.2](https://github.com/nanobpm/nano-workforce/compare/v0.118.1...v0.118.2) (2026-08-21)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Coverage for the Epic-detail wave visualization + task→representation links (issue #411).
|
|
2
|
+
//
|
|
3
|
+
// Two guards, mirroring the repo's split between a derived-read-model test (migration042.test.ts —
|
|
4
|
+
// apply the migration to a real in-memory DB and assert its output) and a page-projection guard
|
|
5
|
+
// (waitGateVisibility.test.ts — pure text assertions that the declarative page wires the surface):
|
|
6
|
+
//
|
|
7
|
+
// 1. The VIEW rollup over sample `plan_tasks` × `pull_requests` rows: the six-way per-wave count
|
|
8
|
+
// partition and the pre-formatted `bar` string. Because `plan_wave_summary` is a VIEW (the whole
|
|
9
|
+
// point of #411 — a single derived source of truth, enabled by nano-ide#424) this exercises the
|
|
10
|
+
// real SQLite view, not a re-implementation.
|
|
11
|
+
// 2. The epic-detail page projects the wave banner, the per-wave summary section, and the
|
|
12
|
+
// task→representation links (PR url + processExplorer instance) on the wave-state grid.
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { DatabaseSync } from "node:sqlite";
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { assert, assertEquals } from "#test-assert";
|
|
18
|
+
|
|
19
|
+
const MIGRATION = fileURLToPath(new URL("../db/migrations/059_plan_wave_summary.sql", import.meta.url));
|
|
20
|
+
const PAGE = fileURLToPath(new URL("../pages/epic-detail.page.json", import.meta.url));
|
|
21
|
+
|
|
22
|
+
/** A DB with the base `plan_tasks` / `pull_requests` shapes the views read, plus the views applied. */
|
|
23
|
+
function viewDb(): DatabaseSync {
|
|
24
|
+
const db = new DatabaseSync(":memory:");
|
|
25
|
+
db.exec(
|
|
26
|
+
`CREATE TABLE plan_tasks (
|
|
27
|
+
id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
|
|
28
|
+
prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
|
|
29
|
+
wave INTEGER, open_question TEXT, answer TEXT, draft_pr_key TEXT, corr_key TEXT);
|
|
30
|
+
CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, url TEXT, status TEXT, process_key TEXT);`,
|
|
31
|
+
);
|
|
32
|
+
db.exec(readFileSync(MIGRATION, "utf8"));
|
|
33
|
+
return db;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function addTask(
|
|
37
|
+
db: DatabaseSync,
|
|
38
|
+
plan_key: string,
|
|
39
|
+
task_index: number,
|
|
40
|
+
status: string,
|
|
41
|
+
wave: number,
|
|
42
|
+
pr?: { pr_key: string; url: string; status: string; process_key: string },
|
|
43
|
+
): void {
|
|
44
|
+
db.prepare(
|
|
45
|
+
"INSERT INTO plan_tasks (plan_key, task_index, task_id, status, pr_key, wave) VALUES (?, ?, ?, ?, ?, ?)",
|
|
46
|
+
).run(plan_key, task_index, `t${task_index}`, status, pr?.pr_key ?? null, wave);
|
|
47
|
+
if (pr) {
|
|
48
|
+
db.prepare(
|
|
49
|
+
"INSERT INTO pull_requests (pr_key, url, status, process_key) VALUES (?, ?, ?, ?)",
|
|
50
|
+
).run(pr.pr_key, pr.url, pr.status, pr.process_key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("plan_wave_summary partitions each wave's tasks and pre-formats the progress bar", () => {
|
|
55
|
+
const db = viewDb();
|
|
56
|
+
const plan = "o/r#1";
|
|
57
|
+
// Wave 0 — 5 tasks: 3 merged, 1 converging (in-flight), 1 blocked (no PR).
|
|
58
|
+
addTask(db, plan, 0, "opened", 0, { pr_key: "o/r#10", url: "https://gh/10", status: "merged", process_key: "P10" });
|
|
59
|
+
addTask(db, plan, 1, "opened", 0, { pr_key: "o/r#11", url: "https://gh/11", status: "merged", process_key: "P11" });
|
|
60
|
+
addTask(db, plan, 2, "opened", 0, { pr_key: "o/r#12", url: "https://gh/12", status: "merged", process_key: "P12" });
|
|
61
|
+
addTask(db, plan, 3, "opened", 0, { pr_key: "o/r#13", url: "https://gh/13", status: "converging", process_key: "P13" });
|
|
62
|
+
addTask(db, plan, 4, "blocked", 0);
|
|
63
|
+
// Wave 1 — an escalated slice (with a draft PR) and a skipped slice.
|
|
64
|
+
addTask(db, plan, 5, "escalated", 1, { pr_key: "o/r#14", url: "https://gh/14", status: "escalated", process_key: "P14" });
|
|
65
|
+
addTask(db, plan, 6, "skipped", 1);
|
|
66
|
+
|
|
67
|
+
const rows = db
|
|
68
|
+
.prepare("SELECT * FROM plan_wave_summary WHERE plan_key = ? ORDER BY wave")
|
|
69
|
+
.all(plan) as Array<Record<string, unknown>>;
|
|
70
|
+
assertEquals(rows.length, 2);
|
|
71
|
+
|
|
72
|
+
const w0 = rows[0];
|
|
73
|
+
assertEquals(w0.total, 5);
|
|
74
|
+
assertEquals(w0.merged, 3);
|
|
75
|
+
assertEquals(w0.in_flight, 1);
|
|
76
|
+
assertEquals(w0.blocked, 1);
|
|
77
|
+
assertEquals(w0.escalated, 0);
|
|
78
|
+
assertEquals(w0.skipped, 0);
|
|
79
|
+
// 3 filled + 2 empty glyphs (width = total), then the named non-zero categories.
|
|
80
|
+
assertEquals(w0.bar, "▓▓▓░░ 3/5 merged · 1 in-flight · 1 blocked");
|
|
81
|
+
|
|
82
|
+
const w1 = rows[1];
|
|
83
|
+
assertEquals(w1.total, 2);
|
|
84
|
+
assertEquals(w1.merged, 0);
|
|
85
|
+
assertEquals(w1.in_flight, 0);
|
|
86
|
+
assertEquals(w1.escalated, 1);
|
|
87
|
+
assertEquals(w1.skipped, 1);
|
|
88
|
+
assertEquals(w1.bar, "░░ 0/2 merged · 1 escalated · 1 skipped");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a merged PR wins over the task's own status, and unlevelized tasks are excluded", () => {
|
|
92
|
+
const db = viewDb();
|
|
93
|
+
const plan = "o/r#2";
|
|
94
|
+
// An escalated task whose PR nonetheless merged counts as merged, not escalated (PR wins).
|
|
95
|
+
addTask(db, plan, 0, "escalated", 0, { pr_key: "o/r#20", url: "https://gh/20", status: "merged", process_key: "P20" });
|
|
96
|
+
// A task with no wave yet (not levelized) must not appear in any wave row.
|
|
97
|
+
db.prepare(
|
|
98
|
+
"INSERT INTO plan_tasks (plan_key, task_index, task_id, status, wave) VALUES (?, ?, ?, ?, NULL)",
|
|
99
|
+
).run(plan, 1, "t1", "pending");
|
|
100
|
+
|
|
101
|
+
const rows = db
|
|
102
|
+
.prepare("SELECT wave, total, merged, escalated FROM plan_wave_summary WHERE plan_key = ?")
|
|
103
|
+
.all(plan) as Array<Record<string, unknown>>;
|
|
104
|
+
assertEquals(rows.length, 1);
|
|
105
|
+
assertEquals(rows[0].wave, 0);
|
|
106
|
+
assertEquals(rows[0].total, 1);
|
|
107
|
+
assertEquals(rows[0].merged, 1);
|
|
108
|
+
assertEquals(rows[0].escalated, 0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("plan_wave_tasks carries each task's PR url + process_key link targets", () => {
|
|
112
|
+
const db = viewDb();
|
|
113
|
+
addTask(db, "o/r#3", 0, "opened", 0, { pr_key: "o/r#30", url: "https://gh/30", status: "converging", process_key: "P30" });
|
|
114
|
+
addTask(db, "o/r#3", 1, "blocked", 0); // no PR → null link targets
|
|
115
|
+
|
|
116
|
+
const rows = db
|
|
117
|
+
.prepare("SELECT task_id, pr_key, pr_url, process_key FROM plan_wave_tasks WHERE plan_key = ? ORDER BY task_index")
|
|
118
|
+
.all("o/r#3") as Array<Record<string, unknown>>;
|
|
119
|
+
assertEquals(rows[0].pr_url, "https://gh/30");
|
|
120
|
+
assertEquals(rows[0].process_key, "P30");
|
|
121
|
+
assertEquals(rows[1].pr_url, null);
|
|
122
|
+
assertEquals(rows[1].process_key, null);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("epic-detail projects the wave banner, the per-wave summary, and task→representation links", () => {
|
|
126
|
+
const page = JSON.parse(readFileSync(PAGE, "utf8"));
|
|
127
|
+
const byId = (id: string) => page.nodes.find((n: { id: string }) => n.id === id);
|
|
128
|
+
|
|
129
|
+
// 1. The epic-level wave banner: a prose node reading wave_label + epic_phase off `plans`.
|
|
130
|
+
const banner = byId("wave-banner");
|
|
131
|
+
assert(banner, "epic detail must show the epic-level wave banner");
|
|
132
|
+
assertEquals(banner.props.data.table, "plans");
|
|
133
|
+
assert(
|
|
134
|
+
banner.props.data.filter.some((f: { field: string; eqParam?: boolean }) => f.field === "plan_key" && f.eqParam),
|
|
135
|
+
"the banner is scoped to this epic",
|
|
136
|
+
);
|
|
137
|
+
assert(/\{\{\s*wave_label\s*\}\}/.test(banner.props.header), "the banner surfaces the wave_label");
|
|
138
|
+
assert(/\{\{\s*epic_phase\s*\}\}/.test(banner.props.header), "the banner surfaces the epic phase");
|
|
139
|
+
|
|
140
|
+
// 2. The per-wave summary section: a grid over the derived VIEW, ordered by wave, with the bar.
|
|
141
|
+
const summary = byId("wave-summary");
|
|
142
|
+
assert(summary, "epic detail must show the per-wave progress summary");
|
|
143
|
+
assertEquals(summary.props.data.table, "plan_wave_summary");
|
|
144
|
+
assertEquals(summary.props.data.orderBy.field, "wave");
|
|
145
|
+
const summaryCols: string[] = summary.props.columns.map((c: { field: string }) => c.field);
|
|
146
|
+
for (const f of ["wave", "bar", "merged", "in_flight", "blocked", "escalated", "skipped", "total"]) {
|
|
147
|
+
assert(summaryCols.includes(f), `the summary grid shows ${f}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 3. The wave-state grid links each in-flight task to its representation (PR + process instance).
|
|
151
|
+
const waveState = byId("wave-state");
|
|
152
|
+
assert(waveState, "epic detail must keep the wave-state grid");
|
|
153
|
+
assertEquals(waveState.props.data.table, "plan_wave_tasks");
|
|
154
|
+
const cols: Array<Record<string, unknown>> = waveState.props.columns;
|
|
155
|
+
const prCol = cols.find((c) => c.field === "pr_key");
|
|
156
|
+
assertEquals(prCol?.linkField, "pr_url", "the PR cell links to the GitHub PR url");
|
|
157
|
+
const statusCol = cols.find((c) => c.field === "status") as {
|
|
158
|
+
link?: { kind?: string; keyField?: string };
|
|
159
|
+
};
|
|
160
|
+
assertEquals(statusCol.link?.kind, "processExplorer", "the status cell links to the process instance");
|
|
161
|
+
assertEquals(statusCol.link?.keyField, "process_key");
|
|
162
|
+
// The existing tabs (Active / Skipped / All) and detail drawer must still be present.
|
|
163
|
+
assertEquals(waveState.props.tabs.length, 3);
|
|
164
|
+
const detailFields: string[] = waveState.props.detail.fields.map((f: { field: string }) => f.field);
|
|
165
|
+
for (const f of ["open_question", "answer", "draft_pr_key", "prompt"]) {
|
|
166
|
+
assert(detailFields.includes(f), `the detail drawer keeps ${f}`);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
-- Epic-detail wave visualization + task→representation links (issue #411).
|
|
2
|
+
--
|
|
3
|
+
-- The Epic detail page (pages/epic-detail.page.json) had no glanceable per-wave progress and no
|
|
4
|
+
-- click-through from an in-flight task to the thing that represents it (its PR / process instance).
|
|
5
|
+
-- Both are pure ROLLUPS of data that already exists — `plan_tasks` (the slices + their wave) joined
|
|
6
|
+
-- to `pull_requests` (each slice's PR url + engine `process_key`) — so per AGENTS.md "Derivation over
|
|
7
|
+
-- duplication / no drift surfaces" they are DERIVED, not denormalised onto a worker-written table.
|
|
8
|
+
--
|
|
9
|
+
-- Historically the codebase reached for a denormalised flat table here (see 022_plan_wave_progress,
|
|
10
|
+
-- 029_plan_delivery, 051_merges_per_day) purely because Urban's datasource could not read a SQL
|
|
11
|
+
-- VIEW. That constraint is gone (nano-ide#424: `gateway.schema()` now introspects
|
|
12
|
+
-- `type IN ('table','view')` and tags a view read-only), so these are VIEWs — a single source of
|
|
13
|
+
-- truth with NO write-path and no possibility of drift from `plan_tasks`.
|
|
14
|
+
--
|
|
15
|
+
-- Three views, layered so each is a plain `CREATE VIEW <name> AS SELECT … FROM …` (no CTE / no
|
|
16
|
+
-- select-list subquery) — which keeps them parseable by the static pages↔schema contract guard
|
|
17
|
+
-- (scripts/pages-contract.test.ts) that introspects the migrations to whitelist page columns:
|
|
18
|
+
--
|
|
19
|
+
-- • plan_wave_tasks — per-task rows (every `plan_tasks` column) PLUS the link targets the
|
|
20
|
+
-- wave-state grid needs to reach a task's representation: `pr_url`
|
|
21
|
+
-- (pull_requests.url — the GitHub PR) and `process_key`
|
|
22
|
+
-- (pull_requests.process_key — the engine instance, for the processExplorer
|
|
23
|
+
-- link, exactly as the Plan grid links its own `process_key`).
|
|
24
|
+
-- • plan_wave_counts — one row per (plan_key, wave) with the six-way task partition
|
|
25
|
+
-- (total / merged / in_flight / blocked / escalated / skipped). A task is
|
|
26
|
+
-- `merged` iff its PR reached `pull_requests.status = 'merged'` (the same
|
|
27
|
+
-- merged predicate app/delivery.ts derives the epic rollup from); otherwise
|
|
28
|
+
-- it falls to its `plan_tasks.status` bucket, and everything else
|
|
29
|
+
-- (pending/opened/waiting-for-lane/abandoned) is `in_flight`. The CASE
|
|
30
|
+
-- priority makes the five named buckets DISJOINT so they always sum to
|
|
31
|
+
-- `total` — which is what lets the bar below use `total` as its width.
|
|
32
|
+
-- • plan_wave_summary — the same counts PLUS `bar`, a PRE-FORMATTED progress string
|
|
33
|
+
-- (e.g. "▓▓▓░░ 3/5 merged · 1 in-flight · 1 blocked"). It is pre-formatted
|
|
34
|
+
-- because the dataGrid renderer has no per-cell templating — a bar has to be
|
|
35
|
+
-- a ready-to-show string. `▓` = merged, `░` = not-yet-merged; the block run
|
|
36
|
+
-- is built with SQLite string funcs (`hex(zeroblob(n))` → n '0' chars →
|
|
37
|
+
-- `replace` to the glyph), and only non-zero categories are named in the
|
|
38
|
+
-- suffix.
|
|
39
|
+
--
|
|
40
|
+
-- Forward-only, additive (a new read model, no schema change to any base table). The runner wraps
|
|
41
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after the
|
|
42
|
+
-- current highest prefix (058).
|
|
43
|
+
|
|
44
|
+
CREATE VIEW plan_wave_tasks AS
|
|
45
|
+
SELECT
|
|
46
|
+
t.id AS id,
|
|
47
|
+
t.plan_key AS plan_key,
|
|
48
|
+
t.task_index AS task_index,
|
|
49
|
+
t.task_id AS task_id,
|
|
50
|
+
t.title AS title,
|
|
51
|
+
t.prompt AS prompt,
|
|
52
|
+
t.status AS status,
|
|
53
|
+
t.pr_key AS pr_key,
|
|
54
|
+
t.summary AS summary,
|
|
55
|
+
t.created_at AS created_at,
|
|
56
|
+
t.updated_at AS updated_at,
|
|
57
|
+
t.wave AS wave,
|
|
58
|
+
t.open_question AS open_question,
|
|
59
|
+
t.answer AS answer,
|
|
60
|
+
t.draft_pr_key AS draft_pr_key,
|
|
61
|
+
t.corr_key AS corr_key,
|
|
62
|
+
p.url AS pr_url,
|
|
63
|
+
p.process_key AS process_key
|
|
64
|
+
FROM plan_tasks t
|
|
65
|
+
LEFT JOIN pull_requests p ON p.pr_key = t.pr_key;
|
|
66
|
+
|
|
67
|
+
CREATE VIEW plan_wave_counts AS
|
|
68
|
+
SELECT
|
|
69
|
+
t.plan_key AS plan_key,
|
|
70
|
+
t.wave AS wave,
|
|
71
|
+
COUNT(*) AS total,
|
|
72
|
+
SUM(CASE WHEN p.status = 'merged' THEN 1 ELSE 0 END) AS merged,
|
|
73
|
+
SUM(CASE WHEN p.status = 'merged' THEN 0 WHEN t.status = 'skipped' THEN 1 ELSE 0 END) AS skipped,
|
|
74
|
+
SUM(CASE WHEN p.status = 'merged' THEN 0 WHEN t.status = 'blocked' THEN 1 ELSE 0 END) AS blocked,
|
|
75
|
+
SUM(CASE WHEN p.status = 'merged' THEN 0 WHEN t.status = 'escalated' THEN 1 ELSE 0 END) AS escalated,
|
|
76
|
+
SUM(CASE WHEN p.status = 'merged' THEN 0 WHEN t.status IN ('skipped', 'blocked', 'escalated') THEN 0 ELSE 1 END) AS in_flight
|
|
77
|
+
FROM plan_tasks t
|
|
78
|
+
LEFT JOIN pull_requests p ON p.pr_key = t.pr_key
|
|
79
|
+
WHERE t.wave IS NOT NULL
|
|
80
|
+
GROUP BY t.plan_key, t.wave;
|
|
81
|
+
|
|
82
|
+
CREATE VIEW plan_wave_summary AS
|
|
83
|
+
SELECT
|
|
84
|
+
c.plan_key AS plan_key,
|
|
85
|
+
c.wave AS wave,
|
|
86
|
+
c.total AS total,
|
|
87
|
+
c.merged AS merged,
|
|
88
|
+
c.in_flight AS in_flight,
|
|
89
|
+
c.blocked AS blocked,
|
|
90
|
+
c.escalated AS escalated,
|
|
91
|
+
c.skipped AS skipped,
|
|
92
|
+
replace(substr(hex(zeroblob(c.merged)), 1, c.merged), '0', '▓') || replace(substr(hex(zeroblob(c.total - c.merged)), 1, c.total - c.merged), '0', '░') || ' ' || c.merged || '/' || c.total || ' merged' || CASE WHEN c.in_flight > 0 THEN ' · ' || c.in_flight || ' in-flight' ELSE '' END || CASE WHEN c.blocked > 0 THEN ' · ' || c.blocked || ' blocked' ELSE '' END || CASE WHEN c.escalated > 0 THEN ' · ' || c.escalated || ' escalated' ELSE '' END || CASE WHEN c.skipped > 0 THEN ' · ' || c.skipped || ' skipped' ELSE '' END AS bar
|
|
93
|
+
FROM plan_wave_counts c;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.119.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",
|
|
@@ -82,6 +82,51 @@
|
|
|
82
82
|
"variant": "sub"
|
|
83
83
|
}
|
|
84
84
|
},
|
|
85
|
+
{
|
|
86
|
+
"type": "prose",
|
|
87
|
+
"id": "wave-banner",
|
|
88
|
+
"props": {
|
|
89
|
+
"refreshMs": 5000,
|
|
90
|
+
"measure": 80,
|
|
91
|
+
"empty": "No plan recorded for this epic yet.",
|
|
92
|
+
"data": {
|
|
93
|
+
"kind": "datasource",
|
|
94
|
+
"source": "app",
|
|
95
|
+
"table": "plans",
|
|
96
|
+
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
97
|
+
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
98
|
+
},
|
|
99
|
+
"header": "Wave {{wave_label}} · {{epic_phase}}",
|
|
100
|
+
"body": "delivery_label"
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"type": "dataGrid",
|
|
105
|
+
"id": "wave-summary",
|
|
106
|
+
"props": {
|
|
107
|
+
"title": "Wave progress",
|
|
108
|
+
"rowKey": "wave",
|
|
109
|
+
"refreshMs": 5000,
|
|
110
|
+
"empty": "No waves yet — this epic hasn't been levelized into waves.",
|
|
111
|
+
"data": {
|
|
112
|
+
"kind": "datasource",
|
|
113
|
+
"source": "app",
|
|
114
|
+
"table": "plan_wave_summary",
|
|
115
|
+
"orderBy": { "field": "wave", "dir": "asc" },
|
|
116
|
+
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
117
|
+
},
|
|
118
|
+
"columns": [
|
|
119
|
+
{ "field": "wave", "header": "Wave" },
|
|
120
|
+
{ "field": "bar", "header": "Progress", "width": "40%" },
|
|
121
|
+
{ "field": "merged", "header": "Merged" },
|
|
122
|
+
{ "field": "in_flight", "header": "In flight" },
|
|
123
|
+
{ "field": "blocked", "header": "Blocked" },
|
|
124
|
+
{ "field": "escalated", "header": "Escalated" },
|
|
125
|
+
{ "field": "skipped", "header": "Skipped" },
|
|
126
|
+
{ "field": "total", "header": "Total" }
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
},
|
|
85
130
|
{
|
|
86
131
|
"type": "dataGrid",
|
|
87
132
|
"id": "epic-plan",
|
|
@@ -161,7 +206,7 @@
|
|
|
161
206
|
"data": {
|
|
162
207
|
"kind": "datasource",
|
|
163
208
|
"source": "app",
|
|
164
|
-
"table": "
|
|
209
|
+
"table": "plan_wave_tasks",
|
|
165
210
|
"orderBy": { "field": "wave", "dir": "asc" },
|
|
166
211
|
"filter": [
|
|
167
212
|
{ "field": "plan_key", "eqParam": true },
|
|
@@ -192,8 +237,8 @@
|
|
|
192
237
|
{ "field": "wave", "header": "Wave" },
|
|
193
238
|
{ "field": "task_id", "header": "Task" },
|
|
194
239
|
{ "field": "title", "header": "Title" },
|
|
195
|
-
{ "field": "status", "header": "Status" },
|
|
196
|
-
{ "field": "pr_key", "header": "PR" },
|
|
240
|
+
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
241
|
+
{ "field": "pr_key", "header": "PR", "linkField": "pr_url" },
|
|
197
242
|
{ "field": "summary", "header": "Summary" },
|
|
198
243
|
{ "field": "updated_at", "header": "Updated", "format": "datetime" }
|
|
199
244
|
],
|
|
@@ -49,6 +49,54 @@ function parseSchema(sql: string, schema: Map<string, Set<string>>): void {
|
|
|
49
49
|
cols.add(m[2]);
|
|
50
50
|
schema.set(m[1], cols);
|
|
51
51
|
}
|
|
52
|
+
// CREATE VIEW [IF NOT EXISTS] <name> AS SELECT <select-list> FROM …
|
|
53
|
+
//
|
|
54
|
+
// A page datasource can bind a VIEW as well as a base table (nano-ide#424), so a view's OUTPUT
|
|
55
|
+
// columns must join the whitelist exactly like a table's. A view has no `PRAGMA`-visible column
|
|
56
|
+
// list in this static parse, so we read them off its SELECT projection: take the select-list up
|
|
57
|
+
// to the first top-level `FROM`, split it on top-level commas, and record each item's output name
|
|
58
|
+
// (its `AS <alias>`, or the trailing identifier of a bare `t.col` / `col`). This is why the
|
|
59
|
+
// repo's views (see 059_plan_wave_summary.sql) alias every projected column and avoid select-list
|
|
60
|
+
// subqueries — it keeps this purely-textual contract guard able to see their columns.
|
|
61
|
+
const viewRe = /CREATE\s+VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?\s+AS\s+SELECT\s+/gi;
|
|
62
|
+
while ((m = viewRe.exec(sql)) !== null) {
|
|
63
|
+
const selectList = topLevelSelectList(sql, viewRe.lastIndex);
|
|
64
|
+
if (selectList === null) continue;
|
|
65
|
+
const cols = schema.get(m[1]) ?? new Set<string>();
|
|
66
|
+
for (const frag of splitTopLevel(selectList)) {
|
|
67
|
+
const col = viewColumnName(frag);
|
|
68
|
+
if (col) cols.add(col);
|
|
69
|
+
}
|
|
70
|
+
schema.set(m[1], cols);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The select-list of a view: the text between `SELECT` (its end at `start`) and the first top-level
|
|
75
|
+
// `FROM` keyword (one at paren depth 0 — a `FROM` inside a function/subquery doesn't end the list).
|
|
76
|
+
function topLevelSelectList(s: string, start: number): string | null {
|
|
77
|
+
let depth = 0;
|
|
78
|
+
for (let i = start; i < s.length; i++) {
|
|
79
|
+
const c = s[i];
|
|
80
|
+
if (c === "(") depth++;
|
|
81
|
+
else if (c === ")") depth--;
|
|
82
|
+
else if (depth === 0 && (c === "F" || c === "f")) {
|
|
83
|
+
// whole-word FROM with a non-identifier char (or string start) on each side
|
|
84
|
+
if (/from/i.test(s.slice(i, i + 4)) && !/\w/.test(s[i - 1] ?? " ") && !/\w/.test(s[i + 4] ?? " ")) {
|
|
85
|
+
return s.slice(start, i);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The output name of one select-list item: its `AS <alias>` if present, else the trailing
|
|
93
|
+
// identifier of a bare `table.column` / `column` reference.
|
|
94
|
+
function viewColumnName(frag: string): string | null {
|
|
95
|
+
const trimmed = frag.trim();
|
|
96
|
+
const asMatch = trimmed.match(/\bAS\s+["`]?(\w+)["`]?\s*$/i);
|
|
97
|
+
if (asMatch) return asMatch[1];
|
|
98
|
+
const bare = trimmed.match(/(\w+)\s*$/);
|
|
99
|
+
return bare ? bare[1] : null;
|
|
52
100
|
}
|
|
53
101
|
|
|
54
102
|
// Return the text inside the parentheses whose opener is at `openIdx`, honouring nesting.
|