From 620d79d3f8ad260e12f3d590969602d547ecd3f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:22:32 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(app-shell):=20Setup=20page=20for=20pac?= =?UTF-8?q?kaged=20automation=20=E2=80=94=20on/off=20+=20clone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the operational Setup surface for the flows an installed package ships (ADR-0126 section 7.4). Automation authoring stays in Studio; this page holds only what the activation ledger knows. Reached through the component-registry nav contribution, the way every other framework-contributed Setup surface is: `services/builtinComponents` registers `automation:packaged`, app navigation names that ref, and `ComponentNavView` resolves it at `/apps//component/automation/packaged`. No bespoke route is added — a second way in would be a URL the app metadata does not know about. Per packaged flow, exactly two actions: * on/off for this scope — reads the engine's activation state (`GET /automation/_status`, backed by the section 7.2 `sys_metadata_activation` ledger), flips it via `POST /automation/:name/toggle`; * clone — `POST /automation/:name/clone` with a mandatory new machine name and label (section 7.1). The carried-over definition is never an editable form field; the copy is edited in Studio like any other flow. The list is scoped to packaged flows by the server's own three-clause provenance test (`isCodeArtifactBody`, ADR-0029 D9.6), transcribed in `packagedFlows.ts` rather than shortened to the `_packageId` sentinel — that shortcut classifies a tenant overlay bound to a package as packaged (the cloud#970 misread), which here would put a tenant's own flow behind an install-wide switch. Server refusals reach the operator verbatim, with no client-side softening: the section 5 posture gate (403 PERMISSION_DENIED, naming the posture and the sanctioned clone path), the section 7.3 subflow guard (409 DELETE_RESTRICTED, naming the packaged callers that would break mid-run) and the section 7.1 clone name conflict (409). `actionErrorDetail` is the single reader; a fallback string is used only when the response carried no message at all. No drift or ancestry surface (section 9): no diff-vs-base, no "customized" badge, no base-moved notice, no link from a clone to its source. Cloned-without-disabled and disabled-without-clone are ordinary states shown plainly. Tests pin the absence, including a response that carries a `clonedFrom` key anyway — proving the rule is enforced at the renderer and not only on the wire. Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh --- .../6301-packaged-automation-setup-page.md | 37 ++ packages/app-shell/src/index.ts | 6 + .../src/services/builtinComponents.tsx | 15 + ...gedAutomationPage.navContribution.test.tsx | 87 +++ .../setup/PackagedAutomationPage.test.tsx | 413 ++++++++++++++ .../views/setup/PackagedAutomationPage.tsx | 508 ++++++++++++++++++ .../src/views/setup/packagedFlows.test.ts | 125 +++++ .../src/views/setup/packagedFlows.ts | 145 +++++ 8 files changed, 1336 insertions(+) create mode 100644 .changeset/6301-packaged-automation-setup-page.md create mode 100644 packages/app-shell/src/views/setup/PackagedAutomationPage.navContribution.test.tsx create mode 100644 packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx create mode 100644 packages/app-shell/src/views/setup/PackagedAutomationPage.tsx create mode 100644 packages/app-shell/src/views/setup/packagedFlows.test.ts create mode 100644 packages/app-shell/src/views/setup/packagedFlows.ts diff --git a/.changeset/6301-packaged-automation-setup-page.md b/.changeset/6301-packaged-automation-setup-page.md new file mode 100644 index 0000000000..2f35175c79 --- /dev/null +++ b/.changeset/6301-packaged-automation-setup-page.md @@ -0,0 +1,37 @@ +--- +'@object-ui/app-shell': minor +--- + +Setup gains a **Packaged automation** page — the operational surface for the flows an +installed package ships (ADR-0126 §7.4, objectui#6301). Reached the way every other +framework-contributed Setup surface is: the page registers the component-registry ref +`automation:packaged`, so app navigation names the ref and `ComponentNavView` resolves it +at `/apps//component/automation/packaged`. No bespoke route is added — a second way +in would be a URL the app metadata does not know about. + +Per packaged flow the page does exactly two things: + +- **on/off for this scope** — reads the activation state the engine reports + (`GET /api/v1/automation/_status`, backed by the ADR-0126 §7.2 `sys_metadata_activation` + ledger) and flips it through `POST /api/v1/automation//toggle`; +- **clone** — `POST /api/v1/automation//clone` with a mandatory new machine name and + label (§7.1). The carried-over definition is never offered as editable form fields; the + copy is edited in Studio like any other flow. + +Authoring stays in Studio. The list is scoped to packaged flows by the server's own +three-clause provenance test (`isCodeArtifactBody`, ADR-0029 D9.6) rather than the +`_packageId`-only shortcut, which classifies a tenant overlay bound to a package as +packaged — the cloud#970 misread, and here it would put a tenant's own flow behind an +install-wide switch. + +**Server refusals reach the operator verbatim** — no client-side softening or rewording. +Three shapes are relayed as sent: the §5 posture gate (403 `PERMISSION_DENIED`, whose +message names the tenancy posture *and* the sanctioned clone path), the §7.3 subflow guard +(409 `DELETE_RESTRICTED`, which names the packaged callers that would break mid-run — a +list nothing on the client could reconstruct), and the §7.1 clone name conflict (409). + +⛔ **No drift or ancestry surface** (§9): no diff-vs-base, no "customized" badge, no +base-moved notice, no link from a clone back to its source. Cloned-without-disabled and +disabled-without-clone are ordinary states, shown plainly. Tests pin the absence, including +the case where a response carries a `clonedFrom` key anyway — the platform does not track +that lineage, so a page that displayed it would be displaying something it invented. diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 8e95fa3829..be4860bcb2 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -355,6 +355,12 @@ export type { // Standalone at `/studio` and embedded via the `studio:builder` component ref. export { BuilderLanding } from './views/studio-design/BuilderLanding.js'; +// Setup › Packaged automation (ADR-0126 §7.4) — on/off + clone for the flows +// installed packages ship. Reached through the `automation:packaged` component +// ref registered in `services/builtinComponents`; exported so a host app can +// compose the page directly. +export { PackagedAutomationPage } from './views/setup/PackagedAutomationPage.js'; + // AI assistant bus — connects the metadata designers to the global chat. export { assistantBus, diff --git a/packages/app-shell/src/services/builtinComponents.tsx b/packages/app-shell/src/services/builtinComponents.tsx index 16c382b552..6e706838f8 100644 --- a/packages/app-shell/src/services/builtinComponents.tsx +++ b/packages/app-shell/src/services/builtinComponents.tsx @@ -23,6 +23,7 @@ import { } from '../views/metadata-admin/index.js'; import { PermissionMatrixEditPage } from '../views/metadata-admin/PermissionMatrixEditor.js'; import { PackagesPage } from '../views/metadata-admin/PackagesPage.js'; +import { PackagedAutomationPage } from '../views/setup/PackagedAutomationPage.js'; import { isAggregatedViewContainer, viewDisplayType, @@ -53,6 +54,20 @@ registerAppComponent({ component: PackagesPage, }); +/** + * ADR-0126 §7.4 — the Setup page for the flows installed packages ship: + * on/off for this scope, and clone. Contributed the way every other + * framework-owned Setup surface is, by ref rather than by route, so the Setup + * app's navigation names `automation:packaged` and `ComponentNavView` resolves + * it. Automation AUTHORING stays in Studio; this page is operational state. + */ +registerAppComponent({ + ref: 'automation:packaged', + label: 'Packaged Automation', + source: '@object-ui/app-shell', + component: PackagedAutomationPage, +}); + /* -------------------------------------------------------------------------- */ /* 2) Generic resources — list + JSONSchema-driven form for every type. */ /* -------------------------------------------------------------------------- */ diff --git a/packages/app-shell/src/views/setup/PackagedAutomationPage.navContribution.test.tsx b/packages/app-shell/src/views/setup/PackagedAutomationPage.navContribution.test.tsx new file mode 100644 index 0000000000..1ed0d39e5e --- /dev/null +++ b/packages/app-shell/src/views/setup/PackagedAutomationPage.navContribution.test.tsx @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `automation:packaged` — the component-registry key the Setup navigation + * names for the packaged-automation page (ADR-0126 §7.4). + * + * The page is reached the way every other framework-contributed Setup surface + * is: app navigation names a REGISTRY KEY, and `ComponentNavView` resolves it. + * Three properties, in the order a nav item exercises them: + * + * 1. the key is registered at all — by importing the registration module the + * way `index.ts` does, as a side effect, so what is measured is the + * production registration and not a re-creation of it; + * 2. `automation:packaged` addresses `component/automation/packaged`, so the + * URL a sidebar builds and the key the framework's metadata declares + * cannot drift apart. The URL below is BUILT from the ref through the same + * helper `AppContent` uses rather than spelled out, so a change to either + * moves both; + * 3. the page actually mounts through that route. + * + * ⛔ No bespoke `` is added for this page, and none is asserted here: a + * second way in is a URL the app metadata does not know about, and the Setup + * nav contribution is the mechanism the card asks for. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +// Side effect: the production registration, imported the way `index.ts` does. +import '../../services/builtinComponents.js'; +import { + componentRefToUrlSegments, + getAppComponent, +} from '../../services/componentRegistry.js'; +import { ComponentNavView } from '../ComponentNavView.js'; + +const REF = 'automation:packaged'; + +beforeEach(() => { + // The page loads its two lists on mount. Empty answers are enough: what this + // file measures is that the route resolves to the page at all. + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.endsWith('/meta/flow') ? { items: [] } : { success: true, data: { flows: [] } }; + return { ok: true, status: 200, json: async () => body } as unknown as Response; + }), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('the packaged-automation Setup nav contribution', () => { + it('registers the ref, owned by app-shell', () => { + const entry = getAppComponent(REF); + expect(entry).toBeDefined(); + expect(entry?.source).toBe('@object-ui/app-shell'); + }); + + it('addresses component/automation/packaged', () => { + expect(componentRefToUrlSegments(REF)).toEqual(['automation', 'packaged']); + }); + + it('mounts the page through the component route the ref builds', async () => { + const url = `/apps/setup/component/${componentRefToUrlSegments(REF).join('/')}`; + + render( + + + } /> + + , + ); + + expect(await screen.findByRole('heading', { name: 'Packaged automation' })).toBeInTheDocument(); + // The "Component not registered" empty state is what an unresolved ref + // renders; its absence is the other half of the assertion above. + expect(screen.queryByText('Component not registered')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx b/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx new file mode 100644 index 0000000000..6973b0410f --- /dev/null +++ b/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx @@ -0,0 +1,413 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup › Packaged automation (ADR-0126 §7.4) — behaviour. + * + * Five properties, in the order the page exercises them: + * + * 1. **scope** — the list is packaged flows only. The rule itself lives in + * `packagedFlows.ts` and is measured directly in `packagedFlows.test.ts`; + * what is checked here is that the rendered page applies it. + * 2. **round-trip** — flipping the switch calls the toggle route with the + * requested state and the shown state follows the SERVER's answer. + * 3. **refusals, verbatim** — the three shapes this page must relay: the + * §5 posture gate (403 `PERMISSION_DENIED`), the §7.3 subflow guard + * (409 `DELETE_RESTRICTED`, which NAMES the callers) and the §7.1 clone + * name conflict (409). Each is asserted as the exact server string. Two + * of the three are transcribed character-for-character from the runtime's + * own message builders; what is under test either way is that the page + * renders what it was sent, unedited. + * 4. **clone** — completes with the new name, and puts EXACTLY + * `{ name, label }` on the wire. The carried-over definition is not an + * editable form field (the #11753 discipline). + * 5. **no ancestry** — the withdrawn §9 shapes are absent. The sharpest case + * is last: even when a response carries a `clonedFrom` key, the page must + * not display it. Pinning the rule at the RENDERER and not only at the + * wire is the point — a response field is the cheapest place for ancestry + * to reappear. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import React from 'react'; + +import { PackagedAutomationPage } from './PackagedAutomationPage'; + +/* -------------------------------------------------------------------------- */ +/* The server's own refusal strings */ +/* -------------------------------------------------------------------------- */ + +/** + * `refuseUngrantedActivationWrite` (runtime `domains/automation.ts`), for the + * `group` posture. Transcribed exactly: the sentence naming the posture AND the + * one naming the sanctioned path are both load-bearing, and a client that + * shortened it would drop the half that says what to do instead. + */ +const POSTURE_REFUSAL = + "Enabling or disabling a packaged flow writes an INSTALL-WIDE activation row, and this deployment runs the " + + "'group' tenancy posture, where that reaches every organization. It requires the platform operator " + + '(ADR-0126 §5) — an organization administrator cannot flip an install-wide switch. To customize this flow ' + + 'for your organization, clone it under a new name instead.'; + +/** + * The §7.3 subflow guard from the automation engine's `toggleFlow`, two + * callers. The NAMES are the whole value of this refusal — nothing on the + * client can reconstruct which packaged flows call this one as a subflow. + */ +const SUBFLOW_REFUSAL = + "Flow 'pkg_notify' cannot be disabled while 2 packaged flows still call it as a subflow: " + + "'pkg_escalate', 'pkg_onboard'. Disabling it would break those callers mid-run at their subflow node " + + 'with a late, inexplicable failure (ADR-0126 §7.3). Disable the calling flows first, or leave this one armed.'; + +/** + * `flowCloneNameTakenMessage`. The trailing suggestion is the server's to + * choose (`suggestCloneName`); what this file pins is that whatever it sends + * arrives unedited, so the suggestion is fixture text here, not a contract. + */ +const CLONE_CONFLICT_REFUSAL = + "Flow 'pkg_notify_copy' already exists — a clone must take a NEW machine name. Same-name clones are " + + 'refused on purpose: the automation engine keys flows by bare name, so a second definition under one ' + + 'name silently shadows the other and which of the two actually dispatches depends on registration ' + + "order (ADR-0126 §7.1). Retry with a machine name no flow uses (for example 'pkg_notify_copy_2')."; + +/** `FLOW_CLONE_NOTICE` — returned on every successful clone. */ +const CLONE_NOTICE = + 'References are not re-pointed: this clone calls exactly what the original called (subflows, actions ' + + 'and objects are unchanged). It is created with status `draft`, which is a lifecycle label and NOT an ' + + 'off-switch — a cloned record-change or schedule flow is bound to its trigger and will run alongside ' + + 'the flow it was copied from.'; + +/* -------------------------------------------------------------------------- */ +/* Fake server */ +/* -------------------------------------------------------------------------- */ + +interface Fixture { + runtime: Array>; + meta: Array>; + /** Queued answers for `POST …/toggle`, in call order. */ + toggle: Array<{ status: number; body: unknown }>; + /** Queued answers for `POST …/clone`, in call order. */ + clone: Array<{ status: number; body: unknown }>; +} + +let fixture: Fixture; +let calls: Array<{ url: string; method: string; body?: unknown }>; + +function response(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as unknown as Response; +} + +/** ADR-0112 error envelope, as the runtime's `deps.error` puts it on the wire. */ +function errorEnvelope(code: string, message: string) { + return { success: false, error: { code, message } }; +} + +/** A packaged flow metadata item — loader-introduced, real package id. */ +function packagedItem(name: string, label: string) { + return { name, label, _packageId: 'com.objectstack.crm', _provenance: 'package' }; +} + +beforeEach(() => { + calls = []; + fixture = { + runtime: [ + { name: 'pkg_notify', enabled: true, bound: true }, + { name: 'pkg_escalate', enabled: false, bound: false }, + ], + meta: [packagedItem('pkg_notify', 'Notify owner'), packagedItem('pkg_escalate', 'Escalate case')], + toggle: [], + clone: [], + }; + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? 'GET').toUpperCase(); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ url, method, body }); + + if (url.endsWith('/automation/_status')) { + return response(200, { success: true, data: { flows: fixture.runtime } }); + } + if (url.endsWith('/meta/flow')) { + return response(200, { items: fixture.meta }); + } + if (url.includes('/toggle')) { + const next = fixture.toggle.shift(); + if (next) return response(next.status, next.body); + const enabled = (body as { enabled?: boolean } | undefined)?.enabled ?? true; + const name = decodeURIComponent(url.split('/automation/')[1].replace('/toggle', '')); + // The engine is the authority on what the state became — mirror it. + const row = fixture.runtime.find((r) => r.name === name); + if (row) row.enabled = enabled; + return response(200, { success: true, data: { name, enabled } }); + } + if (url.includes('/clone')) { + const next = fixture.clone.shift(); + if (next) return response(next.status, next.body); + const payload = body as { name: string; label: string }; + return response(200, { + success: true, + data: { flow: { name: payload.name, label: payload.label }, notice: CLONE_NOTICE }, + }); + } + throw new Error(`unexpected fetch: ${method} ${url}`); + }), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** Render and wait for the first list paint. */ +async function renderPage() { + render(); + await screen.findByText('Notify owner'); +} + +const toggleCalls = () => calls.filter((c) => c.url.includes('/toggle')); +const cloneCalls = () => calls.filter((c) => c.url.includes('/clone')); + +/** + * The Clone button of ONE named row. Rows are sorted by label, so an index + * into `getAllByRole` names whichever flow happens to sort first — which is + * how the first draft of this file clicked `pkg_escalate` while asserting + * against `pkg_notify`. + */ +function cloneButtonFor(flowName: string) { + return within(screen.getByTestId(`packaged-flow-${flowName}`)).getByRole('button', { + name: 'Clone', + }); +} + +/* -------------------------------------------------------------------------- */ +/* 1) Scope */ +/* -------------------------------------------------------------------------- */ + +describe('scoping to packaged flows', () => { + it('renders the packaged flows and leaves a tenant-authored one off the page', async () => { + fixture.runtime = [...fixture.runtime, { name: 'my_own_flow', enabled: true, bound: true }]; + fixture.meta = [ + ...fixture.meta, + // A tenant overlay BOUND to a package: it carries a real package id, so + // only the provenance clause keeps it off this page (cloud#970). + { name: 'my_own_flow', label: 'My own flow', _packageId: 'app.crm', _provenance: 'org' }, + ]; + + await renderPage(); + + expect(screen.getByText('Notify owner')).toBeInTheDocument(); + expect(screen.getByText('Escalate case')).toBeInTheDocument(); + expect(screen.queryByText('My own flow')).toBeNull(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 2) Activation round-trip */ +/* -------------------------------------------------------------------------- */ + +describe('activation round-trip', () => { + it('disables and re-enables a packaged flow, showing what the server reports', async () => { + await renderPage(); + + const notify = screen.getByRole('switch', { name: 'Activation for Notify owner' }); + expect(notify).toBeChecked(); + + fireEvent.click(notify); + await waitFor(() => expect(toggleCalls()).toHaveLength(1)); + expect(toggleCalls()[0].method).toBe('POST'); + expect(toggleCalls()[0].url).toContain('/automation/pkg_notify/toggle'); + expect(toggleCalls()[0].body).toEqual({ enabled: false }); + await waitFor(() => + expect(screen.getByRole('switch', { name: 'Activation for Notify owner' })).not.toBeChecked(), + ); + + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Notify owner' })); + await waitFor(() => expect(toggleCalls()).toHaveLength(2)); + expect(toggleCalls()[1].body).toEqual({ enabled: true }); + await waitFor(() => + expect(screen.getByRole('switch', { name: 'Activation for Notify owner' })).toBeChecked(), + ); + }); + + it('shows a disabled packaged flow as off without any further ceremony', async () => { + await renderPage(); + expect(screen.getByRole('switch', { name: 'Activation for Escalate case' })).not.toBeChecked(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 3) Refusals, verbatim */ +/* -------------------------------------------------------------------------- */ + +describe('server refusals reach the operator verbatim', () => { + it('renders the §5 posture gate refusal (403 PERMISSION_DENIED) unedited', async () => { + fixture.toggle = [{ status: 403, body: errorEnvelope('PERMISSION_DENIED', POSTURE_REFUSAL) }]; + await renderPage(); + + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Notify owner' })); + + expect(await screen.findByText(POSTURE_REFUSAL)).toBeInTheDocument(); + // The refused flip did not move the shown state. + expect(screen.getByRole('switch', { name: 'Activation for Notify owner' })).toBeChecked(); + }); + + it('renders the §7.3 subflow guard refusal (409 DELETE_RESTRICTED) with its named callers', async () => { + fixture.toggle = [{ status: 409, body: errorEnvelope('DELETE_RESTRICTED', SUBFLOW_REFUSAL) }]; + await renderPage(); + + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Notify owner' })); + + const shown = await screen.findByText(SUBFLOW_REFUSAL); + expect(shown).toBeInTheDocument(); + // The caller names survive — the half a summarising client would drop. + expect(shown.textContent).toContain("'pkg_escalate', 'pkg_onboard'"); + }); + + it('renders the §7.1 clone name conflict (409) unedited, keeping the dialog open', async () => { + fixture.clone = [{ status: 409, body: errorEnvelope('RESOURCE_CONFLICT', CLONE_CONFLICT_REFUSAL) }]; + await renderPage(); + + fireEvent.click(cloneButtonFor('pkg_notify')); + fireEvent.change(await screen.findByLabelText('New machine name'), { + target: { value: 'pkg_notify_copy' }, + }); + fireEvent.change(screen.getByLabelText('New label'), { target: { value: 'Notify owner (copy)' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create clone' })); + + expect(await screen.findByText(CLONE_CONFLICT_REFUSAL)).toBeInTheDocument(); + // The refusal stays beside the input that caused it. + expect(screen.getByLabelText('New machine name')).toHaveValue('pkg_notify_copy'); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 4) Clone */ +/* -------------------------------------------------------------------------- */ + +describe('clone', () => { + it('completes with the new name and sends exactly { name, label }', async () => { + await renderPage(); + + fireEvent.click(cloneButtonFor('pkg_notify')); + fireEvent.change(await screen.findByLabelText('New machine name'), { + target: { value: 'crm_notify_owner' }, + }); + fireEvent.change(screen.getByLabelText('New label'), { target: { value: 'Notify owner (ours)' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create clone' })); + + await waitFor(() => expect(cloneCalls()).toHaveLength(1)); + expect(cloneCalls()[0].url).toContain('/automation/pkg_notify/clone'); + // ⛔ No definition blob on the wire: a clone copies the whole definition + // server-side, and this page never offers it as editable form fields. + expect(cloneCalls()[0].body).toEqual({ name: 'crm_notify_owner', label: 'Notify owner (ours)' }); + + expect(await screen.findByText(/Created flow "crm_notify_owner"\./)).toBeInTheDocument(); + // The server's post-clone notice, verbatim. + expect(screen.getByText(CLONE_NOTICE)).toBeInTheDocument(); + }); + + it('will not submit until both the new machine name and the new label are given', async () => { + await renderPage(); + + fireEvent.click(cloneButtonFor('pkg_notify')); + const confirm = await screen.findByRole('button', { name: 'Create clone' }); + expect(confirm).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('New machine name'), { target: { value: 'crm_notify' } }); + expect(confirm).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('New label'), { target: { value: 'Ours' } }); + expect(confirm).toBeEnabled(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 5) No ancestry or drift surface (ADR-0126 §9) */ +/* -------------------------------------------------------------------------- */ + +describe('no ancestry or drift surface', () => { + /** + * The §9 shapes, as UI vocabulary. + * + * ⛔ `/copied from/` is deliberately NOT in this list, and its absence is the + * distinction the rule actually draws. The server's own post-clone notice + * ends "…will run alongside the flow it was copied from", and that notice + * must be relayed VERBATIM. What §9 withdraws is a lineage SURFACE this page + * would have to invent and maintain — a badge, a diff, a base-moved banner, + * a link back to a source. It does not censor the server's sentences. The + * first draft of this file scanned for the phrase and failed on the + * platform's own words, which is the wrong end of the rule. + */ + const WITHDRAWN = [ + /based on/i, + /customized/i, + /diff/i, + /compare/i, + /ancestry/i, + /lineage/i, + /out of date/i, + /base (has )?moved/i, + /upstream chang/i, + ]; + + it('shows a packaged flow with no drift badge, no diff-vs-base and no base-moved notice', async () => { + await renderPage(); + for (const shape of WITHDRAWN) { + expect(screen.queryByText(shape)).toBeNull(); + } + // The withdrawn shape by name — a "based on v3" style provenance line. + expect(screen.queryByText(/based on v\d/i)).toBeNull(); + // Nor the flat spelling of it anywhere in the list. + expect(screen.queryByText(/cloned from/i)).toBeNull(); + }); + + it('does not display ancestry even when a response carries it', async () => { + // The clone route deliberately returns no `clonedFrom`. Feeding one anyway + // proves the ABSENCE is enforced by this page and not merely by the wire. + fixture.clone = [ + { + status: 200, + body: { + success: true, + data: { + flow: { name: 'crm_notify_owner', label: 'Ours' }, + notice: CLONE_NOTICE, + clonedFrom: 'pkg_notify', + baseVersion: 'v3', + }, + }, + }, + ]; + await renderPage(); + + fireEvent.click(cloneButtonFor('pkg_notify')); + fireEvent.change(await screen.findByLabelText('New machine name'), { + target: { value: 'crm_notify_owner' }, + }); + fireEvent.change(screen.getByLabelText('New label'), { target: { value: 'Ours' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create clone' })); + + await screen.findByText(/Created flow "crm_notify_owner"\./); + for (const shape of WITHDRAWN) { + expect(screen.queryByText(shape)).toBeNull(); + } + + // The two ancestry keys the response smuggled in reach the DOM nowhere. + // Scoped to the clone result: `pkg_notify` legitimately appears in the + // table as a row of its own, and a document-wide scan for it would pass + // for the wrong reason. + const result = screen.getByRole('status'); + expect(result.textContent).not.toContain('pkg_notify'); + expect(result.textContent).not.toContain('v3'); + expect(screen.queryByText(/v3/)).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx b/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx new file mode 100644 index 0000000000..18558172f5 --- /dev/null +++ b/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx @@ -0,0 +1,508 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PackagedAutomationPage — Setup › "Packaged automation" (ADR-0126 §7.4). + * + * ## What this page is, and what it deliberately is not + * + * Automation AUTHORING lives in Studio and stays there. This page is the + * OPERATIONAL surface for the flows a code package shipped: per packaged flow + * it does exactly two things, and claims nothing beyond them — + * + * 1. **on/off for this scope** — reads the activation state the engine + * reports (`GET /api/v1/automation/_status`, backed by the ADR-0126 §7.2 + * `sys_metadata_activation` ledger) and flips it through + * `POST /api/v1/automation//toggle`; + * 2. **clone** — `POST /api/v1/automation//clone` with a mandatory NEW + * machine name and label (ADR-0126 §7.1). + * + * Everything else a "packaged flow" page could plausibly show is withdrawn by + * ADR-0126 §9, and its absence is load-bearing rather than unfinished: + * + * ⛔ no diff-vs-base, ⛔ no "customized" badge, ⛔ no "based on v3" / + * base-moved notice, ⛔ no ancestry link from a clone back to its source. + * + * The platform does not track that lineage, so a surface that displayed it + * would be displaying something it had to invent. Cloned-without-disabled and + * disabled-without-clone are ordinary states, shown plainly, not halves of an + * unfinished ceremony. `PackagedAutomationPage.test.tsx` pins the absence of + * the withdrawn shapes so a future "helpful" addition fails loudly. + * + * For the same reason this page shows no `bound` / `status` column even though + * `_status` carries both: §7.4 scopes it to what the activation ledger knows. + * The Studio rail is where a flow's binding is diagnosed. + * + * ## Server refusals reach the operator VERBATIM + * + * Three refusals are expected here and all three are the server's words, + * rendered as sent — no client-side softening, shortening or re-wording: + * + * - **403 `PERMISSION_DENIED`** — the ADR-0126 §5 posture gate. In a + * `group` / `isolated` deployment the install-wide activation row requires + * the platform operator; the server's message names the posture AND the + * sanctioned path (clone under a new name). Rewording it here would drop + * the half that tells the admin what to do instead. + * - **409 `DELETE_RESTRICTED`** — the §7.3 subflow guard, on disable only. + * The message NAMES the packaged callers that would break mid-run. That + * list is the entire value of the refusal and only the server can build it. + * - **409** on clone — the name is taken. The message explains why same-name + * clones are refused (the engine keys flows by bare name) and suggests a + * free name. + * + * `actionErrorDetail` is the one place this repo reads an ADR-0112 error + * envelope's message; a fallback string is used ONLY when the response carried + * no message at all, never in place of one. + * + * ## How this page is reached + * + * Through the component-registry nav contribution — the same mechanism every + * other framework-contributed Setup surface uses (`metadata:directory`, + * `metadata:resource`, `developer:packages` in `services/builtinComponents`). + * The Setup app's navigation names the ref `automation:packaged`, and + * `ComponentNavView` resolves it at + * `/apps//component/automation/packaged`. No bespoke route is added: + * a route would be a second way in that the app metadata does not know about. + */ + +import * as React from 'react'; +import { RefreshCw } from 'lucide-react'; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Empty, + EmptyDescription, + EmptyTitle, + Input, + Label, + Skeleton, + Switch, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@object-ui/components'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { actionErrorDetail } from '@object-ui/core'; + +import { apiBase } from '../metadata-admin/previews/useFlowNodePalette.js'; +import { + envelopeData, + envelopeRefused, + joinPackagedFlows, + readMetadataItems, + readRuntimeStates, + type FlowRuntimeStateRow, + type PackagedFlowRow, +} from './packagedFlows.js'; + +/* -------------------------------------------------------------------------- */ +/* Fetch helpers */ +/* -------------------------------------------------------------------------- */ + +const JSON_HEADERS = { Accept: 'application/json' } as const; + +async function readJson(res: Response): Promise { + return res.json().catch(() => null); +} + +/** + * The engine's runtime states. `payload.data.flows ?? payload.flows` is how + * this repo already reads this exact endpoint (`StudioDesignSurface`); the two + * shapes are the wrapped and bare forms of one response, not two dialects. + */ +async function fetchRuntimeStates(signal: AbortSignal): Promise { + const res = await fetch(`${apiBase()}/automation/_status`, { + credentials: 'include', + headers: JSON_HEADERS, + cache: 'no-store', + signal, + }); + if (!res.ok) throw new Error(`automation status HTTP ${res.status}`); + return readRuntimeStates(await readJson(res)); +} + +/** The `flow` metadata list — bare array or `{ items }`, as this route answers. */ +async function fetchFlowMetadata(signal: AbortSignal): Promise { + const res = await fetch(`${apiBase()}/meta/flow`, { + credentials: 'include', + headers: JSON_HEADERS, + cache: 'no-store', + signal, + }); + if (!res.ok) throw new Error(`flow metadata HTTP ${res.status}`); + return readMetadataItems(await readJson(res)); +} + +/* -------------------------------------------------------------------------- */ +/* Clone dialog state */ +/* -------------------------------------------------------------------------- */ + +interface CloneDraft { + /** Machine name of the flow being copied. */ + source: string; + /** New machine name — mandatory (ADR-0126 §7.1). */ + name: string; + /** New display name — mandatory. */ + label: string; + /** The server's refusal, verbatim. */ + refusal: string | null; + busy: boolean; +} + +/* -------------------------------------------------------------------------- */ +/* Page */ +/* -------------------------------------------------------------------------- */ + +export function PackagedAutomationPage() { + const { t } = useObjectTranslation(); + + const [rows, setRows] = React.useState(null); + const [loadError, setLoadError] = React.useState(null); + const [nonce, setNonce] = React.useState(0); + /** Machine name of the flow whose toggle is in flight. */ + const [busyFlow, setBusyFlow] = React.useState(null); + /** Per-flow server refusal, keyed by machine name. Verbatim. */ + const [refusals, setRefusals] = React.useState>({}); + const [clone, setClone] = React.useState(null); + /** The server's post-clone notice, verbatim, plus the name it created. */ + const [cloneResult, setCloneResult] = React.useState<{ name: string; notice: string } | null>(null); + + React.useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + setLoadError(null); + (async () => { + try { + const [runtime, meta] = await Promise.all([ + fetchRuntimeStates(controller.signal), + fetchFlowMetadata(controller.signal), + ]); + if (cancelled) return; + setRows(joinPackagedFlows(runtime, meta)); + } catch (e) { + if (cancelled || controller.signal.aborted) return; + setRows([]); + setLoadError( + e instanceof Error && e.message + ? e.message + : t('packagedAutomation.loadFailed', { defaultValue: 'Could not load packaged automation.' }), + ); + } + })(); + return () => { + cancelled = true; + controller.abort(); + }; + }, [nonce, t]); + + const clearRefusal = React.useCallback((name: string) => { + setRefusals((prev) => { + if (!(name in prev)) return prev; + const next = { ...prev }; + delete next[name]; + return next; + }); + }, []); + + async function onToggle(row: PackagedFlowRow, enabled: boolean) { + setBusyFlow(row.name); + clearRefusal(row.name); + setCloneResult(null); + try { + const res = await fetch(`${apiBase()}/automation/${encodeURIComponent(row.name)}/toggle`, { + method: 'POST', + credentials: 'include', + headers: { ...JSON_HEADERS, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + const json = await readJson(res); + if (!res.ok || envelopeRefused(json)) { + // VERBATIM. The posture gate (403) and the subflow guard (409) both + // carry the whole of what the admin needs in this string — the second + // one names the callers, which nothing on the client could reconstruct. + setRefusals((prev) => ({ + ...prev, + [row.name]: actionErrorDetail( + json, + t('packagedAutomation.toggleFailed', { + defaultValue: `Could not change activation (HTTP ${res.status}).`, + }), + ), + })); + return; + } + const reported = envelopeData(json).enabled; + const next = typeof reported === 'boolean' ? reported : enabled; + setRows((prev) => prev?.map((r) => (r.name === row.name ? { ...r, enabled: next } : r)) ?? prev); + } catch (e) { + setRefusals((prev) => ({ + ...prev, + [row.name]: + e instanceof Error && e.message + ? e.message + : t('packagedAutomation.toggleFailed', { defaultValue: 'Could not change activation.' }), + })); + } finally { + setBusyFlow(null); + } + } + + async function onCloneSubmit(draft: CloneDraft) { + setClone({ ...draft, busy: true, refusal: null }); + try { + const res = await fetch(`${apiBase()}/automation/${encodeURIComponent(draft.source)}/clone`, { + method: 'POST', + credentials: 'include', + headers: { ...JSON_HEADERS, 'Content-Type': 'application/json' }, + // ⛔ EXACTLY the two keys the clone body declares. The carried-over + // definition (nodes, triggers, connectors) is NOT offered as editable + // form fields: a clone copies the whole definition and the copy is + // edited in Studio afterwards, like any other flow. + body: JSON.stringify({ name: draft.name.trim(), label: draft.label.trim() }), + }); + const json = await readJson(res); + if (!res.ok || envelopeRefused(json)) { + setClone({ + ...draft, + busy: false, + refusal: actionErrorDetail( + json, + t('packagedAutomation.cloneFailed', { + defaultValue: `Could not clone this flow (HTTP ${res.status}).`, + }), + ), + }); + return; + } + const data = envelopeData(json); + const created = (data.flow as { name?: unknown } | undefined)?.name; + const notice = data.notice; + setClone(null); + setCloneResult({ + name: typeof created === 'string' && created ? created : draft.name.trim(), + notice: typeof notice === 'string' ? notice : '', + }); + // The clone is a tenant artifact, so it does NOT join this packaged + // list. Refetch anyway: the source flow's own state is re-read, and a + // stale list is the one thing an operational page must not show. + setNonce((n) => n + 1); + } catch (e) { + setClone({ + ...draft, + busy: false, + refusal: + e instanceof Error && e.message + ? e.message + : t('packagedAutomation.cloneFailed', { defaultValue: 'Could not clone this flow.' }), + }); + } + } + + const title = t('packagedAutomation.title', { defaultValue: 'Packaged automation' }); + const cloneNameValid = !!clone && clone.name.trim() !== '' && clone.label.trim() !== ''; + + return ( +
+
+
+

{title}

+

+ {t('packagedAutomation.subtitle', { + defaultValue: + 'Flows shipped by installed packages. Turn one off for this deployment, or clone it under a new name to customize it. Editing happens in Studio.', + })} +

+
+ +
+ + {cloneResult && ( +
+

+ {t('packagedAutomation.cloneCreated', { + defaultValue: `Created flow "${cloneResult.name}".`, + name: cloneResult.name, + })} +

+ {/* The server's own post-clone notice, verbatim. */} + {cloneResult.notice &&

{cloneResult.notice}

} +
+ )} + + {loadError && ( +
+ {loadError} +
+ )} + + {rows === null && ( +
+ + + +
+ )} + + {rows !== null && rows.length === 0 && !loadError && ( + + + {t('packagedAutomation.emptyTitle', { defaultValue: 'No packaged flows' })} + + + {t('packagedAutomation.emptyBody', { + defaultValue: + 'No installed package ships an automation flow on this deployment. Flows you author yourself live in Studio.', + })} + + + )} + + {rows !== null && rows.length > 0 && ( + + + + {t('packagedAutomation.colFlow', { defaultValue: 'Flow' })} + + {t('packagedAutomation.colActivation', { defaultValue: 'Activation' })} + + + {t('packagedAutomation.colActions', { defaultValue: 'Actions' })} + + + + + {rows.map((row) => { + const refusal = refusals[row.name]; + return ( + + +
{row.label}
+ {row.name} + {refusal && ( + // VERBATIM server refusal, next to the control that + // caused it. `role="alert"` so it is announced. +

+ {refusal} +

+ )} +
+ +
+ void onToggle(row, next)} + aria-label={t('packagedAutomation.toggleLabel', { + defaultValue: `Activation for ${row.label}`, + label: row.label, + })} + /> + + {row.enabled + ? t('packagedAutomation.on', { defaultValue: 'On' }) + : t('packagedAutomation.off', { defaultValue: 'Off' })} + +
+
+ + + +
+ ); + })} +
+
+ )} + + !open && setClone(null)}> + + + + {t('packagedAutomation.cloneTitle', { defaultValue: 'Clone packaged flow' })} + + + {t('packagedAutomation.cloneBody', { + defaultValue: + 'The copy carries the whole definition and takes a new machine name and label. Edit the copy in Studio.', + })} + + + + {clone && ( +
+
+ + ) => + setClone({ ...clone, name: e.target.value, refusal: null }) + } + /> +
+
+ + ) => + setClone({ ...clone, label: e.target.value, refusal: null }) + } + /> +
+ {clone.refusal && ( +

+ {clone.refusal} +

+ )} +
+ )} + + + + + +
+
+
+ ); +} + +export default PackagedAutomationPage; diff --git a/packages/app-shell/src/views/setup/packagedFlows.test.ts b/packages/app-shell/src/views/setup/packagedFlows.test.ts new file mode 100644 index 0000000000..7b59e2be1b --- /dev/null +++ b/packages/app-shell/src/views/setup/packagedFlows.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which flows Setup › Packaged automation lists (ADR-0126 §7.4). + * + * The scoping rule is the part of that page most worth measuring directly. + * Its hard case is not "does a package ship this" but the counterexample: a + * TENANT-authored overlay bound to a package carries a real `_packageId`, so + * every two-clause shortcut classifies it as packaged. cloud#970 measured that + * misread on a Studio-authored artifact after a kernel rebuild; here it would + * put a tenant's own flow behind an install-wide switch. + */ + +import { describe, expect, it } from 'vitest'; + +import { + envelopeData, + envelopeRefused, + isPackagedFlowItem, + joinPackagedFlows, + readMetadataItems, + readRuntimeStates, +} from './packagedFlows'; + +/** A packaged flow metadata item — loader-introduced, real package id. */ +function packagedItem(name: string, label: string) { + return { name, label, _packageId: 'com.objectstack.crm', _provenance: 'package' }; +} + +describe('isPackagedFlowItem', () => { + it('accepts a loader-introduced item', () => { + expect(isPackagedFlowItem({ _packageId: 'com.objectstack.crm', _provenance: 'package' })).toBe(true); + // A package id with no provenance is still a code artifact — the server's + // test excludes provenance `'org'`, it does not require `'package'`. + expect(isPackagedFlowItem({ _packageId: 'com.objectstack.crm' })).toBe(true); + }); + + it('refuses the tenant overlay bound to a package — the cloud#970 counterexample', () => { + expect(isPackagedFlowItem({ _packageId: 'app.crm', _provenance: 'org' })).toBe(false); + }); + + it('refuses the overlay-bound-to-no-package sentinel and unpackaged items', () => { + expect(isPackagedFlowItem({ _packageId: 'sys_metadata' })).toBe(false); + expect(isPackagedFlowItem({ name: 'my_flow' })).toBe(false); + expect(isPackagedFlowItem({ _packageId: '' })).toBe(false); + expect(isPackagedFlowItem(null)).toBe(false); + expect(isPackagedFlowItem(undefined)).toBe(false); + }); +}); + +describe('joinPackagedFlows', () => { + it('lists only registered flows a package ships, labelled from the metadata item', () => { + const rows = joinPackagedFlows( + [ + { name: 'pkg_notify', enabled: false }, + { name: 'tenant_flow', enabled: true }, + { name: 'overlay_flow', enabled: true }, + ], + [ + packagedItem('pkg_notify', 'Notify owner'), + { name: 'tenant_flow', label: 'Mine', _packageId: 'sys_metadata' }, + { name: 'overlay_flow', label: 'Mine too', _packageId: 'app.crm', _provenance: 'org' }, + // Packaged, but the engine never registered it — no row, because both + // actions the page offers go through the engine. + packagedItem('pkg_dormant', 'Dormant'), + ], + ); + + expect(rows).toEqual([{ name: 'pkg_notify', label: 'Notify owner', enabled: false }]); + }); + + it('falls back to the machine name when the packaged item has no label', () => { + const rows = joinPackagedFlows( + [{ name: 'pkg_notify', enabled: true }], + [{ name: 'pkg_notify', _packageId: 'com.objectstack.crm', _provenance: 'package' }], + ); + expect(rows[0]).toEqual({ name: 'pkg_notify', label: 'pkg_notify', enabled: true }); + }); + + it('treats an omitted `enabled` as on — an engine that reports none has nothing disabled', () => { + const rows = joinPackagedFlows([{ name: 'pkg_notify' }], [packagedItem('pkg_notify', 'Notify')]); + expect(rows[0].enabled).toBe(true); + }); + + it('orders by label, then by machine name', () => { + const rows = joinPackagedFlows( + [{ name: 'b_flow' }, { name: 'a_flow' }, { name: 'c_flow' }], + [packagedItem('b_flow', 'Alpha'), packagedItem('a_flow', 'Zulu'), packagedItem('c_flow', 'Alpha')], + ); + expect(rows.map((r) => r.name)).toEqual(['b_flow', 'c_flow', 'a_flow']); + }); + + it('ignores rows and items with no usable name', () => { + const rows = joinPackagedFlows( + [{ name: '' }, { enabled: true }, { name: 'pkg_notify' }], + [{ name: '', _packageId: 'p' }, null, packagedItem('pkg_notify', 'Notify')], + ); + expect(rows.map((r) => r.name)).toEqual(['pkg_notify']); + }); +}); + +describe('envelope readers', () => { + it('reads the runtime list wrapped or bare, and nothing else', () => { + expect(readRuntimeStates({ data: { flows: [{ name: 'a' }] } })).toEqual([{ name: 'a' }]); + expect(readRuntimeStates({ flows: [{ name: 'b' }] })).toEqual([{ name: 'b' }]); + expect(readRuntimeStates({ flows: 'nope' })).toEqual([]); + expect(readRuntimeStates(null)).toEqual([]); + }); + + it('reads the metadata list as a bare array or `{ items }`', () => { + expect(readMetadataItems([{ name: 'a' }])).toEqual([{ name: 'a' }]); + expect(readMetadataItems({ items: [{ name: 'b' }] })).toEqual([{ name: 'b' }]); + expect(readMetadataItems({ items: 3 })).toEqual([]); + expect(readMetadataItems(null)).toEqual([]); + }); + + it('narrows `data` and spots an envelope refusal under a 2xx', () => { + expect(envelopeData({ data: { enabled: false } })).toEqual({ enabled: false }); + expect(envelopeData({ data: [1, 2] })).toEqual({}); + expect(envelopeData(null)).toEqual({}); + expect(envelopeRefused({ success: false })).toBe(true); + expect(envelopeRefused({ success: true })).toBe(false); + expect(envelopeRefused(null)).toBe(false); + }); +}); diff --git a/packages/app-shell/src/views/setup/packagedFlows.ts b/packages/app-shell/src/views/setup/packagedFlows.ts new file mode 100644 index 0000000000..c632feaf64 --- /dev/null +++ b/packages/app-shell/src/views/setup/packagedFlows.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The reading rules behind Setup › Packaged automation (ADR-0126 §7.4) — which + * flows the page lists, and how it reads the two responses it depends on. + * + * Separate from `PackagedAutomationPage.tsx` because these are not components: + * the scoping rule is the part of that page most worth testing directly, and + * it costs a full DOM render to reach through the UI. It runs in the `unit` + * project from here. + */ + +/** + * One row of `GET /api/v1/automation/_status`. + * + * A read projection over what the engine's `getFlowRuntimeStates()` returns — + * every field optional because older backends answer with fewer of them, and + * this page must degrade rather than throw. Only `name` and `enabled` are read. + */ +export interface FlowRuntimeStateRow { + name?: unknown; + enabled?: unknown; + [key: string]: unknown; +} + +/** A packaged flow as the page renders it. */ +export interface PackagedFlowRow { + /** Machine name — the key every automation route takes. */ + name: string; + /** Display name; falls back to the machine name when the item has none. */ + label: string; + /** Activation state as the engine reports it. */ + enabled: boolean; +} + +/** + * Is this `flow` metadata item shipped by a CODE PACKAGE? + * + * A clause-for-clause transcription of `isCodeArtifactBody` + * (`@objectstack/objectql` `registry.ts`, ADR-0029 D9.6) — the server's own + * answer to "does a code package ship this name?". It cannot be imported here + * (objectql is a server package), and the shortened variants already present + * in this repo are wrong in exactly the direction that matters on this page: + * + * - **truthy `_packageId` is not enough.** A TENANT-authored overlay bound + * to a package carries a real package id too — on the save path and on the + * boot-time rehydration of `sys_metadata` alike. cloud#970 measured a + * Studio-authored artifact reading back as a code artifact after a kernel + * rebuild, which is the same misread this page would make. + * - **`_packageId === 'sys_metadata'`** is the sentinel for an overlay bound + * to no package at all — runtime-authored, never packaged. + * - **`_provenance`** is the axis that actually separates the two (ADR-0010: + * `'package'` for loader-introduced items, `'org'` for tenant-authored). + * + * Erring either way is a real defect, not cosmetics: a tenant's own flow shown + * on that page offers an install-wide switch for something Studio owns, and a + * packaged flow filtered out leaves an admin with no off-switch at all. + */ +export function isPackagedFlowItem(item: unknown): boolean { + const it = item as { _packageId?: unknown; _provenance?: unknown } | null | undefined; + const packageId = it?._packageId; + if (typeof packageId !== 'string' || !packageId || packageId === 'sys_metadata') return false; + return it?._provenance !== 'org'; +} + +/** + * Join the engine's runtime states with the `flow` metadata list, scoped to + * the packaged ones. + * + * The runtime list is the SPINE — it holds every flow the engine can actually + * toggle or clone — and the metadata list answers two things it does not + * carry: whether a package shipped the flow, and the flow's display label. + * A packaged flow that the engine never registered is therefore not listed: + * both actions the page offers go through the engine, and a row with two dead + * controls tells an admin less than no row. + */ +export function joinPackagedFlows( + runtime: readonly FlowRuntimeStateRow[], + metaItems: readonly unknown[], +): PackagedFlowRow[] { + const packaged = new Map>(); + for (const raw of metaItems) { + const item = raw as Record | null; + const name = item?.name; + if (typeof name !== 'string' || !name) continue; + if (!isPackagedFlowItem(item)) continue; + packaged.set(name, item as Record); + } + + const rows: PackagedFlowRow[] = []; + for (const state of runtime) { + const name = state?.name; + if (typeof name !== 'string' || !name) continue; + const item = packaged.get(name); + if (!item) continue; + const label = item.label; + rows.push({ + name, + label: typeof label === 'string' && label ? label : name, + // `enabled` is TRUE unless the engine says otherwise: an older backend + // that omits the field has no ledger to disable anything with. + enabled: state.enabled !== false, + }); + } + return rows.sort((a, b) => a.label.localeCompare(b.label) || a.name.localeCompare(b.name)); +} + +/** + * The `data` object of a transport envelope, or `{}`. + * + * A parsed HTTP body is never a value the type system has vouched for, so it + * arrives as `unknown` and is narrowed here once rather than cast at each read. + */ +export function envelopeData(json: unknown): Record { + const data = (json as { data?: unknown } | null | undefined)?.data; + if (!data || typeof data !== 'object' || Array.isArray(data)) return {}; + return data as Record; +} + +/** + * Did the transport envelope itself report a refusal under a 2xx? + * + * `!res.ok` is the usual signal; this is the other one, and both mean the + * request was refused before anything changed. + */ +export function envelopeRefused(json: unknown): boolean { + return (json as { success?: unknown } | null | undefined)?.success === false; +} + +/** The runtime-state list out of `GET /automation/_status`, wrapped or bare. */ +export function readRuntimeStates(payload: unknown): FlowRuntimeStateRow[] { + const p = payload as + | { data?: { flows?: unknown }; flows?: unknown } + | null + | undefined; + const list = p?.data?.flows ?? p?.flows ?? []; + return Array.isArray(list) ? (list as FlowRuntimeStateRow[]) : []; +} + +/** The `flow` metadata list — bare array or `{ items }`, as this route answers. */ +export function readMetadataItems(payload: unknown): unknown[] { + if (Array.isArray(payload)) return payload; + const items = (payload as { items?: unknown } | null | undefined)?.items; + return Array.isArray(items) ? items : []; +} From 7306a813506b4cd616c31b64ba91e9ed925d942c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:47:03 +0000 Subject: [PATCH 2/3] refactor(app-shell): move apiBase to a leaf module so the eager page stays light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apiBase()` lived in `views/metadata-admin/previews/useFlowNodePalette.ts`, whose module scope reaches `flow-canvas-parts.tsx` and the whole flow-designer canvas below it. That costs nothing for the two modules already importing it (the palette fetch and the runs panel, both inside the designer), and costs a chunk for one that is not: the new packaged-automation page is imported EAGERLY by `services/builtinComponents`, so everything in its graph joins the console's eager closure that `check:eager-closure` weighs. Three lines of URL arithmetic now live in `utils/apiBase.ts`, a leaf. `useFlowNodePalette.ts` imports and re-exports the name, so its existing importers are untouched and there is still exactly ONE definition — the repo already carries several hand-rolled copies of the same `VITE_SERVER_URL` arithmetic, and a seventh was the wrong way to avoid the drag. Imported and then exported rather than `export { apiBase } from …`: that module calls `apiBase()` itself, and a bare re-export forwards the name without binding it in local scope. Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh --- packages/app-shell/src/utils/apiBase.ts | 26 +++++++++++++++++++ .../previews/useFlowNodePalette.ts | 19 ++++++++++---- .../views/setup/PackagedAutomationPage.tsx | 6 ++++- 3 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 packages/app-shell/src/utils/apiBase.ts diff --git a/packages/app-shell/src/utils/apiBase.ts b/packages/app-shell/src/utils/apiBase.ts new file mode 100644 index 0000000000..f441c4dd41 --- /dev/null +++ b/packages/app-shell/src/utils/apiBase.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The server API base — `/api/v1`, or `/api/v1` when the + * console is served from the same origin as the backend. + * + * A LEAF module on purpose. This function lived in + * `views/metadata-admin/previews/useFlowNodePalette.ts`, whose module scope + * reaches the whole flow-designer canvas (`flow-canvas-parts.tsx` and the + * region views, providers and icon set below it). Importing three lines of URL + * arithmetic from there dragged all of that into the importer's graph — which + * costs nothing for a module the designer already loads, and costs a chunk for + * one that does not. Setup's packaged-automation page is the second kind: it is + * imported EAGERLY by `services/builtinComponents`, so anything in its graph is + * in the console's eager closure (`check:eager-closure`). + * + * `useFlowNodePalette.ts` re-exports this name, so its existing importers are + * unchanged and there is still exactly ONE definition. ⛔ Do not re-derive the + * base inline: this repo already carries several hand-rolled copies of the same + * `VITE_SERVER_URL` arithmetic, and they are why a trailing-slash difference + * can behave differently in two panels of one page. + */ +export function apiBase(): string { + const url = (import.meta as { env?: { VITE_SERVER_URL?: string } }).env?.VITE_SERVER_URL || ''; + return `${String(url).replace(/\/$/, '')}/api/v1`; +} diff --git a/packages/app-shell/src/views/metadata-admin/previews/useFlowNodePalette.ts b/packages/app-shell/src/views/metadata-admin/previews/useFlowNodePalette.ts index ab85aa28f3..62b1c81259 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/useFlowNodePalette.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/useFlowNodePalette.ts @@ -22,6 +22,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { NODE_PALETTE, nodeCategory, type PaletteItem } from './flow-canvas-parts.js'; +import { apiBase } from '../../../utils/apiBase.js'; /** Minimal shape of an engine action descriptor we consume. */ interface ActionDescriptorLite { @@ -38,11 +39,19 @@ interface ActionDescriptorLite { configSchema?: unknown; } -/** Server API base — shared by the palette fetch and the runs panel. */ -export function apiBase(): string { - const url = (import.meta as { env?: { VITE_SERVER_URL?: string } }).env?.VITE_SERVER_URL || ''; - return `${String(url).replace(/\/$/, '')}/api/v1`; -} +/** + * Server API base — shared by the palette fetch and the runs panel. + * + * The definition moved to `utils/apiBase.ts`; this module re-exports it so its + * existing importers are unchanged. It moved because importing it from HERE + * pulls this module's scope — and so the whole flow-designer canvas below it — + * into the importer's graph; see that file's header for what that cost. + * + * ⚠️ Imported and then exported, NOT `export { apiBase } from …`: this module + * calls `apiBase()` itself further down, and a bare re-export forwards the name + * without binding it in local scope. + */ +export { apiBase }; /** * Merge engine descriptors onto the hardcoded base. Base order is canonical; diff --git a/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx b/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx index 18558172f5..ccde381d6e 100644 --- a/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx +++ b/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx @@ -92,7 +92,11 @@ import { import { useObjectTranslation } from '@object-ui/i18n'; import { actionErrorDetail } from '@object-ui/core'; -import { apiBase } from '../metadata-admin/previews/useFlowNodePalette.js'; +// The LEAF module, not `previews/useFlowNodePalette.js` which re-exports it: +// this page is imported eagerly by `services/builtinComponents`, so anything +// in its graph joins the console's eager closure — and that module's scope +// reaches the whole flow-designer canvas. +import { apiBase } from '../../utils/apiBase.js'; import { envelopeData, envelopeRefused, From 2eb4fb473eda303e3c5554e068f47072af8ff918 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:18:46 +0000 Subject: [PATCH 3/3] fix(i18n): give the packaged-automation page a real key group in all ten packs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-i18n-call-site-keys.mjs` failed the Type Check job: all 22 keys the new Setup page names exist in no locale pack, so every string on it was reachable only through its inline `defaultValue`. That renders English at the call site and leaves the page untranslatable everywhere (objectui#3517) — the gate says in as many words that an inline default is NOT a fix. Adds the `packagedAutomation` group to `packages/i18n/src/locales/en.ts`, the source of truth, and real translations to the other nine packs. Terminology follows each pack's existing vocabulary rather than being invented here — the words for "flow" and "package" are taken from `flowRunner.completed` and `common.package` in that same pack (zh 流程/软件包, ja フロー/パッケージ, ko 플로우/패키지, de Flow/Paket, fr Flux/Package, es Flujo/Paquete, pt Fluxo/Pacote, ru Поток/Пакет, ar التدفق/الحزمة), as is each one's quotation convention. The group deliberately carries no drift or ancestry wording (ADR-0126 section 9): a translatable string for a lineage the platform does not track is the cheapest way for that surface to reappear. It also carries no refusal prose — the posture gate, the subflow guard and the clone name conflict are server-authored and rendered verbatim; the four `*Failed*` keys are last-resort fallbacks for a response that carried no message at all. Two call-site changes the keys required: * `toggleFailed` and `cloneFailed` each split in two. The response arm has an HTTP status to name and the transport-exception arm does not, and one key cannot carry a hole only half its call sites can fill — the gate's interpolation-parity leg checks exactly that. * `cloneCreated` and `toggleLabel` now spell their holes `{{name}}` / `{{label}}` instead of interpolating in a JS template literal, so the inline default is byte-identical to the value the pack serves and a provider-less render cannot drift from a translated one. `de-quote-pairing-3876.test.ts` moves 53 -> 54 on both of its counts. The German `cloneCreated` adds one MATCHED „…“ span around an interpolated hole, the same shape `flowRunner.completed` contributed; `rdq` stays 0, which is the half of that ratchet that carries the meaning. Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh --- .../6301-packaged-automation-setup-page.md | 9 ++++ .../views/setup/PackagedAutomationPage.tsx | 22 ++++++--- .../__tests__/de-quote-pairing-3876.test.ts | 18 ++++--- packages/i18n/src/locales/ar.ts | 30 ++++++++++++ packages/i18n/src/locales/de.ts | 30 ++++++++++++ packages/i18n/src/locales/en.ts | 47 +++++++++++++++++++ packages/i18n/src/locales/es.ts | 30 ++++++++++++ packages/i18n/src/locales/fr.ts | 30 ++++++++++++ packages/i18n/src/locales/ja.ts | 29 ++++++++++++ packages/i18n/src/locales/ko.ts | 29 ++++++++++++ packages/i18n/src/locales/pt.ts | 30 ++++++++++++ packages/i18n/src/locales/ru.ts | 30 ++++++++++++ packages/i18n/src/locales/zh.ts | 30 ++++++++++++ 13 files changed, 352 insertions(+), 12 deletions(-) diff --git a/.changeset/6301-packaged-automation-setup-page.md b/.changeset/6301-packaged-automation-setup-page.md index 2f35175c79..f8b9ddb079 100644 --- a/.changeset/6301-packaged-automation-setup-page.md +++ b/.changeset/6301-packaged-automation-setup-page.md @@ -1,5 +1,6 @@ --- '@object-ui/app-shell': minor +'@object-ui/i18n': minor --- Setup gains a **Packaged automation** page — the operational surface for the flows an @@ -35,3 +36,11 @@ base-moved notice, no link from a clone back to its source. Cloned-without-disab disabled-without-clone are ordinary states, shown plainly. Tests pin the absence, including the case where a response carries a `clonedFrom` key anyway — the platform does not track that lineage, so a page that displayed it would be displaying something it invented. + +`@object-ui/i18n` gains the `packagedAutomation` key group — 24 keys in `en` and real +translations in all nine other packs, matching each pack's existing vocabulary for "flow" +and "package" (zh 流程/软件包, ja フロー/パッケージ, ko 플로우/패키지, de Flow/Paket, +fr Flux/Package, es Flujo/Paquete, pt Fluxo/Pacote, ru Поток/Пакет, ar التدفق/الحزمة) and +each one's quotation convention. The group deliberately carries no drift or ancestry +wording, and no server refusal text: those arrive as server-authored prose and are +rendered verbatim. diff --git a/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx b/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx index ccde381d6e..1cba6b4135 100644 --- a/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx +++ b/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx @@ -236,8 +236,12 @@ export function PackagedAutomationPage() { ...prev, [row.name]: actionErrorDetail( json, - t('packagedAutomation.toggleFailed', { - defaultValue: `Could not change activation (HTTP ${res.status}).`, + // A SEPARATE key from the catch arm's below: this one has a status + // to name and that one does not, and one key cannot carry a hole + // only half its call sites can fill. + t('packagedAutomation.toggleFailedHttp', { + defaultValue: 'Could not change activation (HTTP {{status}}).', + status: res.status, }), ), })); @@ -279,8 +283,11 @@ export function PackagedAutomationPage() { busy: false, refusal: actionErrorDetail( json, - t('packagedAutomation.cloneFailed', { - defaultValue: `Could not clone this flow (HTTP ${res.status}).`, + // Separate key from the catch arm's below, for the same reason as + // the toggle pair: a status hole only one of the two can fill. + t('packagedAutomation.cloneFailedHttp', { + defaultValue: 'Could not clone this flow (HTTP {{status}}).', + status: res.status, }), ), }); @@ -340,7 +347,10 @@ export function PackagedAutomationPage() {

