Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/6301-packaged-automation-setup-page.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
'@object-ui/app-shell': minor
'@object-ui/i18n': 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/<app>/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/<name>/toggle`;
- **clone** — `POST /api/v1/automation/<name>/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.

`@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.
6 changes: 6 additions & 0 deletions packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
15 changes: 15 additions & 0 deletions packages/app-shell/src/services/builtinComponents.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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. */
/* -------------------------------------------------------------------------- */
Expand Down
26 changes: 26 additions & 0 deletions packages/app-shell/src/utils/apiBase.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The server API base — `<VITE_SERVER_URL>/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`;
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand All@@ -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;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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 `<Route>` 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(
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route path="/apps/:appName/component/:ns/:name/*" element={<ComponentNavView />} />
</Routes>
</MemoryRouter>,
);

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();
});
});
Loading
Loading