@nanobpm/nano-workforce 0.178.5 → 0.179.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/openapi.yaml CHANGED
@@ -2084,10 +2084,31 @@ components:
2084
2084
  proposal and launches the retained runner for its previewed graph. Dispatch is an operator
2085
2085
  action — this request carries NO graph and NO token (the graph is already staged; the operator's
2086
2086
  click is the approval).
2087
+
2088
+ Repository provisioning is REQUIRED by default (#729): the run either names EXACTLY the
2089
+ `repository` + `baseBranch` its `agent` nodes implement against, OR opts out with `repoless: true`
2090
+ for a genuinely checkout-less graph — never both, never neither. Modeled as `oneOf` named variants
2091
+ (Camunda REST v2 pattern, as the convergence/plan/feature start bodies are) so the runtime rejects
2092
+ the "neither/nor" and "both" shapes AT THE EDGE with a 400 that names the allowed shapes, rather
2093
+ than the delegate being the only thing that enforces the mutual exclusivity. Dispatching without
2094
+ either would silently share the worker's launch dir across concurrent fan-out agents (the issue
2095
+ #684 field failure).
2096
+ oneOf:
2097
+ - $ref: "#/components/schemas/DeliveryGraphDispatchWithRepository"
2098
+ - $ref: "#/components/schemas/DeliveryGraphDispatchRepoless"
2099
+ DeliveryGraphDispatchWithRepository:
2100
+ description: >-
2101
+ The repository-provisioned dispatch shape (#729): names the `repository` + `baseBranch` the run's
2102
+ `agent` nodes implement against, so the runner seeds the canonical
2103
+ `io.nanobpm.agentTask.repository` isolation envelope onto every agent job. `repoless` is not a
2104
+ member of this variant (`additionalProperties: false`), so supplying it alongside a repository is
2105
+ rejected — the mutual exclusivity the `oneOf` enforces.
2087
2106
  type: object
2088
2107
  additionalProperties: false
2089
2108
  required:
2090
2109
  - digest
2110
+ - repository
2111
+ - baseBranch
2091
2112
  properties:
2092
2113
  digest:
2093
2114
  type: string
@@ -2123,24 +2144,77 @@ components:
2123
2144
  maxLength: 255
2124
2145
  pattern: '^[A-Za-z0-9-]+/(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$'
2125
2146
  description: >-
2126
- OPTIONAL `owner/repo` the run's `agent` nodes implement against (#684/#686). When supplied
2127
- together with `baseBranch`, the runner seeds the canonical `io.nanobpm.agentTask.repository`
2128
- provisioning envelope (`repoEnvelopeVars`) as a run-root process variable so every agent
2129
- node's servicing `senior:*` job gets an ISOLATED throwaway clone instead of inheriting the
2130
- worker's launch dir. Absent no envelope (legacy launch-dir behaviour, unchanged). A value
2131
- that is not exactly `owner/repo` is rejected at submit.
2147
+ The `owner/repo` the run's `agent` nodes implement against (#684/#686). REQUIRED together with
2148
+ `baseBranch` on this variant: the runner seeds the canonical
2149
+ `io.nanobpm.agentTask.repository` provisioning envelope (`repoEnvelopeVars`) as a run-root
2150
+ process variable so every agent node's servicing `senior:*` job gets an ISOLATED throwaway
2151
+ clone instead of inheriting and clobbering the worker's launch dir. A value that is not
2152
+ exactly `owner/repo` is rejected at submit.
2132
2153
  baseBranch:
2133
2154
  type: string
2134
2155
  maxLength: 255
2135
2156
  pattern: '^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$'
2136
2157
  description: >-
2137
- OPTIONAL base branch the run's `agent` nodes branch off (#684/#686) — the `ref` the harness
2138
- checks out in the isolated clone (the PRE-PR shape: each agent cuts its own `feat/<node.id>`
2139
- branch off this base). Only consulted when `repository` is also set; absent no envelope. A
2140
- value that is not a plausible git branch name (whitespace, shell metacharacters, a leading
2141
- `-`, `..`/`//`, a path segment starting with `.` or ending in `.lock`, etc.) is rejected at
2142
- submit. This pattern mirrors the authoritative server-side gate (`isPlausibleBranchName`,
2143
- app/baseBranch.ts) so the documented contract and the door agree.
2158
+ The base branch the run's `agent` nodes branch off (#684/#686) — the `ref` the harness checks
2159
+ out in the isolated clone (the PRE-PR shape: each agent cuts its own `feat/<node.id>` branch
2160
+ off this base). REQUIRED together with `repository` on this variant. A value that is not a
2161
+ plausible git branch name (whitespace, shell metacharacters, a leading `-`, `..`/`//`, a path
2162
+ segment starting with `.` or ending in `.lock`, etc.) is rejected at submit. This pattern
2163
+ mirrors the authoritative server-side gate (`isPlausibleBranchName`, app/baseBranch.ts) so the
2164
+ documented contract and the door agree.
2165
+ DeliveryGraphDispatchRepoless:
2166
+ description: >-
2167
+ The checkout-less dispatch shape (#729): an EXPLICIT `repoless: true` opt-out of repository
2168
+ provisioning for a genuinely repo-less graph (e.g. one whose nodes touch no repository). NO
2169
+ isolation envelope is seeded — the legacy launch-dir behaviour, but now a CONSCIOUS choice rather
2170
+ than a silent fallback. `repository`/`baseBranch` are not members of this variant
2171
+ (`additionalProperties: false`), so supplying either alongside `repoless: true` is rejected — the
2172
+ mutual exclusivity the `oneOf` enforces.
2173
+ type: object
2174
+ additionalProperties: false
2175
+ required:
2176
+ - digest
2177
+ - repoless
2178
+ properties:
2179
+ digest:
2180
+ type: string
2181
+ description: The staged proposal's content digest (its primary key) — the proposal to dispatch.
2182
+ idempotencyKey:
2183
+ type: string
2184
+ maxLength: 255
2185
+ description: OPTIONAL idempotency key. A re-dispatch with the same key (or, when omitted, the same digest) does not double-launch. Blank/whitespace is treated as absent.
2186
+ nodeTimeout:
2187
+ type: string
2188
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
2189
+ maxLength: 64
2190
+ description: >-
2191
+ OPTIONAL run-level ISO-8601 SLA timeout for `agent`/`connector` nodes (#505) — the
2192
+ bounded-timeout → escalate boundary bound every such node inherits unless it declares its own
2193
+ per-node `timeout`. Absent → the `PT1H` default. An invalid duration is rejected at submit.
2194
+ probeTimeout:
2195
+ type: string
2196
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
2197
+ maxLength: 64
2198
+ description: >-
2199
+ OPTIONAL run-level ISO-8601 poll budget for `wait` gates (#505) before they escalate. Absent →
2200
+ the `PT30M` default. An invalid duration is rejected at submit.
2201
+ escalationSlaTimeout:
2202
+ type: string
2203
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
2204
+ maxLength: 64
2205
+ description: >-
2206
+ OPTIONAL run-level ISO-8601 SLA for `human` nodes (#505) before they record an `escalated`
2207
+ outcome. Absent → the `P1D` default. An invalid duration is rejected at submit.
2208
+ repoless:
2209
+ type: boolean
2210
+ enum: [true]
2211
+ description: >-
2212
+ EXPLICIT opt-out of repository provisioning (#729). MUST be `true` on this variant — it
2213
+ dispatches a genuinely checkout-less graph with NO isolation envelope (the legacy launch-dir
2214
+ behaviour, now a CONSCIOUS choice). Mutually exclusive with `repository`/`baseBranch`: those
2215
+ fields are not members of this variant, so supplying either alongside `repoless: true` fails
2216
+ `oneOf` matching and is a 400. To provision a repository, use the repository variant (supply
2217
+ `repository` + `baseBranch`) and omit `repoless` entirely.
2144
2218
  DeliveryGraphDismissRequest:
2145
2219
  description: >-
2146
2220
  The OPERATOR dismiss request (#520). The cockpit's staged-proposals grid posts the content
@@ -50,7 +50,10 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
50
50
  test("a missing/blank digest → 400 with a human error, nothing launched", async () => {
51
51
  const app = await boot();
52
52
  assert.ok(app.api);
53
- const res = await app.api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", { body: { digest: " " } });
53
+ // Carry a valid `repoless: true` variant so the body clears the edge `oneOf` and reaches the door's
54
+ // own blank-digest guard (the concern under test) rather than being rejected earlier as a shapeless
55
+ // request — a blank digest is still a clean 400 with a human error, launching nothing.
56
+ const res = await app.api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", { body: { digest: " ", repoless: true } });
54
57
  assert.equal(res.status, 400);
55
58
  assert.equal(res.body.ok, false);
56
59
  assert.ok(typeof res.body.error === "string" && res.body.error.length > 0);
@@ -60,7 +63,7 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
60
63
  test("an unknown / never-staged digest → 400, nothing launched", async () => {
61
64
  const app = await boot();
62
65
  assert.ok(app.api);
63
- const res = await app.api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", { body: { digest: "deadbeef0000" } });
66
+ const res = await app.api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", { body: { digest: "deadbeef0000", repoless: true } });
64
67
  assert.equal(res.status, 400);
65
68
  assert.equal(res.body.ok, false);
66
69
  assert.ok(/no staged proposal/.test(res.body.error ?? ""));
@@ -82,7 +85,7 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
82
85
  // The operator dispatches that digest.
83
86
  const res = await api.call<{ ok: boolean; status: string; runKey: string; alreadyRunning?: boolean }>(
84
87
  "dispatchDeliveryGraph",
85
- { body: { digest } },
88
+ { body: { digest, repoless: true } },
86
89
  );
87
90
  assert.equal(res.status, 202);
88
91
  assert.equal(res.body.ok, true);
@@ -107,7 +110,7 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
107
110
 
108
111
  const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
109
112
  const res = await api.call<{ ok: boolean; status: string; sideEffecting: boolean }>("dispatchDeliveryGraph", {
110
- body: { digest: staged.body.digest },
113
+ body: { digest: staged.body.digest, repoless: true },
111
114
  });
112
115
  assert.equal(res.status, 202);
113
116
  assert.equal(res.body.ok, true);
@@ -122,9 +125,9 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
122
125
  assert.ok(app.api);
123
126
  const api = app.api;
124
127
  const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
125
- await api.call("dispatchDeliveryGraph", { body: { digest: staged.body.digest } });
128
+ await api.call("dispatchDeliveryGraph", { body: { digest: staged.body.digest, repoless: true } });
126
129
  await app.settle();
127
- const again = await api.call<{ ok: boolean }>("dispatchDeliveryGraph", { body: { digest: staged.body.digest } });
130
+ const again = await api.call<{ ok: boolean }>("dispatchDeliveryGraph", { body: { digest: staged.body.digest, repoless: true } });
128
131
  assert.equal(again.status, 400);
129
132
  assert.equal(again.body.ok, false);
130
133
  // Still exactly one run — the consumed proposal cannot re-launch.
@@ -143,7 +146,7 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
143
146
 
144
147
  // Dispatch graph A under a shared idempotencyKey — it launches and stays running (parks on a human).
145
148
  const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
146
- body: { digest: a.body.digest, idempotencyKey: "shared-key" },
149
+ body: { digest: a.body.digest, idempotencyKey: "shared-key", repoless: true },
147
150
  });
148
151
  assert.equal(first.status, 202);
149
152
  await app.settle();
@@ -151,7 +154,7 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
151
154
  // Dispatch graph B under the SAME idempotencyKey — it short-circuits onto A's run. B's graph was
152
155
  // never launched, so B must NOT be consumed: refuse with 409 and leave B staged.
153
156
  const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
154
- body: { digest: b.body.digest, idempotencyKey: "shared-key" },
157
+ body: { digest: b.body.digest, idempotencyKey: "shared-key", repoless: true },
155
158
  });
156
159
  assert.equal(second.status, 409);
157
160
  assert.equal(second.body.ok, false);
@@ -174,7 +177,7 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
174
177
  assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "staged");
175
178
  await deliveryGraphProposals(app.db).update(digest, { graph: "{not-json" });
176
179
 
177
- const res = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", { body: { digest } });
180
+ const res = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", { body: { digest, repoless: true } });
178
181
  assert.equal(res.status, 400);
179
182
  assert.equal(res.body.ok, false);
180
183
  assert.ok(/corrupt/.test(res.body.error ?? ""));
@@ -238,7 +241,7 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
238
241
  const api = app.api;
239
242
  const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
240
243
  const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
241
- body: { digest: staged.body.digest, nodeTimeout: "PT2H" },
244
+ body: { digest: staged.body.digest, nodeTimeout: "PT2H", repoless: true },
242
245
  });
243
246
  assert.equal(res.status, 202);
244
247
  assert.equal(res.body.ok, true);
@@ -309,4 +312,127 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
309
312
  assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
310
313
  assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
311
314
  });
315
+
316
+ // Issue #729: the fan-out dispatch door must REQUIRE repository provisioning — either a resolvable
317
+ // `repository` + `baseBranch`, or an EXPLICIT `repoless: true` opt-out — so it can never silently
318
+ // dispatch envelope-less and let concurrent fan-out agents share (and clobber) the worker's launch
319
+ // dir (issue #684's field failure re-opened as a silent fallback). This "neither/nor" shape is now
320
+ // rejected at the EDGE by the request `oneOf` (the two allowed variants), not only inside the door.
321
+ test("a dispatch with NEITHER repository/baseBranch NOR repoless is rejected at submit → 400, nothing launched (#729)", async () => {
322
+ const app = await boot();
323
+ assert.ok(app.api);
324
+ const api = app.api;
325
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
326
+ const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
327
+ body: { digest: staged.body.digest },
328
+ });
329
+ assert.equal(res.status, 400);
330
+ // The `oneOf` edge validator names the two allowed shapes — the neither/nor body matches neither.
331
+ assert.ok(typeof res.body.error === "string" && res.body.error.length > 0);
332
+ // Loud, not silent: nothing launched and the proposal stays staged (re-dispatchable once a repo /
333
+ // repoless choice is supplied).
334
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
335
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
336
+ });
337
+
338
+ test("a dispatch with ONLY repository (no baseBranch) is rejected → 400, nothing launched (#729)", async () => {
339
+ const app = await boot();
340
+ assert.ok(app.api);
341
+ const api = app.api;
342
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
343
+ const res = await api.call<{ ok?: boolean }>("dispatchDeliveryGraph", {
344
+ body: { digest: staged.body.digest, repository: "owner/repo" },
345
+ });
346
+ assert.equal(res.status, 400);
347
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
348
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
349
+ });
350
+
351
+ test("a dispatch with ONLY baseBranch (no repository) is rejected → 400, nothing launched (#729)", async () => {
352
+ const app = await boot();
353
+ assert.ok(app.api);
354
+ const api = app.api;
355
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
356
+ const res = await api.call<{ ok?: boolean }>("dispatchDeliveryGraph", {
357
+ body: { digest: staged.body.digest, baseBranch: "main" },
358
+ });
359
+ assert.equal(res.status, 400);
360
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
361
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
362
+ });
363
+
364
+ test("an EXPLICIT repoless: true dispatches a checkout-less graph → 202 running (#729)", async () => {
365
+ const app = await boot();
366
+ assert.ok(app.api);
367
+ const api = app.api;
368
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
369
+ const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
370
+ body: { digest: staged.body.digest, repoless: true },
371
+ });
372
+ assert.equal(res.status, 202);
373
+ assert.equal(res.body.ok, true);
374
+ assert.equal(res.body.status, "running");
375
+ await app.settle();
376
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
377
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
378
+ });
379
+
380
+ test("repoless: true is mutually exclusive with repository/baseBranch → 400, nothing launched (#729)", async () => {
381
+ const app = await boot();
382
+ assert.ok(app.api);
383
+ const api = app.api;
384
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
385
+ const res = await api.call<{ ok?: boolean }>("dispatchDeliveryGraph", {
386
+ body: { digest: staged.body.digest, repoless: true, repository: "owner/repo", baseBranch: "main" },
387
+ });
388
+ assert.equal(res.status, 400);
389
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
390
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
391
+ });
392
+
393
+ test("a non-boolean repoless is rejected at submit → 400, nothing launched (#729)", async () => {
394
+ const app = await boot();
395
+ assert.ok(app.api);
396
+ const api = app.api;
397
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
398
+ const res = await api.call<{ ok?: boolean }>("dispatchDeliveryGraph", {
399
+ body: { digest: staged.body.digest, repoless: "yes" },
400
+ });
401
+ assert.equal(res.status, 400);
402
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
403
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
404
+ });
405
+
406
+ // `repoless` is a `true`-only opt-out (OpenAPI `oneOf`: `enum: [true]` on the repoless variant, absent
407
+ // from the repository variant with `additionalProperties: false`). An explicit `repoless: false` — with
408
+ // OR without a repository envelope — must be a clean 400 at the contract boundary, never silently folded
409
+ // into the "not repoless" path. The edge schema rejects it end-to-end here; the door's own guard
410
+ // (dispatchDeliveryGraph.ts) mirrors it as defense-in-depth for any non-schema-validated caller.
411
+ test("an explicit repoless: false (bare) is rejected at submit → 400, nothing launched (#729)", async () => {
412
+ const app = await boot();
413
+ assert.ok(app.api);
414
+ const api = app.api;
415
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
416
+ const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
417
+ body: { digest: staged.body.digest, repoless: false },
418
+ });
419
+ assert.equal(res.status, 400);
420
+ assert.ok(typeof res.body.error === "string" && res.body.error.length > 0);
421
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
422
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
423
+ });
424
+
425
+ test("an explicit repoless: false alongside a repository envelope is rejected → 400, nothing launched (#729)", async () => {
426
+ const app = await boot();
427
+ assert.ok(app.api);
428
+ const api = app.api;
429
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
430
+ const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
431
+ body: { digest: staged.body.digest, repository: "owner/repo", baseBranch: "main", repoless: false },
432
+ });
433
+ assert.equal(res.status, 400);
434
+ assert.ok(typeof res.body.error === "string" && res.body.error.length > 0);
435
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
436
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
437
+ });
312
438
  });
