Skip to content

Commit 86dc686

Browse files
feat(studio): switch a read-only package's flow off for this deployment from the Automations pillar (#28420)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu85pY93xKTmJgLfTt1CGoVP)_ Fixes #28148 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio 自动化里打开一个只读软件包(如 HotCRM)的流程时,头部的开关改为「本部署开关」:读写部署的启用账本(`_status` / `toggle`),文案为「本部署中已开启 / 本部署中已关闭 / 本部署中已开启,但未在此运行」,与 Setup › 打包自动化同一套三种读法;可写软件包的流程仍是原来的定义开关和删除。Setup 页的开关请求挪进共用模块,两处发同一个请求。Clone 不在本 PR(归 #28149)。 - **为什么改**:以前只读流程头部的锁定开关读的是定义,旁边的状态读的是账本,会出现「状态: 已停用」旁边写「🔒 已启用」;管理员也无法就地关掉打包流程,只能去 Setup 找(ADR-0146 D4,裁决「28148 暂停解除」)。 - **风险与回滚**:只动前端;服务端仍是唯一裁判,在 group / isolated 姿态下非平台管理员看到禁用开关和一句原因,真拒绝时原样显示服务端的话。新增 `engine.studio.auto.ledger.*` 文案键(en、zh),属 Clause-② widening。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx`(`ledgerRow`、`toggleLedger`、`flowSwitchEl`);`ui/packages/app-shell/src/views/studio-design/FlowLedgerSwitch.tsx`;`ui/packages/app-shell/src/views/setup/packagedFlowLedger.ts`。 - **两分钟验证**:showcase 里进 Studio → 只读软件包 → 自动化,打开 `showcase_urgent_task_alert`,点头部开关;预期文案在「本部署中已开启 / 已关闭」之间切换、网络面板只有一次 `POST /api/v1/automation/showcase_urgent_task_alert/toggle`,旁边的状态随之一致;可写软件包的流程仍显示「已启用 / 已停用」。 - **需要你做的**:无,席位落地。 ## What changes - **The ledger switch on the item** (`FlowLedgerSwitch.tsx`, new). On a read-only package's open flow, when `GET /api/v1/automation/_status` serves a row for it, the Automations pillar's header switch binds the ADR-0126 §7.2 activation ledger instead of the definition: `aria-checked` is the row's `enabled`, and a click sends `POST /api/v1/automation/NAME/toggle` at once (no draft is written). Its word is `activationReading` (`views/setup/packagedFlows.ts`) for the same row, the reading Setup › Packaged automation shows: "On for this deployment", "On for this deployment, not running here" (muted track, the platform's `reason` as sent, described on the switch and in its title, never parsed), "Off for this deployment". A writable package's flow keeps its definition switch and its Delete, unchanged. Two switches, two words. - **Posture-aware, refusal verbatim.** `useFlowLedgerWalledReadOnly` (`views/setup/packagedFlowLedger.ts`) pre-judges the toggle gate (`activation-gate.ts#refuseUngrantedActivationWrite`) from the two served facts, `features.tenancyPosture` via `useTenancyPosture()` and `user.isPlatformAdmin`, the posture-and-rung half of `walledCatalogReadOnly` without its catalog-type filter (director note 6526396053; ⛔ not that function, which answers `false` for a flow). Under `group` / `isolated` a non-operator gets the switch disabled with the console's own sentence; after any attempt the server's refusal is shown verbatim in the pillar's refusal strip. - **One toggle request.** `requestFlowToggle` moved out of `PackagedAutomationPage.tsx` into `packagedFlowLedger.ts`; both surfaces call it with their own fallback words. Setup's clone request stays on Setup's page, untouched. - **The rail keeps `unboundCause`.** `flowRailState` now keeps the `_status` row's `unboundCause` (non-empty string only) beside `reason`, so `activationReading` reads it for presence. The rail, the header status and the Problems panel read exactly as before. - **i18n.** A contiguous block of `engine.studio.auto.ledger.*` keys in the studio namespace (en, zh). This is the widening behind `Clause-②: yes (widening)`. - **Resumed work.** The branch merges the unreviewed WIP `claude/project-thread-dhj1ou@1a9e770` (release 6541889272) and then drops its Clone half per ruling 6542841890: `FlowCloneDialog.tsx`, the Clone menu entry, the shared clone request and the `engine.studio.auto.clone.*` keys are gone; `FlowItemMoreMenu.tsx` and the Delete test are byte-identical to `main`. ## Tests `ui/packages/app-shell/src/views/studio-design/AutomationsPillar.ledgerSwitch-28148.test.tsx`, 8 cases, the metadata server doubled at the client and the `/automation` doors doubled at `fetch`: - the switch reads the ledger, not the definition: definition `obsolete` + ledger on reads on; a flip posts `{ enabled: false }`, re-reads `_status`, saves no draft, toasts; - the measured disagreement (definition `active`, ledger off) reads "Off for this deployment", one switch only, no read-only hint; - an enabled flow with an `unboundCause` reads "not running here" with the platform's sentence, wired into `aria-describedby`; - `group` posture, non-operator: disabled, the console's sentence, nothing sent; CONTROLS `single` and `isolated` + operator: live; - a 403 refusal is shown verbatim and the switch keeps the ledger's reading; - CONTROL: a writable package's flow keeps its definition switch and Delete, and its switch never reaches the ledger door. All readings below are on head `b328333c86` (this branch after its one merge of `origin/main` at `3fb1a24540`), the dependency closure rebuilt first (`turbo run build --filter='@object-ui/app-shell^...'`, 64/64). <details><summary>Tests and type-check</summary> - `pnpm --filter @object-ui/app-shell --filter @object-ui/i18n run type-check` (both run `tsc --noEmit` and `tsc -p tsconfig.test.json`, so the tests are compiled): lock VERDICT command-exit 0. - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/views/studio-design/ packages/app-shell/src/views/setup/ packages/i18n/`: Test Files 386 passed (386), Tests 3573 passed, 13 skipped (3586). - The new file alone, verbose: Tests 8 passed (8). - eslint on the seven changed source files: 0 errors; the 22 warnings are all on lines this diff does not touch. </details> <details><summary>Ablations (each through scripts/ablation-replace.mjs, restored to blob == HEAD, git diff HEAD empty)</summary> Run on the committed head before the main merge (`6d9de7534a`; the merge touched none of these files). Each mutation landed (anchor 1 to 0) and the ledger test file went red: - A1 `checked={ledgerRow.enabled}` to `checked={flowEnabled}` (bind the definition): 4 failed (ledger binding, measured disagreement, not running, refusal). - A2 `reading={activationReading(ledgerRow)}` to a two-way on/off: 1 failed (not running here). - A3 `flowLedgerWalledReadOnly` returns `false`: 1 failed (walled non-operator). - A4 the toggle refusal uses the fallback instead of `actionErrorDetail`: 1 failed (refusal verbatim). - A5 the ledger row no longer requires `readOnly`: 1 failed (writable package CONTROL). The first A5 attempt was a no-op the tool refused (its replacement was a substring of its anchor, so the count did not rise); re-run with a distinct replacement. </details> ## Acceptance notes - When `_status` serves no row for the open flow (an older backend, or a flow the engine never registered), there is no ledger flag to bind, so the read-only definition switch stays as it was, with its lock and the read-only package hint. Card item 3 removes that hint where the ledger switch is drawn, which is every case with a row. - The PR body's attribution line links this card thread's project thread, as the harness requires; the seat order names the seat thread instead. Noted, not corrected. - Branch name is the platform-assigned card-thread branch `claude/project-thread-krgxy0`, the recorded exception to `claude/issue-N-slug` (claim 6542901778). ## Verification <details><summary>Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, 34 commands, on b328333</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero, every line carries its exit code). `node scripts/check-sdui-manifest.mjs` is in the list (exit 0). The five type-check-program CI steps the derivation names are CI's. </details> - Serial: no open PR touches `studio-design/**`, `views/setup/**` or the studio i18n namespace (read from each open UI PR's file list against its merge base). `git merge-tree --write-tree` of this head with `origin/main` is clean. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgXdweWzW44zdh5YEjY)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgXdweWzW44zdh5YEjY --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 380a107 commit 86dc686

8 files changed

Lines changed: 622 additions & 48 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(studio): switch a read-only package's flow off for this deployment from the Automations pillar
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
An administrator who wanted a packaged flow off in their deployment had to leave Studio for Setup › Packaged automation, and the open flow's header showed a locked switch that read the flow's definition beside a status that read the deployment's activation ledger, so the two could disagree. A read-only package's open flow in Studio's Automations pillar now carries the activation-ledger switch on the item (ADR-0146 D4): it reads the flag `GET /automation/_status` reports and flips it through `POST /automation/<name>/toggle`, at once, with Setup's three readings: "On for this deployment", "On for this deployment, not running here" (muted, with the platform's reason as sent) and "Off for this deployment". A writable package's flow keeps its definition switch ("Enabled" / "Disabled", applied on publish) and its Delete. Under a walled tenancy posture, a caller without the platform-operator rung gets the switch disabled with the console's own sentence, read from `features.tenancyPosture` and `user.isPlatformAdmin`; a server refusal is shown as sent. The toggle request moved out of Setup's Packaged automation page into a shared `packagedFlowLedger.ts`, so the two surfaces send one request. The studio namespace gains a contiguous block of `engine.studio.auto.ledger.*` keys (en, zh).

‎ui/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx‎

Lines changed: 15 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@
9595
*
9696
* `actionErrorDetail` is the one place this repo reads an ADR-0112 error
9797
* envelope's message; a fallback string is used ONLY when the response carried
98-
* no message at all, never in place of one.
98+
* no message at all, never in place of one. The toggle request lives in
99+
* `packagedFlowLedger.ts`, shared with Studio's Automations pillar, which
100+
* offers the same switch on a read-only package's flow (ADR-0146 D4).
99101
*
100102
* ## How this page is reached
101103
*
@@ -156,6 +158,7 @@ import {
156158
type FlowRuntimeStateRow,
157159
type PackagedFlowRow,
158160
} from './packagedFlows.js';
161+
import { requestFlowToggle, type FlowToggleOutcome } from './packagedFlowLedger.js';
159162
import { PackagedActionsSection } from './PackagedActionsSection.js';
160163
import { studioOrgScopePath } from '../studio-design/studioScope.js';
161164
import { PageHeader } from '../../layout/PageHeader.js';
@@ -238,8 +241,7 @@ interface CloneResult {
238241
switchRefusal: string | null;
239242
}
240243

241-
/** The toggle's answer: the flag the server reports, or its refusal verbatim. */
242-
type ToggleOutcome = { ok: true; enabled: boolean } | { ok: false; message: string };
244+
type ToggleOutcome = FlowToggleOutcome;
243245

244246
/* -------------------------------------------------------------------------- */
245247
/* The reason disclosure */
@@ -364,43 +366,16 @@ export function PackagedAutomationPage() {
364366
* refusal comes back as the server's sentence, verbatim.
365367
*/
366368
async function requestToggle(name: string, enabled: boolean): Promise<ToggleOutcome> {
367-
try {
368-
const res = await fetch(`${apiBase()}/automation/${encodeURIComponent(name)}/toggle`, {
369-
method: 'POST',
370-
credentials: 'include',
371-
headers: { ...JSON_HEADERS, 'Content-Type': 'application/json' },
372-
body: JSON.stringify({ enabled }),
373-
});
374-
const json = await readJson(res);
375-
if (!res.ok || envelopeRefused(json)) {
376-
// VERBATIM. The posture gate (403) and the subflow guard (409) both
377-
// carry the whole of what the admin needs in this string — the second
378-
// one names the callers, which nothing on the client could reconstruct.
379-
return {
380-
ok: false,
381-
message: actionErrorDetail(
382-
json,
383-
// A SEPARATE key from the catch arm's below: this one has a status
384-
// to name and that one does not, and one key cannot carry a hole
385-
// only half its call sites can fill.
386-
t('packagedAutomation.toggleFailedHttp', {
387-
defaultValue: 'Could not change activation (HTTP {{status}}).',
388-
status: res.status,
389-
}),
390-
),
391-
};
392-
}
393-
const reported = envelopeData(json).enabled;
394-
return { ok: true, enabled: typeof reported === 'boolean' ? reported : enabled };
395-
} catch (e) {
396-
return {
397-
ok: false,
398-
message:
399-
e instanceof Error && e.message
400-
? e.message
401-
: t('packagedAutomation.toggleFailed', { defaultValue: 'Could not change activation.' }),
402-
};
403-
}
369+
return requestFlowToggle(name, enabled, {
370+
// Two keys: the HTTP one has a status to name and the network one does
371+
// not, and one key cannot carry a hole only half its call sites can fill.
372+
http: (status) =>
373+
t('packagedAutomation.toggleFailedHttp', {
374+
defaultValue: 'Could not change activation (HTTP {{status}}).',
375+
status,
376+
}),
377+
network: t('packagedAutomation.toggleFailed', { defaultValue: 'Could not change activation.' }),
378+
});
404379
}
405380

406381
/** A toggle the server accepted: the row shows its answer, and the runtime is re-read. */
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The packaged-flow ledger switch the console offers in two places (ADR-0146
5+
* D4): Setup › Packaged automation, the deployment-wide overview, and Studio's
6+
* Automations pillar, on a read-only package's open flow. It is
7+
* `POST /api/v1/automation/<name>/toggle`, the ADR-0126 §7.2 activation
8+
* ledger's switch, the bit `GET /automation/_status` reports as `enabled`.
9+
*
10+
* One copy of the request, so the two surfaces cannot drift on the body they
11+
* send or on how a refusal reaches the operator: VERBATIM, through
12+
* `actionErrorDetail`, the one reader of an ADR-0112 envelope's message. A
13+
* fallback string is used only when the response carried no message at all,
14+
* and the caller supplies it in its own surface's words.
15+
*
16+
* And one pre-judgement of the gate, from the two facts the server publishes
17+
* for it ({@link flowLedgerWalledReadOnly}), never a client copy of its sentence.
18+
*/
19+
20+
import { useAuth } from '@object-ui/auth';
21+
import type { TenancyPosture } from '@object-ui/auth';
22+
import { actionErrorDetail } from '@object-ui/core';
23+
import { postureHasOrgWall, useTenancyPosture } from '../../hooks/useTenancyPosture.js';
24+
import { apiBase } from '../../utils/apiBase.js';
25+
import { envelopeData, envelopeRefused } from './packagedFlows.js';
26+
27+
const JSON_HEADERS = { Accept: 'application/json', 'Content-Type': 'application/json' } as const;
28+
29+
/** The caller's own words for a refusal that carried no message. */
30+
export interface FlowDoorFallbacks {
31+
/** The response failed with this status and no message. */
32+
http: (status: number) => string;
33+
/** The request itself failed (network, abort) with no message. */
34+
network: string;
35+
}
36+
37+
/** The toggle's answer: the flag the server reports, or its refusal verbatim. */
38+
export type FlowToggleOutcome = { ok: true; enabled: boolean } | { ok: false; message: string };
39+
40+
async function readJson(res: Response): Promise<unknown> {
41+
return res.json().catch(() => null);
42+
}
43+
44+
function thrownMessage(e: unknown, fallback: string): string {
45+
return e instanceof Error && e.message ? e.message : fallback;
46+
}
47+
48+
/**
49+
* `POST …/toggle`. Never throws: a refusal comes back as the server's
50+
* sentence. The posture gate (403) and the subflow guard (409, which names the
51+
* callers that would break) both carry the whole of what the operator needs in
52+
* that string, and nothing on the client could reconstruct it.
53+
*/
54+
export async function requestFlowToggle(
55+
name: string,
56+
enabled: boolean,
57+
fallbacks: FlowDoorFallbacks,
58+
): Promise<FlowToggleOutcome> {
59+
try {
60+
const res = await fetch(`${apiBase()}/automation/${encodeURIComponent(name)}/toggle`, {
61+
method: 'POST',
62+
credentials: 'include',
63+
headers: JSON_HEADERS,
64+
body: JSON.stringify({ enabled }),
65+
});
66+
const json = await readJson(res);
67+
if (!res.ok || envelopeRefused(json)) {
68+
return { ok: false, message: actionErrorDetail(json, fallbacks.http(res.status)) };
69+
}
70+
const reported = envelopeData(json).enabled;
71+
return { ok: true, enabled: typeof reported === 'boolean' ? reported : enabled };
72+
} catch (e) {
73+
return { ok: false, message: thrownMessage(e, fallbacks.network) };
74+
}
75+
}
76+
77+
/**
78+
* Does the toggle door refuse this caller on this deployment?
79+
*
80+
* ADR-0126 §5: under the walled `group` / `isolated` postures the activation
81+
* row is install-wide, so the door
82+
* (`activation-gate.ts#refuseUngrantedActivationWrite`) admits only the
83+
* `PLATFORM_ADMIN` posture rung; under `single`, or with no posture served, it
84+
* admits the `manage_metadata` holder Studio already requires. The verdict is
85+
* read from the two facts `walledCatalogReadOnly`
86+
* (`metadata-admin/catalog-scope.ts`) reads for the catalog doors, the posture
87+
* (`features.tenancyPosture`) and the session's `user.isPlatformAdmin`, without
88+
* that function's catalog-type filter: the ledger gate has no type input, so
89+
* the function itself answers `false` for a flow. The server stays the judge:
90+
* a refusal after an attempt is still shown as sent. An unknown posture fails
91+
* toward a live control; a session that never published the flag reads as no
92+
* operator.
93+
*/
94+
export function flowLedgerWalledReadOnly(input: {
95+
posture: TenancyPosture | undefined;
96+
isPlatformOperator: boolean;
97+
}): boolean {
98+
return postureHasOrgWall(input.posture) && !input.isPlatformOperator;
99+
}
100+
101+
/** {@link flowLedgerWalledReadOnly} for this session and this deployment. */
102+
export function useFlowLedgerWalledReadOnly(): boolean {
103+
const posture = useTenancyPosture();
104+
const { user } = useAuth();
105+
return flowLedgerWalledReadOnly({ posture, isPlatformOperator: user?.isPlatformAdmin === true });
106+
}

0 commit comments

Comments
 (0)