@nanobpm/nano-workforce 0.178.4 → 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/.github/workflows/release.yml +34 -0
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraphDeploy.test.ts +7 -7
- package/app/deliveryGraphDispatch.test.ts +6 -6
- package/app/deliveryGraphDispatch.ts +7 -4
- package/app/deliveryRunner.test.ts +49 -15
- package/app/deliveryRunner.ts +52 -23
- package/app/feature.ts +7 -4
- package/app/plan.ts +6 -3
- package/app/repoEnvelope.ts +75 -4
- package/e2e/delivery-graph-dispatch.e2e.ts +3 -3
- package/e2e/delivery-graph.e2e.ts +3 -3
- package/openapi.yaml +87 -13
- package/operations/dispatchDeliveryGraph.test.ts +136 -10
- package/operations/dispatchDeliveryGraph.ts +49 -1
- package/operations/listStagedProposals.test.ts +1 -1
- package/package.json +1 -1
- package/pages/delivery-graphs/delivery-graphs.css +27 -0
- package/pages/delivery-graphs/staged.mount.js +93 -14
- package/test/delivery-graphs-staged-confirm.test.ts +63 -5
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
|
-
|
|
2127
|
-
|
|
2128
|
-
provisioning envelope (`repoEnvelopeVars`) as a run-root
|
|
2129
|
-
node's servicing `senior:*` job gets an ISOLATED throwaway
|
|
2130
|
-
|
|
2131
|
-
|
|
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
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
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
|
-
|
|
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.
|
|
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`
|
|
7
|
-
//
|
|
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
|
|
234
|
-
//
|
|
235
|
-
// (
|
|
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,
|
|
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
|
-
//
|
|
463
|
-
|
|
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
|
|
466
|
-
if (
|
|
467
|
-
|
|
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]");
|