@@ -108,6 +108,54 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
108
108
  baseBranch = baseRaw;
109
109
  }
110
110
 
111
+ // Repository provisioning is REQUIRED on this fan-out door (issue #729): a delivery-graph run whose
112
+ // `agent` nodes implement against a repo MUST be dispatched with a resolvable `repository` + base
113
+ // branch so every `senior:*` job provisions an ISOLATED clone. Silently dispatching envelope-less
114
+ // (issue #684's field failure, re-opened as a silent fallback) let concurrent fan-out workers on one
115
+ // host share — and clobber — a single launch-dir checkout. So the operator must EITHER supply BOTH
116
+ // `repository` and `baseBranch`, OR explicitly opt out with `repoless: true` for a genuinely
117
+ // checkout-less graph — the default can never silently share a checkout.
118
+ const hasRepoless = body !== null && typeof body === "object" && "repoless" in body;
119
+ const repolessRaw = hasRepoless ? body.repoless : undefined;
120
+ // `repoless` is a `true`-ONLY opt-out, exactly as the OpenAPI `oneOf` models it: it is `enum: [true]`
121
+ // on the repoless variant and NOT a member of the repository variant (`additionalProperties: false`).
122
+ // So reject any present-but-not-`true` value — `false`, `"yes"`, `0`, … — rather than silently folding
123
+ // `repoless: false` into the "not repoless" path. Otherwise a caller that bypasses schema validation
124
+ // could send `{ digest, repository, baseBranch, repoless: false }` (or `{ digest, repoless: false }`)
125
+ // and slip through with a confusing tri-state the `oneOf` contract never permits.
126
+ if (hasRepoless && repolessRaw !== true) {
127
+ app.log.warn("dispatch-delivery-graph rejected: repoless is a true-only opt-out", { type: typeof repolessRaw });
128
+ return {
129
+ status: 400,
130
+ body: {
131
+ ok: false,
132
+ error: "`repoless` is a `true`-only opt-out — omit it to provision a repository (with `repository` + `baseBranch`), or set `repoless: true` to dispatch a checkout-less graph",
133
+ },
134
+ };
135
+ }
136
+ const repoless = repolessRaw === true;
137
+ if (repoless && (repository !== undefined || baseBranch !== undefined)) {
138
+ app.log.warn("dispatch-delivery-graph rejected: repoless with repository/baseBranch");
139
+ return {
140
+ status: 400,
141
+ body: { ok: false, error: "`repoless: true` is mutually exclusive with `repository`/`baseBranch` — pass one or the other, not both" },
142
+ };
143
+ }
144
+ if (!repoless && (repository === undefined || baseBranch === undefined)) {
145
+ app.log.warn("dispatch-delivery-graph rejected: missing repository/baseBranch (no repoless opt-in)", {
146
+ hasRepository: repository !== undefined,
147
+ hasBaseBranch: baseBranch !== undefined,
148
+ });
149
+ return {
150
+ status: 400,
151
+ body: {
152
+ ok: false,
153
+ error:
154
+ "a delivery-graph dispatch must provision an isolated checkout: supply BOTH `repository` (`owner/repo`) and `baseBranch`, or set `repoless: true` to dispatch a checkout-less graph — dispatching without either would silently share the worker's launch dir across agents (issue #729)",
155
+ },
156
+ };
157
+ }
158
+
111
159
  // Load the live staged proposal for this digest — refuses an unknown/expired/superseded/already-
