@nanobpm/nano-workforce 0.70.2 → 0.71.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 +7 -0
- package/SPEC.md +7 -2
- package/app/agentCompletion.test.ts +69 -0
- package/app/agentCompletion.ts +78 -0
- package/app/instance-tracking.test.ts +44 -1
- package/app/pollUserTasks.test.ts +151 -0
- package/app/service.ts +229 -2
- package/app/trialMerge.ts +5 -0
- package/app/userTasks.test.ts +208 -0
- package/app/userTasks.ts +217 -0
- package/db/migrations/034_user_tasks_inbox.sql +51 -0
- package/nano.app.json +2 -1
- package/openapi.yaml +53 -0
- package/operations/completeUserTask.test.ts +146 -0
- package/operations/completeUserTask.ts +72 -0
- package/package.json +1 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/pages/feature.page.json +1 -0
- package/pages/home.page.json +4 -0
- package/pages/overview.page.json +1 -0
- package/pages/tasks.page.json +297 -0
package/app/service.ts
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Data access goes through the record-oriented gateway (`data.table<T>(name, pk)` — the RAD
|
|
9
9
|
// `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
10
11
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
11
12
|
import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
12
13
|
import { agentSlaTimeout } from "./agentSla.ts";
|
|
13
|
-
import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureRun, featureRuns } from "./feature.ts";
|
|
14
|
+
import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
|
|
14
15
|
import {
|
|
15
16
|
classifyMergeability,
|
|
16
17
|
ensureFreshHeadRun,
|
|
@@ -26,8 +27,22 @@ import {
|
|
|
26
27
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
27
28
|
import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
28
29
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
29
|
-
import { plans, planTaskDeps, planTasks } from "./plan.ts";
|
|
30
|
+
import { planReviews, plans, planTaskDeps, planTasks } from "./plan.ts";
|
|
30
31
|
import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
32
|
+
import { trialMergeAudits } from "./trialMerge.ts";
|
|
33
|
+
import {
|
|
34
|
+
buildUserTaskRow,
|
|
35
|
+
latestOpenEscalationQuestion,
|
|
36
|
+
latestPlanReviewFindings,
|
|
37
|
+
latestTrialMergeQuestion,
|
|
38
|
+
PLAN_REVIEW_ELEMENT,
|
|
39
|
+
PR_WAIT_ANSWER_ELEMENT,
|
|
40
|
+
prEscalations,
|
|
41
|
+
reconcileUserTasks,
|
|
42
|
+
TRIAL_MERGE_ELEMENT,
|
|
43
|
+
type UserTaskRow,
|
|
44
|
+
userTasks,
|
|
45
|
+
} from "./userTasks.ts";
|
|
31
46
|
import { waveMergeTargets } from "./waves.ts";
|
|
32
47
|
|
|
33
48
|
/** The BPMN process that drives review convergence (`resources/processes/convergence-loop.bpmn`). */
|
|
@@ -1374,6 +1389,217 @@ export async function pollFeatureBlocked(data: DataLayer, engine: EngineClient)
|
|
|
1374
1389
|
}
|
|
1375
1390
|
}
|
|
1376
1391
|
|
|
1392
|
+
/** The app manifest, read and parsed exactly ONCE at module load. `activeStatusesFor` is invoked
|
|
1393
|
+
* three times during module initialization (the PR/plan/feature constants below); parsing here keeps
|
|
1394
|
+
* that to a single synchronous `readFileSync` + `JSON.parse` instead of one per lookup. */
|
|
1395
|
+
const APP_MANIFEST: { instanceTracking?: { table: string; activeStatuses?: string[] }[] } = JSON.parse(
|
|
1396
|
+
readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"),
|
|
1397
|
+
);
|
|
1398
|
+
|
|
1399
|
+
/** Read a tracked table's parked-and-active statuses from the single source of truth
|
|
1400
|
+
* (`instanceTracking.<table>.activeStatuses` in nano.app.json), so an app-side scan can never drift
|
|
1401
|
+
* from the reconciler's notion of "in-flight". Throws if the binding is missing/empty. */
|
|
1402
|
+
function activeStatusesFor(table: string): readonly string[] {
|
|
1403
|
+
const binding = APP_MANIFEST.instanceTracking?.find((b) => b.table === table);
|
|
1404
|
+
if (!binding?.activeStatuses?.length) {
|
|
1405
|
+
throw new Error(
|
|
1406
|
+
`nano.app.json: instanceTracking[table="${table}"].activeStatuses is missing or empty`,
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
return binding.activeStatuses;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** The `pull_requests` statuses a PR instance can be parked-and-active on, DERIVED from the single
|
|
1413
|
+
* source of truth (`instanceTracking.pull_requests.activeStatuses` in nano.app.json) so the app-side
|
|
1414
|
+
* scan can never drift from the reconciler's notion of "in-flight". `pollUserTasks` scans only these
|
|
1415
|
+
* for an open `wait-answer` escalation, so the pass stays O(in-flight PRs), not O(all PRs). */
|
|
1416
|
+
export const PR_ACTIVE_STATUSES: readonly string[] = activeStatusesFor("pull_requests");
|
|
1417
|
+
|
|
1418
|
+
/** The `plans` statuses a plan instance can be parked-and-active on, DERIVED from the same single
|
|
1419
|
+
* source of truth (`instanceTracking.plans.activeStatuses`) so `pollUserTasks`' plan scan can never
|
|
1420
|
+
* drift from the reconciler — mirroring `PR_ACTIVE_STATUSES` rather than hard-coding a second list. */
|
|
1421
|
+
export const PLAN_ACTIVE_STATUSES: readonly string[] = activeStatusesFor("plans");
|
|
1422
|
+
|
|
1423
|
+
/** The `feature_runs` statuses a feature instance can be parked-and-active on, DERIVED from the same
|
|
1424
|
+
* single source of truth (`instanceTracking.feature_runs.activeStatuses`) so `pollUserTasks`' feature
|
|
1425
|
+
* scan can never drift from the reconciler — mirroring `PR_ACTIVE_STATUSES`/`PLAN_ACTIVE_STATUSES`
|
|
1426
|
+
* rather than hard-coding a second list. Notably includes the non-terminal `awaiting_operator`, so a
|
|
1427
|
+
* run that terminates while parked at `feature-blocked` still gets the `onTerminated` reconciliation
|
|
1428
|
+
* (rather than stranding at `awaiting_operator` and blocking re-dispatch). Narrowed to the typed
|
|
1429
|
+
* `FeatureRunStatus` union (throwing on any manifest status the code doesn't know) so the derived
|
|
1430
|
+
* list can feed `featureRuns(data).find({ status })` directly. */
|
|
1431
|
+
function toFeatureRunStatus(status: string): FeatureRunStatus {
|
|
1432
|
+
for (const known of FEATURE_RUN_STATUSES) if (known === status) return known;
|
|
1433
|
+
throw new Error(`nano.app.json: feature_runs.activeStatuses has unknown status "${status}"`);
|
|
1434
|
+
}
|
|
1435
|
+
export const FEATURE_ACTIVE_STATUSES: readonly FeatureRunStatus[] =
|
|
1436
|
+
activeStatusesFor("feature_runs").map(toFeatureRunStatus);
|
|
1437
|
+
|
|
1438
|
+
/** Reconcile the unified Tasks-inbox read-model (`user_tasks`) against the engine's currently-open
|
|
1439
|
+
* native user-task escalations (issue #236). The Tasks page lists EVERY open escalation awaiting a
|
|
1440
|
+
* human decision — the feature kinds (already denormalised onto `feature_runs` by the two feature
|
|
1441
|
+
* pollers, which run earlier in this pass) plus the epic/PR kinds (`plan-review-decision`,
|
|
1442
|
+
* `trial-merge-decision`, `wait-answer`) that had no app-side pointer at all, so the pages could not
|
|
1443
|
+
* drive their completion. This is the generalisation of `pollFeatureEscalations`/`pollFeatureBlocked`
|
|
1444
|
+
* across all subjects: for each in-flight plan / PR it reads the instance's open user tasks and
|
|
1445
|
+
* projects one `user_tasks` row per escalation, enriching the display `question` from the audit
|
|
1446
|
+
* tables each kind already records. `reconcileUserTasks` then diffs the desired open set against the
|
|
1447
|
+
* persisted rows so a completed task's row is deleted (answered here, via the task inbox, or
|
|
1448
|
+
* out-of-band) and `showCount` reflects live pending work. Best-effort + idempotent — per-instance
|
|
1449
|
+
* failures are isolated so one bad instance never stalls the pass. */
|
|
1450
|
+
export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
1451
|
+
const at = now();
|
|
1452
|
+
const desired: UserTaskRow[] = [];
|
|
1453
|
+
const push = (row: UserTaskRow | null) => {
|
|
1454
|
+
if (row) desired.push(row);
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
// Feature-run escalations — the completable keys are denormalised onto `feature_runs` by
|
|
1458
|
+
// pollFeatureEscalations/pollFeatureBlocked (earlier in this pass), so no per-instance engine read
|
|
1459
|
+
// is needed here.
|
|
1460
|
+
const featureSeen = new Set<string>();
|
|
1461
|
+
for (const status of FEATURE_ACTIVE_STATUSES) {
|
|
1462
|
+
for (const run of await featureRuns(data).find({ status })) {
|
|
1463
|
+
if (featureSeen.has(run.feature_key)) continue;
|
|
1464
|
+
featureSeen.add(run.feature_key);
|
|
1465
|
+
if (run.escalation_user_task_key) {
|
|
1466
|
+
push(
|
|
1467
|
+
buildUserTaskRow(
|
|
1468
|
+
{
|
|
1469
|
+
userTaskKey: run.escalation_user_task_key,
|
|
1470
|
+
elementId: FEATURE_ESCALATION_ELEMENT,
|
|
1471
|
+
subjectType: "feature",
|
|
1472
|
+
subjectKey: run.feature_key,
|
|
1473
|
+
subjectUrl: run.issue_url,
|
|
1474
|
+
question: run.escalation_question,
|
|
1475
|
+
processKey: run.process_key,
|
|
1476
|
+
},
|
|
1477
|
+
at,
|
|
1478
|
+
),
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
if (run.blocked_user_task_key) {
|
|
1482
|
+
push(
|
|
1483
|
+
buildUserTaskRow(
|
|
1484
|
+
{
|
|
1485
|
+
userTaskKey: run.blocked_user_task_key,
|
|
1486
|
+
elementId: FEATURE_BLOCKED_ELEMENT,
|
|
1487
|
+
subjectType: "feature",
|
|
1488
|
+
subjectKey: run.feature_key,
|
|
1489
|
+
subjectUrl: run.issue_url,
|
|
1490
|
+
question: run.delivery_label,
|
|
1491
|
+
processKey: run.process_key,
|
|
1492
|
+
},
|
|
1493
|
+
at,
|
|
1494
|
+
),
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// Plan escalations (`plan-review-decision` / `trial-merge-decision`) — read each in-flight plan's
|
|
1501
|
+
// open user tasks and pair them with the open audit row's question/findings. Dedupe by `plan_key`
|
|
1502
|
+
// across the status queries (mirroring the feature-run scan above): a plan whose status transitions
|
|
1503
|
+
// mid-pass could otherwise match twice and push duplicate `desired` rows for one `user_task_key`.
|
|
1504
|
+
const planSeen = new Set<string>();
|
|
1505
|
+
for (const status of PLAN_ACTIVE_STATUSES) {
|
|
1506
|
+
for (const plan of await plans(data).find({ status })) {
|
|
1507
|
+
if (!plan.process_key) continue;
|
|
1508
|
+
if (planSeen.has(plan.plan_key)) continue;
|
|
1509
|
+
planSeen.add(plan.plan_key);
|
|
1510
|
+
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
1511
|
+
try {
|
|
1512
|
+
tasks = await engine.searchUserTasks({ processInstanceKey: plan.process_key });
|
|
1513
|
+
} catch (err) {
|
|
1514
|
+
console.error(`[poller] user tasks (plan ${plan.plan_key}): ${err}`);
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
for (const t of tasks) {
|
|
1518
|
+
if (t.elementId === PLAN_REVIEW_ELEMENT) {
|
|
1519
|
+
const question = latestPlanReviewFindings(await planReviews(data).find({ plan_key: plan.plan_key }));
|
|
1520
|
+
push(
|
|
1521
|
+
buildUserTaskRow(
|
|
1522
|
+
{
|
|
1523
|
+
userTaskKey: t.userTaskKey,
|
|
1524
|
+
elementId: PLAN_REVIEW_ELEMENT,
|
|
1525
|
+
subjectType: "plan",
|
|
1526
|
+
subjectKey: plan.plan_key,
|
|
1527
|
+
subjectUrl: plan.issue_url,
|
|
1528
|
+
question,
|
|
1529
|
+
processKey: plan.process_key,
|
|
1530
|
+
},
|
|
1531
|
+
at,
|
|
1532
|
+
),
|
|
1533
|
+
);
|
|
1534
|
+
} else if (t.elementId === TRIAL_MERGE_ELEMENT) {
|
|
1535
|
+
const question = latestTrialMergeQuestion(await trialMergeAudits(data, plan.plan_key));
|
|
1536
|
+
push(
|
|
1537
|
+
buildUserTaskRow(
|
|
1538
|
+
{
|
|
1539
|
+
userTaskKey: t.userTaskKey,
|
|
1540
|
+
elementId: TRIAL_MERGE_ELEMENT,
|
|
1541
|
+
subjectType: "plan",
|
|
1542
|
+
subjectKey: plan.plan_key,
|
|
1543
|
+
subjectUrl: plan.issue_url,
|
|
1544
|
+
question,
|
|
1545
|
+
processKey: plan.process_key,
|
|
1546
|
+
},
|
|
1547
|
+
at,
|
|
1548
|
+
),
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// PR review-loop escalations (`wait-answer`) — read each in-flight PR's open user tasks and pair the
|
|
1556
|
+
// escalation with the open audit row's question. Dedupe by `pr_key` across the status queries (as the
|
|
1557
|
+
// feature-run / plan scans do): a PR whose status transitions mid-pass could otherwise be processed
|
|
1558
|
+
// twice and push duplicate `desired` rows for one `user_task_key`.
|
|
1559
|
+
const prSeen = new Set<string>();
|
|
1560
|
+
for (const status of PR_ACTIVE_STATUSES) {
|
|
1561
|
+
for (const pr of await prs(data).find({ status })) {
|
|
1562
|
+
if (!pr.process_key) continue;
|
|
1563
|
+
if (prSeen.has(pr.pr_key)) continue;
|
|
1564
|
+
prSeen.add(pr.pr_key);
|
|
1565
|
+
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
1566
|
+
try {
|
|
1567
|
+
tasks = await engine.searchUserTasks({ processInstanceKey: pr.process_key });
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
console.error(`[poller] user tasks (pr ${pr.pr_key}): ${err}`);
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
for (const t of tasks) {
|
|
1573
|
+
if (t.elementId !== PR_WAIT_ANSWER_ELEMENT) continue;
|
|
1574
|
+
const question = latestOpenEscalationQuestion(await prEscalations(data).find({ pr_key: pr.pr_key, status: "open" }));
|
|
1575
|
+
push(
|
|
1576
|
+
buildUserTaskRow(
|
|
1577
|
+
{
|
|
1578
|
+
userTaskKey: t.userTaskKey,
|
|
1579
|
+
elementId: PR_WAIT_ANSWER_ELEMENT,
|
|
1580
|
+
subjectType: "pr",
|
|
1581
|
+
subjectKey: pr.pr_key,
|
|
1582
|
+
subjectUrl: pr.url,
|
|
1583
|
+
question,
|
|
1584
|
+
processKey: pr.process_key,
|
|
1585
|
+
},
|
|
1586
|
+
at,
|
|
1587
|
+
),
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const persisted = await userTasks(data).all();
|
|
1594
|
+
const { inserts, updates, deletes } = reconcileUserTasks(persisted, desired);
|
|
1595
|
+
for (const row of inserts) await userTasks(data).insert(row);
|
|
1596
|
+
for (const row of updates) {
|
|
1597
|
+
const { user_task_key, created_at, ...patch } = row;
|
|
1598
|
+
await userTasks(data).update(user_task_key, { ...patch, updated_at: at });
|
|
1599
|
+
}
|
|
1600
|
+
for (const key of deletes) await userTasks(data).delete(key);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1377
1603
|
/** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
|
|
1378
1604
|
* (when the engine REST endpoint is supplied) the job-activation visibility pass and the
|
|
1379
1605
|
* technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
|
|
@@ -1390,6 +1616,7 @@ export async function pollOnce(
|
|
|
1390
1616
|
await pollFeatureDelivery(data);
|
|
1391
1617
|
await pollFeatureEscalations(data, engine);
|
|
1392
1618
|
await pollFeatureBlocked(data, engine);
|
|
1619
|
+
await pollUserTasks(data, engine);
|
|
1393
1620
|
if (engineRest) {
|
|
1394
1621
|
await pollJobActivation(data, engineRest.restAddress, engineRest.token);
|
|
1395
1622
|
await pollIncidents(data, engineRest.restAddress, engineRest.token);
|
package/app/trialMerge.ts
CHANGED
|
@@ -60,6 +60,11 @@ export function trialMergeWaveFromTaskId(taskId: string): number | null {
|
|
|
60
60
|
|
|
61
61
|
const auditTable = (data: DataLayer) => data.table<TrialMergeAuditRow>("plan_trial_merges", "id");
|
|
62
62
|
|
|
63
|
+
/** All trial-merge audit rows for a plan (append-only D3 gate log, 014/021). Display-only read used
|
|
64
|
+
* by `pollUserTasks` to enrich the open `trial-merge-decision` escalation with its red summary. */
|
|
65
|
+
export const trialMergeAudits = (data: DataLayer, planKey: string) =>
|
|
66
|
+
auditTable(data).find({ plan_key: planKey });
|
|
67
|
+
|
|
63
68
|
function jsonOrNull(v: unknown): string | null {
|
|
64
69
|
if (v == null) return null;
|
|
65
70
|
try {
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Pure derivation tests for the unified Tasks-inbox read-model (issue #236). `buildUserTaskRow` turns
|
|
2
|
+
// one resolved open escalation user task into its desired `user_tasks` row (or null for a
|
|
3
|
+
// non-escalation / blank key), and `reconcileUserTasks` diffs the desired open set against the
|
|
4
|
+
// persisted rows into the minimal insert/update/delete plan the `pollUserTasks` reconcile applies.
|
|
5
|
+
// These are the pure source of truth the poller projects, mirroring `deriveFeatureEscalationPatch`.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import type { PlanReview } from "./plan.ts";
|
|
9
|
+
import type { TrialMergeAuditRow } from "./trialMerge.ts";
|
|
10
|
+
import {
|
|
11
|
+
buildUserTaskRow,
|
|
12
|
+
PLAN_REVIEW_ELEMENT,
|
|
13
|
+
PR_WAIT_ANSWER_ELEMENT,
|
|
14
|
+
latestOpenEscalationQuestion,
|
|
15
|
+
latestPlanReviewFindings,
|
|
16
|
+
latestTrialMergeQuestion,
|
|
17
|
+
type PrEscalationRow,
|
|
18
|
+
reconcileUserTasks,
|
|
19
|
+
TRIAL_MERGE_ELEMENT,
|
|
20
|
+
type UserTaskRow,
|
|
21
|
+
} from "./userTasks.ts";
|
|
22
|
+
|
|
23
|
+
const AT = "2026-01-01T00:00:00.000Z";
|
|
24
|
+
|
|
25
|
+
test("buildUserTaskRow: a plan-review task becomes a labelled row with its findings as the question", () => {
|
|
26
|
+
const row = buildUserTaskRow(
|
|
27
|
+
{
|
|
28
|
+
userTaskKey: "ut-1",
|
|
29
|
+
elementId: PLAN_REVIEW_ELEMENT,
|
|
30
|
+
subjectType: "plan",
|
|
31
|
+
subjectKey: "o/r#1",
|
|
32
|
+
subjectUrl: "https://github.com/o/r/issues/1",
|
|
33
|
+
question: " cap reached: revise scope ",
|
|
34
|
+
processKey: "pk-1",
|
|
35
|
+
},
|
|
36
|
+
AT,
|
|
37
|
+
);
|
|
38
|
+
assertEquals(row, {
|
|
39
|
+
user_task_key: "ut-1",
|
|
40
|
+
element_id: PLAN_REVIEW_ELEMENT,
|
|
41
|
+
kind_label: "Plan review",
|
|
42
|
+
subject_type: "plan",
|
|
43
|
+
subject_key: "o/r#1",
|
|
44
|
+
subject_url: "https://github.com/o/r/issues/1",
|
|
45
|
+
question: "cap reached: revise scope",
|
|
46
|
+
process_key: "pk-1",
|
|
47
|
+
created_at: AT,
|
|
48
|
+
updated_at: AT,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("buildUserTaskRow: a blank question / missing url normalises to null", () => {
|
|
53
|
+
const row = buildUserTaskRow(
|
|
54
|
+
{ userTaskKey: "ut-2", elementId: TRIAL_MERGE_ELEMENT, subjectType: "plan", subjectKey: "o/r#2", question: " " },
|
|
55
|
+
AT,
|
|
56
|
+
);
|
|
57
|
+
assert(row !== null);
|
|
58
|
+
assertEquals(row?.question, null);
|
|
59
|
+
assertEquals(row?.subject_url, null);
|
|
60
|
+
assertEquals(row?.process_key, null);
|
|
61
|
+
assertEquals(row?.kind_label, "Trial merge");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("buildUserTaskRow: an unknown (non-escalation) element yields null — no arbitrary user task leaks", () => {
|
|
65
|
+
const row = buildUserTaskRow(
|
|
66
|
+
{ userTaskKey: "ut-3", elementId: "some-internal-task", subjectType: "plan", subjectKey: "o/r#3" },
|
|
67
|
+
AT,
|
|
68
|
+
);
|
|
69
|
+
assertEquals(row, null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("buildUserTaskRow: a blank userTaskKey or subjectKey yields null", () => {
|
|
73
|
+
assertEquals(
|
|
74
|
+
buildUserTaskRow({ userTaskKey: " ", elementId: PR_WAIT_ANSWER_ELEMENT, subjectType: "pr", subjectKey: "o/r#4" }, AT),
|
|
75
|
+
null,
|
|
76
|
+
);
|
|
77
|
+
assertEquals(
|
|
78
|
+
buildUserTaskRow({ userTaskKey: "ut-4", elementId: PR_WAIT_ANSWER_ELEMENT, subjectType: "pr", subjectKey: " " }, AT),
|
|
79
|
+
null,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function row(key: string, extra: Partial<UserTaskRow> = {}): UserTaskRow {
|
|
84
|
+
return {
|
|
85
|
+
user_task_key: key,
|
|
86
|
+
element_id: PLAN_REVIEW_ELEMENT,
|
|
87
|
+
kind_label: "Plan review",
|
|
88
|
+
subject_type: "plan",
|
|
89
|
+
subject_key: "o/r#1",
|
|
90
|
+
subject_url: null,
|
|
91
|
+
question: null,
|
|
92
|
+
process_key: null,
|
|
93
|
+
created_at: AT,
|
|
94
|
+
updated_at: AT,
|
|
95
|
+
...extra,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test("reconcileUserTasks: a new open task is an insert; a vanished task is a delete", () => {
|
|
100
|
+
const persisted = [row("keep"), row("gone")];
|
|
101
|
+
const desired = [row("keep"), row("fresh")];
|
|
102
|
+
const { inserts, updates, deletes } = reconcileUserTasks(persisted, desired);
|
|
103
|
+
assertEquals(inserts.map((r) => r.user_task_key), ["fresh"]);
|
|
104
|
+
assertEquals(updates, []);
|
|
105
|
+
assertEquals(deletes, ["gone"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("reconcileUserTasks: an unchanged steady state yields no writes (idempotent)", () => {
|
|
109
|
+
const persisted = [row("a", { question: "q" }), row("b")];
|
|
110
|
+
const desired = [row("a", { question: "q" }), row("b")];
|
|
111
|
+
assertEquals(reconcileUserTasks(persisted, desired), { inserts: [], updates: [], deletes: [] });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("reconcileUserTasks: a drifted question is an update that preserves the original created_at", () => {
|
|
115
|
+
const persisted = [row("a", { question: "old", created_at: "2025-06-01T00:00:00.000Z", updated_at: "2025-06-01T00:00:00.000Z" })];
|
|
116
|
+
const desired = [row("a", { question: "new" })];
|
|
117
|
+
const { inserts, updates, deletes } = reconcileUserTasks(persisted, desired);
|
|
118
|
+
assertEquals(inserts, []);
|
|
119
|
+
assertEquals(deletes, []);
|
|
120
|
+
assertEquals(updates.length, 1);
|
|
121
|
+
assertEquals(updates[0].question, "new");
|
|
122
|
+
assertEquals(updates[0].created_at, "2025-06-01T00:00:00.000Z");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── Audit-log derivations for the plan escalations (migration 027 retired the bespoke mirror tables,
|
|
126
|
+
// so the question text is derived from the surviving `plan_reviews` / `plan_trial_merges` logs). ──
|
|
127
|
+
|
|
128
|
+
const review = (over: Partial<PlanReview>): PlanReview => ({
|
|
129
|
+
plan_key: "o/r#20",
|
|
130
|
+
epoch: 0,
|
|
131
|
+
round: 0,
|
|
132
|
+
approved: 0,
|
|
133
|
+
findings: null,
|
|
134
|
+
created_at: AT,
|
|
135
|
+
job_key: null,
|
|
136
|
+
...over,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const audit = (over: Partial<TrialMergeAuditRow>): TrialMergeAuditRow => ({
|
|
140
|
+
id: 1,
|
|
141
|
+
plan_key: "o/r#20",
|
|
142
|
+
wave: 0,
|
|
143
|
+
result: "suite-failed",
|
|
144
|
+
heads: null,
|
|
145
|
+
conflicts: null,
|
|
146
|
+
failing: null,
|
|
147
|
+
summary: null,
|
|
148
|
+
job_key: null,
|
|
149
|
+
resolved: 0,
|
|
150
|
+
created_at: AT,
|
|
151
|
+
updated_at: AT,
|
|
152
|
+
...over,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("latestPlanReviewFindings: picks the latest round's findings across epochs", () => {
|
|
156
|
+
const reviews = [
|
|
157
|
+
review({ epoch: 0, round: 0, findings: "epoch0 round0" }),
|
|
158
|
+
review({ epoch: 1, round: 1, findings: "latest" }),
|
|
159
|
+
review({ epoch: 1, round: 0, findings: "epoch1 round0" }),
|
|
160
|
+
];
|
|
161
|
+
assertEquals(latestPlanReviewFindings(reviews), "latest");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("latestPlanReviewFindings: null when there are no review rows", () => {
|
|
165
|
+
assertEquals(latestPlanReviewFindings([]), null);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("latestTrialMergeQuestion: picks the newest UNRESOLVED red row's summary", () => {
|
|
169
|
+
const audits = [
|
|
170
|
+
audit({ id: 1, wave: 0, result: "suite-failed", summary: "old red", resolved: 1 }),
|
|
171
|
+
audit({ id: 2, wave: 0, result: "suite-failed", summary: "latest red", resolved: 0 }),
|
|
172
|
+
audit({ id: 3, wave: 1, result: "clean", summary: "green", resolved: 0 }),
|
|
173
|
+
];
|
|
174
|
+
assertEquals(latestTrialMergeQuestion(audits), "latest red");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("latestTrialMergeQuestion: null when every red row is resolved (escalation answered)", () => {
|
|
178
|
+
const audits = [audit({ id: 1, result: "suite-failed", summary: "red", resolved: 1 })];
|
|
179
|
+
assertEquals(latestTrialMergeQuestion(audits), null);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
function esc(o: Partial<PrEscalationRow> & { id: number }): PrEscalationRow {
|
|
183
|
+
return {
|
|
184
|
+
pr_key: "o/r#1",
|
|
185
|
+
round_no: 1,
|
|
186
|
+
kind: "question",
|
|
187
|
+
question: "q",
|
|
188
|
+
answer: null,
|
|
189
|
+
status: "open",
|
|
190
|
+
asked_at: "t",
|
|
191
|
+
answered_at: null,
|
|
192
|
+
...o,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
test("latestOpenEscalationQuestion: picks the newest OPEN row (highest id), not a positional [0]", () => {
|
|
197
|
+
const rows = [
|
|
198
|
+
esc({ id: 3, question: "stale open" }),
|
|
199
|
+
esc({ id: 7, question: "newest open" }),
|
|
200
|
+
esc({ id: 9, status: "answered", question: "answered" }),
|
|
201
|
+
];
|
|
202
|
+
assertEquals(latestOpenEscalationQuestion(rows), "newest open");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("latestOpenEscalationQuestion: null when there is no open escalation", () => {
|
|
206
|
+
assertEquals(latestOpenEscalationQuestion([esc({ id: 1, status: "answered" })]), null);
|
|
207
|
+
assertEquals(latestOpenEscalationQuestion([]), null);
|
|
208
|
+
});
|