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
19 changes: 19 additions & 0 deletions .changeset/22450-flow-refusal-translation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": minor
"@objectstack/runtime": minor
"@objectstack/lint": minor
"@objectstack/cli": minor
---

feat(i18n): a refused `end` node's message translates in the run's locale (`flows.<flow>.refusals.<node_id>.message`)

Clause-②: yes (widening)

- **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows.<flow_name>.refusals.<node_id>.message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console.
- **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets.
- **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key.
- **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message.
- **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error).
- **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it.
- **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one.
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1624,7 +1624,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object>; refusals?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
Expand Down
9 changes: 6 additions & 3 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ Platform translation data — the per-app groups plus the platform-only `setting
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object>; refusals?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
Expand Down Expand Up @@ -259,6 +259,7 @@ Translation data for a single object
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated flow label |
| **screens** | `Record<string, { title?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |

### Nested Shape: `PlatformTranslationData.metadataForms[string]`

Expand Down Expand Up @@ -375,7 +376,7 @@ Per-app translation data for objects, apps, and UI messages
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object>; refusals?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |

Expand Down Expand Up @@ -457,6 +458,7 @@ Translation data for a single object
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated flow label |
| **screens** | `Record<string, { title?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |

### Nested Shape: `TranslationData.metadataForms[string]`

Expand Down Expand Up @@ -524,7 +526,7 @@ One locale of translations — the `translation` metadata type
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object>; refusals?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
| **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") |
Expand Down Expand Up @@ -616,6 +618,7 @@ Translation data for a single object
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated flow label |
| **screens** | `Record<string, { title?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |

### Nested Shape: `TranslationItem.metadataForms[string]`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `system/` | 354 |
| `system/` | 355 |
5 changes: 3 additions & 2 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,8 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
picklist: 'picklist',
page: 'page',
// Screen-flow copy (`flows.<f>.label`, `flows.<f>.screens.<n>.title`, and
// the per-field `label` / `placeholder`) — the author's own wizard text, so
// the per-field `label` / `placeholder`) and a refused `end` node's message
// (`flows.<f>.refusals.<n>.message`, #22450) — the author's own text, so
// it keeps its own bucket and reports as `i18n/missing-flow` rather than
// folding away with `--include-platform`. Until this bucket existed the
// family could not report a screen-flow gap at all: HotCRM measured
Expand Down Expand Up @@ -425,7 +426,7 @@ const SOURCE_SURFACE: Record<CoverageIssue['source'], string> = {
dataset: 'datasets',
picklist: 'picklists',
page: 'pages',
flow: 'flow screens',
flow: 'flow screens and refusals',
metadataForm: 'metadata forms',
};

Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
* flows.<flow>.screens.<node_id>.fields.<field>.label
* flows.<flow>.screens.<node_id>.fields.<field>.placeholder
* flows.<flow>.screens.<node_id>.fields.<field>.inlineHelpText
* flows.<flow>.refusals.<node_id>.message (#22450)
* ^ walked because the ledger's `flows` row is `live` and warns no
* author; a group the ledger does warn on is left out of the walk
* (see `authorWarnedTranslationGroups`)
Expand Down Expand Up @@ -1674,6 +1675,13 @@ function walkPicklists(config: any, out: ExpectedEntry[]): void {
*/
const SCREEN_NODE_TYPE = 'screen';

/**
* [#22450] The node whose message `flows.<flow>.refusals.<node_id>.message`
* addresses: an `end` node declaring `outcome: 'refused'` (`EndConfigSchema`).
*/
const END_NODE_TYPE = 'end';
const REFUSED_OUTCOME = 'refused';

/**
* Depth ceiling for the region recursion, mirroring the ceiling the spec-side
* walks use (`conversions/walk.ts`, `automation/control-flow.zod.ts`) and for
Expand Down Expand Up @@ -1877,6 +1885,26 @@ function walkScreenFlows(config: any, out: ExpectedEntry[]): void {
}
}
}

// `flows.<flow>.refusals.<node_id>.message` (#22450) — the message of every
// `end` node declaring `outcome: 'refused'`, at any depth of the same node
// universe. The engine reads that key (`flowRefusalMessageKey`) in the
// run's locale before it renders the holes, so the seed is the authored
// TEMPLATE, holes and all: a translator keeps the `{{ }}` holes, and the
// schema judges the translation as the same text slot. Unlike the flow
// `label`, this is demanded for a flow with no screen too: the trigger and
// action doors start any flow by name for a person, and the refusal is the
// answer that person reads (hotcrm's `quote_generation` refuses before its
// first screen). A run no person started carries no locale and stores the
// authored message.
for (const node of nodes) {
if (!node || typeof node !== 'object' || node.type !== END_NODE_TYPE) continue;
const cfg = node.config && typeof node.config === 'object' ? node.config : {};
if (cfg.outcome !== REFUSED_OUTCOME) continue;
const nodeId = typeof node.id === 'string' && node.id.length > 0 ? node.id : undefined;
if (!nodeId) continue;
pushOptional(out, ['flows', flowName, 'refusals', nodeId, 'message'], inlineText(cfg.message), 'flow', scope);
}
}
}

Expand Down
112 changes: 112 additions & 0 deletions packages/cli/test/i18n-flow-refusal-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
//
// objectstack#22450 — a refused `end` node's message joins the flow bucket.
//
// An `end` node declaring `outcome: 'refused'` carries a `message` the person
// who started the run reads in place of a completion. The bundle now has a key
// for it (`flows.<flow>.refusals.<node_id>.message`) and the engine reads that
// key in the run's locale before it renders the holes. These pins are the CLI
// half: the walker scaffolds the key, the coverage gate demands it, and the
// key the walker writes is the very address the engine asks for
// (`flowRefusalMessageKey`), so the two cannot drift.
//
// The ledger mock is `i18n-flow-screen-coverage.test.ts`'s, for its reason:
// these pins measure the bucket itself, whatever the shipped ledger says next.

import { describe, it, expect, vi } from 'vitest';

vi.mock('@objectstack/lint', async (importOriginal) => ({
...(await importOriginal<typeof import('@objectstack/lint')>()),
authorWarnedProperties: () => new Set<string>(),
}));

import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js';
import { computeI18nCoverage } from '../src/utils/i18n-coverage.js';
import { TranslationDataSchema, flowRefusalMessageKey } from '@objectstack/spec/system';

const REFUSAL = 'Qualification approval comes first: {{ record.name }} is held.';

/** hotcrm's measured shape: a refusal on the start leg, before the first screen. */
const quoteGeneration = {
name: 'quote_generation',
label: 'Generate Quote',
type: 'screen',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'get_held', type: 'decision', label: 'Held?' },
{ id: 'refuse_held', type: 'end', label: 'Quote Refused', config: { outcome: 'refused', message: REFUSAL } },
{ id: 'screen_1', type: 'screen', label: 'Quote', config: { title: 'Quote' } },
{ id: 'done', type: 'end', label: 'Done', config: { outcome: 'completed' } },
],
edges: [],
};

const refusalEntries = (config: any) =>
collectExpectedEntries(config).filter((e) => e.path[0] === 'flows' && e.path[2] === 'refusals');

describe('what the walker harvests from a refusing `end` node (#22450)', () => {
it('emits the message of the refused end only, keyed as the engine reads it, seeded with the template', () => {
const entries = refusalEntries({ flows: [quoteGeneration] });

expect(entries.map((e) => e.path.join('.'))).toEqual([flowRefusalMessageKey('quote_generation', 'refuse_held')]);
// The seed is the authored TEMPLATE, holes and all: the translator keeps them.
expect(entries[0]).toMatchObject({ sourceValue: REFUSAL, inline: REFUSAL, source: 'flow' });
});

it('reaches a refusing `end` nested in an ADR-0031 region', () => {
const flow = {
name: 'bulk_check',
label: 'Bulk Check',
nodes: [
{
id: 'guard', type: 'try_catch', label: 'Guard',
config: {
try: { nodes: [{ id: 'nested_refusal', type: 'end', label: 'No', config: { outcome: 'refused', message: 'Refused.' } }] },
catch: { nodes: [] },
},
},
],
};
expect(refusalEntries({ flows: [flow] }).map((e) => e.path.join('.'))).toEqual([
'flows.bulk_check.refusals.nested_refusal.message',
]);
});
});

describe('the coverage gate demands the refusal (#22450)', () => {
const tree = (flows?: Record<string, unknown>) => ({
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
flows: [quoteGeneration],
translations: [{ 'zh-CN': { ...(flows ? { flows } : {}) } }],
});
const zhRefusals = (config: any) =>
computeI18nCoverage(config).issues.filter((i) => i.locale === 'zh-CN' && i.key.includes('.refusals.'));

it('reports the untranslated refusal in the flow bucket', () => {
const issues = zhRefusals(tree());
expect(issues.map((i) => i.key)).toEqual(['flows.quote_generation.refusals.refuse_held.message']);
expect(issues[0]!.source).toBe('flow');
});

it('goes quiet once it is translated', () => {
const issues = zhRefusals(tree({
quote_generation: { refusals: { refuse_held: { message: '须先完成资格审批:{{ record.name }} 已被暂缓。' } } },
}));
expect(issues).toEqual([]);
});
});

describe('the skeleton `os i18n extract` writes (#22450)', () => {
const result = extractTranslations({ flows: [quoteGeneration] }, { locales: ['en', 'zh-CN'] });

it('writes the template into the default locale and an empty slot into the others', () => {
expect((result.bundles.en as any).flows.quote_generation.refusals).toEqual({ refuse_held: { message: REFUSAL } });
expect((result.bundles['zh-CN'] as any).flows.quote_generation.refusals).toEqual({ refuse_held: { message: '' } });
});

it('parses through the schema it is written for, in every locale', () => {
for (const [locale, bundle] of Object.entries(result.bundles)) {
expect(TranslationDataSchema.safeParse(bundle).success, `skeleton for ${locale}`).toBe(true);
}
});
});
Loading
Loading