112
160
  // dispatched digest cleanly (no run is launched).
113
161
  const proposal = await getStagedProposal(app.data, digest);
@@ -131,7 +179,7 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
131
179
  return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
132
180
  }
133
181
 
134
- const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, repository, baseBranch, ...timeouts });
182
+ const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, repository, baseBranch, repoless, ...timeouts });
135
183
  if (!dispatched.ok) {
136
184
  app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
137
185
  const outBody: DeliveryGraphTextResult = {
@@ -115,7 +115,7 @@ describe("listStagedProposals — the live staged-proposals list", () => {
115
115
  const digest = staged.body.digest;
116
116
  assert.equal((await api.call<ListResponse>("listStagedProposals", {})).body.count, 1);
117
117
 
118
- const dispatched = await api.call<{ ok: boolean }>("dispatchDeliveryGraph", { body: { digest } });
118
+ const dispatched = await api.call<{ ok: boolean }>("dispatchDeliveryGraph", { body: { digest, repoless: true } });
119
119
  assert.equal(dispatched.body.ok, true);
120
120
 
121
121
  const after = await api.call<ListResponse>("listStagedProposals", {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.178.5",
3
+ "version": "0.179.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",
@@ -206,6 +206,33 @@
206
206
  min-width: 200px;
207
207
  }
208
208
 
209
+ /* The inline Dispatch envelope fields (issue #729): the operator supplies `repository` + `baseBranch`
210
+ (or ticks "checkout-less") before confirming a dispatch, since a staged proposal carries no repo
211
+ metadata. A disabled repo/base input (dimmed) reads as inactive while checkout-less is ticked. */
212
+ .dg .confirm-field {
213
+ display: inline-flex;
214
+ align-items: center;
215
+ gap: 6px;
216
+ color: #d7e0ea;
217
+ font-size: 13px;
218
+ }
219
+
220
+ .dg .confirm-field .confirm-input {
221
+ min-width: 160px;
222
+ }
223
+
224
+ .dg .confirm-input:disabled {
225
+ opacity: 0.5;
226
+ }
227
+
228
+ .dg .confirm-check {
229
+ display: inline-flex;
230
+ align-items: center;
231
+ gap: 6px;
232
+ color: #d7e0ea;
233
+ font-size: 13px;
234
+ }
235
+
209
236
  /* The filesystem Import control (issue #524): a <label class="btn"> wrapping a visually-hidden native
210
237
  <input type=file>, so the file picker reads as a button consistent with the other actions. The label
211
238
  dims while its input is disabled (busy) via :has(), mirroring .btn:disabled. */
@@ -3,8 +3,10 @@
3
3
  // has staged: it lists every LIVE staged proposal and, per row, offers
4
4
  // • Preview DI — recompile the proposal's BPMN (with diagram interchange) and hand it UP to the host
5
5
  // console's process explorer over the `nano-navigate` bridge, rendered read-only BEFORE dispatch;
6
- // • Dispatch — the operator's launch action (#460): POST the proposal's `digest` to the dispatch
7
- // door. Clicking Dispatch IS the approval, content-addressed to exactly the graph previewed.
6
+ // • Dispatch — the operator's launch action (#460): POST the proposal's `digest` plus the
7
+ // repository-isolation envelope (`repository` + `baseBranch`, or an explicit `repoless` opt-out,
8
+ // #729) the operator supplies inline — to the dispatch door. Clicking Dispatch IS the approval,
9
+ // content-addressed to exactly the graph previewed.
8
10
  //
9
11
  // It REPLACES the old declarative `dataGrid` (a grid row-action can POST but cannot take the recompiled
10
12
  // BPMN and `postMessage` it to the explorer — so a staged proposal had a Dispatch button but no way to
@@ -97,10 +99,23 @@ function isPending(pending, kind, digest) {
97
99
  // The Dispatch affordance for a row: normally a single button; while its inline confirmation is open,
98
100
  // an in-DOM "Confirm dispatch / Cancel" pair (NOT a native window.confirm, suppressed in the sandboxed
99
101
  // App-View iframe — #569). Clicking "Confirm dispatch" IS the operator approval (#460).
102
+ //
103
+ // The dispatch door (issue #729) now REQUIRES a repository-isolation envelope: the operator must supply
104
+ // BOTH `repository` (`owner/repo`) and `baseBranch`, OR explicitly opt out with `repoless` for a
105
+ // checkout-less graph — dispatching with neither would silently share the worker's launch dir across
106
+ // agents. A staged proposal carries no repository metadata, so the operator provides it HERE at
107
+ // dispatch time via the inline fields below. Ticking "checkout-less" disables the repo/base inputs.
100
108
  function dispatchControl(p, pending) {
101
109
  if (isPending(pending, "dispatch", p.digest)) {
110
+ const repository = typeof pending.repository === "string" ? pending.repository : "";
111
+ const baseBranch = typeof pending.baseBranch === "string" ? pending.baseBranch : "";
112
+ const repoless = pending.repoless === true;
113
+ const provDisabled = repoless ? " disabled" : "";
102
114
  return `<span class="confirm" data-confirm="dispatch">
103
115
  <span class="confirm-msg">${esc(DISPATCH_CONFIRM)}</span>
116
+ <label class="confirm-field">Repository <input class="confirm-input" type="text" data-dispatch-repository="${esc(p.digest)}" value="${esc(repository)}" placeholder="owner/repo" aria-label="Repository (owner/repo)"${provDisabled} /></label>
117
+ <label class="confirm-field">Base branch <input class="confirm-input" type="text" data-dispatch-base="${esc(p.digest)}" value="${esc(baseBranch)}" placeholder="main" aria-label="Base branch"${provDisabled} /></label>
118
+ <label class="confirm-check"><input type="checkbox" data-dispatch-repoless="${esc(p.digest)}"${repoless ? " checked" : ""} /> Dispatch checkout-less (no repository)</label>
104
119
  <button class="btn btn-primary" type="button" data-dispatch-confirm="${esc(p.digest)}">Confirm dispatch</button>
105
120
  <button class="btn btn-ghost" type="button" data-dispatch-cancel="${esc(p.digest)}">Cancel</button>
106
121
  </span>`;
@@ -230,9 +245,11 @@ export function mountStagedProposals(host, config = {}) {
230
245
  // click can re-render the SAME list synchronously (without waiting for the next poll).
231
246
  let currentProposals = [];
232
247
  // The single open in-DOM confirmation, or null. Shape: { kind: "dispatch"|"dismiss"|"save",
233
- // digest: string, name?: string }. This REPLACES the native window.confirm/window.prompt the console's
234
- // sandboxed App-View iframe suppresses (#569): the operator approval is an inline two-step control
235
- // (Confirm/Cancel) or an inline name input, rendered by renderProposal from this state.
248
+ // digest: string, name?: string, repository?: string, baseBranch?: string, repoless?: boolean }. This
249
+ // REPLACES the native window.confirm/window.prompt the console's sandboxed App-View iframe suppresses
250
+ // (#569): the operator approval is an inline two-step control (Confirm/Cancel), an inline name input
251
+ // (Save), or — for Dispatch — the repository/baseBranch/repoless envelope fields (#729), all rendered
252
+ // by renderProposal from this state so a background poll re-render preserves what the operator typed.
236
253
  let pending = null;
237
254
  // A re-render (renderList → new buttons) resets every button to enabled, so the disabled state is
238
255
  // NOT stored on the elements — it is derived from busyCount and re-applied after each render (below)
@@ -352,13 +369,26 @@ export function mountStagedProposals(host, config = {}) {
352
369
  // the click handler, #569) — by the time we're here the operator has clicked "Confirm dispatch", so we
353
370
  // POST the digest to the dispatch door; on success the proposal flips to `dispatched` and drops off
354
371
  // the list on the next poll — refresh immediately so the operator sees it leave.
355
- async function doDispatch(digest) {
372
+ async function doDispatch(digest, opts) {
356
373
  const staged = typeof digest === "string" ? digest.trim() : "";
357
374
  if (staged === "") return;
375
+ const options = opts && typeof opts === "object" ? opts : {};
376
+ const repoless = options.repoless === true;
377
+ const repository = typeof options.repository === "string" ? options.repository.trim() : "";
378
+ const baseBranch = typeof options.baseBranch === "string" ? options.baseBranch.trim() : "";
379
+ // The dispatch door (issue #729) requires an isolation envelope: BOTH `repository` + `baseBranch`,
380
+ // or an explicit `repoless: true` opt-out. Guard here so the operator gets an inline hint instead of
381
+ // a bare 400 from the door (the confirm has already been closed by the click handler on a valid one).
382
+ if (!repoless && (repository === "" || baseBranch === "")) {
383
+ setStatus("Provide both a repository (owner/repo) and a base branch, or tick “Dispatch checkout-less”.", "err");
384
+ return;
385
+ }
386
+ // Mirror the door's mutual-exclusivity contract: pass EITHER the repo envelope OR `repoless`, never both.
387
+ const payload = repoless ? { digest: staged, repoless: true } : { digest: staged, repository, baseBranch };
358
388
  busy(true);
359
389
  setStatus("Dispatching…");
360
390
  try {
361
- const { status, body } = await post(dispatchUrl, { digest: staged });
391
+ const { status, body } = await post(dispatchUrl, payload);
362
392
  if ((status === 202 || status === 200) && body.ok) {
363
393
  setStatus("\u2713 Dispatched — the run is now in flight.", "ok");
364
394
  await refresh();
@@ -431,7 +461,7 @@ export function mountStagedProposals(host, config = {}) {
431
461
  function openConfirm(kind, digest, name) {
432
462
  const staged = typeof digest === "string" ? digest.trim() : "";
433
463
  if (staged === "") return;
434
- pending = { kind, digest: staged, name: typeof name === "string" ? name : "" };
464
+ pending = { kind, digest: staged, name: typeof name === "string" ? name : "", repository: "", baseBranch: "", repoless: false };
435
465
  rerender();
436
466
  // Move focus into the name input so the operator can type immediately (best-effort; not all hosts
437
467
  // implement focus()).
@@ -459,12 +489,49 @@ export function mountStagedProposals(host, config = {}) {
459
489
  return input && typeof input.value === "string" ? input.value : "";
460
490
  }
461
491
 
462
- // Keep pending.name in sync as the operator types, so a background poll re-render (or a later confirm)
463
- // preserves what they've entered.
492
+ // Read the current text of an inline Dispatch envelope field (`repository`/`base`) for `digest`.
493
+ function readDispatchField(digest, which) {
494
+ const attr = which === "repository" ? "data-dispatch-repository" : "data-dispatch-base";
495
+ const input = listEl.querySelector(`[${attr}="${cssAttr(digest)}"]`);
496
+ return input && typeof input.value === "string" ? input.value : "";
497
+ }
498
+
499
+ // Read the current state of the inline "Dispatch checkout-less" checkbox for `digest`.
500
+ function readRepoless(digest) {
501
+ const box = listEl.querySelector(`[data-dispatch-repoless="${cssAttr(digest)}"]`);
502
+ return !!(box && box.checked);
503
+ }
504
+
505
+ // Keep pending in sync as the operator types, so a background poll re-render (or a later confirm)
506
+ // preserves what they've entered — the library name (Save) and the repo/base envelope fields (Dispatch).
464
507
  listEl.addEventListener("input", (ev) => {
465
- const input = ev.target && ev.target.closest ? ev.target.closest("[data-library-name]") : null;
466
- if (input && pending != null && pending.kind === "save") {
467
- pending.name = typeof input.value === "string" ? input.value : "";
508
+ const target = ev.target && ev.target.closest ? ev.target : null;
509
+ if (target == null || pending == null) return;
510
+ if (pending.kind === "save") {
511
+ const input = target.closest("[data-library-name]");
512
+ if (input) pending.name = typeof input.value === "string" ? input.value : "";
513
+ return;
514
+ }
515
+ if (pending.kind === "dispatch") {
516
+ const repoInput = target.closest("[data-dispatch-repository]");
517
+ if (repoInput) {
518
+ pending.repository = typeof repoInput.value === "string" ? repoInput.value : "";
519
+ return;
520
+ }
521
+ const baseInput = target.closest("[data-dispatch-base]");
522
+ if (baseInput) pending.baseBranch = typeof baseInput.value === "string" ? baseInput.value : "";
523
+ }
524
+ });
525
+
526
+ // The "Dispatch checkout-less" checkbox toggles the repo/base inputs (disabled when checkout-less), so
527
+ // re-render on change — first hoisting the live text values into pending so the toggle doesn't drop them.
528
+ listEl.addEventListener("change", (ev) => {
529
+ const box = ev.target && ev.target.closest ? ev.target.closest("[data-dispatch-repoless]") : null;
530
+ if (box && pending != null && pending.kind === "dispatch") {
531
+ pending.repository = readDispatchField(pending.digest, "repository");
532
+ pending.baseBranch = readDispatchField(pending.digest, "base");
533
+ pending.repoless = !!box.checked;
534
+ rerender();
468
535
  }
469
536
  });
470
537
 
@@ -490,8 +557,20 @@ export function mountStagedProposals(host, config = {}) {
490
557
  if (dispatchConfirmBtn) {
491
558
  ev.preventDefault();
492
559
  const digest = dispatchConfirmBtn.getAttribute("data-dispatch-confirm");
560
+ const isThisPending = pending != null && pending.kind === "dispatch" && pending.digest === digest;
561
+ // `pending.repoless` is the durable source of truth (synced on the checkbox `change`); fall back to
562
+ // the live DOM checkbox so a confirm without a prior toggle still reads correctly.
563
+ const repoless = readRepoless(digest) || (isThisPending && pending.repoless === true);
564
+ const repository = readDispatchField(digest, "repository").trim();
565
+ const baseBranch = readDispatchField(digest, "base").trim();
566
+ // Keep the inline confirmation OPEN on an incomplete envelope so the operator can fix it in place
567
+ // (issue #729) — closing it would drop the fields they'd started filling.
568
+ if (!repoless && (repository === "" || baseBranch === "")) {
569
+ setStatus("Provide both a repository (owner/repo) and a base branch, or tick “Dispatch checkout-less”.", "err");
570
+ return;
571
+ }
493
572
  closeConfirm();
494
- doDispatch(digest);
573
+ doDispatch(digest, { repository, baseBranch, repoless });
495
574
  return;
496
575
  }
497
576
  const dispatchCancelBtn = closest("[data-dispatch-cancel]");