@nanobpm/nano-workforce 0.111.0 → 0.112.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 +14 -0
- package/app/deliveryGraph.ts +46 -0
- package/app/deliveryGraphCompiler.test.ts +275 -0
- package/app/deliveryGraphCompiler.ts +487 -0
- package/app/escalationTaxonomy.test.ts +1 -1
- package/app/escalationTaxonomy.ts +4 -2
- package/app/github.test.ts +225 -1
- package/app/github.ts +102 -2
- package/app/planFanoutCleanTerminal.test.ts +40 -0
- package/app/pollUserTasks.test.ts +208 -0
- package/app/readiness.test.ts +2 -0
- package/app/readiness.ts +3 -1
- package/app/service.ts +216 -198
- package/app/userTasks.test.ts +20 -2
- package/app/userTasks.ts +11 -4
- package/nano.app.json +4 -0
- package/openapi.yaml +248 -0
- package/operations/compileDeliveryGraph.test.ts +60 -0
- package/operations/compileDeliveryGraph.ts +35 -0
- package/package.json +1 -1
- package/resources/processes/plan-fanout.bpmn +161 -135
- package/workers/record-wave/worker.test.ts +181 -0
- package/workers/record-wave/worker.ts +44 -11
- package/workers/record-wave-escalation/worker.test.ts +95 -0
- package/workers/record-wave-escalation/worker.ts +69 -0
package/app/readiness.test.ts
CHANGED
|
@@ -414,6 +414,8 @@ function prObs(over: Partial<PrObservation> = {}): PrObservation {
|
|
|
414
414
|
failingCheckNames: [],
|
|
415
415
|
totalChecks: 0,
|
|
416
416
|
presentCheckNames: [],
|
|
417
|
+
pendingCheckNames: [],
|
|
418
|
+
checkConclusions: {},
|
|
417
419
|
isDraft: false,
|
|
418
420
|
headRefOid: "abc123",
|
|
419
421
|
mergedSha: null,
|
package/app/readiness.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// any credential is read at execution time from the typed env-contract (`credentialEnv` names a
|
|
19
19
|
// declared {@link EnvKey}; ADR 0004 pinned decision 2) and is redacted from every log line.
|
|
20
20
|
import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts";
|
|
21
|
-
import { allCheckNames, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
|
|
21
|
+
import { allCheckNames, checkConclusions, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
|
|
22
22
|
import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
|
|
23
23
|
|
|
24
24
|
/** The built-in readiness sources. `command` is the escape hatch that subsumes the long tail
|
|
@@ -565,6 +565,8 @@ export function parsePrView(payload: unknown): PrObservation {
|
|
|
565
565
|
failingCheckNames: names,
|
|
566
566
|
totalChecks: rollup.length,
|
|
567
567
|
presentCheckNames: allCheckNames(rollup),
|
|
568
|
+
pendingCheckNames: pending,
|
|
569
|
+
checkConclusions: checkConclusions(rollup),
|
|
568
570
|
isDraft: j.isDraft === true,
|
|
569
571
|
headRefOid: str(j.headRefOid).trim() || null,
|
|
570
572
|
mergedSha,
|
package/app/service.ts
CHANGED
|
@@ -85,6 +85,8 @@ import {
|
|
|
85
85
|
prEscalations,
|
|
86
86
|
reconcileUserTasks,
|
|
87
87
|
TRIAL_MERGE_ELEMENT,
|
|
88
|
+
USER_TASK_KIND_LABELS,
|
|
89
|
+
type UserTaskContext,
|
|
88
90
|
type UserTaskRow,
|
|
89
91
|
userTasks,
|
|
90
92
|
} from "./userTasks.ts";
|
|
@@ -1150,7 +1152,11 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1150
1152
|
// (#342/#350). Reuse the `st` we just read so we don't double-fetch. This is the proven terminal
|
|
1151
1153
|
// path the whole class (#368) now shares.
|
|
1152
1154
|
if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
|
|
1153
|
-
|
|
1155
|
+
// Load the repo's merge protocol ONCE per PR iteration and pass it into the classifier so the
|
|
1156
|
+
// protocol-aware backstop (#392) can gate a red DECLARED-required check even when GitHub reports
|
|
1157
|
+
// the PR as UNSTABLE. The same handle is reused by the frugal-CI fresh-head-run branch below.
|
|
1158
|
+
const protocol = await loadMergeProtocol(repo, token).catch(() => null);
|
|
1159
|
+
const verdict = classifyMergeability(st, protocol ?? undefined);
|
|
1154
1160
|
if (verdict === "waiting") {
|
|
1155
1161
|
// Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
|
|
1156
1162
|
// head run and the PR has NO required head run yet, review has converged but the last push
|
|
@@ -1161,7 +1167,6 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1161
1167
|
// `pull_request` run once per head (mark ready / close+reopen); rebases change
|
|
1162
1168
|
// `headRefOid`, so downstream merge-train PRs get a new nudge after every post-rebase
|
|
1163
1169
|
// landing attempt.
|
|
1164
|
-
const protocol = await loadMergeProtocol(repo, token).catch(() => null);
|
|
1165
1170
|
if (protocol) {
|
|
1166
1171
|
const action = freshHeadRunAction(protocol, verdict, headRunPresenceCount(protocol, st), st.isDraft, {
|
|
1167
1172
|
headRefOid: st.headRefOid,
|
|
@@ -2098,215 +2103,228 @@ function toFeatureRunStatus(status: string): FeatureRunStatus {
|
|
|
2098
2103
|
export const FEATURE_ACTIVE_STATUSES: readonly FeatureRunStatus[] =
|
|
2099
2104
|
activeStatusesFor("feature_runs").map(toFeatureRunStatus);
|
|
2100
2105
|
|
|
2106
|
+
/** The subset of a Camunda-8 `/v2/user-tasks/search` result item this app reads for the engine-first
|
|
2107
|
+
* sweep. `userTaskKey` is the completable key; `elementId` is the BPMN element (the escalation kind);
|
|
2108
|
+
* `processInstanceKey` is the instance the task parks on (the raw REST surface carries it — the typed
|
|
2109
|
+
* `EngineClient.openUserTasks` seam deliberately omits it from `UserTaskSummary`); `state` is the task
|
|
2110
|
+
* lifecycle (`CREATED` while open/answerable). Keys are stringified defensively (the wire may send
|
|
2111
|
+
* either a JSON number or string). */
|
|
2112
|
+
interface UserTaskSearchItem {
|
|
2113
|
+
userTaskKey?: string | number;
|
|
2114
|
+
elementId?: string;
|
|
2115
|
+
processInstanceKey?: string | number;
|
|
2116
|
+
state?: string;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
/** One discovered open escalation user task, normalised for projection. */
|
|
2120
|
+
interface OpenUserTask {
|
|
2121
|
+
userTaskKey: string;
|
|
2122
|
+
elementId: string;
|
|
2123
|
+
processInstanceKey: string;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/** Engine-first sweep (issue #358): read EVERY open (`CREATED`) native user task from the engine over
|
|
2127
|
+
* the raw Camunda-8 `/v2/user-tasks/search` surface (the same raw-REST search surface
|
|
2128
|
+
* `pollIncidents`/`pollJobActivation`/`pollWaveGates` use) and keep those whose `elementId` is a
|
|
2129
|
+
* surfaced escalation kind (`USER_TASK_KIND_LABELS`). This is the authoritative "what is open" set —
|
|
2130
|
+
* a task is discovered IFF the ENGINE reports it open, regardless of whether any tracked subject row
|
|
2131
|
+
* references its instance — so an escalation on an untracked/orphaned instance (the reported 19153
|
|
2132
|
+
* case) is surfaced too. Pages defensively so a large open set is never silently truncated to the
|
|
2133
|
+
* first page; best-effort transport (a failed page projects what was gathered and retries next pass).
|
|
2134
|
+
* Deduped by `userTaskKey` so a page overlap can't double-project one task. */
|
|
2135
|
+
async function sweepOpenEscalationTasks(base: string, headers: Record<string, string>): Promise<OpenUserTask[]> {
|
|
2136
|
+
const out: OpenUserTask[] = [];
|
|
2137
|
+
const seen = new Set<string>();
|
|
2138
|
+
const limit = 100;
|
|
2139
|
+
let from = 0;
|
|
2140
|
+
for (let guard = 0; guard < 1000; guard++) {
|
|
2141
|
+
let items: UserTaskSearchItem[];
|
|
2142
|
+
try {
|
|
2143
|
+
const res = await fetch(`${base}/user-tasks/search`, {
|
|
2144
|
+
method: "POST",
|
|
2145
|
+
headers,
|
|
2146
|
+
body: JSON.stringify({ filter: { state: "CREATED" }, page: { from, limit } }),
|
|
2147
|
+
});
|
|
2148
|
+
if (!res.ok) break; // engine unhappy → project what we have, retry next pass
|
|
2149
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
2150
|
+
const body = (await res.json()) as { items?: UserTaskSearchItem[] };
|
|
2151
|
+
items = body.items ?? [];
|
|
2152
|
+
} catch (err) {
|
|
2153
|
+
console.error(`[poller] user-task sweep: ${err}`);
|
|
2154
|
+
break;
|
|
2155
|
+
}
|
|
2156
|
+
for (const it of items) {
|
|
2157
|
+
// Re-filter state defensively in case the wire filter is ignored — only OPEN (`CREATED`) tasks are
|
|
2158
|
+
// answerable, so a lagging COMPLETED/CANCELED read must never surface a dead affordance (#294).
|
|
2159
|
+
if (typeof it.state === "string" && it.state.toUpperCase() !== "CREATED") continue;
|
|
2160
|
+
const elementId = typeof it.elementId === "string" ? it.elementId : undefined;
|
|
2161
|
+
if (!elementId || !Object.hasOwn(USER_TASK_KIND_LABELS, elementId)) continue;
|
|
2162
|
+
const userTaskKey = it.userTaskKey == null ? "" : String(it.userTaskKey);
|
|
2163
|
+
if (!userTaskKey || seen.has(userTaskKey)) continue;
|
|
2164
|
+
seen.add(userTaskKey);
|
|
2165
|
+
out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey) });
|
|
2166
|
+
}
|
|
2167
|
+
if (items.length < limit) break; // last page
|
|
2168
|
+
from += items.length;
|
|
2169
|
+
}
|
|
2170
|
+
return out;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2101
2173
|
/** Reconcile the unified Tasks-inbox read-model (`user_tasks`) against the engine's currently-open
|
|
2102
|
-
* native user-task escalations (
|
|
2103
|
-
* human decision — the feature kinds (`feature-escalation` / `feature-blocked`)
|
|
2104
|
-
* (`plan-review-decision`, `trial-merge-decision`, `wait-answer`
|
|
2105
|
-
*
|
|
2106
|
-
*
|
|
2107
|
-
*
|
|
2108
|
-
*
|
|
2109
|
-
*
|
|
2110
|
-
*
|
|
2111
|
-
|
|
2174
|
+
* native user-task escalations (issues #236, #358). The Tasks page lists EVERY open escalation awaiting
|
|
2175
|
+
* a human decision — the feature kinds (`feature-escalation` / `feature-blocked`), the epic/PR kinds
|
|
2176
|
+
* (`plan-review-decision`, `trial-merge-decision`, `wait-answer` / `wait-merge-answer`), and the
|
|
2177
|
+
* conformance ack (`conformance-escalation`) — that otherwise had no app-side pointer, so the pages
|
|
2178
|
+
* could not drive their completion.
|
|
2179
|
+
*
|
|
2180
|
+
* The source of truth for WHICH escalations are open is the ENGINE, not the tracked subject set (#358):
|
|
2181
|
+
* • When the raw-REST surface is available (`engineRest`, always supplied in production), an
|
|
2182
|
+
* engine-first sweep (`sweepOpenEscalationTasks`) lists every open escalation the engine reports —
|
|
2183
|
+
* tracked OR orphaned — so a task on an untracked/orphaned instance (the reported 19153 case) is
|
|
2184
|
+
* surfaced and answerable, not stranded invisible.
|
|
2185
|
+
* • Subject rows only ENRICH (they never gate): `subjectByInstance` maps the task's
|
|
2186
|
+
* `processInstanceKey` to its feature/plan/PR/conformance subject for `subject_title` / `subject_url`
|
|
2187
|
+
* / `question`. A task whose instance no subject row references falls back to a stable non-blank
|
|
2188
|
+
* subject (the instance key) and a null question, so it still renders.
|
|
2189
|
+
*
|
|
2190
|
+
* Without `engineRest` (unit tests / a degraded no-REST host) it falls back to the typed-seam
|
|
2191
|
+
* per-active-subject scan: the `openUserTasks` seam carries no `processInstanceKey`, so a task can only
|
|
2192
|
+
* be reached THROUGH a tracked subject whose instance key we already hold — hence that path is
|
|
2193
|
+
* tracked-only. Both paths feed the SAME `project`/`contextFor` enrichment derivation, so a tracked task
|
|
2194
|
+
* projects identically however it was discovered; only DISCOVERY differs by capability (no duplicate
|
|
2195
|
+
* enrichment). `reconcileUserTasks` then diffs the desired open set against the persisted rows so a
|
|
2196
|
+
* completed task's row is deleted (answered here, via the task inbox, or out-of-band) and `showCount`
|
|
2197
|
+
* reflects live pending work. Best-effort + idempotent — per-instance failures are isolated so one bad
|
|
2198
|
+
* instance never stalls the pass. */
|
|
2199
|
+
export async function pollUserTasks(
|
|
2200
|
+
data: DataLayer,
|
|
2201
|
+
engine: EngineClient,
|
|
2202
|
+
engineRest?: { restAddress: string; token?: string },
|
|
2203
|
+
) {
|
|
2112
2204
|
const at = now();
|
|
2113
|
-
const desired: UserTaskRow[] = [];
|
|
2114
|
-
const push = (row: UserTaskRow | null) => {
|
|
2115
|
-
if (row) desired.push(row);
|
|
2116
|
-
};
|
|
2117
2205
|
|
|
2118
|
-
//
|
|
2119
|
-
// from
|
|
2120
|
-
//
|
|
2121
|
-
//
|
|
2122
|
-
//
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
continue;
|
|
2136
|
-
}
|
|
2137
|
-
for (const t of tasks) {
|
|
2138
|
-
if (t.elementId === FEATURE_ESCALATION_ELEMENT) {
|
|
2139
|
-
// Source the question from the canonical append-only `feature_escalations` audit log (issue
|
|
2140
|
-
// #305) — the surviving table `record-feature-escalation` writes — the feature analogue of the
|
|
2141
|
-
// plan-review/trial-merge/PR-loop question enrichment below.
|
|
2142
|
-
const question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: run.feature_key }));
|
|
2143
|
-
push(
|
|
2144
|
-
buildUserTaskRow(
|
|
2145
|
-
{
|
|
2146
|
-
userTaskKey: t.userTaskKey,
|
|
2147
|
-
elementId: FEATURE_ESCALATION_ELEMENT,
|
|
2148
|
-
subjectType: "feature",
|
|
2149
|
-
subjectKey: run.feature_key,
|
|
2150
|
-
subjectTitle: run.title,
|
|
2151
|
-
subjectUrl: run.issue_url,
|
|
2152
|
-
question,
|
|
2153
|
-
processKey: run.process_key,
|
|
2154
|
-
},
|
|
2155
|
-
at,
|
|
2156
|
-
),
|
|
2157
|
-
);
|
|
2158
|
-
} else if (t.elementId === FEATURE_BLOCKED_ELEMENT) {
|
|
2159
|
-
push(
|
|
2160
|
-
buildUserTaskRow(
|
|
2161
|
-
{
|
|
2162
|
-
userTaskKey: t.userTaskKey,
|
|
2163
|
-
elementId: FEATURE_BLOCKED_ELEMENT,
|
|
2164
|
-
subjectType: "feature",
|
|
2165
|
-
subjectKey: run.feature_key,
|
|
2166
|
-
subjectTitle: run.title,
|
|
2167
|
-
subjectUrl: run.issue_url,
|
|
2168
|
-
question: run.delivery_label,
|
|
2169
|
-
processKey: run.process_key,
|
|
2170
|
-
},
|
|
2171
|
-
at,
|
|
2172
|
-
),
|
|
2173
|
-
);
|
|
2174
|
-
}
|
|
2175
|
-
}
|
|
2206
|
+
// ── Enrichment: subject descriptors keyed by the ENGINE process-instance the task parks on ────────
|
|
2207
|
+
// Built from EVERY subject row (regardless of status), so a task on a subject whose row already went
|
|
2208
|
+
// terminal is still enriched from it. `activeConformanceReviews` carries the retro instance + audit
|
|
2209
|
+
// summary for the `conformance-escalation` ack (its instance is tracked on `plan_conformance`, not a
|
|
2210
|
+
// delivery aggregate).
|
|
2211
|
+
interface Subject {
|
|
2212
|
+
type: "feature" | "plan" | "pr";
|
|
2213
|
+
key: string;
|
|
2214
|
+
title?: string | null;
|
|
2215
|
+
url?: string | null;
|
|
2216
|
+
deliveryLabel?: string | null;
|
|
2217
|
+
conformanceSummary?: string | null;
|
|
2218
|
+
}
|
|
2219
|
+
const subjectByInstance = new Map<string, Subject>();
|
|
2220
|
+
for (const run of await featureRuns(data).all()) {
|
|
2221
|
+
if (run.process_key) {
|
|
2222
|
+
subjectByInstance.set(run.process_key, { type: "feature", key: run.feature_key, title: run.title, url: run.issue_url, deliveryLabel: run.delivery_label });
|
|
2176
2223
|
}
|
|
2177
2224
|
}
|
|
2225
|
+
for (const plan of await plans(data).all()) {
|
|
2226
|
+
if (plan.process_key) subjectByInstance.set(plan.process_key, { type: "plan", key: plan.plan_key, title: plan.title, url: plan.issue_url });
|
|
2227
|
+
}
|
|
2228
|
+
for (const pr of await prs(data).all()) {
|
|
2229
|
+
if (pr.process_key) subjectByInstance.set(pr.process_key, { type: "pr", key: pr.pr_key, title: pr.title, url: pr.url });
|
|
2230
|
+
}
|
|
2231
|
+
for (const review of await activeConformanceReviews(data)) {
|
|
2232
|
+
if (!review.process_key) continue;
|
|
2233
|
+
const plan = await plans(data).get(review.plan_key);
|
|
2234
|
+
subjectByInstance.set(review.process_key, { type: "plan", key: review.plan_key, title: plan?.title ?? null, url: plan?.issue_url ?? null, conformanceSummary: review.summary });
|
|
2235
|
+
}
|
|
2178
2236
|
|
|
2179
|
-
//
|
|
2180
|
-
//
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
subjectUrl: plan.issue_url,
|
|
2225
|
-
question,
|
|
2226
|
-
processKey: plan.process_key,
|
|
2227
|
-
},
|
|
2228
|
-
at,
|
|
2229
|
-
),
|
|
2230
|
-
);
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2237
|
+
// Per-element subject type for an ORPHANED task (no subject row) — the kind implies its aggregate even
|
|
2238
|
+
// when tracking is lost, so the fallback row still buckets correctly on the page.
|
|
2239
|
+
const DEFAULT_SUBJECT_TYPE: Readonly<Record<string, "feature" | "plan" | "pr">> = {
|
|
2240
|
+
[FEATURE_ESCALATION_ELEMENT]: "feature",
|
|
2241
|
+
[FEATURE_BLOCKED_ELEMENT]: "feature",
|
|
2242
|
+
[PLAN_REVIEW_ELEMENT]: "plan",
|
|
2243
|
+
[TRIAL_MERGE_ELEMENT]: "plan",
|
|
2244
|
+
[CONFORMANCE_ESCALATION_ELEMENT]: "plan",
|
|
2245
|
+
[PR_WAIT_ANSWER_ELEMENT]: "pr",
|
|
2246
|
+
[PR_WAIT_MERGE_ANSWER_ELEMENT]: "pr",
|
|
2247
|
+
};
|
|
2248
|
+
|
|
2249
|
+
// The SINGLE enrichment derivation both discovery paths feed: resolve one open escalation task (by
|
|
2250
|
+
// element + the instance it parks on) into its desired-row context, enriching from its subject row
|
|
2251
|
+
// when the instance is tracked or a per-kind fallback when it is orphaned. Returns `null` for a
|
|
2252
|
+
// non-escalation element (the leak guard) so an arbitrary internal user task can never reach the inbox.
|
|
2253
|
+
const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string): Promise<UserTaskContext | null> => {
|
|
2254
|
+
if (!Object.hasOwn(USER_TASK_KIND_LABELS, elementId)) return null;
|
|
2255
|
+
const subj = subjectByInstance.get(processInstanceKey);
|
|
2256
|
+
const subjectType = subj?.type ?? DEFAULT_SUBJECT_TYPE[elementId] ?? "plan";
|
|
2257
|
+
const subjectKey = subj?.key ?? processInstanceKey;
|
|
2258
|
+
let question: string | null = null;
|
|
2259
|
+
switch (elementId) {
|
|
2260
|
+
case FEATURE_ESCALATION_ELEMENT:
|
|
2261
|
+
// The escalate arm writes the synthesised question to `feature_escalations` keyed by the subject
|
|
2262
|
+
// (a standalone slice's `feature_key`, or the epic's `plan_key` for a plan-embedded slice, which
|
|
2263
|
+
// has no standalone `feature_runs` row) — the same key `subjectKey` resolves to for either subject.
|
|
2264
|
+
question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: subjectKey }));
|
|
2265
|
+
break;
|
|
2266
|
+
case FEATURE_BLOCKED_ELEMENT:
|
|
2267
|
+
question = subj?.deliveryLabel ?? null;
|
|
2268
|
+
break;
|
|
2269
|
+
case PLAN_REVIEW_ELEMENT:
|
|
2270
|
+
question = latestPlanReviewFindings(await planReviews(data).find({ plan_key: subjectKey }));
|
|
2271
|
+
break;
|
|
2272
|
+
case TRIAL_MERGE_ELEMENT:
|
|
2273
|
+
question = latestTrialMergeQuestion(await trialMergeAudits(data, subjectKey));
|
|
2274
|
+
break;
|
|
2275
|
+
case PR_WAIT_ANSWER_ELEMENT:
|
|
2276
|
+
case PR_WAIT_MERGE_ANSWER_ELEMENT:
|
|
2277
|
+
question = latestOpenEscalationQuestion(await prEscalations(data).find({ pr_key: subjectKey, status: "open" }));
|
|
2278
|
+
break;
|
|
2279
|
+
case CONFORMANCE_ESCALATION_ELEMENT:
|
|
2280
|
+
question = conformanceEscalationQuestion(subj ? { summary: subj.conformanceSummary } : undefined);
|
|
2281
|
+
break;
|
|
2233
2282
|
}
|
|
2234
|
-
|
|
2283
|
+
return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey };
|
|
2284
|
+
};
|
|
2285
|
+
|
|
2286
|
+
// Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a
|
|
2287
|
+
// subject seen under two statuses mid-pass).
|
|
2288
|
+
const desiredByKey = new Map<string, UserTaskRow>();
|
|
2289
|
+
const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string) => {
|
|
2290
|
+
if (!elementId) return;
|
|
2291
|
+
const rowKey = userTaskKey.trim();
|
|
2292
|
+
if (!rowKey || desiredByKey.has(rowKey)) return;
|
|
2293
|
+
const ctx = await contextFor(elementId, userTaskKey, processInstanceKey);
|
|
2294
|
+
if (!ctx) return;
|
|
2295
|
+
const row = buildUserTaskRow(ctx, at);
|
|
2296
|
+
if (row) desiredByKey.set(rowKey, row);
|
|
2297
|
+
};
|
|
2235
2298
|
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2299
|
+
if (engineRest) {
|
|
2300
|
+
const base = engineRest.restAddress.replace(/\/+$/, "");
|
|
2301
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
2302
|
+
if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
|
|
2303
|
+
for (const t of await sweepOpenEscalationTasks(base, headers)) {
|
|
2304
|
+
await project(t.elementId, t.userTaskKey, t.processInstanceKey);
|
|
2305
|
+
}
|
|
2306
|
+
} else {
|
|
2307
|
+
// Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan, tracked-only.
|
|
2308
|
+
const seen = new Set<string>();
|
|
2309
|
+
const scanInstance = async (processKey: string | null | undefined) => {
|
|
2310
|
+
if (!processKey || seen.has(processKey)) return;
|
|
2311
|
+
seen.add(processKey);
|
|
2246
2312
|
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
2247
2313
|
try {
|
|
2248
|
-
tasks = await engine.openUserTasks({ processInstanceKey:
|
|
2314
|
+
tasks = await engine.openUserTasks({ processInstanceKey: processKey });
|
|
2249
2315
|
} catch (err) {
|
|
2250
|
-
console.error(`[poller] user tasks (
|
|
2251
|
-
|
|
2316
|
+
console.error(`[poller] user tasks (${processKey}): ${err}`);
|
|
2317
|
+
return;
|
|
2252
2318
|
}
|
|
2253
|
-
for (const t of tasks)
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
userTaskKey: t.userTaskKey,
|
|
2260
|
-
elementId: t.elementId,
|
|
2261
|
-
subjectType: "pr",
|
|
2262
|
-
subjectKey: pr.pr_key,
|
|
2263
|
-
subjectTitle: pr.title,
|
|
2264
|
-
subjectUrl: pr.url,
|
|
2265
|
-
question,
|
|
2266
|
-
processKey: pr.process_key,
|
|
2267
|
-
},
|
|
2268
|
-
at,
|
|
2269
|
-
),
|
|
2270
|
-
);
|
|
2271
|
-
}
|
|
2272
|
-
}
|
|
2273
|
-
}
|
|
2274
|
-
|
|
2275
|
-
// Conformance-review acks (`conformance-escalation`) — the advisory `retro` process parks on a
|
|
2276
|
-
// human ack when the spec-conformance audit finds the epic did NOT cleanly meet its spec (issue
|
|
2277
|
-
// #216). retro is not one of the delivery aggregates above, so its instance is tracked on
|
|
2278
|
-
// `plan_conformance` (migration 054): scan each row still `reviewing`, read its open ack task, and
|
|
2279
|
-
// project it keyed to the epic (plan) subject, sourcing the question from the audit's `summary`.
|
|
2280
|
-
for (const review of await activeConformanceReviews(data)) {
|
|
2281
|
-
if (!review.process_key) continue;
|
|
2282
|
-
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
2283
|
-
try {
|
|
2284
|
-
tasks = await engine.openUserTasks({ processInstanceKey: review.process_key });
|
|
2285
|
-
} catch (err) {
|
|
2286
|
-
console.error(`[poller] user tasks (conformance ${review.plan_key}): ${err}`);
|
|
2287
|
-
continue;
|
|
2288
|
-
}
|
|
2289
|
-
const plan = await plans(data).get(review.plan_key);
|
|
2290
|
-
for (const t of tasks) {
|
|
2291
|
-
if (t.elementId !== CONFORMANCE_ESCALATION_ELEMENT) continue;
|
|
2292
|
-
push(
|
|
2293
|
-
buildUserTaskRow(
|
|
2294
|
-
{
|
|
2295
|
-
userTaskKey: t.userTaskKey,
|
|
2296
|
-
elementId: CONFORMANCE_ESCALATION_ELEMENT,
|
|
2297
|
-
subjectType: "plan",
|
|
2298
|
-
subjectKey: review.plan_key,
|
|
2299
|
-
subjectTitle: plan?.title ?? null,
|
|
2300
|
-
subjectUrl: plan?.issue_url ?? null,
|
|
2301
|
-
question: conformanceEscalationQuestion(review),
|
|
2302
|
-
processKey: review.process_key,
|
|
2303
|
-
},
|
|
2304
|
-
at,
|
|
2305
|
-
),
|
|
2306
|
-
);
|
|
2307
|
-
}
|
|
2319
|
+
for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey);
|
|
2320
|
+
};
|
|
2321
|
+
for (const status of FEATURE_ACTIVE_STATUSES) for (const run of await featureRuns(data).find({ status })) await scanInstance(run.process_key);
|
|
2322
|
+
for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
|
|
2323
|
+
for (const status of PR_ACTIVE_STATUSES) for (const pr of await prs(data).find({ status })) await scanInstance(pr.process_key);
|
|
2324
|
+
for (const review of await activeConformanceReviews(data)) await scanInstance(review.process_key);
|
|
2308
2325
|
}
|
|
2309
2326
|
|
|
2327
|
+
const desired = [...desiredByKey.values()];
|
|
2310
2328
|
const persisted = await userTasks(data).all();
|
|
2311
2329
|
const { inserts, updates, deletes } = reconcileUserTasks(persisted, desired);
|
|
2312
2330
|
for (const row of inserts) await userTasks(data).insert(row);
|
|
@@ -2365,7 +2383,7 @@ export async function pollOnce(
|
|
|
2365
2383
|
await pollFeatureDelivery(data);
|
|
2366
2384
|
await pollLineage(data);
|
|
2367
2385
|
await pollMergesPerDay(data);
|
|
2368
|
-
await pollUserTasks(data, engine);
|
|
2386
|
+
await pollUserTasks(data, engine, engineRest);
|
|
2369
2387
|
if (engineRest) {
|
|
2370
2388
|
const base = engineRest.restAddress.replace(/\/+$/, "");
|
|
2371
2389
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
package/app/userTasks.test.ts
CHANGED
|
@@ -91,13 +91,31 @@ test("buildUserTaskRow: an unknown (non-escalation) element yields null — no a
|
|
|
91
91
|
assertEquals(row, null);
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
test("buildUserTaskRow: a blank userTaskKey
|
|
94
|
+
test("buildUserTaskRow: a blank userTaskKey yields null; a known kind with a blank subject key still builds (issue #358)", () => {
|
|
95
|
+
// The completable key is load-bearing (the page completes THROUGH it), so a blank one is still null.
|
|
95
96
|
assertEquals(
|
|
96
97
|
buildUserTaskRow({ userTaskKey: " ", elementId: PR_WAIT_ANSWER_ELEMENT, subjectType: "pr", subjectKey: "o/r#4" }, AT),
|
|
97
98
|
null,
|
|
98
99
|
);
|
|
100
|
+
// But a KNOWN-kind escalation with no resolved subject (orphaned/untracked instance) is NO LONGER
|
|
101
|
+
// dropped — it falls back to a stable, non-blank subject: the instance (`processKey`) when known…
|
|
102
|
+
const orphaned = buildUserTaskRow(
|
|
103
|
+
{ userTaskKey: "ut-4", elementId: PR_WAIT_ANSWER_ELEMENT, subjectType: "pr", subjectKey: " ", processKey: "pik-4" },
|
|
104
|
+
AT,
|
|
105
|
+
);
|
|
106
|
+
assert(orphaned !== null);
|
|
107
|
+
assertEquals(orphaned?.subject_key, "pik-4");
|
|
108
|
+
assertEquals(orphaned?.subject_title, "pik-4");
|
|
109
|
+
assertEquals(orphaned?.kind_label, "PR review");
|
|
110
|
+
// …else the completable key itself, so a row always renders.
|
|
111
|
+
const noInstance = buildUserTaskRow(
|
|
112
|
+
{ userTaskKey: "ut-5", elementId: PR_WAIT_ANSWER_ELEMENT, subjectType: "pr", subjectKey: "" },
|
|
113
|
+
AT,
|
|
114
|
+
);
|
|
115
|
+
assertEquals(noInstance?.subject_key, "ut-5");
|
|
116
|
+
// The leak guard still holds: an unknown element is null regardless of subject fallback.
|
|
99
117
|
assertEquals(
|
|
100
|
-
buildUserTaskRow({ userTaskKey: "ut-
|
|
118
|
+
buildUserTaskRow({ userTaskKey: "ut-6", elementId: "some-internal-task", subjectType: "plan", subjectKey: "", processKey: "pik-6" }, AT),
|
|
101
119
|
null,
|
|
102
120
|
);
|
|
103
121
|
});
|
package/app/userTasks.ts
CHANGED
|
@@ -95,13 +95,20 @@ export interface UserTaskContext {
|
|
|
95
95
|
|
|
96
96
|
/** Pure: turn one resolved open escalation task into its desired read-model row, or `null` when the
|
|
97
97
|
* element is not one of the surfaced escalation kinds (so a non-escalation user task is never listed)
|
|
98
|
-
* or the
|
|
99
|
-
* reconcile preserves the original `created_at` on an update.
|
|
98
|
+
* or the completable key is blank. `created_at`/`updated_at` default to now for a fresh row; the
|
|
99
|
+
* reconcile preserves the original `created_at` on an update.
|
|
100
|
+
*
|
|
101
|
+
* Engine-first sweep (#358): a KNOWN-kind escalation is NOT dropped when no tracked subject row
|
|
102
|
+
* resolved a subject key for it (an orphaned/untracked instance — the reported 19153 case). The
|
|
103
|
+
* subject key falls back to a stable, non-blank value — the instance (`processKey`) if known, else the
|
|
104
|
+
* completable `userTaskKey` — so the row still renders and stays answerable. The blank-`userTaskKey`
|
|
105
|
+
* and unknown-kind guards below remain (the `USER_TASK_KIND_LABELS` gate keeps arbitrary internal user
|
|
106
|
+
* tasks out of the inbox). */
|
|
100
107
|
export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): UserTaskRow | null {
|
|
101
108
|
const userTaskKey = ctx.userTaskKey.trim();
|
|
102
|
-
const subjectKey = ctx.subjectKey.trim();
|
|
103
109
|
const kindLabel = USER_TASK_KIND_LABELS[ctx.elementId];
|
|
104
|
-
if (!userTaskKey || !
|
|
110
|
+
if (!userTaskKey || !kindLabel) return null;
|
|
111
|
+
const subjectKey = ctx.subjectKey.trim() || (ctx.processKey ?? "").trim() || userTaskKey;
|
|
105
112
|
const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
|
|
106
113
|
const subjectTitle = typeof ctx.subjectTitle === "string" && ctx.subjectTitle.trim() ? ctx.subjectTitle.trim() : subjectKey;
|
|
107
114
|
return {
|
package/nano.app.json
CHANGED
|
@@ -140,6 +140,10 @@
|
|
|
140
140
|
"taskType": "pr.record-wave",
|
|
141
141
|
"handler": "workers/record-wave/worker.ts"
|
|
142
142
|
},
|
|
143
|
+
{
|
|
144
|
+
"taskType": "pr.record-wave-escalation",
|
|
145
|
+
"handler": "workers/record-wave-escalation/worker.ts"
|
|
146
|
+
},
|
|
143
147
|
{
|
|
144
148
|
"taskType": "pr.record-feature",
|
|
145
149
|
"handler": "workers/record-feature/worker.ts"
|