@nanobpm/nano-workforce 0.188.1 → 0.189.1
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 +12 -0
- package/SPEC.md +16 -0
- package/app/adjudications.test.ts +735 -0
- package/app/adjudications.ts +378 -0
- package/app/agentCompletion.test.ts +282 -10
- package/app/agentCompletion.ts +163 -23
- package/app/agentic/cockpit/mount.test.ts +50 -0
- package/app/agentic/cockpit/supply-render.test.ts +20 -0
- package/app/agentic/cockpit/supply-render.ts +15 -0
- package/app/agentic/cockpit/supply-view.ts +19 -2
- package/app/agentic/permission-bridge.test.ts +2 -2
- package/app/agentic/vocab/demand-report.test.ts +66 -1
- package/app/agentic/vocab/demand-report.ts +54 -6
- package/app/answer-escalation.test.ts +415 -2
- package/app/answerContextMapping.test.ts +83 -0
- package/app/contracts.ts +24 -0
- package/app/convergenceAdjudicationResume.test.ts +274 -0
- package/app/github.ts +10 -0
- package/app/harnessProtocol.test.ts +170 -0
- package/app/harnessProtocol.ts +312 -0
- package/app/mcpToolSurface.ts +7 -1
- package/app/service.test.ts +178 -1
- package/app/service.ts +104 -4
- package/app/terminalReaderBehaviour.test.ts +21 -0
- package/db/migrations/107_worker_harness_protocol.sql +30 -0
- package/db/migrations/109_pr_adjudications.sql +61 -0
- package/db/migrations/110_task_completions_auto_applied.sql +34 -0
- package/openapi.yaml +71 -1
- package/operations/completeUserTask.test.ts +5 -5
- package/operations/enrolAgenticWorker.test.ts +84 -0
- package/operations/enrolAgenticWorker.ts +67 -7
- package/operations/getAgenticRegistry.ts +1 -1
- package/operations/getAgenticSupply.test.ts +80 -0
- package/operations/getAgenticSupply.ts +15 -3
- package/operations/listEscalations.test.ts +1 -1
- package/package.json +1 -1
- package/pages/cockpit/mount.js +18 -0
- package/resources/processes/convergence-loop.bpmn +9 -0
- package/resources/processes/merge-loop.bpmn +1 -0
- package/test/worldDb.ts +6 -0
- package/workers/answer-escalation/worker.ts +191 -11
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
import type { Capability } from "@nanobpm/agentic/protocol";
|
|
14
14
|
import { resolveEnrolment } from "../app/agentic/vocab/enrol.ts";
|
|
15
15
|
import { DurableResumeRegistry } from "../app/durableResume.ts";
|
|
16
|
+
import {
|
|
17
|
+
HarnessProtocolRegistry,
|
|
18
|
+
isStaleProtocol,
|
|
19
|
+
minHarnessProtocol,
|
|
20
|
+
staleHarnessPolicy,
|
|
21
|
+
} from "../app/harnessProtocol.ts";
|
|
16
22
|
import { envVar } from "../app/version.ts";
|
|
17
23
|
import type { EnrolResult } from "../nano-generated/api-io.d.ts";
|
|
18
24
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
@@ -73,6 +79,22 @@ export default defineOperation("enrolAgenticWorker", async ({ req, body }, app)
|
|
|
73
79
|
app.log.warn("enrolAgenticWorker rejected: non-boolean durableResume");
|
|
74
80
|
return { status: 400, body: { error: "`durableResume` must be a boolean when provided" } };
|
|
75
81
|
}
|
|
82
|
+
// The harness-protocol enrolment attribute (issue #802) — a non-negative integer the harness
|
|
83
|
+
// advertises declaring which machine-readable artifacts it emits (AgentInstance, transcript flush,
|
|
84
|
+
// result envelope). A directly-invoked delegate bypasses the OpenAPI runtime validation, so guard the
|
|
85
|
+
// type here: a non-integer / negative value would corrupt the persisted staleness gate.
|
|
86
|
+
if (
|
|
87
|
+
body.harnessProtocol !== undefined &&
|
|
88
|
+
(typeof body.harnessProtocol !== "number" ||
|
|
89
|
+
!Number.isInteger(body.harnessProtocol) ||
|
|
90
|
+
body.harnessProtocol < 0)
|
|
91
|
+
) {
|
|
92
|
+
app.log.warn("enrolAgenticWorker rejected: non-integer harnessProtocol");
|
|
93
|
+
return {
|
|
94
|
+
status: 400,
|
|
95
|
+
body: { error: "`harnessProtocol` must be a non-negative integer when provided" },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
76
98
|
|
|
77
99
|
// Fold a top-level `host` into the capability when the capability didn't carry its own — a worker
|
|
78
100
|
// may declare its host either on the capability or beside it (ADR 0059 `{ capability, host }`).
|
|
@@ -102,19 +124,57 @@ export default defineOperation("enrolAgenticWorker", async ({ req, body }, app)
|
|
|
102
124
|
}
|
|
103
125
|
}
|
|
104
126
|
|
|
127
|
+
// Harness-protocol enrolment gate (issue #802): record the advertised protocol per instance so the
|
|
128
|
+
// app can expose it in getAgenticSupply / the registry and gate on it. Recorded even on a downgrade
|
|
129
|
+
// (a re-enrol WITHOUT a version clears a stale-healthy value to absent → stale). Like durable-resume
|
|
130
|
+
// it needs a non-blank `instance` and is best-effort — a registry write hiccup must not fail enrol.
|
|
131
|
+
if (app.data && instanceKey) {
|
|
132
|
+
try {
|
|
133
|
+
await new HarnessProtocolRegistry(app.data).recordEnrolment(instanceKey, body.harnessProtocol);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
app.log.warn("enrolAgenticWorker: harness-protocol record failed", { instance: instanceKey, err: String(err) });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Derive staleness from the just-advertised protocol against the configured minimum (absent version =
|
|
140
|
+
// stale, the #802 signature). Under the `refuse` policy a stale harness is handed an EMPTY SERVE set
|
|
141
|
+
// so it wins no job leases — a REGISTER→SERVE gate, never an engine/job-protocol change. Under the
|
|
142
|
+
// default `flag` policy the SERVE set is unchanged (no routing regression); the worker is only
|
|
143
|
+
// flagged so the cockpit can surface it for drain.
|
|
144
|
+
const harnessStale = isStaleProtocol(body.harnessProtocol, minHarnessProtocol());
|
|
145
|
+
const refuseRouting = harnessStale && staleHarnessPolicy() === "refuse";
|
|
146
|
+
if (refuseRouting) {
|
|
147
|
+
app.log.warn("enrolAgenticWorker: refusing routing for a stale harness", {
|
|
148
|
+
instance: body.instance,
|
|
149
|
+
harnessProtocol: body.harnessProtocol,
|
|
150
|
+
minHarnessProtocol: minHarnessProtocol(),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
105
154
|
const result: EnrolResult = {
|
|
106
|
-
serve: [...resolved.serve],
|
|
107
|
-
roles:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
155
|
+
serve: refuseRouting ? [] : [...resolved.serve],
|
|
156
|
+
roles: refuseRouting
|
|
157
|
+
? []
|
|
158
|
+
: resolved.roles.map((role) => {
|
|
159
|
+
const out: EnrolResult["roles"][number] = { token: role.token, seatsDistinctFamily: role.seatsDistinctFamily };
|
|
160
|
+
if (role.weight !== undefined) out.weight = role.weight;
|
|
161
|
+
return out;
|
|
162
|
+
}),
|
|
112
163
|
demandVersion: resolved.demandVersion,
|
|
113
164
|
leaseTtl: resolved.leaseTtl,
|
|
165
|
+
// Always surface the staleness verdict so the caller (and the cockpit) can see a stale harness even
|
|
166
|
+
// when it advertised no version at all.
|
|
167
|
+
harnessStale,
|
|
114
168
|
};
|
|
115
169
|
if (body.instance !== undefined) result.instance = body.instance;
|
|
116
170
|
if (body.durableResume !== undefined) result.durableResume = body.durableResume;
|
|
171
|
+
if (body.harnessProtocol !== undefined) result.harnessProtocol = body.harnessProtocol;
|
|
117
172
|
|
|
118
|
-
app.log.info("agentic enrol resolved", {
|
|
173
|
+
app.log.info("agentic enrol resolved", {
|
|
174
|
+
instance: body.instance,
|
|
175
|
+
serve: result.serve,
|
|
176
|
+
family: capability.family,
|
|
177
|
+
harnessStale,
|
|
178
|
+
});
|
|
119
179
|
return { status: 200, body: result };
|
|
120
180
|
});
|
|
@@ -20,6 +20,6 @@ export default defineOperation("getAgenticRegistry", async ({ req }, app) => {
|
|
|
20
20
|
app.log.warn("getAgenticRegistry rejected: missing/invalid shared secret");
|
|
21
21
|
return { status: 401, body: { error: "unauthorized" } };
|
|
22
22
|
}
|
|
23
|
-
const report = await computeRegistryReport(app.log);
|
|
23
|
+
const report = await computeRegistryReport(app.log, app.data);
|
|
24
24
|
return { status: 200, body: toWireReport(report) };
|
|
25
25
|
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// by instance, family/host/jobKeys/liveness) — driven through a REAL AgenticHub + in-memory transport
|
|
6
6
|
// exactly as the presence family is exercised, so the singleton the operation reads is the live one.
|
|
7
7
|
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
8
9
|
import { test } from "node:test";
|
|
9
10
|
import { AgenticHub } from "@nanobpm/agentic/channel";
|
|
10
11
|
import type { Authenticator, ChannelConnection, ChannelTransport } from "@nanobpm/agentic/channel";
|
|
@@ -39,6 +40,19 @@ function memData(db: SqliteDb): DataLayer {
|
|
|
39
40
|
return { source: () => ({ db }) } as unknown as DataLayer;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** Wrap an existing DatabaseSync as a SqliteDb (so the presence store and a raw insert share one db). */
|
|
44
|
+
function memSqliteOver(db: DatabaseSync): SqliteDb {
|
|
45
|
+
return {
|
|
46
|
+
exec: (sql) => db.exec(sql),
|
|
47
|
+
run: (sql, params = []) => {
|
|
48
|
+
const r = db.prepare(sql).run(...(params as never[]));
|
|
49
|
+
return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
|
|
50
|
+
},
|
|
51
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
|
|
52
|
+
db.prepare(sql).all(...(params as never[])) as T[],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
function memTransport(): { transport: ChannelTransport; connect(conn: ChannelConnection): void } {
|
|
43
57
|
let onConnection: ((conn: ChannelConnection) => void) | undefined;
|
|
44
58
|
const transport: ChannelTransport = {
|
|
@@ -259,3 +273,69 @@ test("#738 drift: the supply advertises the producer's instance-scoped stream id
|
|
|
259
273
|
await hub.close();
|
|
260
274
|
}
|
|
261
275
|
});
|
|
276
|
+
|
|
277
|
+
// Harness staleness (issue #802): the supply report flags a worker whose harness protocol is below the
|
|
278
|
+
// minimum / absent, joined by instance from the app's harness-protocol registry.
|
|
279
|
+
test("#802: flags harnessStale per worker, joining the harness-protocol registry by instance", async () => {
|
|
280
|
+
const raw = new DatabaseSync(":memory:");
|
|
281
|
+
raw.exec("PRAGMA foreign_keys = ON;");
|
|
282
|
+
raw.exec(readFileSync(new URL("../db/migrations/107_worker_harness_protocol.sql", import.meta.url), "utf8"));
|
|
283
|
+
const now = new Date().toISOString();
|
|
284
|
+
// wk-a advertised a healthy protocol (>= min 1); wk-a's presence row is minted below.
|
|
285
|
+
raw.prepare("INSERT INTO worker_harness_protocol (instance, harness_protocol, updated_at) VALUES (?, ?, ?)").run("wk-a", 2, now);
|
|
286
|
+
|
|
287
|
+
// A data layer providing BOTH the presence `source().db` handle and the RAD `table()` surface over
|
|
288
|
+
// the SAME db, so the presence family and the harness registry read one store.
|
|
289
|
+
const sqlite = memSqliteOver(raw);
|
|
290
|
+
const quote = (id: string) => `"${id.replace(/"/g, '""')}"`;
|
|
291
|
+
const table = (name: string, pk = "id") => ({
|
|
292
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
|
|
293
|
+
async find(where: any = {}): Promise<any[]> {
|
|
294
|
+
const keys = Object.keys(where);
|
|
295
|
+
const clause = keys.length ? `WHERE ${keys.map((k) => `${quote(k)} = ?`).join(" AND ")}` : "";
|
|
296
|
+
return raw.prepare(`SELECT * FROM ${quote(name)} ${clause}`).all(...keys.map((k) => where[k])) as any[];
|
|
297
|
+
},
|
|
298
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
|
|
299
|
+
async findOne(where: any = {}): Promise<any> {
|
|
300
|
+
return (await this.find(where))[0];
|
|
301
|
+
},
|
|
302
|
+
_pk: pk,
|
|
303
|
+
});
|
|
304
|
+
const data = {
|
|
305
|
+
source: () => ({ db: sqlite }),
|
|
306
|
+
table,
|
|
307
|
+
// The raw-SQL surface the harness registry's bounded `WHERE instance IN (…)` read binds to, over
|
|
308
|
+
// the SAME db as `table`/presence.
|
|
309
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
|
|
310
|
+
open: () => ({ query: async (sql: string, params: any[] = []) => raw.prepare(sql).all(...params) as any[] }),
|
|
311
|
+
} as unknown as DataLayer;
|
|
312
|
+
|
|
313
|
+
const transport = memTransport();
|
|
314
|
+
const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
|
|
315
|
+
await family.mount({ hub, registry: hub.registry, transport: transport.transport as never, data, log: noopLog() });
|
|
316
|
+
const healthyConn = fakeConn("c1", "leafA");
|
|
317
|
+
transport.connect(healthyConn.conn);
|
|
318
|
+
await flush();
|
|
319
|
+
healthyConn.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: {} } });
|
|
320
|
+
const staleConn = fakeConn("c2", "leafA");
|
|
321
|
+
transport.connect(staleConn.conn);
|
|
322
|
+
await flush();
|
|
323
|
+
// wk-b registers but has NO harness-protocol row → absent = stale.
|
|
324
|
+
staleConn.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-b", capability: {} } });
|
|
325
|
+
await flush();
|
|
326
|
+
|
|
327
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
328
|
+
try {
|
|
329
|
+
const res = (await handler(input(), withData)) as { status: number; body: { workers: Array<Record<string, unknown>> } };
|
|
330
|
+
assertEquals(res.status, 200);
|
|
331
|
+
const byInstance = new Map(res.body.workers.map((w) => [w.instance, w]));
|
|
332
|
+
assertEquals(byInstance.get("wk-a")?.harnessStale, false, "healthy protocol is not stale");
|
|
333
|
+
assertEquals(byInstance.get("wk-a")?.harnessProtocol, 2);
|
|
334
|
+
assertEquals(byInstance.get("wk-b")?.harnessStale, true, "no recorded protocol = stale");
|
|
335
|
+
assertEquals("harnessProtocol" in (byInstance.get("wk-b") ?? {}), false, "no protocol echoed for a stale worker");
|
|
336
|
+
} finally {
|
|
337
|
+
family.teardown?.();
|
|
338
|
+
await hub.close();
|
|
339
|
+
raw.close();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { type ClaimRegistry, currentClaimRegistry } from "../app/agentic/claim-registry.ts";
|
|
24
24
|
import { currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
|
|
25
25
|
import { currentPresenceRegistry, type SupplyWorker } from "../app/agentic/families/presence.family.ts";
|
|
26
|
+
import { assessWorkers, type HarnessAssessment } from "../app/harnessProtocol.ts";
|
|
26
27
|
import { envVar } from "../app/version.ts";
|
|
27
28
|
import type { AgenticJobCorrelation, AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
|
|
28
29
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
@@ -36,7 +37,7 @@ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
|
36
37
|
// (`composeStreamId(instance, jobKey)`, issue #738) when the claim registry knows a current claim for
|
|
37
38
|
// it (#713) — keyed by the CLAIM, not by the connection — so drilling in opens the LIVE job's terminal
|
|
38
39
|
// (the exact stream the producer writes) even before any transcript lands.
|
|
39
|
-
function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSupplyWorker {
|
|
40
|
+
function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined, harness: HarnessAssessment | undefined): AgenticSupplyWorker {
|
|
40
41
|
const out: AgenticSupplyWorker = {
|
|
41
42
|
instance: w.instance,
|
|
42
43
|
identity: w.identity,
|
|
@@ -44,9 +45,13 @@ function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSu
|
|
|
44
45
|
jobKeys: [...w.jobKeys],
|
|
45
46
|
live: w.live,
|
|
46
47
|
staleMs: w.staleMs,
|
|
48
|
+
// Harness staleness (issue #802) — an absent registry entry / unmounted data layer reads as stale
|
|
49
|
+
// (fail loud). Distinct from the liveness `staleMs` heartbeat grade above.
|
|
50
|
+
harnessStale: harness?.stale ?? true,
|
|
47
51
|
};
|
|
48
52
|
if (w.family !== undefined) out.family = w.family;
|
|
49
53
|
if (w.host !== undefined) out.host = w.host;
|
|
54
|
+
if (harness?.harnessProtocol !== undefined) out.harnessProtocol = harness.harnessProtocol;
|
|
50
55
|
return out;
|
|
51
56
|
}
|
|
52
57
|
|
|
@@ -82,11 +87,18 @@ export default defineOperation("getAgenticSupply", async ({ req }, app) => {
|
|
|
82
87
|
// process-instance / plan context surfaced in `correlations`, but no longer feeds visibility.
|
|
83
88
|
const correlation = currentCorrelation();
|
|
84
89
|
const snapshot = registry.snapshot(claims ? { jobKeysFor: (instance) => claims.jobKeysFor(instance) } : {});
|
|
90
|
+
// Harness-staleness (issue #802): assess every visible worker's advertised protocol against the
|
|
91
|
+
// configured minimum — the ONE canonical staleness derivation (no second heuristic). Best-effort:
|
|
92
|
+
// an unmounted data layer / read failure reads as stale (fail loud).
|
|
93
|
+
const harness = await assessWorkers(app.data, snapshot.workers.map((w) => w.instance));
|
|
85
94
|
const report: AgenticSupplyReport = {
|
|
86
95
|
count: snapshot.count,
|
|
87
96
|
generatedAt: new Date().toISOString(),
|
|
88
|
-
workers: snapshot.workers.map((w) => toWorker(w, claims)),
|
|
89
|
-
leaves: snapshot.leaves.map((leaf) => ({
|
|
97
|
+
workers: snapshot.workers.map((w) => toWorker(w, claims, harness.get(w.instance))),
|
|
98
|
+
leaves: snapshot.leaves.map((leaf) => ({
|
|
99
|
+
token: leaf.token,
|
|
100
|
+
workers: leaf.workers.map((w) => toWorker(w, claims, harness.get(w.instance))),
|
|
101
|
+
})),
|
|
90
102
|
correlations: correlation ? correlation.snapshot().correlations.map(toCorrelation) : [],
|
|
91
103
|
};
|
|
92
104
|
return { status: 200, body: report };
|
|
@@ -126,7 +126,7 @@ test("listEscalations: round-trip — the listed userTaskKey is exactly what com
|
|
|
126
126
|
assertEquals(done.status, 200);
|
|
127
127
|
assertEquals(done.body.ok, true);
|
|
128
128
|
assertEquals(done.body.elementId, "wait-answer");
|
|
129
|
-
assertEquals(completed, [{ userTaskKey: "ut-answer", variables: { answer: "v2" } }]);
|
|
129
|
+
assertEquals(completed, [{ userTaskKey: "ut-answer", variables: { answer: "v2", completedUserTaskKey: "ut-answer", completedCompletionId: 1 } }]);
|
|
130
130
|
// The answered task's read-model row is dropped, so a re-list no longer shows it.
|
|
131
131
|
assertEquals(stores.user_tasks, []);
|
|
132
132
|
const reListed = await callList(app);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.189.1",
|
|
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",
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -84,6 +84,10 @@ function workerView(worker, staleAfterMs, byJobKey) {
|
|
|
84
84
|
correlations,
|
|
85
85
|
liveness: liveness(worker, staleAfterMs),
|
|
86
86
|
staleMs: worker.staleMs,
|
|
87
|
+
// Fail loud: default a missing harness verdict to STALE (mirrors the typed cockpit view and the
|
|
88
|
+
// server's fail-loud assessment) so an older/cached response can't hide a stale/unknown worker.
|
|
89
|
+
harnessStale: worker.harnessStale ?? true,
|
|
90
|
+
...(worker.harnessProtocol !== undefined ? { harnessProtocol: worker.harnessProtocol } : {}),
|
|
87
91
|
};
|
|
88
92
|
}
|
|
89
93
|
|
|
@@ -141,6 +145,7 @@ function workerRow(doc, worker, onDrill, onOpenWorker) {
|
|
|
141
145
|
row.setAttribute("data-worker", worker.instance);
|
|
142
146
|
row.setAttribute("data-liveness", worker.liveness);
|
|
143
147
|
row.setAttribute("data-stream", worker.stream);
|
|
148
|
+
row.setAttribute("data-harness-stale", String(worker.harnessStale));
|
|
144
149
|
|
|
145
150
|
const nameCell = el(doc, "td", "cockpit-td cockpit-supply-name");
|
|
146
151
|
nameCell.appendChild(dot(doc, worker.liveness));
|
|
@@ -162,6 +167,19 @@ function workerRow(doc, worker, onDrill, onOpenWorker) {
|
|
|
162
167
|
if (onDrill) drill.addEventListener("click", () => onDrill(worker.stream));
|
|
163
168
|
nameCell.appendChild(drill);
|
|
164
169
|
}
|
|
170
|
+
// A stale harness silently swallows machine-readable artifacts (issue #802) — surface it as a
|
|
171
|
+
// distinct badge (mirrors app/agentic/cockpit/supply-render.ts).
|
|
172
|
+
if (worker.harnessStale) {
|
|
173
|
+
const badge = el(
|
|
174
|
+
doc,
|
|
175
|
+
"span",
|
|
176
|
+
"cockpit-supply-harness-stale",
|
|
177
|
+
worker.harnessProtocol === undefined ? "stale harness" : `stale harness (v${worker.harnessProtocol})`,
|
|
178
|
+
);
|
|
179
|
+
badge.setAttribute("data-harness-stale", "true");
|
|
180
|
+
badge.setAttribute("title", "Harness protocol below the configured minimum (or none advertised); jobs may dead-end.");
|
|
181
|
+
nameCell.appendChild(badge);
|
|
182
|
+
}
|
|
165
183
|
row.appendChild(nameCell);
|
|
166
184
|
|
|
167
185
|
row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
|
|
@@ -88,6 +88,10 @@
|
|
|
88
88
|
<nano:shape id="PrAnswerEscalationIn" name="Answer escalation — input">
|
|
89
89
|
<nano:extend name="prKey" type="string" />
|
|
90
90
|
<nano:extend name="answer" type="string" optional="true" />
|
|
91
|
+
<nano:extend name="answerContext" type="string" optional="true" />
|
|
92
|
+
<nano:extend name="completedUserTaskKey" type="string" optional="true" />
|
|
93
|
+
<nano:extend name="completedCompletionId" type="integer" optional="true" />
|
|
94
|
+
<nano:extend name="escalationId" type="integer" optional="true" />
|
|
91
95
|
</nano:shape>
|
|
92
96
|
<nano:shape id="PrFinalizeIn" name="Mark converged — input">
|
|
93
97
|
<nano:extend name="prKey" type="string" />
|
|
@@ -308,9 +312,14 @@
|
|
|
308
312
|
<zeebe:ioMapping>
|
|
309
313
|
<zeebe:input source="=prKey" target="prKey" />
|
|
310
314
|
<zeebe:input source="=answer" target="answer" />
|
|
315
|
+
<zeebe:input source="="convergence"" target="answerContext" />
|
|
316
|
+
<zeebe:input source="=completedUserTaskKey" target="completedUserTaskKey" />
|
|
317
|
+
<zeebe:input source="=completedCompletionId" target="completedCompletionId" />
|
|
311
318
|
<zeebe:output source="=(if scopePending = true then answer else scopeAnswer)" target="scopeAnswer" />
|
|
312
319
|
<zeebe:output source="=0" target="huskRetries" />
|
|
313
320
|
<zeebe:output source="=false" target="reviewStale" />
|
|
321
|
+
<zeebe:output source="=null" target="completedUserTaskKey" />
|
|
322
|
+
<zeebe:output source="=null" target="completedCompletionId" />
|
|
314
323
|
</zeebe:ioMapping>
|
|
315
324
|
</bpmn:extensionElements>
|
|
316
325
|
<bpmn:incoming>f_answerRecord</bpmn:incoming>
|
|
@@ -329,6 +329,7 @@
|
|
|
329
329
|
<zeebe:ioMapping>
|
|
330
330
|
<zeebe:input source="=prKey" target="prKey" />
|
|
331
331
|
<zeebe:input source="=answer" target="answer" />
|
|
332
|
+
<zeebe:input source="="merge"" target="answerContext" />
|
|
332
333
|
</zeebe:ioMapping>
|
|
333
334
|
</bpmn:extensionElements>
|
|
334
335
|
<bpmn:incoming>f_m_answerRecord</bpmn:incoming>
|
package/test/worldDb.ts
CHANGED
|
@@ -69,12 +69,18 @@ function gateway(db: DatabaseSync, name: string, pk: string) {
|
|
|
69
69
|
* that decorates `table` on the returned source sees its decoration inside the transaction too. */
|
|
70
70
|
type MemDataSource = {
|
|
71
71
|
table: (name: string, pk?: string) => ReturnType<typeof gateway>;
|
|
72
|
+
query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
72
73
|
tx<T>(fn: (t: MemDataSource) => Promise<T>): Promise<T>;
|
|
73
74
|
};
|
|
74
75
|
|
|
75
76
|
function openDataSource(db: DatabaseSync): MemDataSource {
|
|
76
77
|
const ds: MemDataSource = {
|
|
77
78
|
table: (name, pk = "id") => gateway(db, name, pk),
|
|
79
|
+
// The row-returning raw-SQL surface (mirrors the runtime `DataSource.query`) so tests exercise a
|
|
80
|
+
// handler's bounded `WHERE … IN (…)` reads against genuine SQLite rather than a mock.
|
|
81
|
+
async query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
82
|
+
return db.prepare(sql).all(...params.map(coerce)) as T[];
|
|
83
|
+
},
|
|
78
84
|
async tx(fn) {
|
|
79
85
|
db.exec("BEGIN");
|
|
80
86
|
try {
|
|
@@ -17,21 +17,26 @@
|
|
|
17
17
|
// durable rows; it returns no variables, leaving the submitted `answer` untouched so it flows on to
|
|
18
18
|
// the next review round.
|
|
19
19
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
20
|
+
import { generationGuard, recordAdjudication } from "../../app/adjudications.ts";
|
|
21
|
+
import { taskCompletions } from "../../app/agentCompletion.ts";
|
|
20
22
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
21
23
|
|
|
22
24
|
interface Escalation extends Record<string, unknown> {
|
|
23
25
|
id: number;
|
|
24
26
|
pr_key: string;
|
|
25
27
|
status: string;
|
|
28
|
+
question: string;
|
|
26
29
|
answer: string | null;
|
|
27
30
|
answered_at: string | null;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
// The PR-row fields this worker reconciles when an escalation is answered. Only `status`/`updated_at`
|
|
31
|
-
// are written; the rest of the row is untouched.
|
|
34
|
+
// are written; the rest of the row is untouched. `process_key` is READ (never written here) to reject a
|
|
35
|
+
// delayed/redelivered job from a superseded process instance (Copilot review of #806).
|
|
32
36
|
interface PullRequest extends Record<string, unknown> {
|
|
33
37
|
pr_key: string;
|
|
34
38
|
status: string;
|
|
39
|
+
process_key: string | null;
|
|
35
40
|
updated_at: string;
|
|
36
41
|
}
|
|
37
42
|
|
|
@@ -42,10 +47,62 @@ type In = WorkerInputs["pr.answer-escalation"];
|
|
|
42
47
|
function nonBlank(v: unknown): string | undefined {
|
|
43
48
|
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
44
49
|
}
|
|
50
|
+
// A finite integer variable, or undefined when absent/blank/non-numeric. The `completedCompletionId`
|
|
51
|
+
// envelope field is typed `integer`, so it arrives as a number; tolerate a numeric string defensively.
|
|
52
|
+
function nonBlankInt(v: unknown): number | undefined {
|
|
53
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : undefined;
|
|
54
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
55
|
+
const n = Number(v);
|
|
56
|
+
return Number.isFinite(n) ? n : undefined;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
45
60
|
|
|
46
61
|
const handler: AppJobHandler<In> = async (job, app) => {
|
|
47
62
|
const { prKey } = job.variables;
|
|
48
63
|
const answer = nonBlank(job.variables.answer);
|
|
64
|
+
// The exact identity of the `wait-*` completion that resumed THIS token (Copilot review of #806),
|
|
65
|
+
// stamped by `completeUserTaskAttributed` on the resumed token. Used to correlate the durable
|
|
66
|
+
// adjudication to the completion the engine actually accepted; absent for an out-of-band resume.
|
|
67
|
+
const completedUserTaskKey = nonBlank(job.variables.completedUserTaskKey);
|
|
68
|
+
// The exact ledger id of the winning completion (Copilot review of #806), stamped by
|
|
69
|
+
// `completeUserTaskAttributed`. When present it uniquely identifies the winner — even against a
|
|
70
|
+
// same-answer losing racer on the same `user_task_key` — so record-answer selects that exact row
|
|
71
|
+
// rather than correlating by answer. Absent for an out-of-band resume.
|
|
72
|
+
const completedCompletionId = nonBlankInt(job.variables.completedCompletionId);
|
|
73
|
+
// The durable id of the escalation THIS completion answers (Copilot review of #806). The originating
|
|
74
|
+
// `pr.persist-escalation` returns its inserted `escalations.id` as a process variable (`EscalationOut`),
|
|
75
|
+
// which the `wait-*` completion carries through here. Answering by this exact id — rather than the
|
|
76
|
+
// newest open row — is what makes a REDELIVERED older answer safe: if the process has since opened a
|
|
77
|
+
// NEWER escalation (a different question), a stale Q1 `record-answer` whose `escalationId` no longer
|
|
78
|
+
// matches any open row is a no-op instead of misfiling Q1's answer under Q2's fingerprint and retiring
|
|
79
|
+
// Q2 unanswered. Absent for a pre-#806 / out-of-band resume — we then fall back to the newest open row.
|
|
80
|
+
const escalationId = nonBlankInt(job.variables.escalationId);
|
|
81
|
+
const prs = app.data.table<PullRequest>("pull_requests", "pr_key");
|
|
82
|
+
// Reject a delayed/redelivered job from a SUPERSEDED process instance (Copilot review of #806).
|
|
83
|
+
// `pull_requests.process_key` always tracks the CURRENT loop instance for this PR — the convergence
|
|
84
|
+
// instance at submit, reassigned to the merge instance at merge-start (app/service.ts). A job whose
|
|
85
|
+
// `processInstanceKey` no longer matches is from an old run that a re-submit (or the merge hand-off)
|
|
86
|
+
// replaced; accepting it would let a stale process reinsert its adjudication after the fresh run's
|
|
87
|
+
// reset, or even answer the new run's open escalation, so a re-submit would not be a reliable
|
|
88
|
+
// fresh-decision boundary. No-op such jobs BEFORE any adjudication/escalation/PR write. When the
|
|
89
|
+
// current process is unknown (no row / null `process_key`) we cannot classify the job as stale, so
|
|
90
|
+
// we proceed rather than silently drop a legitimate operator answer.
|
|
91
|
+
//
|
|
92
|
+
// The `process_key` is read AS LATE AS POSSIBLE — immediately before the writes below, not at handler
|
|
93
|
+
// entry — so a concurrent re-submit that advances `process_key` and resets the adjudication memory is
|
|
94
|
+
// observed here (Copilot review of #806). Paired with submitPr advancing `process_key` BEFORE it
|
|
95
|
+
// clears the memory, this shrinks the reset window to a single check-then-write step: a straggler is
|
|
96
|
+
// rejected once the new identity is installed, and any pre-advance insert is cleared by the
|
|
97
|
+
// post-advance reset.
|
|
98
|
+
const jobProcessKey = job.processInstanceKey != null ? String(job.processInstanceKey) : undefined;
|
|
99
|
+
// This one worker services BOTH loops' answer steps (`record-answer` in the convergence loop AND
|
|
100
|
+
// `record-merge-answer` in the merge loop, #256). Only a CONVERGENCE answer may feed the durable
|
|
101
|
+
// adjudication memory the convergence poller replays — a merge-loop decision recorded here would let
|
|
102
|
+
// a later convergence `wait-answer` with the same text replay a merge-context answer that was never a
|
|
103
|
+
// convergence adjudication (Copilot review of #806). The originating step stamps `answerContext` via a
|
|
104
|
+
// literal ioMapping so this reconcile can tell them apart.
|
|
105
|
+
const isConvergence = nonBlank(job.variables.answerContext) === "convergence";
|
|
49
106
|
const escs = app.data.table<Escalation>("escalations", "id");
|
|
50
107
|
// Retire EVERY still-open escalation for this PR. `pr.persist-escalation` always INSERTs a new
|
|
51
108
|
// open row, so a retry/duplicate activation can leave more than one open — answering only the
|
|
@@ -53,24 +110,147 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
53
110
|
// is still `escalated`. Answer the newest (it carries the operator's reply) and mark any remaining
|
|
54
111
|
// open rows `stale`, mirroring `submitPr`'s resubmit cleanup.
|
|
55
112
|
const open = (await escs.find({ pr_key: prKey, status: "open" })).sort((a, b) => b.id - a.id);
|
|
56
|
-
|
|
113
|
+
const currentProcessKey = nonBlank((await prs.find({ pr_key: prKey }))[0]?.process_key);
|
|
114
|
+
if (currentProcessKey !== undefined && jobProcessKey !== undefined && jobProcessKey !== currentProcessKey) {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
// The escalation this completion actually answers. Prefer the exact `escalationId` the winning
|
|
118
|
+
// completion carried; fall back to the newest open row only when it is absent (a pre-#806 / out-of-band
|
|
119
|
+
// resume). If an `escalationId` was carried but no OPEN row bears it, this is a redelivered answer for
|
|
120
|
+
// an escalation that has already been retired (or superseded by a newer question) — no-op rather than
|
|
121
|
+
// misfile the answer under a different escalation's question (Copilot review of #806).
|
|
122
|
+
const target = escalationId != null ? open.find((e) => e.id === escalationId) : open[0];
|
|
123
|
+
if (target !== undefined) {
|
|
57
124
|
const ts = new Date().toISOString();
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
125
|
+
// Persist the DURABLE adjudication FIRST, BEFORE the escalation/PR rows transition off `open`
|
|
126
|
+
// (issue #806, Copilot review — crash safety). `recordAdjudication` is INSERT-if-absent idempotent,
|
|
127
|
+
// so if the worker crashes after this write but before the row transitions, a retry re-finds the
|
|
128
|
+
// still-open row and re-records a no-op; whereas recording it LAST would mean a crash in the window
|
|
129
|
+
// after the rows flip loses the adjudication entirely (the retry finds no open row and returns), so
|
|
130
|
+
// the answered question could re-escalate after restart. Scoped to convergence answers only.
|
|
131
|
+
if (isConvergence) {
|
|
132
|
+
const adjudicator = await latestAdjudicator(app, job.processInstanceKey, answer, completedUserTaskKey, completedCompletionId);
|
|
133
|
+
await recordAdjudication(app.data, {
|
|
134
|
+
prKey,
|
|
135
|
+
question: target.question,
|
|
136
|
+
answer,
|
|
137
|
+
adjudicatedBy: adjudicator?.id,
|
|
138
|
+
adjudicatedKind: adjudicator?.kind,
|
|
139
|
+
// Link the decision to the winning completion (issue #806 review) so a later revert of that
|
|
140
|
+
// completion can tombstone it even when it is a FIRST-HAND agent answer with no
|
|
141
|
+
// `source_adjudication_id`. INSERT-if-absent, so it pins to the original first-hand winner.
|
|
142
|
+
sourceCompletionId: completedCompletionId,
|
|
143
|
+
// Run generation this answer belongs to: every adjudication write is fenced on the PR's current
|
|
144
|
+
// `process_key` still matching it, so a pre-reset straggler (one that passed the check above,
|
|
145
|
+
// then paused across a re-submit that advanced `process_key` and cleared the memory) cannot
|
|
146
|
+
// resurrect a stale adjudication after the reset (issue #806, Copilot review). Undefined when the
|
|
147
|
+
// job carries no instance key — the fence then fails open, exactly like the staleness gate above.
|
|
148
|
+
expectedProcessKey: jobProcessKey,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// Fence the escalation/PR transitions on the run generation too (Copilot review of #806). The
|
|
152
|
+
// ownership check above is READ-TIME — not atomic with these writes — so in the window between it
|
|
153
|
+
// and here a concurrent re-submit could advance `process_key` and open a FRESH escalation; a naive
|
|
154
|
+
// `escs.update`/`prs.update` would then answer the new run's snapshot and flip its PR to
|
|
155
|
+
// `converging`. Each write is now a guarded conditional statement carrying the SAME `generationGuard`
|
|
156
|
+
// predicate as the adjudication writes (one guard, no second implementation): it applies only while
|
|
157
|
+
// the PR's current `process_key` still matches this job's generation, so a superseded worker touches
|
|
158
|
+
// nothing. Fails open when `jobProcessKey` is undefined, exactly like the gate above.
|
|
159
|
+
const db = app.data.open();
|
|
160
|
+
const guard = generationGuard(prKey, jobProcessKey);
|
|
161
|
+
await db.exec(
|
|
162
|
+
`UPDATE "escalations" SET "answer" = ?, "status" = 'answered', "answered_at" = ? WHERE "id" = ? AND ${guard.sql}`,
|
|
163
|
+
[answer ?? null, ts, target.id, ...guard.params],
|
|
164
|
+
);
|
|
165
|
+
// Mark any OTHER still-open row `stale` (a `pr.persist-escalation` retry can leave more than one
|
|
166
|
+
// open for the same question). We retire every open row except the one we just answered so a phantom
|
|
167
|
+
// `activePrs` never keeps deriving while the PR is `escalated`.
|
|
168
|
+
for (const dup of open) {
|
|
169
|
+
if (dup.id === target.id) continue;
|
|
170
|
+
await db.exec(
|
|
171
|
+
`UPDATE "escalations" SET "status" = 'stale' WHERE "id" = ? AND ${guard.sql}`,
|
|
172
|
+
[dup.id, ...guard.params],
|
|
173
|
+
);
|
|
65
174
|
}
|
|
66
175
|
// Move the PR off `status="escalated"` back to
|
|
67
176
|
// `"converging"` now that the question is answered. Without this the row stays `escalated` (with
|
|
68
177
|
// a now-null derived `openEscalation`) until the re-entered round's `persist-round` runs — a
|
|
69
178
|
// `/status` inconsistency and a divergence from the merge path both loops are meant to share.
|
|
70
|
-
|
|
71
|
-
|
|
179
|
+
await db.exec(
|
|
180
|
+
`UPDATE "pull_requests" SET "status" = 'converging', "updated_at" = ? WHERE "pr_key" = ? AND ${guard.sql}`,
|
|
181
|
+
[ts, prKey, ...guard.params],
|
|
182
|
+
);
|
|
72
183
|
}
|
|
73
184
|
return {};
|
|
74
185
|
};
|
|
75
186
|
|
|
187
|
+
/** Who just completed this `wait-answer`: the `{ id, kind }` of the `task_completions` row that
|
|
188
|
+
* actually WON the user-task race, so the durable adjudication is attributed to the completion the
|
|
189
|
+
* engine accepted — never a losing racer's transient row NOR an older round's completion. Both
|
|
190
|
+
* canonical completers (`completeUserTaskAttributed`) insert their ledger row BEFORE calling
|
|
191
|
+
* `completeUserTask` and the loser only removes its row AFTER the engine rejects it, so a bare
|
|
192
|
+
* "newest row for this process instance" can transiently select a higher-id LOSER; and because a
|
|
193
|
+
* convergence-loop instance is REUSED across rounds, an older round's completion (or a delayed
|
|
194
|
+
* same-answer redelivery) with a higher id can also linger for the SAME process instance (Copilot
|
|
195
|
+
* review of #806). Correlate on the EXACT completion identity: the resumed token carries the
|
|
196
|
+
* `completedCompletionId` — the ledger id of the exact completion the engine accepted — so the winner
|
|
197
|
+
* is the row with that id, unambiguous even when both racers submitted the IDENTICAL answer (answer
|
|
198
|
+
* correlation alone cannot separate two same-answer rows on one `user_task_key`; the higher-id one may
|
|
199
|
+
* be the loser). A pre-#806-fix out-of-band resume that carried no `completedCompletionId` falls back
|
|
200
|
+
* to the exact `completedUserTaskKey` + winning-answer correlation, and attributes ONLY when that
|
|
201
|
+
* leaves exactly one candidate — an ambiguous same-answer set fails open rather than guess a winner.
|
|
202
|
+
* The KIND (`human`/`agent`, ADR
|
|
203
|
+
* 0046) is preserved so a later auto-resume replays with the ORIGINAL attribution and never launders
|
|
204
|
+
* an agent decision into a human one. `undefined` (fails open to a fresh human task) when the identity
|
|
205
|
+
* is unavailable or no ledger row exactly matches — rather than attribute a wrong one. */
|
|
206
|
+
async function latestAdjudicator(app: Parameters<AppJobHandler<In>>[1], processInstanceKey: unknown, winningAnswer: string | undefined, completedUserTaskKey: string | undefined, completedCompletionId: number | undefined): Promise<{ id: string; kind: string } | undefined> {
|
|
207
|
+
// Exact ledger-id match by PRIMARY KEY. The engine resumed this token with exactly ONE completion's
|
|
208
|
+
// variables, and that completion stamped its own (globally unique) ledger id here — so look the
|
|
209
|
+
// winner up DIRECTLY by primary key, BEFORE the process-instance fallback below. It must NOT be found
|
|
210
|
+
// via a `process_instance_key`-scoped query: the canonical completers (`completeUserTaskAttributed`)
|
|
211
|
+
// resolve the task through the typed `openUserTasks` seam, which deliberately omits `processInstanceKey`
|
|
212
|
+
// (app/service.ts), so EVERY real ledger row is stored with `process_instance_key = null`. A
|
|
213
|
+
// `process_instance_key`-filtered lookup therefore never matched the winning row and always failed
|
|
214
|
+
// open — recording a null adjudicator and letting the convergence poller re-park an already-answered
|
|
215
|
+
// question (Copilot review). Selecting nothing still returns undefined and fails open (never attribute
|
|
216
|
+
// a wrong row).
|
|
217
|
+
if (completedCompletionId != null) {
|
|
218
|
+
const exact = await taskCompletions(app.data).get(completedCompletionId);
|
|
219
|
+
return exact ? { id: exact.actor_id, kind: exact.actor_kind } : undefined;
|
|
220
|
+
}
|
|
221
|
+
// Process-instance fallback for a resume that carried no completion id (a pre-fix / out-of-band
|
|
222
|
+
// token): require the carried user-task identity, then — when the winning answer is known — keep only
|
|
223
|
+
// rows whose recorded answer matches it, dropping the same-task losing racer whose recorded submission
|
|
224
|
+
// differs. Without a completion id there is no evidence WHICH row won, so attribute ONLY when the
|
|
225
|
+
// candidate set is exactly one (Copilot review of #806): more than one same-answer row on this
|
|
226
|
+
// `user_task_key` is the very ambiguity the completion id was added to resolve — picking the newest
|
|
227
|
+
// could attribute to the losing racer — so return undefined and fail open to a human rather than guess.
|
|
228
|
+
const key = processInstanceKey != null ? String(processInstanceKey) : "";
|
|
229
|
+
if (key === "") return undefined;
|
|
230
|
+
const rows = await taskCompletions(app.data).find({ process_instance_key: key });
|
|
231
|
+
if (completedUserTaskKey == null || completedUserTaskKey === "") return undefined;
|
|
232
|
+
let candidates = rows.filter((r) => String(r.user_task_key) === completedUserTaskKey);
|
|
233
|
+
if (winningAnswer != null && winningAnswer !== "") {
|
|
234
|
+
candidates = candidates.filter((r) => completionAnswer(r.variables_json) === winningAnswer);
|
|
235
|
+
}
|
|
236
|
+
if (candidates.length !== 1) return undefined;
|
|
237
|
+
const only = candidates[0];
|
|
238
|
+
return { id: only.actor_id, kind: only.actor_kind };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The trimmed `answer` field recorded in a completion's `variables_json`, or undefined when the JSON
|
|
242
|
+
* is unparseable or carries no string answer. Used to correlate a ledger row with the submission the
|
|
243
|
+
* engine actually accepted (see `latestAdjudicator`). */
|
|
244
|
+
function completionAnswer(variablesJson: unknown): string | undefined {
|
|
245
|
+
if (typeof variablesJson !== "string") return undefined;
|
|
246
|
+
try {
|
|
247
|
+
const parsed: unknown = JSON.parse(variablesJson);
|
|
248
|
+
if (parsed === null || typeof parsed !== "object" || !("answer" in parsed)) return undefined;
|
|
249
|
+
const answer = parsed.answer;
|
|
250
|
+
return typeof answer === "string" && answer.trim() !== "" ? answer.trim() : undefined;
|
|
251
|
+
} catch {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
76
256
|
export default handler;
|