@nanobpm/nano-workforce 0.113.0 → 0.114.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 +14 -0
- package/app/agentCompletion.ts +14 -2
- package/app/convergeGate.test.ts +5 -2
- package/app/deliveryConnector.test.ts +215 -0
- package/app/deliveryConnector.ts +210 -0
- package/app/deliveryGraphCompiler.test.ts +35 -13
- package/app/deliveryGraphCompiler.ts +394 -47
- package/app/deliveryHuman.ts +13 -0
- package/app/deliveryRunner.test.ts +111 -0
- package/app/deliveryRunner.ts +169 -0
- package/app/persist-escalation.test.ts +33 -0
- package/app/scopeGuard.test.ts +38 -0
- package/app/scopeGuard.ts +34 -0
- package/app/userTasks.test.ts +36 -0
- package/app/userTasks.ts +12 -2
- package/db/migrations/055_delivery_connector_dedupe.sql +26 -0
- package/db/migrations/056_escalation_head_override.sql +23 -0
- package/e2e/delivery-graph.e2e.ts +197 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +6 -0
- package/workers/converge-gate/worker.test.ts +116 -0
- package/workers/converge-gate/worker.ts +112 -4
- package/workers/delivery-connector/worker.test.ts +44 -0
- package/workers/delivery-connector/worker.ts +83 -0
- package/workers/persist-escalation/worker.ts +8 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// pr.delivery-connector — the delivery-graph `connector` node's engine-native execution body (ADR 0005
|
|
2
|
+
// slice S4). The service-task half of a compiled `connector` subProcess: it drives the forward-declared
|
|
3
|
+
// connector I/O surface AT-MOST-ONCE per dedupe key, so the engine's at-least-once job delivery (a
|
|
4
|
+
// worker/hub restart, a lost ack, a graph resume) can never double-fire the side effect (Decision 7).
|
|
5
|
+
//
|
|
6
|
+
// All the idempotency logic lives in `app/deliveryConnector.ts` (the durable-fence ledger + claim→act
|
|
7
|
+
// envelope) so it is unit-testable without the engine and shares ONE implementation with any other
|
|
8
|
+
// caller; this worker is the thin engine adapter that resolves the effective dedupe key from the job's
|
|
9
|
+
// author-supplied `dedupeKey` or its stable engine identity (`processInstanceKey:elementId`).
|
|
10
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
11
|
+
import {
|
|
12
|
+
type BoundFact,
|
|
13
|
+
type ConnectorDispatchResult,
|
|
14
|
+
connectorDedupeKey,
|
|
15
|
+
dispatchConnector,
|
|
16
|
+
} from "../../app/deliveryConnector.ts";
|
|
17
|
+
|
|
18
|
+
// Typed off the compiled `connector` subProcess ioMapping (a RUNTIME-generated definition, so there is
|
|
19
|
+
// no static data-envelope to derive from): `target`/`dedupeKey`/`payload` are seeded from the node's
|
|
20
|
+
// config, `boundFacts` is the late-bound list of upstream producers' emitted facts.
|
|
21
|
+
interface In extends Record<string, unknown> {
|
|
22
|
+
target?: string;
|
|
23
|
+
dedupeKey?: string | null;
|
|
24
|
+
payload?: Record<string, unknown> | null;
|
|
25
|
+
boundFacts?: BoundFact[] | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A plain (non-array, non-null) object — the only shape a connector `payload` may take. */
|
|
29
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
30
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Validate + normalise the untyped job variables into the shape the connector surface accepts. Job
|
|
34
|
+
* variables are untyped at runtime, so a misconfigured node (or a hand-seeded instance) could hand us a
|
|
35
|
+
* blank `target`, a scalar `payload`, or a non-array `boundFacts`. Fail CLOSED on a blank `target` (a
|
|
36
|
+
* connector with no destination is meaningless and would write a junk ledger row); coerce a wrong-shaped
|
|
37
|
+
* `payload`/`boundFacts` to `null` (they are optional) and surface the coercion via `warnings` so the
|
|
38
|
+
* caller can log it rather than silently pass garbage into the I/O surface. */
|
|
39
|
+
export function readConnectorInput(vars: In): {
|
|
40
|
+
target: string;
|
|
41
|
+
payload: Record<string, unknown> | null;
|
|
42
|
+
boundFacts: BoundFact[] | null;
|
|
43
|
+
warnings: string[];
|
|
44
|
+
} {
|
|
45
|
+
const target = typeof vars.target === "string" ? vars.target.trim() : "";
|
|
46
|
+
if (!target) throw new Error("delivery-connector: 'target' is required (blank connector target)");
|
|
47
|
+
const warnings: string[] = [];
|
|
48
|
+
let payload: Record<string, unknown> | null = null;
|
|
49
|
+
if (vars.payload != null) {
|
|
50
|
+
if (isPlainObject(vars.payload)) payload = vars.payload;
|
|
51
|
+
else warnings.push("payload is not a plain object — coerced to null");
|
|
52
|
+
}
|
|
53
|
+
let boundFacts: BoundFact[] | null = null;
|
|
54
|
+
if (vars.boundFacts != null) {
|
|
55
|
+
if (Array.isArray(vars.boundFacts)) boundFacts = vars.boundFacts;
|
|
56
|
+
else warnings.push("boundFacts is not an array — coerced to null");
|
|
57
|
+
}
|
|
58
|
+
return { target, payload, boundFacts, warnings };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) => {
|
|
62
|
+
const { target, payload, boundFacts, warnings } = readConnectorInput(job.variables);
|
|
63
|
+
const dedupeKey = connectorDedupeKey({
|
|
64
|
+
dedupeKey: job.variables.dedupeKey ?? null,
|
|
65
|
+
processInstanceKey: job.processInstanceKey ?? null,
|
|
66
|
+
elementId: job.elementId ?? null,
|
|
67
|
+
});
|
|
68
|
+
if (!dedupeKey) {
|
|
69
|
+
// No author key AND no engine identity to derive one — un-dedupable. Fail closed rather than
|
|
70
|
+
// perform a side effect we could not make idempotent (never double-fire).
|
|
71
|
+
throw new Error("delivery-connector: no dedupe key (author-supplied or graph-derived) available");
|
|
72
|
+
}
|
|
73
|
+
for (const w of warnings) app.log.info("delivery-connector: input coerced", { target, dedupeKey, warning: w });
|
|
74
|
+
const result = await dispatchConnector(
|
|
75
|
+
app.data,
|
|
76
|
+
{ dedupeKey, target, payload, boundFacts },
|
|
77
|
+
new Date().toISOString(),
|
|
78
|
+
);
|
|
79
|
+
app.log.info("delivery-connector", { target, dedupeKey, outcome: result.connectorOutcome });
|
|
80
|
+
return result;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export default handler;
|
|
@@ -50,7 +50,7 @@ function workerOf(vars: Record<string, unknown>): string | undefined {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
const handler: AppJobHandler<In> = async (job, app) => {
|
|
53
|
-
const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl } = job.variables;
|
|
53
|
+
const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl, headSha, scopeBlock } = job.variables;
|
|
54
54
|
// `status` drives the escalation kind (control flow); a blank/absent status is an
|
|
55
55
|
// unclassified escalation -> a question needing input. `question` is returned as a
|
|
56
56
|
// process variable below so the downstream `wait-answer` userTask + `pr-escalation.form`
|
|
@@ -116,6 +116,13 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
116
116
|
worker,
|
|
117
117
|
status: "open",
|
|
118
118
|
asked_at: now,
|
|
119
|
+
// Bind a scope-integrity escalation to the reviewed commit (issue #395) so the converge-gate
|
|
120
|
+
// can honour a human answer as an override for THIS HEAD instead of re-deriving the block from
|
|
121
|
+
// the PR body and re-escalating forever. Only the scope-integrity arm passes these; every other
|
|
122
|
+
// arm leaves them absent (→ head_sha NULL, scope_block DEFAULT 0), so the override door opens
|
|
123
|
+
// exclusively for the block the human can actually answer.
|
|
124
|
+
head_sha: headSha,
|
|
125
|
+
scope_block: scopeBlock === true ? 1 : 0,
|
|
119
126
|
});
|
|
120
127
|
await app.data.table("pull_requests", "pr_key").update(prKey, {
|
|
121
128
|
status: "escalated",
|