{t('packagedAutomation.cloneCreated', { - defaultValue: `Created flow "${cloneResult.name}".`, + // `{{name}}`, not a JS template literal: the inline default must + // be the SAME string the pack carries, so a provider-less render + // and a translated one cannot drift apart. + defaultValue: 'Created flow "{{name}}".', name: cloneResult.name, })}

@@ -413,7 +423,7 @@ export function PackagedAutomationPage() { disabled={busyFlow === row.name} onCheckedChange={(next: boolean) => void onToggle(row, next)} aria-label={t('packagedAutomation.toggleLabel', { - defaultValue: `Activation for ${row.label}`, + defaultValue: 'Activation for {{label}}', label: row.label, })} /> diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 872155003b..874cfdabec 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -260,8 +260,12 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // asserted around a hole rather than around literal prose, // 53 once objectui#5232 added `console.objectView.viewConfigPermissionDenied`, // which names the same withheld „Metadaten verwalten“ permission as - // `home.build.noCapability` above — the org-wide view-config gate's refusal. - expect(okSpans, 'correctly paired spans').toBe(53); + // `home.build.noCapability` above — the org-wide view-config gate's refusal, + // 54 once objectui#6301 added `packagedAutomation.cloneCreated`, which names + // the flow a clone just produced — „{{name}}“, an interpolated span like + // `flowRunner.completed` above, so the pairing is again asserted around a + // hole rather than around literal prose. + expect(okSpans, 'correctly paired spans').toBe(54); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -279,10 +283,12 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // now true for a *different* reason (rdq went to zero), which is why the // identity below is asserted as arithmetic rather than as `close === open`. // 53 / 53 / 0 after objectui#5232 added - // `console.objectView.viewConfigPermissionDenied`. `rdq` staying at 0 is the - // load-bearing half: the new value added a MATCHED „…“ pair, not a stray - // closer that would have made `close === open` true for the wrong reason. - expect({ open, close, rdq }).toEqual({ open: 53, close: 53, rdq: 0 }); + // `console.objectView.viewConfigPermissionDenied`; 54 / 54 / 0 after + // objectui#6301 added `packagedAutomation.cloneCreated`. `rdq` staying at 0 + // is the load-bearing half: each new value added a MATCHED „…“ pair, not a + // stray closer that would have made `close === open` true for the wrong + // reason. + expect({ open, close, rdq }).toEqual({ open: 54, close: 54, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 2aa85428bb..4ddcc01832 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3415,6 +3415,36 @@ const ar = { bulkOperationFailed: "فشلت العملية الجماعية: {{reason}}", }, }, + // objectui#6301 — الإعداد › أتمتة الحزم (ADR-0126 §7.4). المصطلحات كما في + // بقية الحزمة: ‏`common.package` = الحزمة، و`flowRunner.completed` = التدفق، + // وعلامات الاقتباس «…». ⛔ لا توجد صياغة عن الأصل أو الانحراف في هذه + // المجموعة (§9). + packagedAutomation: { + title: "أتمتة الحزم", + subtitle: "تدفقات تأتي مع الحزم المثبّتة. يمكنك إيقاف أحدها في هذا النشر، أو استنساخه باسم جديد لتخصيصه. التحرير يتم في Studio.", + refresh: "تحديث", + colFlow: "التدفق", + colActivation: "التفعيل", + colActions: "إجراءات", + toggleLabel: "تفعيل {{label}}", + on: "مفعل", + off: "إيقاف", + clone: "استنساخ", + cloneTitle: "استنساخ تدفق الحزمة", + cloneBody: "تحتفظ النسخة بالتعريف كاملاً وتتطلب اسم آلة وتسمية جديدين. حرّر النسخة في Studio.", + cloneName: "اسم آلة جديد", + cloneLabel: "تسمية جديدة", + cancel: "إلغاء", + cloneConfirm: "إنشاء نسخة", + cloneCreated: "تم إنشاء التدفق «{{name}}».", + emptyTitle: "لا توجد تدفقات من الحزم", + emptyBody: "لا توجد حزمة مثبّتة توفّر تدفق أتمتة في هذا النشر. التدفقات التي تنشئها بنفسك موجودة في Studio.", + loadFailed: "تعذّر تحميل أتمتة الحزم.", + toggleFailedHttp: "تعذّر تغيير التفعيل (HTTP {{status}}).", + toggleFailed: "تعذّر تغيير التفعيل.", + cloneFailedHttp: "تعذّر استنساخ هذا التدفق (HTTP {{status}}).", + cloneFailed: "تعذّر استنساخ هذا التدفق.", + }, }; export default ar; diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f2d3303fba..3f01acac1f 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3408,6 +3408,36 @@ const de = { bulkOperationFailed: "Massenvorgang fehlgeschlagen: {{reason}}", }, }, + // objectui#6301 — Setup › Paketautomatisierung (ADR-0126 §7.4). Terminologie + // wie im übrigen Pack: `common.package` = Paket, `flowRunner.completed` = + // Flow, Anführungszeichen „…“. ⛔ Keine Herkunfts- oder Drift-Formulierungen + // in dieser Gruppe (§9). + packagedAutomation: { + title: "Paketautomatisierung", + subtitle: "Flows aus installierten Paketen. Schalten Sie einen für diese Installation ab oder klonen Sie ihn unter einem neuen Namen, um ihn anzupassen. Bearbeitet wird im Studio.", + refresh: "Aktualisieren", + colFlow: "Flow", + colActivation: "Aktivierung", + colActions: "Aktionen", + toggleLabel: "Aktivierung für {{label}}", + on: "An", + off: "Aus", + clone: "Klonen", + cloneTitle: "Paket-Flow klonen", + cloneBody: "Die Kopie übernimmt die gesamte Definition und benötigt einen neuen Maschinennamen und ein neues Label. Bearbeiten Sie die Kopie im Studio.", + cloneName: "Neuer Maschinenname", + cloneLabel: "Neues Label", + cancel: "Abbrechen", + cloneConfirm: "Klon erstellen", + cloneCreated: "Flow „{{name}}“ erstellt.", + emptyTitle: "Keine Paket-Flows", + emptyBody: "In dieser Installation liefert kein installiertes Paket einen Automatisierungs-Flow. Selbst erstellte Flows finden Sie im Studio.", + loadFailed: "Paketautomatisierung konnte nicht geladen werden.", + toggleFailedHttp: "Aktivierung konnte nicht geändert werden (HTTP {{status}}).", + toggleFailed: "Aktivierung konnte nicht geändert werden.", + cloneFailedHttp: "Dieser Flow konnte nicht geklont werden (HTTP {{status}}).", + cloneFailed: "Dieser Flow konnte nicht geklont werden.", + }, }; export default de; diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index cfba3209be..9f5bfda13f 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3751,6 +3751,53 @@ const en = { bulkOperationFailed: 'Bulk operation failed: {{reason}}', }, }, + // objectui#6301 — Setup › Packaged automation (ADR-0126 §7.4): the + // operational surface for the flows an installed package ships. Authoring + // stays in Studio, so this group has no editing vocabulary at all; it is + // on/off, clone, and the states those two produce. + // + // ⛔ No drift or ancestry wording anywhere in this group (ADR-0126 §9). There + // is deliberately no "customized", no "based on v3", no "out of date" — the + // platform does not track that lineage, and a translatable string for it is + // the cheapest way for the surface to grow one. + // + // ⛔ Server refusals are NOT in this group either. The posture gate, the + // subflow guard and the clone name conflict all arrive as server-authored + // prose and are rendered verbatim; the four `*Failed*` keys below are the + // last-resort fallbacks for a response that carried no message at all. + packagedAutomation: { + title: 'Packaged automation', + subtitle: + 'Flows shipped by installed packages. Turn one off for this deployment, or clone it under a new name to customize it. Editing happens in Studio.', + refresh: 'Refresh', + colFlow: 'Flow', + colActivation: 'Activation', + colActions: 'Actions', + // The switch's accessible name — the only place a row's label is spoken. + toggleLabel: 'Activation for {{label}}', + on: 'On', + off: 'Off', + clone: 'Clone', + cloneTitle: 'Clone packaged flow', + cloneBody: + 'The copy carries the whole definition and takes a new machine name and label. Edit the copy in Studio.', + cloneName: 'New machine name', + cloneLabel: 'New label', + cancel: 'Cancel', + cloneConfirm: 'Create clone', + cloneCreated: 'Created flow "{{name}}".', + emptyTitle: 'No packaged flows', + emptyBody: + 'No installed package ships an automation flow on this deployment. Flows you author yourself live in Studio.', + loadFailed: 'Could not load packaged automation.', + // Two keys per action, not one: the response arm has an HTTP status to + // name and the transport-exception arm does not, and a single key cannot + // carry a hole only half its call sites can fill. + toggleFailedHttp: 'Could not change activation (HTTP {{status}}).', + toggleFailed: 'Could not change activation.', + cloneFailedHttp: 'Could not clone this flow (HTTP {{status}}).', + cloneFailed: 'Could not clone this flow.', + }, } as const; export default en; diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 490c307bba..400e1327e4 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3412,6 +3412,36 @@ const es = { bulkOperationFailed: "Error en la operación masiva: {{reason}}", }, }, + // objectui#6301 — Configuración › Automatización de paquetes (ADR-0126 + // §7.4). Terminología del pack: `common.package` = Paquete, + // `flowRunner.completed` = Flujo, comillas «…». ⛔ Ninguna formulación de + // linaje o desviación en este grupo (§9). + packagedAutomation: { + title: "Automatización de paquetes", + subtitle: "Flujos incluidos en los paquetes instalados. Desactiva uno en esta implementación o clónalo con un nombre nuevo para personalizarlo. La edición se hace en Studio.", + refresh: "Actualizar", + colFlow: "Flujo", + colActivation: "Activación", + colActions: "Acciones", + toggleLabel: "Activación de {{label}}", + on: "Activado", + off: "Desactivado", + clone: "Clonar", + cloneTitle: "Clonar flujo del paquete", + cloneBody: "La copia conserva toda la definición y necesita un nombre de máquina y una etiqueta nuevos. Edita la copia en Studio.", + cloneName: "Nuevo nombre de máquina", + cloneLabel: "Nueva etiqueta", + cancel: "Cancelar", + cloneConfirm: "Crear clon", + cloneCreated: "Flujo «{{name}}» creado.", + emptyTitle: "Sin flujos de paquete", + emptyBody: "Ningún paquete instalado incluye un flujo de automatización en esta implementación. Los flujos que creas tú están en Studio.", + loadFailed: "No se pudo cargar la automatización de paquetes.", + toggleFailedHttp: "No se pudo cambiar la activación (HTTP {{status}}).", + toggleFailed: "No se pudo cambiar la activación.", + cloneFailedHttp: "No se pudo clonar este flujo (HTTP {{status}}).", + cloneFailed: "No se pudo clonar este flujo.", + }, }; export default es; diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index f16338e705..bbeef56b8b 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3410,6 +3410,36 @@ const fr = { bulkOperationFailed: "Échec de l'opération groupée : {{reason}}", }, }, + // objectui#6301 — Configuration › Automatisation des packages (ADR-0126 + // §7.4). Terminologie du pack : `common.package` = Package, + // `flowRunner.completed` = Flux, guillemets « … ». ⛔ Aucune formulation + // d'ascendance ou de dérive dans ce groupe (§9). + packagedAutomation: { + title: "Automatisation des packages", + subtitle: "Flux fournis par les packages installés. Désactivez-en un pour ce déploiement, ou clonez-le sous un nouveau nom pour le personnaliser. La modification se fait dans Studio.", + refresh: "Actualiser", + colFlow: "Flux", + colActivation: "Activation", + colActions: "Actions", + toggleLabel: "Activation de {{label}}", + on: "Activé", + off: "Désactivé", + clone: "Cloner", + cloneTitle: "Cloner le flux du package", + cloneBody: "La copie reprend toute la définition et exige un nouveau nom machine et un nouveau libellé. Modifiez la copie dans Studio.", + cloneName: "Nouveau nom machine", + cloneLabel: "Nouveau libellé", + cancel: "Annuler", + cloneConfirm: "Créer le clone", + cloneCreated: "Flux « {{name}} » créé.", + emptyTitle: "Aucun flux de package", + emptyBody: "Aucun package installé ne fournit de flux d'automatisation sur ce déploiement. Les flux que vous créez vous-même se trouvent dans Studio.", + loadFailed: "Impossible de charger l'automatisation des packages.", + toggleFailedHttp: "Impossible de modifier l'activation (HTTP {{status}}).", + toggleFailed: "Impossible de modifier l'activation.", + cloneFailedHttp: "Impossible de cloner ce flux (HTTP {{status}}).", + cloneFailed: "Impossible de cloner ce flux.", + }, }; export default fr; diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 7c14d6dd55..ce2ee4b103 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3410,6 +3410,35 @@ const ja = { bulkOperationFailed: "一括操作に失敗しました:{{reason}}", }, }, + // objectui#6301 — 設定 › パッケージ自動化 (ADR-0126 §7.4)。用語は既存訳に + // 合わせています: `common.package` = パッケージ、`flowRunner.completed` = + // フロー、引用符は「」。⛔ 系譜・ドリフトを示す語はこのグループに置きません (§9)。 + packagedAutomation: { + title: "パッケージ自動化", + subtitle: "インストール済みパッケージが提供するフローです。このデプロイでオフにするか、新しい名前でクローンしてカスタマイズできます。編集は Studio で行います。", + refresh: "更新", + colFlow: "フロー", + colActivation: "有効状態", + colActions: "操作", + toggleLabel: "{{label}} の有効状態", + on: "オン", + off: "オフ", + clone: "クローン", + cloneTitle: "パッケージフローをクローン", + cloneBody: "コピーは定義全体を引き継ぎ、新しいマシン名とラベルが必要です。コピーの編集は Studio で行ってください。", + cloneName: "新しいマシン名", + cloneLabel: "新しいラベル", + cancel: "キャンセル", + cloneConfirm: "クローンを作成", + cloneCreated: "フロー「{{name}}」を作成しました。", + emptyTitle: "パッケージフローはありません", + emptyBody: "このデプロイには、自動化フローを提供するインストール済みパッケージがありません。自分で作成したフローは Studio にあります。", + loadFailed: "パッケージ自動化を読み込めませんでした。", + toggleFailedHttp: "有効状態を変更できませんでした (HTTP {{status}})。", + toggleFailed: "有効状態を変更できませんでした。", + cloneFailedHttp: "このフローをクローンできませんでした (HTTP {{status}})。", + cloneFailed: "このフローをクローンできませんでした。", + }, }; export default ja; diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 7f3e40a005..c4cf04d2e7 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3407,6 +3407,35 @@ const ko = { bulkOperationFailed: "일괄 작업 실패: {{reason}}", }, }, + // objectui#6301 — 설정 › 패키지 자동화 (ADR-0126 §7.4). 용어는 기존 번역을 + // 따릅니다: `common.package` = 패키지, `flowRunner.completed` = 플로우, + // 인용부호는 「」. ⛔ 계보·드리프트를 나타내는 표현은 이 그룹에 두지 않습니다 (§9). + packagedAutomation: { + title: "패키지 자동화", + subtitle: "설치된 패키지가 제공하는 플로우입니다. 이 배포에서 끄거나, 새 이름으로 복제해 사용자 지정할 수 있습니다. 편집은 Studio에서 합니다.", + refresh: "새로고침", + colFlow: "플로우", + colActivation: "활성화 상태", + colActions: "작업", + toggleLabel: "{{label}}의 활성화 상태", + on: "켜짐", + off: "꺼짐", + clone: "복제", + cloneTitle: "패키지 플로우 복제", + cloneBody: "사본은 정의 전체를 그대로 가져가며 새 머신 이름과 레이블이 필요합니다. 사본은 Studio에서 편집하세요.", + cloneName: "새 머신 이름", + cloneLabel: "새 레이블", + cancel: "취소", + cloneConfirm: "복제본 만들기", + cloneCreated: "플로우 「{{name}}」을(를) 만들었습니다.", + emptyTitle: "패키지 플로우 없음", + emptyBody: "이 배포에는 자동화 플로우를 제공하는 설치된 패키지가 없습니다. 직접 작성한 플로우는 Studio에 있습니다.", + loadFailed: "패키지 자동화를 불러오지 못했습니다.", + toggleFailedHttp: "활성화 상태를 변경하지 못했습니다 (HTTP {{status}}).", + toggleFailed: "활성화 상태를 변경하지 못했습니다.", + cloneFailedHttp: "이 플로우를 복제하지 못했습니다 (HTTP {{status}}).", + cloneFailed: "이 플로우를 복제하지 못했습니다.", + }, }; export default ko; diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 5a115d4731..92feb548ed 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3407,6 +3407,36 @@ const pt = { bulkOperationFailed: "Falha na operação em massa: {{reason}}", }, }, + // objectui#6301 — Configuração › Automação de pacotes (ADR-0126 §7.4). + // Terminologia do pack: `common.package` = Pacote, `flowRunner.completed` = + // Fluxo, aspas "…". ⛔ Nenhuma formulação de linhagem ou desvio neste + // grupo (§9). + packagedAutomation: { + title: "Automação de pacotes", + subtitle: "Fluxos fornecidos pelos pacotes instalados. Desligue um nesta implantação ou clone-o com um novo nome para personalizá-lo. A edição acontece no Studio.", + refresh: "Atualizar", + colFlow: "Fluxo", + colActivation: "Ativação", + colActions: "Ações", + toggleLabel: "Ativação de {{label}}", + on: "Ativado", + off: "Desativado", + clone: "Clonar", + cloneTitle: "Clonar fluxo do pacote", + cloneBody: "A cópia mantém toda a definição e exige um novo nome de máquina e um novo rótulo. Edite a cópia no Studio.", + cloneName: "Novo nome de máquina", + cloneLabel: "Novo rótulo", + cancel: "Cancelar", + cloneConfirm: "Criar clone", + cloneCreated: 'Fluxo "{{name}}" criado.', + emptyTitle: "Nenhum fluxo de pacote", + emptyBody: "Nenhum pacote instalado fornece um fluxo de automação nesta implantação. Os fluxos que você mesmo cria ficam no Studio.", + loadFailed: "Não foi possível carregar a automação de pacotes.", + toggleFailedHttp: "Não foi possível alterar a ativação (HTTP {{status}}).", + toggleFailed: "Não foi possível alterar a ativação.", + cloneFailedHttp: "Não foi possível clonar este fluxo (HTTP {{status}}).", + cloneFailed: "Não foi possível clonar este fluxo.", + }, }; export default pt; diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 9981b31eab..d95db07099 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3421,6 +3421,36 @@ const ru = { bulkOperationFailed: "Массовая операция не выполнена: {{reason}}", }, }, + // objectui#6301 — Настройка › Автоматизация из пакетов (ADR-0126 §7.4). + // Терминология пакета: `common.package` = Пакет, `flowRunner.completed` = + // Поток, кавычки «…». ⛔ Никаких формулировок о происхождении или + // расхождении в этой группе (§9). + packagedAutomation: { + title: "Автоматизация из пакетов", + subtitle: "Потоки, поставляемые установленными пакетами. Отключите поток в этой установке или клонируйте его под новым именем, чтобы настроить. Редактирование — в Studio.", + refresh: "Обновить", + colFlow: "Поток", + colActivation: "Активация", + colActions: "Действия", + toggleLabel: "Активация для {{label}}", + on: "Вкл.", + off: "Выкл.", + clone: "Клонировать", + cloneTitle: "Клонировать поток из пакета", + cloneBody: "Копия сохраняет определение целиком и требует нового машинного имени и метки. Редактируйте копию в Studio.", + cloneName: "Новое машинное имя", + cloneLabel: "Новая метка", + cancel: "Отмена", + cloneConfirm: "Создать клон", + cloneCreated: "Поток «{{name}}» создан.", + emptyTitle: "Нет потоков из пакетов", + emptyBody: "В этой установке ни один установленный пакет не поставляет поток автоматизации. Потоки, которые вы создаёте сами, находятся в Studio.", + loadFailed: "Не удалось загрузить автоматизацию из пакетов.", + toggleFailedHttp: "Не удалось изменить активацию (HTTP {{status}}).", + toggleFailed: "Не удалось изменить активацию.", + cloneFailedHttp: "Не удалось клонировать этот поток (HTTP {{status}}).", + cloneFailed: "Не удалось клонировать этот поток.", + }, }; export default ru; diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 6ac6f20014..25ecb78164 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3466,6 +3466,36 @@ const zh = { bulkOperationFailed: '批量操作失败:{{reason}}', }, }, + // objectui#6301 — 设置 › 打包自动化(ADR-0126 §7.4)。术语沿用本包既有译法: + // `common.package` = 软件包,`flowRunner.completed` = 流程,`common.duplicate` + // 一族的“复制”在这里写作“克隆”,因为服务端的 clone 是另起机器名的同级流程, + // 不是一次副本粘贴。⛔ 本组不含任何血缘/漂移措辞(§9)。 + packagedAutomation: { + title: '打包自动化', + subtitle: '由已安装软件包提供的流程。可在本部署中关闭某个流程,或以新名称克隆后再自定义。编辑在 Studio 中进行。', + refresh: '刷新', + colFlow: '流程', + colActivation: '启用状态', + colActions: '操作', + toggleLabel: '{{label}} 的启用状态', + on: '已启用', + off: '已关闭', + clone: '克隆', + cloneTitle: '克隆打包流程', + cloneBody: '副本会带上完整定义,并需要新的机器名和标签。请在 Studio 中编辑副本。', + cloneName: '新机器名', + cloneLabel: '新标签', + cancel: '取消', + cloneConfirm: '创建克隆', + cloneCreated: '已创建流程「{{name}}」。', + emptyTitle: '没有打包流程', + emptyBody: '本部署中没有任何已安装软件包提供自动化流程。你自己编写的流程在 Studio 中。', + loadFailed: '无法加载打包自动化。', + toggleFailedHttp: '无法更改启用状态(HTTP {{status}})。', + toggleFailed: '无法更改启用状态。', + cloneFailedHttp: '无法克隆此流程(HTTP {{status}})。', + cloneFailed: '无法克隆此流程。', + }, } as const; export default zh;