Uh oh!
There was an error while loading. Please reload this page.
feat(runtime): flow clone action — whole-definition copy, mandatory new name, no ancestry (ADR-0126 §7.1) - #12190
Conversation
…name `POST /api/v1/automation/:name/clone` (ADR-0126 §7.1): copy a flow's parsed definition to an ordinary org-authored sibling an admin can edit, for the case where the packaged original cannot be edited in place. The copy is whole-definition and never an enumerated facet list — #11703 measured an enumerated clone dropping three of six facets in silence, and a flow has far more facets than a permission set. Exactly `name`, `label` and `status` are mutated; the acceptance test asserts deep equality of the clone against its source minus those three fields, so a dropped facet fails a test rather than shipping. The new machine name is mandatory and a same-name clone is refused 409 `RESOURCE_CONFLICT`, naming the reason and the remedy: the engine keys flows by bare name, so a second definition under one name silently shadows the other and the survivor depends on registration order. No ancestry is recorded (amendment ruling 2, §9) — no provenance field on the definition, none on the response — and the source's ADR-0010 protection envelope is dropped rather than carried across, so the clone is org-owned and editable instead of a second copy of the package's locked artifact. References are not re-pointed; the response says so, along with the fact that `status: 'draft'` is a lifecycle label and not an off-switch. The route joins the `manage_metadata` authoring-write set — it registers flow metadata at environment scope, exactly as `POST /automation` does.
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 11f08a191927124418533b48df1691b02c2236fa && git checkout 11f08a191927124418533b48df1691b02c2236fa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c02fa2214049789405ff93316fdcae6e3df38ab3 032d2e0f19049e4ba82ea1950b1a3c26d874806c && git checkout -B drift-repro c02fa2214049789405ff93316fdcae6e3df38ab3 && git merge --no-ff 032d2e0f19049e4ba82ea1950b1a3c26d874806c
node scripts/docs-audit/affected-docs.mjs --json c02fa2214049789405ff93316fdcae6e3df38ab3
|
os-support-ai
commented
Aug 25, 2026
CI note: if "Type Check · consumer gates" comes back red on this PR, it is the base branch's red, not this diff's — base commit Generated by Claude Code |
…-flow-clone-action Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh
Uh oh!
There was an error while loading. Please reload this page.
Resolves the two additive conflicts in packages/runtime/src/domains/automation.ts against #12190 (#12156, the flow clone door), keeping BOTH sides: * isFlowAuthoringWrite — this branch's delegated toggle arm (`return isFlowEnablementWrite(parts, method)`) AND main's [#12156] clone comment block plus `if (method === 'POST' && parts[1] === 'clone')`. * the route docblock — this branch's `⚑ refused with its OWN sentence (#11666)` sub-line under the toggle row AND main's `POST /:name/clone` row, each sub-line under its own row, in table order. Adds the pin the merge owes: an unentitled `POST /automation/:name/clone` is refused with the AUTHORING sentence and not the enablement one. The clone door is a two-segment POST whose verb lives in `parts[1]`, so a future loosening of isFlowEnablementWrite would hand it this card's own defect on the arm that arrived after the card. Also corrects one enumeration this merge falsified: the FLOW_ENABLEMENT_DENY_MESSAGE docblock and the pin file's header both said the shared sentence reads correctly for "the three definition writes". Clone is a fourth. Part of #11666 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HbG3rGVLjZStHQxHDtzJdJ
Closes#12156
Adds
POST /api/v1/automation/:name/clone— the ADR-0126 §7.1 clone half for flows. An admin who cannot edit a packaged flow in place gets an ordinary org-authored sibling to edit instead. The renderer half rides L5 (objectstack-ai/objectui#6301); this is the door.The diff
packages/runtime/src/flow-clone.ts(new)packages/runtime/src/domains/automation.tsPOST /:name/clone), its arm inisFlowAuthoringWrite, and the route-table doc entry.packages/runtime/src/domains/automation-flow-clone.test.ts(new).changeset/olive-crabs-clone.md@objectstack/runtime(v17 line).The three rules the ADR makes load-bearing
Whole-definition copy — never param-list assembly.
cloneFlowDefinitiondeep-copies the parsed definition and mutates exactlyname/label/status. It enumerates no facet, so adding a facet toFlowSchemaneeds no edit here. The acceptance test is the #11703 counter-example stated as an assertion: deep equality of the clone against its source minus those three fields, over a fat packaged exemplar (scalars, enum, number, nested node/edge arrays, nestedconfig, nestederrorHandling, plus the protection envelope). A spot-check ofnodes/edgeswould be the enumerated list wearing a test's clothes, so the comparison is whole-object.Deep copy, not a spread: the source is the engine's live
FlowParsedout of its flow map, so a shallow copy would leave clone and original sharing onenodesarray.No ancestry (amendment ruling 2, §9). Nothing records what a clone came from — no field on the definition, none on the response. A test pins that the response carries no
clonedFrom/source/basedOn-style key and that nothing in it names the source flow at all.Same-name refused loudly, with the sanctioned path. 409
RESOURCE_CONFLICT; the message names the offending name, the reason (the engine keys flows by bare name, so a second definition silently shadows the other and the survivor depends on registration order), and the remedy (retry under an unused name, with a concrete suggestion). Refused before anything is registered.Three decisions worth a reviewer's eye
1. The source's ADR-0010 protection envelope is dropped.⚠️ This is the one place the implementation reads more into the ADR than the card spelled out, and the one I'd most like confirmed.
FlowSchemaspreadsMetadataProtectionFields, so a packaged flow's parsed definition really does carry_packageId,_provenance: 'package',_lock,_lockReason, … Carried onto a clone verbatim, those keys would (a) name the base's package, which is ancestry by another name; (b) make the clone a package artifact, so upgrade/uninstall would re-seed or remove the admin's own work; (c) carry the base's_lockonto the clone, leaving it exactly as uneditable as the flow the admin cloned to get around; and (d) classify the clone as a code artifact toisCodeArtifactBody, the test the boot flow-pull's precedence reads.So they are stripped, and the strip is derived from
MetadataProtectionFields+METADATA_READ_DECORATIONSrather than restated as a literal list — a new envelope key is stripped the day it lands, with no second list to drift.The consequence for the card's acceptance wording: deep equality is asserted against the source minus the envelope, with the envelope drop pinned by its own separately-named test. If the reviewer would rather the envelope ride across, that is a one-line change here and a card for the ancestry question.
2.
status: 'draft', and the response says plainly that this is not an off-switch. The engine disables a flow onstatus'obsolete'/'invalid'only (engine.tsregisterFlow);'draft'and'active'both stay enabled and both get their trigger bound. So a cloned record-change or schedule flow is armed and runs alongside its base.'obsolete'would have made it inert, but ADR-0126 §7.2 rules clone and disable independent primitives — "not halves of an unfinished ceremony" — so folding a disable in here would invent the ceremony the ADR declined. Stating the fact in the response is the honest alternative, and the notice does that alongside the references sentence §9 requires.3. Built from
getFlow+registerFlow, not a new contract method.IAutomationServicelives inpackages/spec, which this card excludes, and the door needs nothing the contract does not already offer. Upside: the clone goes through the engine's own registration path, so it is canonicalized and validated exactly as a create is, rather than by a second policy that agrees with the first only until one of them moves. Same reason the refusal code is the standard-catalogRESOURCE_CONFLICT(status→code, 409) — no error-ledger entry, no spec change.Clause ② — judged NOT triggered; please confirm
This is a new action surface. No existing accept/reject behaviour moves:
POST /,PUT /:name,DELETE /:name,POST /:name/toggleand every run route answer exactly as before, and the clone's own mandatory-name and same-name refusals are new-surface validation rather than a tightening of an existing one. The one edit to an existing function is theisFlowAuthoringWritearm, which only widens themanage_metadatagate to cover the new route (a clone registers flow metadata at environment scope, so leaving it out would have been a bypass of #10145, not a gap in it). Flagged here for the reviewer to confirm, per the dispatch.Verification
Union run at
2f2e8eca, the final commit.pnpm --filter @objectstack/runtime typecheck→TYPECHECK_EXIT=0pnpm --filter @objectstack/runtime exec vitest run src/domains/automation-flow-clone.test.ts→Test Files 1 passed (1) · Tests 18 passed (18)automation-toggle-unknown-flow.test.ts+automation-write-capability-gate.test.tsre-run green alongside (Test Files 3 passed (3) · Tests 50 passed (50)).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which reads the change set from the merge base itself) — includingcheck:route-envelope, the one this diff most directly moves.Ablation — proving the #11703 test can actually fail.
cloneFlowDefinition's body was replaced with an enumerated four-facet assembly (type/nodes/edges/version), the mutation was confirmed on disk by counting both the injected marker (1) and the deleted text (0) — the editor's exit code proves nothing, a zero-hit replace also exits 0 — and the suite went red:Tests 2 failed | 16 passed, the failures being the deep-equality test and the deep-copy test. The restore leg was confirmed byte-identical (cmpYES, marker 0, deleted text back to 1) and green again at 18/18. No rebuild is involved on either leg: the test imports../flow-clone.js, a relative source specifier vitest resolves tosrc, so nodistsits between the mutation and the measurement.Worth recording: only 2 of 18 tests moved under that ablation. The whole-object deep-equality assertion is the only thing standing between this action and #11703's failure mode — which is exactly why the card insisted on it, and why it should not later be "simplified" into per-facet checks.
Not in this PR
No
packages/specchange, no objectui change, no ledger write (clone has no ledger involvement — amendment ruling 2). No files shared with PR #12026 (#11997 name-shadow diagnostics): that work sits inpackages/objectqlandpackages/services/service-automation(engine.ts,plugin.ts,index.ts,flow-precedence.ts), and this PR touches neither package.Generated by Claude Code