@nanobpm/nano-workforce 0.102.1 → 0.104.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/contracts.ts +8 -0
- package/app/durableResume.test.ts +89 -0
- package/app/durableResume.ts +141 -0
- package/app/feature.test.ts +71 -0
- package/app/feature.ts +42 -0
- package/app/featureReadiness.test.ts +165 -0
- package/app/featureReadiness.ts +151 -0
- package/app/migration052.test.ts +66 -0
- package/app/service.test.ts +33 -1
- package/app/service.ts +23 -5
- package/db/migrations/052_worker_durable_resume.sql +35 -0
- package/e2e/feature-preflight.e2e.ts +191 -0
- package/openapi.yaml +119 -2
- package/operations/enrolAgenticWorker.test.ts +64 -0
- package/operations/enrolAgenticWorker.ts +30 -1
- package/operations/startFeature.ts +29 -1
- package/package.json +1 -1
- package/resources/processes/feature.bpmn +300 -77
- package/test/worldDb.ts +16 -4
package/openapi.yaml
CHANGED
|
@@ -386,6 +386,15 @@ components:
|
|
|
386
386
|
instance:
|
|
387
387
|
type: string
|
|
388
388
|
description: The worker instance id, echoed back for provenance (optional).
|
|
389
|
+
durableResume:
|
|
390
|
+
type: boolean
|
|
391
|
+
description: >-
|
|
392
|
+
Whether this worker's harness advertises durable-resume (issue #325, ADR 0062 Slice 5/5)
|
|
393
|
+
— an ENROLMENT attribute, never a routing token. Recorded per instance so the app emits
|
|
394
|
+
the world-restore marker only to a fleet with a participant; a harness that omits it (or
|
|
395
|
+
sets false) redrives a re-leased round from scratch. Recorded only when `instance` is
|
|
396
|
+
a non-blank string — a missing, empty, or whitespace-only `instance` is echoed back for
|
|
397
|
+
provenance but the flag is not persisted.
|
|
389
398
|
EnrolledRole:
|
|
390
399
|
type: object
|
|
391
400
|
description: One matched role in an enrolment resolution — provenance for the resolved SERVE set.
|
|
@@ -411,6 +420,14 @@ components:
|
|
|
411
420
|
instance:
|
|
412
421
|
type: string
|
|
413
422
|
description: The worker instance id, echoed from the request when supplied.
|
|
423
|
+
durableResume:
|
|
424
|
+
type: boolean
|
|
425
|
+
description: >-
|
|
426
|
+
Echo of the request's durable-resume declaration (issue #325, ADR 0062 Slice 5/5).
|
|
427
|
+
Present only when the request supplied it. This reflects the value the worker sent, not a
|
|
428
|
+
guarantee of durable persistence — recording into the durable-resume registry is
|
|
429
|
+
best-effort (skipped when `instance` is absent/blank, and a registry write hiccup is
|
|
430
|
+
logged without failing enrolment).
|
|
414
431
|
serve:
|
|
415
432
|
type: array
|
|
416
433
|
description: The SERVE token set — sorted, de-duplicated leaf tokens the worker may serve.
|
|
@@ -1153,12 +1170,65 @@ components:
|
|
|
1153
1170
|
Accepted by the schema today but currently has no runtime effect. When implemented it
|
|
1154
1171
|
will be the required acknowledgement when `baseBranch` names the repository default
|
|
1155
1172
|
branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
1173
|
+
ReadinessProbe:
|
|
1174
|
+
description: >-
|
|
1175
|
+
A single durable readiness probe (issue #258, #295) the feature run must satisfy before its
|
|
1176
|
+
implementation agent is dispatched. `kind` selects the source; `target` + `match` are the
|
|
1177
|
+
per-kind predicate. The `capability` kind resolves "which published `pkg@version` first
|
|
1178
|
+
carries capability C?" from publish provenance and late-binds it into the run. See
|
|
1179
|
+
`app/readiness.ts` for the full per-kind semantics.
|
|
1180
|
+
type: object
|
|
1181
|
+
additionalProperties: false
|
|
1182
|
+
required:
|
|
1183
|
+
- kind
|
|
1184
|
+
- target
|
|
1185
|
+
properties:
|
|
1186
|
+
kind:
|
|
1187
|
+
type: string
|
|
1188
|
+
enum: [http, command, npm, github-check, capability]
|
|
1189
|
+
description: The readiness source. `command` is the escape hatch; `capability` resolves a cross-repo published-artifact edge.
|
|
1190
|
+
target:
|
|
1191
|
+
type: string
|
|
1192
|
+
minLength: 1
|
|
1193
|
+
description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, or `github-releases:owner/repo`).
|
|
1194
|
+
onTimeout:
|
|
1195
|
+
type: string
|
|
1196
|
+
enum: [escalate, fail, continue]
|
|
1197
|
+
description: What the gate does when the bounded wait elapses (default `escalate`).
|
|
1198
|
+
credentialEnv:
|
|
1199
|
+
type: string
|
|
1200
|
+
description: A declared env-contract key supplying a credential (http kind only). Names a key, never a secret value.
|
|
1201
|
+
match:
|
|
1202
|
+
type: object
|
|
1203
|
+
additionalProperties: false
|
|
1204
|
+
description: The per-kind readiness predicate; every field is optional and read only by the kinds that understand it.
|
|
1205
|
+
properties:
|
|
1206
|
+
status: { type: integer, description: "http: the exact status that means ready (default any 2xx)." }
|
|
1207
|
+
bodyIncludes: { type: string, description: "http: a substring the response body must contain." }
|
|
1208
|
+
exitCode: { type: integer, description: "command: the exit code that means ready (default 0)." }
|
|
1209
|
+
stdoutIncludes: { type: string, description: "command/npm: a substring stdout must contain." }
|
|
1210
|
+
version: { type: string, description: "npm: the version that must be published." }
|
|
1211
|
+
conclusion: { type: string, description: "github-check: the conclusion that means ready (default success)." }
|
|
1212
|
+
checkName: { type: string, description: "github-check: restrict to the named check run." }
|
|
1213
|
+
capabilityRef: { type: string, description: "capability: the upstream issue/PR handle the resolved version must carry." }
|
|
1214
|
+
package: { type: string, description: "capability: the package whose releases are scanned for provenance." }
|
|
1215
|
+
verifyCommand: { type: string, description: "capability: optional empirical verifier run once at the gate boundary." }
|
|
1216
|
+
poll:
|
|
1217
|
+
type: object
|
|
1218
|
+
additionalProperties: false
|
|
1219
|
+
description: The poll cadence (how often to re-probe, how long to keep trying, and the backoff shape).
|
|
1220
|
+
properties:
|
|
1221
|
+
everyMs: { type: integer, description: Interval between poll attempts (ms). }
|
|
1222
|
+
timeoutMs: { type: integer, description: Bounded budget (ms) before the gate escalates. }
|
|
1223
|
+
backoff: { type: string, enum: [fixed, exponential], description: Backoff shape between attempts. }
|
|
1156
1224
|
FeatureStart:
|
|
1157
1225
|
description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
|
|
1158
1226
|
by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
|
|
1159
1227
|
REQUIRED `baseBranch` (ADR 0003, same admission as the epic path), and the two optional
|
|
1160
|
-
follow-on knobs `converge` / `autoMerge`.
|
|
1161
|
-
|
|
1228
|
+
follow-on knobs `converge` / `autoMerge`. May also carry an intake-time readiness gate
|
|
1229
|
+
(`readiness` and/or `blockedOn` + `consumerPackage`, per issue 295) that parks the run until
|
|
1230
|
+
the declared upstreams land. Modeled as `oneOf` named variants (Camunda REST v2 pattern) so an
|
|
1231
|
+
ambiguous or empty target is a 400 at the edge.
|
|
1162
1232
|
oneOf:
|
|
1163
1233
|
- $ref: "#/components/schemas/FeatureStartByIssue"
|
|
1164
1234
|
- $ref: "#/components/schemas/FeatureStartByUrl"
|
|
@@ -1210,6 +1280,36 @@ components:
|
|
|
1210
1280
|
OPTIONAL free-text steering appended to the implementation agent's prompt for this run
|
|
1211
1281
|
(via the implement task's `appendPrompt`). Blank/whitespace is treated as absent. Persists
|
|
1212
1282
|
on the instance, so it also applies to the agent's answer-loop redispatch.
|
|
1283
|
+
readiness:
|
|
1284
|
+
type: array
|
|
1285
|
+
maxItems: 32
|
|
1286
|
+
items:
|
|
1287
|
+
$ref: "#/components/schemas/ReadinessProbe"
|
|
1288
|
+
description: >-
|
|
1289
|
+
OPTIONAL intake-time readiness gate (issue #295): one or more durable probes the run must
|
|
1290
|
+
ALL satisfy before its implementation agent is dispatched. The run parks (durably, bounded
|
|
1291
|
+
by the gate's escalating timer) at the leading readiness preflight until every probe goes
|
|
1292
|
+
green. Absent/empty ⇒ the run implements immediately, unchanged.
|
|
1293
|
+
blockedOn:
|
|
1294
|
+
type: array
|
|
1295
|
+
maxItems: 32
|
|
1296
|
+
items:
|
|
1297
|
+
type: string
|
|
1298
|
+
minLength: 1
|
|
1299
|
+
description: >-
|
|
1300
|
+
OPTIONAL ergonomic shorthand for `readiness` (issue #295): a list of upstream
|
|
1301
|
+
`owner/repo#123` issue/PR handles the run waits to land. With `consumerPackage` each
|
|
1302
|
+
desugars to a `capability` probe (resolve which published `pkg@version` first carries the
|
|
1303
|
+
handle, and late-bind it into the run); without it, to a `command` probe that goes green
|
|
1304
|
+
once the referenced issue/PR is closed/merged.
|
|
1305
|
+
consumerPackage:
|
|
1306
|
+
type: string
|
|
1307
|
+
minLength: 1
|
|
1308
|
+
description: >-
|
|
1309
|
+
OPTIONAL npm package name (e.g. `@nanobpm/engine-wasm`) the `blockedOn` shorthand resolves
|
|
1310
|
+
its handles against — the consumer dependency whose published provenance must carry each
|
|
1311
|
+
awaited upstream. When present, `blockedOn` desugars to `capability` probes and the
|
|
1312
|
+
resolved `pkg@version` is late-bound into the implementation agent's brief.
|
|
1213
1313
|
FeatureStartByUrl:
|
|
1214
1314
|
type: object
|
|
1215
1315
|
additionalProperties: false
|
|
@@ -1246,6 +1346,23 @@ components:
|
|
|
1246
1346
|
description: >-
|
|
1247
1347
|
OPTIONAL free-text steering appended to the implementation agent's prompt for this run.
|
|
1248
1348
|
See `FeatureStartByIssue.customInstructions`.
|
|
1349
|
+
readiness:
|
|
1350
|
+
type: array
|
|
1351
|
+
maxItems: 32
|
|
1352
|
+
items:
|
|
1353
|
+
$ref: "#/components/schemas/ReadinessProbe"
|
|
1354
|
+
description: OPTIONAL intake-time readiness gate. See `FeatureStartByIssue.readiness`.
|
|
1355
|
+
blockedOn:
|
|
1356
|
+
type: array
|
|
1357
|
+
maxItems: 32
|
|
1358
|
+
items:
|
|
1359
|
+
type: string
|
|
1360
|
+
minLength: 1
|
|
1361
|
+
description: OPTIONAL readiness shorthand — upstream `owner/repo#123` handles to wait on. See `FeatureStartByIssue.blockedOn`.
|
|
1362
|
+
consumerPackage:
|
|
1363
|
+
type: string
|
|
1364
|
+
minLength: 1
|
|
1365
|
+
description: OPTIONAL package the `blockedOn` handles resolve against. See `FeatureStartByIssue.consumerPackage`.
|
|
1249
1366
|
MessageResult:
|
|
1250
1367
|
type: object
|
|
1251
1368
|
description: The result of publishing a message / answering an escalation. Shape varies by message
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import { assert, assertEquals } from "#test-assert";
|
|
4
4
|
import type { AppApi } from "@nanobpm/urban";
|
|
5
|
+
import { memDataFor } from "../test/worldDb.ts";
|
|
6
|
+
import { DurableResumeRegistry } from "../app/durableResume.ts";
|
|
5
7
|
import { noopLog } from "../test/log.ts";
|
|
6
8
|
import handler from "./enrolAgenticWorker.ts";
|
|
7
9
|
|
|
@@ -69,6 +71,68 @@ test("rejects non-finite capability.weight (NaN/Infinity) as 400", async () => {
|
|
|
69
71
|
assertEquals(infWeight.status, 400);
|
|
70
72
|
});
|
|
71
73
|
|
|
74
|
+
// Durable-resume enrolment gate (issue #325, ADR 0062 Slice 5/5).
|
|
75
|
+
test("echoes durableResume back in the result when the worker declares it", async () => {
|
|
76
|
+
const on = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", durableResume: true }), app)) as any;
|
|
77
|
+
assertEquals(on.status, 200);
|
|
78
|
+
assertEquals(on.body.durableResume, true);
|
|
79
|
+
const off = (await handler(input({ capability: { cognition: "decide" }, instance: "w2", durableResume: false }), app)) as any;
|
|
80
|
+
assertEquals(off.body.durableResume, false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("omits durableResume from the result when the worker does not declare it", async () => {
|
|
84
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1" }), app)) as any;
|
|
85
|
+
assertEquals(res.status, 200);
|
|
86
|
+
assertEquals("durableResume" in res.body, false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("rejects a non-boolean durableResume as 400", async () => {
|
|
90
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", durableResume: "yes" }), app)) as any;
|
|
91
|
+
assertEquals(res.status, 400);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("records durable-resume participation in the registry when a data layer + instance are present", async () => {
|
|
95
|
+
const { data } = memDataFor(["052_worker_durable_resume.sql"]);
|
|
96
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
97
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", durableResume: true }), withData)) as any;
|
|
98
|
+
assertEquals(res.status, 200);
|
|
99
|
+
assertEquals(await new DurableResumeRegistry(data).isParticipant("w1"), true);
|
|
100
|
+
assertEquals(await new DurableResumeRegistry(data).anyParticipant(), true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a re-enrol omitting durableResume persists an explicit false, clearing a stale true (degrade to scratch)", async () => {
|
|
104
|
+
const { data } = memDataFor(["052_worker_durable_resume.sql"]);
|
|
105
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
106
|
+
// First enrol advertises durable-resume.
|
|
107
|
+
await handler(input({ capability: { cognition: "decide" }, instance: "w1", durableResume: true }), withData);
|
|
108
|
+
assertEquals(await new DurableResumeRegistry(data).isParticipant("w1"), true);
|
|
109
|
+
// Re-enrol WITHOUT the field (downgrade/rollback/client bug) must clear the stale flag.
|
|
110
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1" }), withData)) as any;
|
|
111
|
+
assertEquals(res.status, 200);
|
|
112
|
+
assertEquals("durableResume" in res.body, false, "still omitted from the echo");
|
|
113
|
+
assertEquals(await new DurableResumeRegistry(data).isParticipant("w1"), false, "stale true cleared");
|
|
114
|
+
assertEquals(await new DurableResumeRegistry(data).anyParticipant(), false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a declaration without an instance is echoed but not persisted (enrolment is per-instance)", async () => {
|
|
118
|
+
const { data } = memDataFor(["052_worker_durable_resume.sql"]);
|
|
119
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
120
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, durableResume: true }), withData)) as any;
|
|
121
|
+
assertEquals(res.status, 200);
|
|
122
|
+
assertEquals(res.body.durableResume, true, "still echoed");
|
|
123
|
+
assertEquals(await new DurableResumeRegistry(data).anyParticipant(), false, "nothing recorded without an instance key");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a blank/whitespace instance is echoed but not persisted (avoids a shared registry-row collision)", async () => {
|
|
127
|
+
const { data } = memDataFor(["052_worker_durable_resume.sql"]);
|
|
128
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
129
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: " ", durableResume: true }), withData)) as any;
|
|
130
|
+
assertEquals(res.status, 200);
|
|
131
|
+
assertEquals(res.body.instance, " ", "still echoed verbatim");
|
|
132
|
+
assertEquals(res.body.durableResume, true, "still echoed");
|
|
133
|
+
assertEquals(await new DurableResumeRegistry(data).anyParticipant(), false, "nothing recorded for a blank instance key");
|
|
134
|
+
});
|
|
135
|
+
|
|
72
136
|
test("enforces the shared secret when NANO_PR_WEBHOOK_SECRET is set", async () => {
|
|
73
137
|
// The module captures the secret at load, so re-import a cache-busted copy with the env var set to
|
|
74
138
|
// exercise the guarded 401 path and the authorized 200 path.
|
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
// NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset → open.
|
|
13
13
|
import type { Capability } from "@nanobpm/agentic/protocol";
|
|
14
14
|
import { resolveEnrolment } from "../app/agentic/vocab/enrol.ts";
|
|
15
|
+
import { DurableResumeRegistry } from "../app/durableResume.ts";
|
|
15
16
|
import { envVar } from "../app/version.ts";
|
|
16
17
|
import type { EnrolResult } from "../nano-generated/api-io.d.ts";
|
|
17
18
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
18
19
|
|
|
19
20
|
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
20
21
|
|
|
21
|
-
export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
|
|
22
|
+
export default defineOperation("enrolAgenticWorker", async ({ req, body }, app) => {
|
|
22
23
|
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
23
24
|
app.log.warn("enrolAgenticWorker rejected: missing/invalid shared secret");
|
|
24
25
|
return { status: 401, body: { error: "unauthorized" } };
|
|
@@ -65,6 +66,13 @@ export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
|
|
|
65
66
|
body: { error: "`capability.weight` must be a finite number when provided" },
|
|
66
67
|
};
|
|
67
68
|
}
|
|
69
|
+
// The durable-resume enrolment attribute (issue #325, ADR 0062 Slice 5/5) — a boolean the harness
|
|
70
|
+
// advertises. A directly-invoked delegate bypasses the OpenAPI runtime validation, so guard the type
|
|
71
|
+
// here (a non-boolean would corrupt the {0,1} enrolment flag the world-restore gate reads).
|
|
72
|
+
if (body.durableResume !== undefined && typeof body.durableResume !== "boolean") {
|
|
73
|
+
app.log.warn("enrolAgenticWorker rejected: non-boolean durableResume");
|
|
74
|
+
return { status: 400, body: { error: "`durableResume` must be a boolean when provided" } };
|
|
75
|
+
}
|
|
68
76
|
|
|
69
77
|
// Fold a top-level `host` into the capability when the capability didn't carry its own — a worker
|
|
70
78
|
// may declare its host either on the capability or beside it (ADR 0059 `{ capability, host }`).
|
|
@@ -74,6 +82,26 @@ export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
|
|
|
74
82
|
: body.capability;
|
|
75
83
|
|
|
76
84
|
const resolved = resolveEnrolment(capability);
|
|
85
|
+
|
|
86
|
+
// Durable-resume enrolment gate (issue #325, ADR 0062 Slice 5/5): record whether this worker's
|
|
87
|
+
// harness advertises durable-resume so the world-restore marker is emitted only to a fleet with a
|
|
88
|
+
// participant. Recorded per instance (ADR 0056 §7 — an enrolment attribute, never a routing token),
|
|
89
|
+
// so it needs a non-blank `instance`; a declaration without one — or with a blank/whitespace
|
|
90
|
+
// string — is echoed but not persisted (a blank key would let unrelated workers collide on the
|
|
91
|
+
// same registry row and wrongly open/close the fleet-wide durable-resume gate). Omission of the
|
|
92
|
+
// field on a re-enrol persists an explicit `false` (degrade to scratch), so a harness that previously
|
|
93
|
+
// advertised durable-resume and later re-enrols without the field clears its stale `true` rather than
|
|
94
|
+
// leaving `fleetSupportsDurableResume()` true indefinitely. Best-effort — the enrolment resolution
|
|
95
|
+
// must not fail on a registry write hiccup.
|
|
96
|
+
const instanceKey = body.instance?.trim();
|
|
97
|
+
if (app.data && instanceKey) {
|
|
98
|
+
try {
|
|
99
|
+
await new DurableResumeRegistry(app.data).recordEnrolment(instanceKey, body.durableResume ?? false);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
app.log.warn("enrolAgenticWorker: durable-resume record failed", { instance: instanceKey, err: String(err) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
77
105
|
const result: EnrolResult = {
|
|
78
106
|
serve: [...resolved.serve],
|
|
79
107
|
roles: resolved.roles.map((role) => {
|
|
@@ -85,6 +113,7 @@ export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
|
|
|
85
113
|
leaseTtl: resolved.leaseTtl,
|
|
86
114
|
};
|
|
87
115
|
if (body.instance !== undefined) result.instance = body.instance;
|
|
116
|
+
if (body.durableResume !== undefined) result.durableResume = body.durableResume;
|
|
88
117
|
|
|
89
118
|
app.log.info("agentic enrol resolved", { instance: body.instance, serve: result.serve, family: capability.family });
|
|
90
119
|
return { status: 200, body: result };
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// confirm-default / shared-base rules, with the same typed-error → HTTP mapping.
|
|
14
14
|
|
|
15
15
|
import { startFeature } from "../app/feature.ts";
|
|
16
|
+
import { parseFeatureReadiness } from "../app/featureReadiness.ts";
|
|
16
17
|
import { BaseBranchMustExistError } from "../app/github.ts";
|
|
17
18
|
import {
|
|
18
19
|
admitPlan,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
parseIssue,
|
|
23
24
|
SharedBaseError,
|
|
24
25
|
} from "../app/plan.ts";
|
|
26
|
+
import type { ReadinessProbe } from "../app/readiness.ts";
|
|
25
27
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
26
28
|
|
|
27
29
|
export default defineOperation("startFeature", async ({ body }, app) => {
|
|
@@ -120,13 +122,39 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
120
122
|
const customInstructions = "customInstructions" in body && typeof body.customInstructions === "string"
|
|
121
123
|
? body.customInstructions
|
|
122
124
|
: null;
|
|
123
|
-
|
|
125
|
+
// Intake-time readiness gate (issue #295): desugar the optional `readiness` descriptors and/or the
|
|
126
|
+
// `blockedOn` shorthand (resolved against `consumerPackage`) into the probes + bound the run parks
|
|
127
|
+
// on before implementing. A malformed gate (bad descriptor, unparseable handle, blank package) is a
|
|
128
|
+
// 400 at the edge — it must never wait forever at runtime.
|
|
129
|
+
let readiness: { probes: ReadinessProbe[]; probeTimeout: string | null };
|
|
130
|
+
try {
|
|
131
|
+
readiness = parseFeatureReadiness({
|
|
132
|
+
readiness: "readiness" in body ? body.readiness : undefined,
|
|
133
|
+
blockedOn: "blockedOn" in body ? body.blockedOn : undefined,
|
|
134
|
+
consumerPackage: "consumerPackage" in body ? body.consumerPackage : undefined,
|
|
135
|
+
});
|
|
136
|
+
} catch (err) {
|
|
137
|
+
const message = err instanceof Error ? err.message : "invalid readiness gate";
|
|
138
|
+
app.log.warn("start-feature rejected: invalid readiness gate", { message });
|
|
139
|
+
return { status: 400, body: { error: message } };
|
|
140
|
+
}
|
|
141
|
+
const result = await startFeature(
|
|
142
|
+
app.data,
|
|
143
|
+
app.engine,
|
|
144
|
+
parsed,
|
|
145
|
+
normalizedBase,
|
|
146
|
+
converge,
|
|
147
|
+
autoMerge,
|
|
148
|
+
customInstructions,
|
|
149
|
+
{ probes: readiness.probes, probeTimeout: readiness.probeTimeout },
|
|
150
|
+
);
|
|
124
151
|
app.log.info("feature run started", {
|
|
125
152
|
featureKey: parsed.planKey,
|
|
126
153
|
requestedBaseBranch: normalizedBase,
|
|
127
154
|
converge,
|
|
128
155
|
autoMerge,
|
|
129
156
|
hasCustomInstructions: typeof customInstructions === "string" && customInstructions.trim() !== "",
|
|
157
|
+
readinessProbes: readiness.probes.length,
|
|
130
158
|
alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
|
|
131
159
|
});
|
|
132
160
|
return { status: 202, body: result };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.104.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",
|