@nanobpm/nano-workforce 0.111.1 → 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 +7 -0
- package/app/escalationTaxonomy.test.ts +1 -1
- package/app/escalationTaxonomy.ts +4 -2
- package/app/planFanoutCleanTerminal.test.ts +40 -0
- package/app/pollUserTasks.test.ts +208 -0
- package/app/service.ts +211 -196
- package/app/userTasks.test.ts +20 -2
- package/app/userTasks.ts +11 -4
- package/nano.app.json +4 -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/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";
|
|
@@ -2101,215 +2103,228 @@ function toFeatureRunStatus(status: string): FeatureRunStatus {
|
|
|
2101
2103
|
export const FEATURE_ACTIVE_STATUSES: readonly FeatureRunStatus[] =
|
|
2102
2104
|
activeStatusesFor("feature_runs").map(toFeatureRunStatus);
|
|
2103
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
|
+
|
|
2104
2173
|
/** Reconcile the unified Tasks-inbox read-model (`user_tasks`) against the engine's currently-open
|
|
2105
|
-
* native user-task escalations (
|
|
2106
|
-
* human decision — the feature kinds (`feature-escalation` / `feature-blocked`)
|
|
2107
|
-
* (`plan-review-decision`, `trial-merge-decision`, `wait-answer`
|
|
2108
|
-
*
|
|
2109
|
-
*
|
|
2110
|
-
*
|
|
2111
|
-
*
|
|
2112
|
-
*
|
|
2113
|
-
*
|
|
2114
|
-
|
|
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
|
+
) {
|
|
2115
2204
|
const at = now();
|
|
2116
|
-
const desired: UserTaskRow[] = [];
|
|
2117
|
-
const push = (row: UserTaskRow | null) => {
|
|
2118
|
-
if (row) desired.push(row);
|
|
2119
|
-
};
|
|
2120
2205
|
|
|
2121
|
-
//
|
|
2122
|
-
// from
|
|
2123
|
-
//
|
|
2124
|
-
//
|
|
2125
|
-
//
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
continue;
|
|
2139
|
-
}
|
|
2140
|
-
for (const t of tasks) {
|
|
2141
|
-
if (t.elementId === FEATURE_ESCALATION_ELEMENT) {
|
|
2142
|
-
// Source the question from the canonical append-only `feature_escalations` audit log (issue
|
|
2143
|
-
// #305) — the surviving table `record-feature-escalation` writes — the feature analogue of the
|
|
2144
|
-
// plan-review/trial-merge/PR-loop question enrichment below.
|
|
2145
|
-
const question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: run.feature_key }));
|
|
2146
|
-
push(
|
|
2147
|
-
buildUserTaskRow(
|
|
2148
|
-
{
|
|
2149
|
-
userTaskKey: t.userTaskKey,
|
|
2150
|
-
elementId: FEATURE_ESCALATION_ELEMENT,
|
|
2151
|
-
subjectType: "feature",
|
|
2152
|
-
subjectKey: run.feature_key,
|
|
2153
|
-
subjectTitle: run.title,
|
|
2154
|
-
subjectUrl: run.issue_url,
|
|
2155
|
-
question,
|
|
2156
|
-
processKey: run.process_key,
|
|
2157
|
-
},
|
|
2158
|
-
at,
|
|
2159
|
-
),
|
|
2160
|
-
);
|
|
2161
|
-
} else if (t.elementId === FEATURE_BLOCKED_ELEMENT) {
|
|
2162
|
-
push(
|
|
2163
|
-
buildUserTaskRow(
|
|
2164
|
-
{
|
|
2165
|
-
userTaskKey: t.userTaskKey,
|
|
2166
|
-
elementId: FEATURE_BLOCKED_ELEMENT,
|
|
2167
|
-
subjectType: "feature",
|
|
2168
|
-
subjectKey: run.feature_key,
|
|
2169
|
-
subjectTitle: run.title,
|
|
2170
|
-
subjectUrl: run.issue_url,
|
|
2171
|
-
question: run.delivery_label,
|
|
2172
|
-
processKey: run.process_key,
|
|
2173
|
-
},
|
|
2174
|
-
at,
|
|
2175
|
-
),
|
|
2176
|
-
);
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
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 });
|
|
2179
2223
|
}
|
|
2180
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
|
+
}
|
|
2181
2236
|
|
|
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
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
subjectUrl: plan.issue_url,
|
|
2228
|
-
question,
|
|
2229
|
-
processKey: plan.process_key,
|
|
2230
|
-
},
|
|
2231
|
-
at,
|
|
2232
|
-
),
|
|
2233
|
-
);
|
|
2234
|
-
}
|
|
2235
|
-
}
|
|
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;
|
|
2236
2282
|
}
|
|
2237
|
-
|
|
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
|
+
};
|
|
2238
2298
|
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
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);
|
|
2249
2312
|
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
2250
2313
|
try {
|
|
2251
|
-
tasks = await engine.openUserTasks({ processInstanceKey:
|
|
2314
|
+
tasks = await engine.openUserTasks({ processInstanceKey: processKey });
|
|
2252
2315
|
} catch (err) {
|
|
2253
|
-
console.error(`[poller] user tasks (
|
|
2254
|
-
|
|
2316
|
+
console.error(`[poller] user tasks (${processKey}): ${err}`);
|
|
2317
|
+
return;
|
|
2255
2318
|
}
|
|
2256
|
-
for (const t of tasks)
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
userTaskKey: t.userTaskKey,
|
|
2263
|
-
elementId: t.elementId,
|
|
2264
|
-
subjectType: "pr",
|
|
2265
|
-
subjectKey: pr.pr_key,
|
|
2266
|
-
subjectTitle: pr.title,
|
|
2267
|
-
subjectUrl: pr.url,
|
|
2268
|
-
question,
|
|
2269
|
-
processKey: pr.process_key,
|
|
2270
|
-
},
|
|
2271
|
-
at,
|
|
2272
|
-
),
|
|
2273
|
-
);
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
}
|
|
2277
|
-
|
|
2278
|
-
// Conformance-review acks (`conformance-escalation`) — the advisory `retro` process parks on a
|
|
2279
|
-
// human ack when the spec-conformance audit finds the epic did NOT cleanly meet its spec (issue
|
|
2280
|
-
// #216). retro is not one of the delivery aggregates above, so its instance is tracked on
|
|
2281
|
-
// `plan_conformance` (migration 054): scan each row still `reviewing`, read its open ack task, and
|
|
2282
|
-
// project it keyed to the epic (plan) subject, sourcing the question from the audit's `summary`.
|
|
2283
|
-
for (const review of await activeConformanceReviews(data)) {
|
|
2284
|
-
if (!review.process_key) continue;
|
|
2285
|
-
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
2286
|
-
try {
|
|
2287
|
-
tasks = await engine.openUserTasks({ processInstanceKey: review.process_key });
|
|
2288
|
-
} catch (err) {
|
|
2289
|
-
console.error(`[poller] user tasks (conformance ${review.plan_key}): ${err}`);
|
|
2290
|
-
continue;
|
|
2291
|
-
}
|
|
2292
|
-
const plan = await plans(data).get(review.plan_key);
|
|
2293
|
-
for (const t of tasks) {
|
|
2294
|
-
if (t.elementId !== CONFORMANCE_ESCALATION_ELEMENT) continue;
|
|
2295
|
-
push(
|
|
2296
|
-
buildUserTaskRow(
|
|
2297
|
-
{
|
|
2298
|
-
userTaskKey: t.userTaskKey,
|
|
2299
|
-
elementId: CONFORMANCE_ESCALATION_ELEMENT,
|
|
2300
|
-
subjectType: "plan",
|
|
2301
|
-
subjectKey: review.plan_key,
|
|
2302
|
-
subjectTitle: plan?.title ?? null,
|
|
2303
|
-
subjectUrl: plan?.issue_url ?? null,
|
|
2304
|
-
question: conformanceEscalationQuestion(review),
|
|
2305
|
-
processKey: review.process_key,
|
|
2306
|
-
},
|
|
2307
|
-
at,
|
|
2308
|
-
),
|
|
2309
|
-
);
|
|
2310
|
-
}
|
|
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);
|
|
2311
2325
|
}
|
|
2312
2326
|
|
|
2327
|
+
const desired = [...desiredByKey.values()];
|
|
2313
2328
|
const persisted = await userTasks(data).all();
|
|
2314
2329
|
const { inserts, updates, deletes } = reconcileUserTasks(persisted, desired);
|
|
2315
2330
|
for (const row of inserts) await userTasks(data).insert(row);
|
|
@@ -2368,7 +2383,7 @@ export async function pollOnce(
|
|
|
2368
2383
|
await pollFeatureDelivery(data);
|
|
2369
2384
|
await pollLineage(data);
|
|
2370
2385
|
await pollMergesPerDay(data);
|
|
2371
|
-
await pollUserTasks(data, engine);
|
|
2386
|
+
await pollUserTasks(data, engine, engineRest);
|
|
2372
2387
|
if (engineRest) {
|
|
2373
2388
|
const base = engineRest.restAddress.replace(/\/+$/, "");
|
|
2374
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"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.112.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",
|