From 363280ca4f76b0db185f2f682810e138f0191490 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sun, 6 Sep 2026 09:43:12 +0800 Subject: [PATCH 01/10] refactor(platform): retire the dead 0.4 shared-lib modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/shared carried modules whose only importer was their own test: the 0.4 Convex/Puck platform vocabulary under lib/shared/platform (render kinds, function bindings, run-capacity indicators, connector list pages — everything but when_predicate.ts, which the task board still uses), the provider-locale / model-variant / model-list / file-url-batch helpers written for 0.4 screens 0.5 never ported (model-list was also a second copy of @tale/shared/utils/model-list), the agent/workflow canonicalizers in canonicalize-config.ts (only sortObjectKeysDeep is read, by file_io.ts), and formatZodErrorFull whose "CLI gate" consumer never existed. Delete them. Drop the knip parking lines that hid the platform tree and the two live modules metrics-window.ts / text-matching (they have real importers) so knip audits them again — that surfaced one dead export in each (utcDayStart is internal; BoundaryMode was never used), fixed here. Findings: lib-shared-rest-1, lib-shared-rest-3, lib-shared-schemas-11. --- knip.config.ts | 6 +- .../lib/shared/file-url-batch.test.ts | 28 - .../platform/lib/shared/file-url-batch.ts | 26 - .../platform/lib/shared/metrics-window.ts | 2 +- .../platform/connector_list_page.test.ts | 75 -- .../shared/platform/connector_list_page.ts | 26 - .../lib/shared/platform/exclude_by.test.ts | 103 --- .../lib/shared/platform/exclude_by.ts | 74 -- .../lib/shared/platform/field_types.ts | 23 - .../platform/filtered_pagination.test.ts | 154 ---- .../shared/platform/filtered_pagination.ts | 111 --- .../shared/platform/function_bindings.test.ts | 733 ------------------ .../lib/shared/platform/function_bindings.ts | 372 --------- .../lib/shared/platform/part_state.ts | 40 - .../lib/shared/platform/render_kinds.ts | 129 --- .../lib/shared/platform/run_capacity.test.ts | 86 -- .../lib/shared/platform/run_capacity.ts | 54 -- .../lib/shared/platform/step_display.test.ts | 305 -------- .../lib/shared/platform/step_display.ts | 149 ---- .../lib/shared/platform/step_modes.ts | 21 - .../lib/shared/platform/vocabulary.test.ts | 127 --- .../lib/shared/schemas/format-error.test.ts | 18 +- .../lib/shared/schemas/format-error.ts | 10 - .../lib/shared/text-matching/index.ts | 3 - .../shared/utils/canonicalize-config.test.ts | 91 +-- .../lib/shared/utils/canonicalize-config.ts | 146 +--- .../utils/expand-model-variants.test.ts | 120 --- .../lib/shared/utils/expand-model-variants.ts | 50 -- .../lib/shared/utils/model-list.test.ts | 103 --- .../platform/lib/shared/utils/model-list.ts | 43 - .../utils/resolve-provider-locale.test.ts | 229 ------ .../shared/utils/resolve-provider-locale.ts | 117 --- 32 files changed, 12 insertions(+), 3562 deletions(-) delete mode 100644 services/platform/lib/shared/file-url-batch.test.ts delete mode 100644 services/platform/lib/shared/file-url-batch.ts delete mode 100644 services/platform/lib/shared/platform/connector_list_page.test.ts delete mode 100644 services/platform/lib/shared/platform/connector_list_page.ts delete mode 100644 services/platform/lib/shared/platform/exclude_by.test.ts delete mode 100644 services/platform/lib/shared/platform/exclude_by.ts delete mode 100644 services/platform/lib/shared/platform/field_types.ts delete mode 100644 services/platform/lib/shared/platform/filtered_pagination.test.ts delete mode 100644 services/platform/lib/shared/platform/filtered_pagination.ts delete mode 100644 services/platform/lib/shared/platform/function_bindings.test.ts delete mode 100644 services/platform/lib/shared/platform/function_bindings.ts delete mode 100644 services/platform/lib/shared/platform/part_state.ts delete mode 100644 services/platform/lib/shared/platform/render_kinds.ts delete mode 100644 services/platform/lib/shared/platform/run_capacity.test.ts delete mode 100644 services/platform/lib/shared/platform/run_capacity.ts delete mode 100644 services/platform/lib/shared/platform/step_display.test.ts delete mode 100644 services/platform/lib/shared/platform/step_display.ts delete mode 100644 services/platform/lib/shared/platform/step_modes.ts delete mode 100644 services/platform/lib/shared/platform/vocabulary.test.ts delete mode 100644 services/platform/lib/shared/utils/expand-model-variants.test.ts delete mode 100644 services/platform/lib/shared/utils/expand-model-variants.ts delete mode 100644 services/platform/lib/shared/utils/model-list.test.ts delete mode 100644 services/platform/lib/shared/utils/model-list.ts delete mode 100644 services/platform/lib/shared/utils/resolve-provider-locale.test.ts delete mode 100644 services/platform/lib/shared/utils/resolve-provider-locale.ts diff --git a/knip.config.ts b/knip.config.ts index 3a590617cf..2f309f1636 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -115,15 +115,12 @@ export default { 'lib/pii/**', 'lib/connectors/natives/**', // Shared contract layer: types declared for the parked consumers - // above (schemas, platform run/render vocabulary, provider catalog - // shapes). Same debt, same exit. + // above (schemas, provider catalog shapes). Same debt, same exit. 'lib/shared/constants/agents.ts', 'lib/shared/schemas/skills.ts', 'lib/shared/config/registry.ts', 'lib/shared/constants/system-message-tags.ts', 'lib/shared/file-types.ts', - 'lib/shared/metrics-window.ts', - 'lib/shared/platform/**', 'lib/shared/providers/attribution.ts', 'lib/shared/schemas/agents.ts', 'lib/shared/schemas/approvals.ts', @@ -132,7 +129,6 @@ export default { 'lib/shared/schemas/connectors.ts', 'lib/shared/schemas/pii.ts', 'lib/shared/schemas/providers.ts', - 'lib/shared/text-matching/**', // E2E helper for the parked chat specs. 'tests/e2e/helpers/chat.ts', ], diff --git a/services/platform/lib/shared/file-url-batch.test.ts b/services/platform/lib/shared/file-url-batch.test.ts deleted file mode 100644 index e14c1cd28e..0000000000 --- a/services/platform/lib/shared/file-url-batch.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { MAX_FILE_URL_IDS } from './file-types'; -import { prepareFileUrlIds } from './file-url-batch'; - -describe('prepareFileUrlIds', () => { - it('dedupes and keeps more than the old chat-era batch of 10', () => { - const ids = Array.from({ length: 25 }, (_, i) => `id_${i}`); - const withDupes = [...ids, 'id_0', 'id_1']; - expect(prepareFileUrlIds(withDupes)).toEqual(ids); - expect(prepareFileUrlIds(withDupes)).toHaveLength(25); - }); - - it('fails loud past the concurrent-IO safety ceiling', () => { - const ids = Array.from( - { length: MAX_FILE_URL_IDS + 1 }, - (_, i) => `id_${i}`, - ); - expect(() => prepareFileUrlIds(ids)).toThrow( - /concurrent-IO safety ceiling/, - ); - }); - - it('accepts exactly the safety ceiling', () => { - const ids = Array.from({ length: MAX_FILE_URL_IDS }, (_, i) => `id_${i}`); - expect(prepareFileUrlIds(ids)).toHaveLength(MAX_FILE_URL_IDS); - }); -}); diff --git a/services/platform/lib/shared/file-url-batch.ts b/services/platform/lib/shared/file-url-batch.ts deleted file mode 100644 index 2cfd36fb0f..0000000000 --- a/services/platform/lib/shared/file-url-batch.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { MAX_FILE_URL_IDS } from './file-types'; - -/** - * Dedupe storage ids for `getFileUrls`. Fail loud past the Convex concurrent-IO - * safety ceiling — never silently truncate. - */ -export function prepareFileUrlIds( - fileIds: readonly T[], - toKey: (id: T) => string = (id) => String(id), -): T[] { - const seen = new Set(); - const unique: T[] = []; - for (const fileId of fileIds) { - const key = toKey(fileId); - if (seen.has(key)) continue; - seen.add(key); - unique.push(fileId); - } - if (unique.length > MAX_FILE_URL_IDS) { - throw new Error( - `getFileUrls: ${unique.length} unique file ids exceeds the ` + - `${MAX_FILE_URL_IDS} concurrent-IO safety ceiling`, - ); - } - return unique; -} diff --git a/services/platform/lib/shared/metrics-window.ts b/services/platform/lib/shared/metrics-window.ts index 5974b0f4e8..d7ccbd7e33 100644 --- a/services/platform/lib/shared/metrics-window.ts +++ b/services/platform/lib/shared/metrics-window.ts @@ -20,7 +20,7 @@ export function utcDateKey(ts: number): string { } /** Start of the UTC day containing `ts`. */ -export function utcDayStart(ts: number): number { +function utcDayStart(ts: number): number { const d = new Date(ts); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); } diff --git a/services/platform/lib/shared/platform/connector_list_page.test.ts b/services/platform/lib/shared/platform/connector_list_page.test.ts deleted file mode 100644 index 7fd7dbe9db..0000000000 --- a/services/platform/lib/shared/platform/connector_list_page.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { readConnectorListPage } from './connector_list_page'; - -/** - * The shape `executeConnector` actually returns: the connector's payload - * (data + pagination) nested under `.result`, alongside envelope metadata. - * Reading the top level instead is the regression these tests guard against — - * it silently produced an empty page and made the issue desk show no issues. - */ -const envelope = ( - data: unknown[], - hasNextPage: boolean, -): Record => ({ - name: 'github', - operation: 'list_issues', - duration: 12, - version: 1, - result: { - success: true, - operation: 'list_issues', - count: data.length, - data, - pagination: { hasNextPage, nextPageInfo: hasNextPage ? '2' : null }, - timestamp: 0, - }, -}); - -describe('readConnectorListPage', () => { - it('unwraps rows + hasNext from the nested `.result` envelope', () => { - const rows = [{ number: 2117 }, { number: 2116 }, { number: 2096 }]; - - expect(readConnectorListPage(envelope(rows, true))).toEqual({ - rows, - hasNext: true, - }); - }); - - it('reports no next page when the envelope says so', () => { - expect(readConnectorListPage(envelope([{ number: 1 }], false))).toEqual({ - rows: [{ number: 1 }], - hasNext: false, - }); - }); - - it('does NOT read the top level — a top-level data/pagination is ignored', () => { - // The bug shape: data/pagination at the top, nothing under `.result`. - // Such a result must NOT be mistaken for a flat payload, because the real - // envelope always carries a `.result` object. - const out = readConnectorListPage({ - name: 'github', - operation: 'list_issues', - result: { success: true, data: [], pagination: { hasNextPage: false } }, - data: [{ number: 99 }], - pagination: { hasNextPage: true }, - }); - expect(out).toEqual({ rows: [], hasNext: false }); - }); - - it('tolerates an already-flat payload (forward-compatible)', () => { - const out = readConnectorListPage({ - data: [{ number: 7 }], - pagination: { hasNextPage: true }, - }); - expect(out).toEqual({ rows: [{ number: 7 }], hasNext: true }); - }); - - it('degrades to an empty, terminal page on a malformed result', () => { - expect(readConnectorListPage(null)).toEqual({ rows: [], hasNext: false }); - expect(readConnectorListPage({ result: {} })).toEqual({ - rows: [], - hasNext: false, - }); - }); -}); diff --git a/services/platform/lib/shared/platform/connector_list_page.ts b/services/platform/lib/shared/platform/connector_list_page.ts deleted file mode 100644 index 74048b6b93..0000000000 --- a/services/platform/lib/shared/platform/connector_list_page.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Adapt an `executeConnector` LIST result into the `{ rows, hasNext }` shape - * that `collectFilteredPage` consumes. - * - * `executeConnector` wraps the connector's payload in an envelope — - * `{ name, operation, result: { data, pagination, ... }, ... }` — so the rows - * array and the upstream "is there a next page" hint live under `.result`, NOT - * at the top level. Reading `res.data` / `res.pagination` directly silently - * yields `undefined` (an empty page with no next page), which collapses a - * filtered list to nothing — the bug that made the issue desk show no issues. - * - * This unwraps `.result` first (mirroring the client's `parsePage`), and - * tolerates an already-flat shape so a future connector that returns - * `{ data, pagination }` directly still works. - */ -import { isRecord } from '../../utils/type-utils'; -import type { SourcePage } from './filtered_pagination'; - -export function readConnectorListPage(res: unknown): SourcePage { - const payload = isRecord(res) && isRecord(res.result) ? res.result : res; - const pagination = isRecord(payload) ? payload.pagination : undefined; - return { - rows: isRecord(payload) && Array.isArray(payload.data) ? payload.data : [], - hasNext: isRecord(pagination) && pagination.hasNextPage === true, - }; -} diff --git a/services/platform/lib/shared/platform/exclude_by.test.ts b/services/platform/lib/shared/platform/exclude_by.test.ts deleted file mode 100644 index 16d17e7843..0000000000 --- a/services/platform/lib/shared/platform/exclude_by.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { buildExclusionSet, excludeExisting } from './exclude_by'; - -describe('buildExclusionSet', () => { - it('collects non-empty keys, skipping falsy + non-record rows', () => { - const set = buildExclusionSet( - [ - { externalId: 'tale-project/tale#1' }, - { externalId: 'tale-project/tale#2' }, - { externalId: undefined }, - { externalId: null }, - { externalId: '' }, - { other: 'x' }, - 'not-a-record', - 42, - ], - 'externalId', - ); - expect([...set].sort()).toEqual([ - 'tale-project/tale#1', - 'tale-project/tale#2', - ]); - // The literal "undefined"/"null"/"" never leak in. - expect(set.has('undefined')).toBe(false); - expect(set.has('null')).toBe(false); - expect(set.has('')).toBe(false); - }); - - it('stringifies numeric keys', () => { - const set = buildExclusionSet([{ id: 7 }], 'id'); - expect(set.has('7')).toBe(true); - }); - - it('treats entries as bare keys when no refField is given', () => { - // A key-only query (e.g. listExternalKeysByProject) returns a string[]; with - // an empty refField each entry IS the key. - const set = buildExclusionSet( - ['tale-project/tale#1', 'tale-project/tale#2', '', 9], - '', - ); - expect([...set].sort()).toEqual([ - '9', - 'tale-project/tale#1', - 'tale-project/tale#2', - ]); - }); -}); - -describe('excludeExisting', () => { - const issues = [ - { number: 1, title: 'a' }, - { number: 2, title: 'b' }, - { number: 3, title: 'c' }, - ]; - const tmpl = 'tale-project/tale#{number}'; - - it('drops rows whose templated key matches a reference key', () => { - const refRows = [ - { externalId: 'tale-project/tale#2' }, - { externalId: 'tale-project/tale#99' }, - ]; - expect(excludeExisting(issues, refRows, 'externalId', tmpl)).toEqual([ - { number: 1, title: 'a' }, - { number: 3, title: 'c' }, - ]); - }); - - it('returns a copy of all rows when there are no reference keys', () => { - const result = excludeExisting(issues, [], 'externalId', tmpl); - expect(result).toEqual(issues); - expect(result).not.toBe(issues); - }); - - it('builds the key from per-install config (owner/repo) merged with the row', () => { - // A repo-agnostic key: owner/repo come from the app's config, number from the - // row — so it matches the externalId the create path wrote from the same config. - const scoped = '{owner}/{repo}#{number}'; - const config = { owner: 'acme', repo: 'widgets' }; - const refRows = [{ externalId: 'acme/widgets#2' }]; - expect( - excludeExisting(issues, refRows, 'externalId', scoped, config), - ).toEqual([ - { number: 1, title: 'a' }, - { number: 3, title: 'c' }, - ]); - }); - - it('ignores reference rows with falsy keys (no accidental exclusion)', () => { - const refRows = [{ externalId: undefined }, { externalId: '' }]; - expect(excludeExisting(issues, refRows, 'externalId', tmpl)).toEqual( - issues, - ); - }); - - it('a row missing the template field keeps the placeholder verbatim and is not excluded', () => { - // interpolateTemplate leaves `{number}` literal when the field is absent, so - // the key becomes "tale-project/tale#{number}" — which won't match a real id. - const rows = [{ title: 'no-number' }]; - const refRows = [{ externalId: 'tale-project/tale#1' }]; - expect(excludeExisting(rows, refRows, 'externalId', tmpl)).toEqual(rows); - }); -}); diff --git a/services/platform/lib/shared/platform/exclude_by.ts b/services/platform/lib/shared/platform/exclude_by.ts deleted file mode 100644 index 62a6e3969d..0000000000 --- a/services/platform/lib/shared/platform/exclude_by.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Generic cross-reference filter — "subtract" rows that already exist in another - * data source. A list block fetched from outside Convex (e.g. GitHub issues) has - * no idea which of its rows were already materialized into a Convex table (e.g. - * tasks bound to issues). This computes the join client-side: build the set of - * keys present in the reference rows, then drop any source row whose key is in - * that set. - * - * The two sides name their key differently on purpose: the reference rows hold - * the key in a plain FIELD (`refField`), while a source row's key is a - * `{field}` TEMPLATE (`rowKeyTemplate`) interpolated over the row — so the same - * key can be reconstructed from row fields the way it was originally written - * (e.g. a task's `externalId` "owner/repo#N" rebuilt from an issue's `number`). - * Both sides go through the shared `interpolateTemplate`, so the strings match - * byte-for-byte. - */ -import { interpolateTemplate } from '../utils/interpolate'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * The set of non-empty join keys present in the reference rows. With a `refField`, - * each entry is a RECORD and the key is read from that field (non-record entries - * are malformed → skipped). With an EMPTY `refField`, the query returns the keys - * directly (a bare `string[]`/`number[]`, e.g. `listExternalKeysByProject`) and - * each entry IS the key. - */ -export function buildExclusionSet( - refRows: readonly unknown[], - refField: string, -): Set { - const bareKeys = refField === ''; - const set = new Set(); - for (const row of refRows) { - let raw: unknown; - if (isRecord(row)) raw = row[refField]; - else if (bareKeys && (typeof row === 'string' || typeof row === 'number')) - raw = row; - else continue; - // Skip falsy keys: `String(undefined)` would seed the set with the literal - // "undefined" and falsely exclude any row whose key resolves to that. - if (raw === undefined || raw === null || raw === '') continue; - set.add(String(raw)); - } - return set; -} - -/** - * Return `rows` minus any whose `rowKeyTemplate` matches a key present in - * `refRows[refField]`. The template is interpolated over the row MERGED WITH - * `templateScope` (the app's per-install config, e.g. a configured `owner`/ - * `repo`); row fields win a name clash. This lets the join key embed both - * configured values and per-row fields (e.g. `"{owner}/{repo}#{number}"`) so it - * still matches the externalId the create path wrote from the same config. Empty - * `refRows` ⇒ `rows` unchanged. - */ -export function excludeExisting>( - rows: readonly T[], - refRows: readonly unknown[], - refField: string, - rowKeyTemplate: string, - templateScope?: Record, -): T[] { - const set = buildExclusionSet(refRows, refField); - if (set.size === 0) return [...rows]; - return rows.filter( - (row) => - !set.has( - interpolateTemplate(rowKeyTemplate, { ...templateScope, ...row }), - ), - ); -} diff --git a/services/platform/lib/shared/platform/field_types.ts b/services/platform/lib/shared/platform/field_types.ts deleted file mode 100644 index 1eb0e1f859..0000000000 --- a/services/platform/lib/shared/platform/field_types.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Field types for `collection` columns and `review` (form) inputs. Drives - * locale-aware formatting in the renderer (currency/date/number via - * `@tale/ui` formatters, which already handle de-CH / fr-CH). - */ -export const FIELD_TYPES = [ - 'text', - 'number', - 'currency', - 'date', - 'datetime', - 'boolean', - 'enum', - 'ref', -] as const; - -type FieldType = (typeof FIELD_TYPES)[number]; - -const FIELD_TYPE_SET = new Set(FIELD_TYPES); - -export function isFieldType(value: string): value is FieldType { - return FIELD_TYPE_SET.has(value); -} diff --git a/services/platform/lib/shared/platform/filtered_pagination.test.ts b/services/platform/lib/shared/platform/filtered_pagination.test.ts deleted file mode 100644 index 4ff7d97ed5..0000000000 --- a/services/platform/lib/shared/platform/filtered_pagination.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { collectFilteredPage, type SourcePage } from './filtered_pagination'; - -/** A fake source backed by an array of pages; page is 1-indexed. */ -function source(pages: SourcePage[]) { - const fetchSourcePage = vi.fn( - async (page: number): Promise => - pages[page - 1] ?? { rows: [], hasNext: false }, - ); - return { fetchSourcePage }; -} - -const issues = (...nums: number[]) => nums.map((number) => ({ number })); -const base = { - excluded: new Set(), - rowKeyTemplate: 'r#{number}', - perPage: 5, - pageBudget: 10, -}; - -describe('collectFilteredPage', () => { - it('fills a full page from one source page and yields a mid-page cursor', async () => { - const { fetchSourcePage } = source([ - { rows: issues(1, 2, 3, 4), hasNext: true }, - ]); - - const out = await collectFilteredPage({ - ...base, - perPage: 2, - fetchSourcePage, - }); - - expect(out.data).toEqual(issues(1, 2)); - expect(out.pagination).toEqual({ - hasNextPage: true, - nextCursor: { sourcePage: 1, sourceOffset: 2 }, - }); - expect(fetchSourcePage).toHaveBeenCalledTimes(1); - }); - - it('accumulates across source pages until perPage visible rows are collected', async () => { - const { fetchSourcePage } = source([ - { rows: issues(1, 2), hasNext: true }, - { rows: issues(3, 4), hasNext: true }, - ]); - - const out = await collectFilteredPage({ - ...base, - perPage: 3, - fetchSourcePage, - }); - - expect(out.data).toEqual(issues(1, 2, 3)); - expect(out.pagination.nextCursor).toEqual({ - sourcePage: 2, - sourceOffset: 1, - }); - expect(fetchSourcePage).toHaveBeenCalledTimes(2); - }); - - it('drops rows failing rowWhen (e.g. pull requests)', async () => { - const { fetchSourcePage } = source([ - { - rows: [{ number: 1, pull_request: {} }, { number: 2 }, { number: 3 }], - hasNext: false, - }, - ]); - - const out = await collectFilteredPage({ - ...base, - rowWhen: '!pull_request', - fetchSourcePage, - }); - - expect(out.data).toEqual(issues(2, 3)); - expect(out.pagination).toEqual({ hasNextPage: false, nextCursor: null }); - }); - - it('drops rows whose key is in the exclusion set', async () => { - const { fetchSourcePage } = source([ - { rows: issues(1, 2, 3), hasNext: false }, - ]); - - const out = await collectFilteredPage({ - ...base, - excluded: new Set(['r#2']), - fetchSourcePage, - }); - - expect(out.data).toEqual(issues(1, 3)); - expect(out.pagination.nextCursor).toBeNull(); - }); - - it('returns nextCursor null when the source is exhausted before the page fills', async () => { - const { fetchSourcePage } = source([ - { rows: issues(1, 2), hasNext: false }, - ]); - - const out = await collectFilteredPage({ ...base, fetchSourcePage }); - - expect(out.data).toEqual(issues(1, 2)); - expect(out.pagination).toEqual({ hasNextPage: false, nextCursor: null }); - }); - - it('stops at the page budget with a resume cursor when nothing visible yet', async () => { - // Every row is excluded and the source always reports more pages; the budget - // (3) — NOT a count of visible rows — bounds the single call. - const pages: SourcePage[] = Array.from({ length: 20 }, (_, i) => ({ - rows: issues(i + 1), - hasNext: true, - })); - const { fetchSourcePage } = source(pages); - - const out = await collectFilteredPage({ - ...base, - excluded: new Set(pages.map((_, i) => `r#${i + 1}`)), - pageBudget: 3, - fetchSourcePage, - }); - - expect(out.data).toEqual([]); - expect(out.pagination).toEqual({ - hasNextPage: true, - nextCursor: { sourcePage: 4, sourceOffset: 0 }, - }); - expect(fetchSourcePage).toHaveBeenCalledTimes(3); - }); - - it('resumes from a given cursor, skipping already-emitted rows', async () => { - const { fetchSourcePage } = source([ - { rows: issues(1, 2, 3, 4), hasNext: false }, - ]); - - const out = await collectFilteredPage({ - ...base, - cursor: { sourcePage: 1, sourceOffset: 2 }, - fetchSourcePage, - }); - - expect(out.data).toEqual(issues(3, 4)); - expect(out.pagination.nextCursor).toBeNull(); - }); - - it('skips non-record rows', async () => { - const { fetchSourcePage } = source([ - { rows: [{ number: 1 }, 'nope', null, { number: 2 }], hasNext: false }, - ]); - - const out = await collectFilteredPage({ ...base, fetchSourcePage }); - - expect(out.data).toEqual(issues(1, 2)); - }); -}); diff --git a/services/platform/lib/shared/platform/filtered_pagination.ts b/services/platform/lib/shared/platform/filtered_pagination.ts deleted file mode 100644 index 866b69b94b..0000000000 --- a/services/platform/lib/shared/platform/filtered_pagination.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Server-side FILTERED pagination — the heart of "page 1 is a full page of - * visible rows". A list whose rows are filtered AFTER the fetch (drop pull - * requests, drop issues already tracked as tasks) can't be paged by raw source - * pages: a whole source page can filter to empty even when later pages have - * matches. This walks the source page-by-page and keeps pulling until it has - * filled `perPage` VISIBLE rows (or the source / the per-call budget is spent), - * returning the visible rows plus an opaque cursor to resume from. - * - * The only bounds are real ones: the page is full, the source is exhausted - * (`hasNext` false ⇒ `nextCursor: null`), or the per-call resource budget - * (`pageBudget`) is hit — in which case it returns what it has plus a cursor, so - * the result is always a correct PREFIX of the filtered stream and never - * dead-ends. There is no probabilistic page-count cap. - * - * I/O is injected (`fetchSourcePage`) so this is a pure, exhaustively-testable - * function; the Convex action supplies the real upstream fetch + exclusion set. - */ -import { interpolateTemplate } from '../utils/interpolate'; -import { evaluateWhen } from './when_predicate'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** One raw source page: its rows and whether the source has a page after it. */ -export interface SourcePage { - rows: unknown[]; - hasNext: boolean; -} - -/** Resume point: 1-indexed source page + index of the first un-emitted row. */ -interface PageCursor { - sourcePage: number; - sourceOffset: number; -} - -interface FilteredPage { - data: Record[]; - pagination: { hasNextPage: boolean; nextCursor: PageCursor | null }; -} - -export async function collectFilteredPage(opts: { - /** Fetch one 1-indexed source page. */ - fetchSourcePage: (page: number) => Promise; - /** Keys already materialized elsewhere — rows whose key is here are dropped. */ - excluded: Set; - /** `{field}` template rebuilding a row's exclusion key. */ - rowKeyTemplate: string; - /** Values merged UNDER each row when interpolating the key (e.g. a configured - * `owner`/`repo` the row itself doesn't carry); row fields win a name clash. - * Must match what the materialize/create path wrote the key from. */ - templateScope?: Record; - /** Optional `when_predicate` row filter (e.g. `!pull_request`). */ - rowWhen?: string; - /** Target page size in VISIBLE (post-filter) rows. */ - perPage: number; - /** Where to resume; absent ⇒ start at the source's first page. */ - cursor?: PageCursor; - /** Max source pages to scan in one call before yielding a cursor. */ - pageBudget: number; -}): Promise { - const { - fetchSourcePage, - excluded, - rowKeyTemplate, - templateScope, - rowWhen, - perPage, - cursor, - pageBudget, - } = opts; - - const visible: Record[] = []; - let sourcePage = cursor?.sourcePage ?? 1; - let sourceOffset = cursor?.sourceOffset ?? 0; - let pagesScanned = 0; - - const done = (nextCursor: PageCursor | null): FilteredPage => ({ - data: visible, - pagination: { hasNextPage: nextCursor !== null, nextCursor }, - }); - - for (;;) { - const { rows, hasNext } = await fetchSourcePage(sourcePage); - - for (let i = sourceOffset; i < rows.length; i++) { - const row = rows[i]; - if (!isRecord(row)) continue; - if (rowWhen && !evaluateWhen(rowWhen, row)) continue; - const key = interpolateTemplate(rowKeyTemplate, { - ...templateScope, - ...row, - }); - if (excluded.has(key)) continue; - visible.push(row); - if (visible.length >= perPage) { - // Page filled mid-source-page — resume from the next row next time. - return done({ sourcePage, sourceOffset: i + 1 }); - } - } - - sourcePage += 1; - sourceOffset = 0; - pagesScanned += 1; - - if (!hasNext) return done(null); // true end of the source - if (pagesScanned >= pageBudget) - return done({ sourcePage, sourceOffset: 0 }); - } -} diff --git a/services/platform/lib/shared/platform/function_bindings.test.ts b/services/platform/lib/shared/platform/function_bindings.test.ts deleted file mode 100644 index f04c522189..0000000000 --- a/services/platform/lib/shared/platform/function_bindings.test.ts +++ /dev/null @@ -1,733 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - type FunctionBinding, - argsReferenceProjectId, - argsReferenceViewState, - bindingArgsResolved, - collectViewBindings, - isFunctionAllowed, - isValidFunctionPath, - resolveBindingArgs, - validateViewBindings, -} from './function_bindings'; - -const allowlist: FunctionBinding[] = [ - { path: 'tasks/queries:listTasksByOrg', mode: 'query' }, - { path: 'tasks/mutations:assignTask', mode: 'mutation' }, - { path: 'workflow_executions/actions:startWorkflowFromFile', mode: 'action' }, -]; - -describe('isValidFunctionPath', () => { - it('accepts the makeFunctionReference form (dir/file:export)', () => { - expect(isValidFunctionPath('tasks/queries:listTasksByOrg')).toBe(true); - expect(isValidFunctionPath('approvals/queries:listActive')).toBe(true); - }); - it('rejects malformed paths', () => { - expect(isValidFunctionPath('tasks.queries.listTasksByOrg')).toBe(false); - expect(isValidFunctionPath('no-colon')).toBe(false); - expect(isValidFunctionPath('tasks/queries:')).toBe(false); - expect(isValidFunctionPath('a b:c')).toBe(false); - }); -}); - -describe('isFunctionAllowed', () => { - it('allows a declared path (optionally checking mode)', () => { - expect(isFunctionAllowed('tasks/queries:listTasksByOrg', allowlist)).toBe( - true, - ); - expect( - isFunctionAllowed('tasks/mutations:assignTask', allowlist, 'mutation'), - ).toBe(true); - }); - it('rejects undeclared paths, wrong modes, and a missing allowlist', () => { - expect(isFunctionAllowed('secret/admin:wipe', allowlist)).toBe(false); - expect( - isFunctionAllowed('tasks/mutations:assignTask', allowlist, 'query'), - ).toBe(false); - expect(isFunctionAllowed('tasks/queries:listTasksByOrg', undefined)).toBe( - false, - ); - }); -}); - -describe('resolveBindingArgs', () => { - const ctx = { organizationId: 'org_1', selected: { _id: 't1', title: 'X' } }; - it('substitutes $orgId, $selected, and $selected.', () => { - expect(resolveBindingArgs('$orgId', ctx)).toBe('org_1'); - expect(resolveBindingArgs('$selected', ctx)).toEqual(ctx.selected); - expect(resolveBindingArgs('$selected._id', ctx)).toBe('t1'); - }); - it('substitutes $projectName for Form initial prefill', () => { - expect( - resolveBindingArgs('$projectName', { - organizationId: 'org_1', - projectName: 'SoftInstall Pro Ltd', - }), - ).toBe('SoftInstall Pro Ltd'); - expect( - resolveBindingArgs('$projectName', { organizationId: 'org_1' }), - ).toBeUndefined(); - }); - it('recurses through nested records + arrays, leaving literals', () => { - expect( - resolveBindingArgs( - { organizationId: '$orgId', input: { task: '$selected' }, n: 5 }, - ctx, - ), - ).toEqual({ organizationId: 'org_1', input: { task: ctx.selected }, n: 5 }); - }); - it('substitutes $result and $result. for onSuccess effects', () => { - const rctx = { organizationId: 'org_1', result: { taskId: 't9' } }; - expect(resolveBindingArgs('$result', rctx)).toEqual(rctx.result); - expect(resolveBindingArgs('$result.taskId', rctx)).toBe('t9'); - // An openDetail effect: only the templated id is substituted; the rest stays. - expect( - resolveBindingArgs( - { kind: 'openDetail', subjectType: 'task', id: '$result.taskId' }, - rctx, - ), - ).toEqual({ kind: 'openDetail', subjectType: 'task', id: 't9' }); - }); - it('leaves $result. untouched when no result is in context', () => { - expect(resolveBindingArgs('$result.taskId', ctx)).toBe('$result.taskId'); - }); - it('substitutes $projectId for project-scoped apps', () => { - expect( - resolveBindingArgs('$projectId', { - organizationId: 'org_1', - projectId: 'proj_1', - }), - ).toBe('proj_1'); - }); - it('resolves unbound $projectId to undefined so callers gate the call', () => { - // Same posture as `$config:` / `$state.`: unresolved → undefined → - // `bindingArgsResolved` false → empty state instead of a Convex reject. - expect(resolveBindingArgs('$projectId', ctx)).toBeUndefined(); - expect( - bindingArgsResolved( - resolveBindingArgs( - { organizationId: '$orgId', projectId: '$projectId' }, - ctx, - ), - ), - ).toBe(false); - }); - it('interpolates $tpl: row fields into a string ({field} syntax)', () => { - const tctx = { - organizationId: 'org_1', - selected: { owner: 'acme', repo: 'app', number: 42 }, - }; - expect(resolveBindingArgs('$tpl:{owner}/{repo}#{number}', tctx)).toBe( - 'acme/app#42', - ); - // Unknown fields stay verbatim (fail-visible). - expect(resolveBindingArgs('$tpl:{missing}', tctx)).toBe('{missing}'); - }); - it('substitutes $config: from the per-install config', () => { - const cctx = { - organizationId: 'org_1', - config: { owner: 'acme', repo: 'widgets' }, - }; - expect(resolveBindingArgs('$config:owner', cctx)).toBe('acme'); - expect(resolveBindingArgs('$config:repo', cctx)).toBe('widgets'); - // Unset key → undefined (a visible miss, not a literal). - expect(resolveBindingArgs('$config:missing', cctx)).toBeUndefined(); - }); - it('$tpl: mixes config and row fields so one key can target the configured repo', () => { - const ctx = { - organizationId: 'org_1', - config: { owner: 'acme', repo: 'widgets' }, - selected: { number: 42 }, - }; - expect(resolveBindingArgs('$tpl:{owner}/{repo}#{number}', ctx)).toBe( - 'acme/widgets#42', - ); - }); - it('$tpl: includes projectId and form input for Form submits', () => { - const ctx = { - organizationId: 'org_1', - projectId: 'proj_9', - input: { caseId: 'CASE-1' }, - }; - expect(resolveBindingArgs('$tpl:acme:{projectId}:profile.yaml', ctx)).toBe( - 'acme:proj_9:profile.yaml', - ); - expect(resolveBindingArgs('$tpl:uid={caseId}', ctx)).toBe('uid=CASE-1'); - }); - it('leaves a bare $label: string verbatim — the retired sentinel is no longer recognized', () => { - // Display strings are literals now (UI translations are platform-owned); - // `resolveBindingArgs` has no special case for this prefix any more, so a - // value that happens to start with it just passes through unchanged. - expect( - resolveBindingArgs('$label:automation.task', { organizationId: 'org_1' }), - ).toBe('$label:automation.task'); - }); - - it('substitutes $state. from the cross-block view state', () => { - const sctx = { - organizationId: 'org_1', - state: { conversationId: 'c1', taskId: 't2' }, - }; - expect(resolveBindingArgs('$state.conversationId', sctx)).toBe('c1'); - expect(resolveBindingArgs('$state.taskId', sctx)).toBe('t2'); - }); - - it('$state. resolves to undefined when unset (gates, not a literal)', () => { - // Same posture as `$config:` / `$projectId`: an unset state key must gate - // the call so the block shows its awaiting placeholder. - expect( - resolveBindingArgs('$state.conversationId', { - organizationId: 'org_1', - state: {}, - }), - ).toBeUndefined(); - expect( - resolveBindingArgs('$state.conversationId', { organizationId: 'org_1' }), - ).toBeUndefined(); - }); - - it('substitutes $selection.ids with the multi-select ids', () => { - expect( - resolveBindingArgs('$selection.ids', { - organizationId: 'org_1', - selectionIds: ['c1', 'c2'], - }), - ).toEqual(['c1', 'c2']); - expect( - resolveBindingArgs('$selection.ids', { organizationId: 'org_1' }), - ).toBeUndefined(); - }); - - it('substitutes $input. from the submitted values', () => { - const ictx = { - organizationId: 'org_1', - input: { body: 'Hello', priority: 2 }, - }; - expect(resolveBindingArgs('$input.body', ictx)).toBe('Hello'); - expect(resolveBindingArgs('$input.priority', ictx)).toBe(2); - expect(resolveBindingArgs('$input.missing', ictx)).toBeUndefined(); - expect( - resolveBindingArgs('$input.body', { organizationId: 'org_1' }), - ).toBeUndefined(); - }); - - it('substitutes $lane with the drop-target lane', () => { - expect( - resolveBindingArgs('$lane', { organizationId: 'org_1', lane: 'done' }), - ).toBe('done'); - expect( - resolveBindingArgs('$lane', { organizationId: 'org_1' }), - ).toBeUndefined(); - }); - - it('resolves the new sentinels inside nested args trees', () => { - expect( - resolveBindingArgs( - { - organizationId: '$orgId', - conversationId: '$state.conversationId', - input: { body: '$input.body' }, - ids: '$selection.ids', - status: '$lane', - }, - { - organizationId: 'org_1', - state: { conversationId: 'c1' }, - input: { body: 'Hi' }, - selectionIds: ['a', 'b'], - lane: 'open', - }, - ), - ).toEqual({ - organizationId: 'org_1', - conversationId: 'c1', - input: { body: 'Hi' }, - ids: ['a', 'b'], - status: 'open', - }); - }); -}); - -describe('collectViewBindings + validateViewBindings', () => { - const view = { - data: { - content: [ - { - type: 'Collection', - props: { - query: { path: 'tasks/queries:listTasksByOrg' }, - actions: [ - { - path: 'workflow_executions/actions:startWorkflowFromFile', - mode: 'action', - }, - { path: 'tasks/mutations:assignTask', mode: 'mutation' }, - ], - }, - }, - ], - }, - }; - - it('collects query + action bindings from a Puck view', () => { - expect(collectViewBindings(view)).toEqual([ - { path: 'tasks/queries:listTasksByOrg', mode: 'query' }, - { - path: 'workflow_executions/actions:startWorkflowFromFile', - mode: 'action', - }, - { path: 'tasks/mutations:assignTask', mode: 'mutation' }, - ]); - }); - - it('passes when every bound path is allowlisted', () => { - expect(validateViewBindings(view, allowlist)).toEqual([]); - }); - - it('collects an action-sourced list `source` (mode defaults to action)', () => { - const sourced = { - data: { - content: [ - { - type: 'ExternalList', - props: { - source: { path: 'connectors/public_actions:listGitHubIssues' }, - actions: [ - { - path: 'tasks/public_actions:createTaskFromExternalIssue', - mode: 'action', - }, - ], - }, - }, - ], - }, - }; - expect(collectViewBindings(sourced)).toEqual([ - { - path: 'connectors/public_actions:listGitHubIssues', - mode: 'action', - }, - { - path: 'tasks/public_actions:createTaskFromExternalIssue', - mode: 'action', - }, - ]); - }); - - it('collects an ExternalList `excludeBy` cross-reference query (mode query)', () => { - const withExclude = { - data: { - content: [ - { - type: 'ExternalList', - props: { - source: { path: 'connectors/public_actions:listGitHubIssues' }, - excludeBy: { - query: { path: 'tasks/queries:listTasksByOrg' }, - refField: 'externalId', - rowKeyTemplate: 'tale-project/tale#{number}', - }, - }, - }, - ], - }, - }; - expect(collectViewBindings(withExclude)).toEqual([ - { path: 'tasks/queries:listTasksByOrg', mode: 'query' }, - { - path: 'connectors/public_actions:listGitHubIssues', - mode: 'action', - }, - ]); - }); - - it('collects nodes from Puck `zones` arrays like `content`', () => { - const zoned = { - data: { - content: [ - { - type: 'Collection', - props: { query: { path: 'tasks/queries:listTasksByOrg' } }, - }, - ], - zones: { - 'node-1:left': [ - { - type: 'ConversationList', - props: { - query: { path: 'conversations/queries:listConversations' }, - }, - }, - ], - 'node-1:right': [ - { - type: 'MessageComposer', - props: { - submit: { - path: 'conversations/mutations:replyToConversation', - mode: 'mutation', - }, - }, - }, - ], - }, - }, - }; - expect(collectViewBindings(zoned)).toEqual([ - { path: 'tasks/queries:listTasksByOrg', mode: 'query' }, - { path: 'conversations/queries:listConversations', mode: 'query' }, - { - path: 'conversations/mutations:replyToConversation', - mode: 'mutation', - }, - ]); - }); - - it('collects the named single-action props (move/submit/improve/onOpen/attachmentAction)', () => { - const node = (props: Record) => ({ - data: { content: [{ type: 'X', props }] }, - }); - expect( - collectViewBindings( - node({ move: { path: 'tasks/mutations:moveTask', mode: 'mutation' } }), - ), - ).toEqual([{ path: 'tasks/mutations:moveTask', mode: 'mutation' }]); - expect( - collectViewBindings( - node({ - submit: { path: 'tasks/mutations:createTask', mode: 'mutation' }, - improve: { - path: 'conversations/actions:improveMessage', - mode: 'action', - }, - }), - ), - ).toEqual([ - { path: 'tasks/mutations:createTask', mode: 'mutation' }, - { path: 'conversations/actions:improveMessage', mode: 'action' }, - ]); - expect( - collectViewBindings( - node({ - onOpen: { - path: 'conversations/mutations:markRead', - mode: 'mutation', - }, - attachmentAction: { - path: 'conversations/actions:getAttachment', - mode: 'action', - }, - }), - ), - ).toEqual([ - { path: 'conversations/mutations:markRead', mode: 'mutation' }, - { path: 'conversations/actions:getAttachment', mode: 'action' }, - ]); - }); - - it('collects the secondary `count` query binding (mode query)', () => { - const withCount = { - data: { - content: [ - { - type: 'ConversationList', - props: { - query: { path: 'conversations/queries:listConversations' }, - count: { path: 'conversations/queries:countByStatus' }, - }, - }, - ], - }, - }; - expect(collectViewBindings(withCount)).toEqual([ - { path: 'conversations/queries:listConversations', mode: 'query' }, - { path: 'conversations/queries:countByStatus', mode: 'query' }, - ]); - }); - - it('collects `bulkActions[]` like `actions[]`', () => { - const withBulk = { - data: { - content: [ - { - type: 'ConversationList', - props: { - query: { path: 'conversations/queries:listConversations' }, - bulkActions: [ - { - path: 'conversations/mutations:bulkArchive', - mode: 'mutation', - }, - { path: 'conversations/mutations:bulkClose', mode: 'mutation' }, - ], - }, - }, - ], - }, - }; - expect(collectViewBindings(withBulk)).toEqual([ - { path: 'conversations/queries:listConversations', mode: 'query' }, - { path: 'conversations/mutations:bulkArchive', mode: 'mutation' }, - { path: 'conversations/mutations:bulkClose', mode: 'mutation' }, - ]); - }); - - it('skips malformed single-action props (no path/mode) without throwing', () => { - const malformed = { - data: { - content: [ - { - type: 'X', - props: { - move: { path: 'tasks/mutations:moveTask' }, // mode missing - submit: 'not-an-object', - count: { args: {} }, // path missing - bulkActions: [{ mode: 'mutation' }], - }, - }, - ], - }, - }; - expect(collectViewBindings(malformed)).toEqual([]); - }); - - it('flags a bound path missing from the allowlist', () => { - const offending = { - data: { - content: [ - { type: 'Collection', props: { query: { path: 'secret/x:peek' } } }, - ], - }, - }; - const errors = validateViewBindings(offending, allowlist); - expect(errors.length).toBe(1); - expect(errors[0]).toContain('secret/x:peek'); - }); - - it('collects the `addAction` bound action (the header create affordance)', () => { - const withAdd = { - data: { - content: [ - { - type: 'Collection', - props: { - query: { path: 'tasks/queries:listTasksByOrg' }, - addAction: { - path: 'tasks/mutations:createTask', - mode: 'mutation', - }, - }, - }, - ], - }, - }; - expect(collectViewBindings(withAdd)).toEqual([ - { path: 'tasks/queries:listTasksByOrg', mode: 'query' }, - { path: 'tasks/mutations:createTask', mode: 'mutation' }, - ]); - }); -}); - -/** - * Completeness guard: the collector must see EVERY binding-bearing prop the - * view schema admits — a prop carrying `{path, mode}` that the collector - * misses is invisible to publish-time allowlist validation. When a block - * schema gains a new binding prop, extend the collector AND this fixture in - * the same change. - */ -describe('collector covers every binding-bearing schema prop', () => { - it('collects all binding props across blocks, tabs, columns and zones', () => { - const everyBindingProp = { - tabs: [ - { - id: 'a', - label: 'A', - columns: [ - { - content: [ - { - type: 'ConversationList', - props: { - query: { path: 'd/f:listQ' }, - count: { path: 'd/f:countQ' }, - onOpen: { path: 'd/f:openM', mode: 'mutation' }, - bulkActions: [{ path: 'd/f:bulkM', mode: 'mutation' }], - }, - }, - ], - zones: { - 'a:side': [ - { - type: 'ConversationThread', - props: { - query: { path: 'd/f:threadQ' }, - attachmentAction: { - path: 'd/f:attachM', - mode: 'mutation', - }, - actions: [{ path: 'd/f:verbM', mode: 'mutation' }], - }, - }, - ], - }, - }, - { - content: [ - { - type: 'MessageComposer', - props: { - submit: { path: 'd/f:sendM', mode: 'mutation' }, - improve: { path: 'd/f:improveA', mode: 'action' }, - }, - }, - { - type: 'Board', - props: { - query: { path: 'd/f:boardQ' }, - move: { path: 'd/f:moveM', mode: 'mutation' }, - }, - }, - { - type: 'ExternalList', - props: { - source: { path: 'd/f:sourceA' }, - excludeBy: { query: { path: 'd/f:excludeQ' } }, - addAction: { path: 'd/f:addM', mode: 'mutation' }, - }, - }, - { - type: 'Form', - props: { - whenQuery: { path: 'd/f:gateQ' }, - submit: { path: 'd/f:submitM', mode: 'mutation' }, - }, - }, - ], - }, - ], - }, - ], - }; - const paths = collectViewBindings(everyBindingProp) - .map((b) => b.path) - .sort(); - expect(paths).toEqual( - [ - 'd/f:listQ', - 'd/f:countQ', - 'd/f:openM', - 'd/f:bulkM', - 'd/f:threadQ', - 'd/f:attachM', - 'd/f:verbM', - 'd/f:sendM', - 'd/f:improveA', - 'd/f:boardQ', - 'd/f:moveM', - 'd/f:sourceA', - 'd/f:excludeQ', - 'd/f:addM', - 'd/f:gateQ', - 'd/f:submitM', - ].sort(), - ); - }); -}); - -describe('argsReferenceViewState', () => { - it('detects $state. references at any depth', () => { - expect(argsReferenceViewState('$state.selected')).toBe(true); - expect(argsReferenceViewState({ a: { b: '$state.k' } })).toBe(true); - expect(argsReferenceViewState(['x', '$state.y'])).toBe(true); - }); - - it('ignores literals, other sentinels, and non-strings', () => { - expect(argsReferenceViewState('$tpl:{owner}/{repo}')).toBe(false); - expect(argsReferenceViewState('state.x')).toBe(false); - expect(argsReferenceViewState({ a: 42, b: null })).toBe(false); - expect(argsReferenceViewState(undefined)).toBe(false); - }); -}); - -describe('argsReferenceProjectId', () => { - it('detects $projectId at any depth', () => { - expect(argsReferenceProjectId('$projectId')).toBe(true); - expect(argsReferenceProjectId({ projectId: '$projectId' })).toBe(true); - expect(argsReferenceProjectId(['x', { a: '$projectId' }])).toBe(true); - }); - - it('ignores other sentinels and non-strings', () => { - expect(argsReferenceProjectId('$orgId')).toBe(false); - expect(argsReferenceProjectId('$state.projectId')).toBe(false); - expect(argsReferenceProjectId({ a: 42 })).toBe(false); - expect(argsReferenceProjectId(undefined)).toBe(false); - }); -}); - -describe('bindingArgsResolved', () => { - it('is true when every value is bound (no undefined)', () => { - const resolved = resolveBindingArgs( - { organizationId: '$orgId', owner: '$config:owner', state: 'open' }, - { organizationId: 'org_1', config: { owner: 'acme' } }, - ); - expect(bindingArgsResolved(resolved)).toBe(true); - }); - - it('is false when a $config: reference is unset (resolves to undefined)', () => { - const resolved = resolveBindingArgs( - { organizationId: '$orgId', owner: '$config:owner' }, - { organizationId: 'org_1', config: {} }, - ); - expect(bindingArgsResolved(resolved)).toBe(false); - }); - - it('recurses through nested objects and arrays', () => { - expect(bindingArgsResolved({ a: { b: [1, 'x', true] } })).toBe(true); - expect(bindingArgsResolved({ a: { b: [1, undefined] } })).toBe(false); - expect(bindingArgsResolved([{ ok: 'y' }, { ok: undefined }])).toBe(false); - }); - - it('treats null as bound (only undefined gates the call)', () => { - expect(bindingArgsResolved({ a: null })).toBe(true); - }); - - it('is false when a $state/$input/$lane/$selection reference is unavailable', () => { - const ctx = { organizationId: 'org_1', state: {} }; - expect( - bindingArgsResolved( - resolveBindingArgs( - { organizationId: '$orgId', conversationId: '$state.conversationId' }, - ctx, - ), - ), - ).toBe(false); - expect( - bindingArgsResolved(resolveBindingArgs({ body: '$input.body' }, ctx)), - ).toBe(false); - expect( - bindingArgsResolved(resolveBindingArgs({ status: '$lane' }, ctx)), - ).toBe(false); - expect( - bindingArgsResolved(resolveBindingArgs({ ids: '$selection.ids' }, ctx)), - ).toBe(false); - }); - - it('is true once the referenced view-state values are live', () => { - const resolved = resolveBindingArgs( - { - organizationId: '$orgId', - conversationId: '$state.conversationId', - ids: '$selection.ids', - }, - { - organizationId: 'org_1', - state: { conversationId: 'c1' }, - selectionIds: [], - }, - ); - // An EMPTY selection is still a bound value (the action decides emptiness). - expect(bindingArgsResolved(resolved)).toBe(true); - }); -}); diff --git a/services/platform/lib/shared/platform/function_bindings.ts b/services/platform/lib/shared/platform/function_bindings.ts deleted file mode 100644 index 7bf0140a50..0000000000 --- a/services/platform/lib/shared/platform/function_bindings.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * The generic, capability-gated function-binding vocabulary — the "data freedom" - * half of the configurable app surface. An app declares an ALLOWLIST of public - * Convex functions it may call (`capabilities.functions`); a bound component or - * action invokes one by its reference path — any public function the app - * declares, rather than a fixed, platform-defined set of named data-sources and - * action verbs. - * - * Security posture (Phase 1, first-party authors): the allowlist is the app's - * declared intent — validated at publish, checked client-side before dispatch, - * and audited. The authoritative boundary remains each function's own auth/RLS - * (every public Convex function gates itself) + the public/internal split - * (internal functions are unreachable by a client reference). A server-side - * dispatch gate that re-checks the allowlist is the Phase-3 hardening for - * untrusted authors. Mirrors the `skillBindings`/`connectorBindings` model: - * an explicit allowlist with no implicit fallback. - */ - -import { interpolateTemplate } from '../utils/interpolate'; - -export const FUNCTION_MODES = ['query', 'mutation', 'action'] as const; -export type FunctionMode = (typeof FUNCTION_MODES)[number]; - -export interface FunctionBinding { - /** - * Convex function reference, in `makeFunctionReference` format: - * `/:` (e.g. `tasks/queries:listTasksByOrg`). The slash - * separates path segments; the colon precedes the export name. - */ - path: string; - mode: FunctionMode; -} - -const PATH_RE = /^[a-zA-Z0-9_]+(\/[a-zA-Z0-9_]+)*:[a-zA-Z0-9_]+$/; - -function isFunctionMode(value: string): value is FunctionMode { - return (FUNCTION_MODES as readonly string[]).includes(value); -} - -/** Shape check for a reference path (not existence — that's verified at runtime). */ -export function isValidFunctionPath(path: string): boolean { - return PATH_RE.test(path); -} - -/** Whether `path` is declared in the app's allowlist (optionally requiring `mode`). */ -export function isFunctionAllowed( - path: string, - allowlist: readonly FunctionBinding[] | undefined, - mode?: FunctionMode, -): boolean { - if (!allowlist) return false; - return allowlist.some( - (b) => b.path === path && (mode === undefined || b.mode === mode), - ); -} - -function isRec(v: unknown): v is Record { - return typeof v === 'object' && v !== null && !Array.isArray(v); -} - -type CollectedBinding = { path: string; mode: FunctionMode }; - -/** - * Named single-action props the collector walks — each a `{path, mode}` bound - * action, collected exactly like an `actions[]` entry: `Board.move`, - * `Form`/`MessageComposer` `submit`, `MessageComposer.improve`, - * `ConversationList.onOpen`, `ConversationThread.attachmentAction`, - * `Collection.addAction` (the header create affordance). - */ -const SINGLE_ACTION_PROPS = [ - 'move', - 'submit', - 'improve', - 'onOpen', - 'attachmentAction', - 'addAction', -] as const; - -/** Push one `{path, mode}` bound-action record (skips malformed shapes). */ -function pushBoundAction(a: unknown, out: CollectedBinding[]): void { - if ( - isRec(a) && - typeof a.path === 'string' && - typeof a.mode === 'string' && - isFunctionMode(a.mode) - ) { - out.push({ path: a.path, mode: a.mode }); - } -} - -/** Collect bindings from one block node's props. */ -function collectFromNode(node: unknown, out: CollectedBinding[]): void { - if (!isRec(node) || !isRec(node.props)) return; - const props = node.props; - if (isRec(props.query) && typeof props.query.path === 'string') { - out.push({ path: props.query.path, mode: 'query' }); - } - // Optional visibility gate (`Form` / `Text` / `Alert` `whenQuery`) — a - // reactive read like `props.query`, collected so publish allowlists it. - if (isRec(props.whenQuery) && typeof props.whenQuery.path === 'string') { - out.push({ path: props.whenQuery.path, mode: 'query' }); - } - // A list block may cross-reference a second reactive query under `excludeBy` - // (hide rows already materialized elsewhere); collect it so the cross-ref - // query is allowlist-checked like any other binding. - if ( - isRec(props.excludeBy) && - isRec(props.excludeBy.query) && - typeof props.excludeBy.query.path === 'string' - ) { - out.push({ path: props.excludeBy.query.path, mode: 'query' }); - } - // An action-sourced list (`ExternalList`) declares its data fetch under - // `source`, not `query`; collect it so it's allowlist-checked like the rest. - if (isRec(props.source) && typeof props.source.path === 'string') { - const mode = - typeof props.source.mode === 'string' && isFunctionMode(props.source.mode) - ? props.source.mode - : 'action'; - out.push({ path: props.source.path, mode }); - } - if (Array.isArray(props.actions)) { - for (const a of props.actions) pushBoundAction(a, out); - } - // Named single-action props (Board `move`, Form/Composer `submit`, …). - for (const key of SINGLE_ACTION_PROPS) pushBoundAction(props[key], out); - // A secondary read binding (`ConversationList.count`) — a query like - // `props.query` (per-status totals for tab badges). - if (isRec(props.count) && typeof props.count.path === 'string') { - out.push({ path: props.count.path, mode: 'query' }); - } - // Multi-select bulk actions (args bind ids via `$selection.ids`) — the same - // shape as `actions[]`. - if (Array.isArray(props.bulkActions)) { - for (const a of props.bulkActions) pushBoundAction(a, out); - } -} - -/** Collect bindings from one Puck Data document — its `content` array plus - * every dropzone array under `zones` (Puck stores zone children per zone id - * at the document level, siblings of `content`). */ -function collectFromData(data: unknown, out: CollectedBinding[]): void { - if (!isRec(data)) return; - if (Array.isArray(data.content)) { - for (const node of data.content) collectFromNode(node, out); - } - if (isRec(data.zones)) { - for (const zone of Object.values(data.zones)) { - if (!Array.isArray(zone)) continue; - for (const node of zone) collectFromNode(node, out); - } - } -} - -/** - * Collect every bound function path in a view — across the whole layout: a flat - * `data` document, a bare Puck Data (`content` at top level), or a tabbed shell - * (`tabs[].data` + `tabs[].columns[]`). Each connected block's `query.path` + - * each action's `path`/`mode`. - */ -export function collectViewBindings(view: unknown): CollectedBinding[] { - const out: CollectedBinding[] = []; - if (!isRec(view)) return out; - if (isRec(view.data)) collectFromData(view.data, out); - if (Array.isArray(view.content)) collectFromData(view, out); - if (Array.isArray(view.tabs)) { - for (const tab of view.tabs) { - if (!isRec(tab)) continue; - if (isRec(tab.data)) collectFromData(tab.data, out); - if (Array.isArray(tab.columns)) { - for (const col of tab.columns) collectFromData(col, out); - } - } - } - return out; -} - -/** - * Publish-time check: every bound path in a view is well-formed AND declared in - * the app's allowlist with a matching mode. Returns human-readable errors (empty - * = valid). The runtime hooks enforce the same gate at dispatch. - */ -export function validateViewBindings( - view: unknown, - allowlist: readonly FunctionBinding[] | undefined, -): string[] { - const errors: string[] = []; - for (const b of collectViewBindings(view)) { - if (!isValidFunctionPath(b.path)) { - errors.push(`malformed function path "${b.path}"`); - } else if (!isFunctionAllowed(b.path, allowlist, b.mode)) { - errors.push( - `function "${b.path}" (${b.mode}) is not in capabilities.functions`, - ); - } - } - return errors; -} - -/** - * Runtime arg-template substitution. A view authors args with sentinels so they - * stay data; the binding hooks resolve them against the live context before the - * call. Recurses through records + arrays. Whole-string sentinels: - * - `$orgId` → the current organization id; - * - `$projectId` → the bound project id (undefined when unbound); - * - `$projectName` → the bound project's display name (undefined until loaded); - * - `$selected` / `$selected.` → the selected row, or one of its fields; - * - `$result` / `$result.` → the just-resolved action result (used by - * `onSuccess` effects to read e.g. a created id). - * - `$config:` → the app's per-install config value for `key` (from - * `ctx.config`, e.g. a configured github `owner`/`repo`); undefined if unset. - * This is what keeps an app repo-agnostic — the operator's target is data, not - * a hardcoded literal. - * - `$state.` → a cross-block view-state value (from `ctx.state`, the - * view's `ViewStateProvider` — e.g. a master-detail `conversationId`); - * - `$selection.ids` → the invoking block's multi-select ids - * (`ctx.selectionIds`, for bulk actions); - * - `$input.` → a Form/Composer submit value (`ctx.input`); - * - `$lane` → the Board drop-target lane (`ctx.lane`). - * These four resolve to `undefined` when the referenced value is unavailable - * (state key unset, nothing selected, …) — the `$config:` posture — so - * `bindingArgsResolved` gates the call and the block shows its awaiting - * placeholder instead of firing a malformed request. - * Prefix templates (interpolated over the row MERGED WITH config, form input, - * and bound ids — `{field}` syntax; later layers win a name clash): - * - `$tpl:…{field}…` → the suffix as an `interpolateTemplate` over - * `{...config, ...selected, ...input, projectId, orgId}`, so one arg can - * mix config + row + form fields (e.g. `"$tpl:{owner}/{repo}#{number}"` — - * owner/repo from config, number from the row; or - * `"$tpl:acme:{projectId}:profile.yaml"` from a Form submit). - */ -export function resolveBindingArgs( - args: unknown, - ctx: { - organizationId: string; - /** Bound project id for a project-scoped app; undefined for org-scoped apps. */ - projectId?: string; - /** Bound project display name (`$projectName`); undefined until loaded. */ - projectName?: string; - selected?: Record; - result?: Record; - /** The app's per-install config values (`$config:`/template `{key}`). */ - config?: Record; - /** Cross-block view state (`$state.`) — the view's `ViewStateProvider`. */ - state?: Record; - /** The invoking block's multi-select ids (`$selection.ids`). */ - selectionIds?: string[]; - /** Form/Composer submit values (`$input.`). */ - input?: Record; - /** Board drop-target lane (`$lane`). */ - lane?: string; - }, -): unknown { - // Templates can reference config, the selected row, form input, and the - // bound project/org ids. Later layers win a name clash (input is the most - // specific per-submit value). - const templateScope = { - ...ctx.config, - ...ctx.selected, - ...ctx.input, - ...(ctx.projectId !== undefined ? { projectId: ctx.projectId } : {}), - ...(ctx.projectName !== undefined ? { projectName: ctx.projectName } : {}), - orgId: ctx.organizationId, - }; - if (typeof args === 'string') { - if (args === '$orgId') return ctx.organizationId; - // Unbound `$projectId` resolves to `undefined` (the `$config:` / `$state.` - // posture) so `bindingArgsResolved` is false and callers gate the call — - // an org-route visit to a project-scoped view shows an empty state instead - // of firing Convex with the literal `"$projectId"`. - if (args === '$projectId') return ctx.projectId; - if (args === '$projectName') return ctx.projectName; - if (args === '$selected') return ctx.selected; - if (args.startsWith('$selected.') && ctx.selected) { - return ctx.selected[args.slice('$selected.'.length)]; - } - if (args === '$result') return ctx.result; - if (args.startsWith('$result.') && ctx.result) { - return ctx.result[args.slice('$result.'.length)]; - } - if (args.startsWith('$config:')) { - return ctx.config?.[args.slice('$config:'.length)]; - } - // View-state sentinels: resolve to `undefined` (NOT the literal) when the - // referenced value is unavailable — the `$config:` posture — so - // `bindingArgsResolved` returns false and the caller gates the call. - if (args === '$lane') return ctx.lane; - if (args === '$selection.ids') return ctx.selectionIds; - if (args.startsWith('$state.')) { - return ctx.state?.[args.slice('$state.'.length)]; - } - if (args.startsWith('$input.')) { - return ctx.input?.[args.slice('$input.'.length)]; - } - if (args.startsWith('$tpl:')) { - return interpolateTemplate(args.slice('$tpl:'.length), templateScope); - } - return args; - } - if (Array.isArray(args)) { - return args.map((a) => resolveBindingArgs(a, ctx)); - } - if (args !== null && typeof args === 'object') { - const out: Record = {}; - for (const [k, v] of Object.entries(args as Record)) { - out[k] = resolveBindingArgs(v, ctx); - } - return out; - } - return args; -} - -/** - * Whether a resolved args tree is fully bound — i.e. holds no `undefined`. A - * `$config:` (or `$projectId` / `$selected.` / `$result.` / `$state.` / - * `$input.` / `$lane` / `$selection.ids`) reference whose value is absent - * resolves to `undefined`; a literal / `$orgId` never does. So an `undefined` - * anywhere means a binding the live context couldn't satisfy yet — typically - * an app whose `requires.config` hasn't been filled in, or a project-scoped - * view opened without a project. Callers gate the actual call on this so an - * unconfigured view shows an empty state instead of firing a malformed - * request (e.g. `listGitHubIssues` missing `owner`). - */ -export function bindingArgsResolved(resolved: unknown): boolean { - if (resolved === undefined) return false; - if (Array.isArray(resolved)) return resolved.every(bindingArgsResolved); - if (resolved !== null && typeof resolved === 'object') { - return Object.values(resolved as Record).every( - bindingArgsResolved, - ); - } - return true; -} - -/** - * Whether an authored args tree references cross-block view state — i.e. the - * block is wired to a selection a sibling block writes (`$state.`). Scans - * the RAW args (before resolution), matching `resolveBindingArgs`' string - * grammar: only whole values starting with `$state.` are state reads. The - * bound hooks report every unresolved binding as `needsConfig`; a block whose - * args bind view state reads that as "awaiting selection" instead (the - * `BindingStates.awaitingState` flavor) — e.g. a ConversationThread before - * any conversation is selected. - */ -export function argsReferenceViewState(args: unknown): boolean { - if (typeof args === 'string') return args.startsWith('$state.'); - if (Array.isArray(args)) return args.some(argsReferenceViewState); - if (args !== null && typeof args === 'object') { - return Object.values(args as Record).some( - argsReferenceViewState, - ); - } - return false; -} - -/** - * Whether an authored args tree references `$projectId` — i.e. the block is - * project-scoped. Same scan shape as `argsReferenceViewState`. Bound hooks - * report an unbound `$projectId` as `needsConfig`; callers that detect this - * sentinel read it as "open from a project" (`BindingStates.needsProject`) - * instead of the generic configure prompt. - */ -export function argsReferenceProjectId(args: unknown): boolean { - if (typeof args === 'string') return args === '$projectId'; - if (Array.isArray(args)) return args.some(argsReferenceProjectId); - if (args !== null && typeof args === 'object') { - return Object.values(args as Record).some( - argsReferenceProjectId, - ); - } - return false; -} diff --git a/services/platform/lib/shared/platform/part_state.ts b/services/platform/lib/shared/platform/part_state.ts deleted file mode 100644 index 33cda57def..0000000000 --- a/services/platform/lib/shared/platform/part_state.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * The lifecycle / streaming STATE axis — an orthogonal concern carried by every - * render-part, NOT a render-kind. Modeling it once here (rather than folding it - * into the `status` kind) means every panel can show loading / running / error / - * waiting / empty uniformly, and dissolves the need for separate error/empty/ - * wait render-kinds. The renderer wraps every kind in a shared part envelope - * that reads this from the step's runtime output. - */ -export const PART_STATES = [ - 'upcoming', // in the plan but not yet reached — a quiet preview row, no skeleton - 'skipped', // a conditional lane the run moved past without running (this round) - 'loading', - 'running', - 'queued_capacity', // park-on-capacity: queued behind the org's sandbox cap - 'output_available', - 'output_error', - 'waiting_human', - 'waiting_external', - 'empty', -] as const; - -export type PartState = (typeof PART_STATES)[number]; - -const PART_STATE_SET = new Set(PART_STATES); - -export function isPartState(value: string): value is PartState { - return PART_STATE_SET.has(value); -} - -/** - * SLA / escalation annotation tokens — an orthogonal annotation overlay on - * actionable parts (deferred / P2). Declared so packs can reference them without - * a schema change when the overlay lands. - */ -export const SLA_ACTIONS = [ - 'timeout', - 'escalate', - 'delegate', - 'reminder', -] as const; diff --git a/services/platform/lib/shared/platform/render_kinds.ts b/services/platform/lib/shared/platform/render_kinds.ts deleted file mode 100644 index 1b8b5572e7..0000000000 --- a/services/platform/lib/shared/platform/render_kinds.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Closed vocabulary of UI "render-kinds" — the contract between a workflow step - * and the generic operator UI. A step's `ui.render` annotation selects exactly - * one kind; the renderer has exactly one component per kind (an exhaustive - * switch, so a missing component is a compile error). - * - * The set is deliberately small and grows only by rare, deliberate platform - * decisions. Variation that looks like "more kinds" is expressed through - * composition PARAMS instead (display / layout / entryKind / mode / - * cardinality) — minting a kind per layout/display is the canonical bloat - * smell. Two orthogonal concerns are NOT kinds either: lifecycle/streaming - * `state` (see ./part_state) rides every render-part, and plan/DAG topology is - * owned by the shell's run-view, not a per-step kind. - * - * Lives in lib/shared so both the Convex layer (Zod validation) and the - * frontend renderer import the identical literals — the single source of truth - * that keeps the UI and the workflow schema from drifting. - */ -export const RENDER_KINDS = [ - 'status', // state badge + step summary (lifecycle rides the `state` axis) - 'ingest', // source / intake summary (counts, sourceRef) - 'transform', // processing-step summary (rows in/out, fields, timing) - 'validation', // machine pass/warn/fail checks (observed vs expected) - 'reconciliation', // match + actionable resolution / adjudication - 'diff', // read-only before/after comparison - 'collection', // N homogeneous items (+ optional row actions) - 'artifact', // a produced payload (file / object / code / embed) - 'stream', // chronological feed — the agent-run spine - 'review', // the human-actionable kind (run resumes with structured output) -] as const; - -export type RenderKind = (typeof RENDER_KINDS)[number]; - -const RENDER_KIND_SET = new Set(RENDER_KINDS); - -export function isRenderKind(value: string): value is RenderKind { - return RENDER_KIND_SET.has(value); -} - -/** - * Composition params — closed sub-vocabularies that let one kind cover a family - * of presentations WITHOUT minting new kinds. - */ -export const ARTIFACT_DISPLAYS = ['blob', 'object', 'code', 'embed'] as const; -export type ArtifactDisplay = (typeof ARTIFACT_DISPLAYS)[number]; - -export const COLLECTION_LAYOUTS = ['table', 'list', 'cards'] as const; -export type CollectionLayout = (typeof COLLECTION_LAYOUTS)[number]; - -export const STREAM_ENTRY_KINDS = ['message', 'tool_call', 'log'] as const; - -export const REVIEW_MODES = ['gate', 'form', 'choice'] as const; -export type ReviewMode = (typeof REVIEW_MODES)[number]; - -export const REVIEW_CARDINALITIES = ['one', 'many'] as const; - -/** - * Which operator surface a step feeds. `outcome` steps are promoted into the - * run's Outcome strip; everything else is `process` (the secondary step list). - * Unknown values degrade to `process` at the renderer — never throw. - */ -export const SURFACES = ['outcome', 'process'] as const; -type Surface = (typeof SURFACES)[number]; - -const SURFACE_SET = new Set(SURFACES); - -export function isSurface(value: string): value is Surface { - return SURFACE_SET.has(value); -} - -/** Resolve a pack-authored surface string; unknown / absent → `process`. */ -export function resolveSurface(value: string | undefined): Surface { - if (value !== undefined && isSurface(value)) return value; - return 'process'; -} - -export type RenderInteraction = 'read_only' | 'actionable'; - -/** - * Per-kind metadata: whether the kind is read-only or actionable (a human acts - * and the run resumes), and the i18n key prefix under which its Tier-1 - * (platform-owned, structural) labels live. Pack-authored Tier-2 labels are - * referenced separately via the step's `ui.labelKey`. - */ -export const RENDER_KIND_META: Record< - RenderKind, - { - interaction: RenderInteraction; - labelKeyPrefix: `platform.render.${RenderKind}`; - } -> = { - status: { - interaction: 'read_only', - labelKeyPrefix: 'platform.render.status', - }, - ingest: { - interaction: 'read_only', - labelKeyPrefix: 'platform.render.ingest', - }, - transform: { - interaction: 'read_only', - labelKeyPrefix: 'platform.render.transform', - }, - validation: { - interaction: 'read_only', - labelKeyPrefix: 'platform.render.validation', - }, - reconciliation: { - interaction: 'actionable', - labelKeyPrefix: 'platform.render.reconciliation', - }, - diff: { interaction: 'read_only', labelKeyPrefix: 'platform.render.diff' }, - collection: { - interaction: 'read_only', - labelKeyPrefix: 'platform.render.collection', - }, - artifact: { - interaction: 'read_only', - labelKeyPrefix: 'platform.render.artifact', - }, - stream: { - interaction: 'read_only', - labelKeyPrefix: 'platform.render.stream', - }, - review: { - interaction: 'actionable', - labelKeyPrefix: 'platform.render.review', - }, -}; diff --git a/services/platform/lib/shared/platform/run_capacity.test.ts b/services/platform/lib/shared/platform/run_capacity.test.ts deleted file mode 100644 index b944600f9e..0000000000 --- a/services/platform/lib/shared/platform/run_capacity.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { deriveRunIndicator, isParkedOnCapacity } from './run_capacity'; - -describe('isParkedOnCapacity', () => { - it('is true when an active run carries the sticky awaiting-capacity slug', () => { - expect( - isParkedOnCapacity({ - status: 'running', - awaitingCapacityStepSlug: 'implement_fix', - }), - ).toBe(true); - expect( - isParkedOnCapacity({ - status: 'pending', - awaitingCapacityStepSlug: 'implement_fix', - }), - ).toBe(true); - }); - - it('is false when the slug is unset (the common, running case)', () => { - expect(isParkedOnCapacity({ status: 'running' })).toBe(false); - expect( - isParkedOnCapacity({ - status: 'running', - awaitingCapacityStepSlug: undefined, - }), - ).toBe(false); - }); - - it('ignores a stale slug left on a settled run (never a stale chip)', () => { - // A run that finished/failed without the admission path clearing the flag - // must not surface as "queued". - expect( - isParkedOnCapacity({ - status: 'completed', - awaitingCapacityStepSlug: 'implement_fix', - }), - ).toBe(false); - expect( - isParkedOnCapacity({ - status: 'failed', - awaitingCapacityStepSlug: 'implement_fix', - }), - ).toBe(false); - }); - - it('is false when there is no execution at all', () => { - expect(isParkedOnCapacity(null)).toBe(false); - expect(isParkedOnCapacity(undefined)).toBe(false); - }); -}); - -describe('deriveRunIndicator', () => { - it("surfaces 'parked' for an active run queued behind the capacity cap", () => { - expect( - deriveRunIndicator({ - status: 'running', - awaitingCapacityStepSlug: 'implement', - }), - ).toBe('parked'); - }); - - it("surfaces 'failed' for a run that ended in failure", () => { - expect(deriveRunIndicator({ status: 'failed' })).toBe('failed'); - }); - - it('surfaces nothing for a healthy run, a settled run, or no run', () => { - expect(deriveRunIndicator({ status: 'running' })).toBe(null); - expect(deriveRunIndicator({ status: 'completed' })).toBe(null); - expect(deriveRunIndicator({ status: 'cancelled' })).toBe(null); - expect(deriveRunIndicator(null)).toBe(null); - expect(deriveRunIndicator(undefined)).toBe(null); - }); - - it("reads a failed run with a stale capacity slug as 'failed', not 'parked'", () => { - // isParkedOnCapacity already gates on an active status, so the stale slug - // never wins: the row reads as failed. - expect( - deriveRunIndicator({ - status: 'failed', - awaitingCapacityStepSlug: 'implement', - }), - ).toBe('failed'); - }); -}); diff --git a/services/platform/lib/shared/platform/run_capacity.ts b/services/platform/lib/shared/platform/run_capacity.ts deleted file mode 100644 index c43d59c325..0000000000 --- a/services/platform/lib/shared/platform/run_capacity.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * A subject's latest run is "parked on sandbox capacity" when it is still active - * (pending/running) AND carries the sticky `awaitingCapacityStepSlug` — a sandbox - * step waiting behind the org's concurrency cap. The active-status gate stops a - * settled run that never cleared the flag from surfacing a stale "Queued" chip. - * - * Pure + shared so the `getSubjectRunIndicator` query and its test agree on one - * definition. The flag is set/cleared at the sandbox admission decision in - * `executeSandboxNode` (see `wfExecutions.awaitingCapacityStepSlug`). - */ -export function isParkedOnCapacity( - execution: - | { status: string; awaitingCapacityStepSlug?: string | undefined } - | null - | undefined, -): boolean { - if (!execution) return false; - if (execution.awaitingCapacityStepSlug === undefined) return false; - return isActiveExecutionStatus(execution.status); -} - -/** - * An execution that is still doing work — scheduled (`pending`) or `running`. - * The one definition of "active" shared by the capacity indicator above and - * the UI surfaces that change behaviour while a subject's run is in flight - * (e.g. the task comment composer's "this run won't see new comments" hint). - */ -export function isActiveExecutionStatus(status: string): boolean { - return status === 'running' || status === 'pending'; -} - -/** - * The single ambient indicator a subject's row should surface in place of its - * own kanban status, derived from the subject's latest run: - * - `'parked'` — active and queued behind the org's sandbox concurrency cap - * - `'failed'` — the run ended in failure (a step errored and it stopped), so - * a crashed automation reads as "Failed" instead of a frozen "in_progress" - * - `null` — nothing to surface; the row shows its own status - * Parked is checked first; the two are mutually exclusive anyway since a parked - * run is still active (a `failed` run with a stale capacity slug reads as - * failed, not parked). Pure + shared so the `getSubjectRunIndicator` query and - * its test agree on one definition. - */ -export function deriveRunIndicator( - execution: - | { status: string; awaitingCapacityStepSlug?: string | undefined } - | null - | undefined, -): 'parked' | 'failed' | null { - if (!execution) return null; - if (isParkedOnCapacity(execution)) return 'parked'; - if (execution.status === 'failed') return 'failed'; - return null; -} diff --git a/services/platform/lib/shared/platform/step_display.test.ts b/services/platform/lib/shared/platform/step_display.test.ts deleted file mode 100644 index f87364e0fa..0000000000 --- a/services/platform/lib/shared/platform/step_display.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - bypassedLaneIndexes, - dedupeSpineLanes, - isAgentRunAction, - isStepVisible, - stepTreatment, - type SpineLaneInput, - type StepTreatment, -} from './step_display'; - -describe('stepTreatment', () => { - // The issue-desk v2.1 workflow's representative steps — the ground-truth - // table both the friendly map and the run view must agree on. Spine = - // advise → review gate → execute → grade → judge (gate) → dream → park; - // plumbing collapses out. - const deskSteps: { - slug: string; - stepType: string; - hasUi: boolean; - display?: string; - expected: StepTreatment; - }[] = [ - { slug: 'start', stepType: 'start', hasUi: false, expected: 'hidden' }, - { slug: 'ack', stepType: 'action', hasUi: false, expected: 'hidden' }, - { - slug: 'advise', - stepType: 'sandbox', - hasUi: true, - expected: 'normal', - }, - { - slug: 'advise_gate', - stepType: 'condition', - hasUi: false, - expected: 'hidden', - }, - { - slug: 'execute', - stepType: 'sandbox', - hasUi: true, - expected: 'normal', - }, - { - slug: 'execute_check', - stepType: 'condition', - hasUi: false, - expected: 'hidden', - }, - { - slug: 'execute_failed_rollback', - stepType: 'action', - hasUi: false, - expected: 'hidden', - }, - { slug: 'grade', stepType: 'sandbox', hasUi: true, expected: 'normal' }, - { - slug: 'judge', - stepType: 'llm', - hasUi: true, - display: 'gate', - expected: 'gate', - }, - { - slug: 'judge_pass', - stepType: 'condition', - hasUi: false, - expected: 'hidden', - }, - { - slug: 'park_approved', - stepType: 'action', - hasUi: true, - expected: 'normal', - }, - { - slug: 'advise_failed_rollback', - stepType: 'action', - hasUi: false, - expected: 'hidden', - }, - { slug: 'done', stepType: 'output', hasUi: false, expected: 'hidden' }, - ]; - - for (const step of deskSteps) { - it(`desk step "${step.slug}" (${step.stepType}) → ${step.expected}`, () => { - expect( - stepTreatment({ - stepType: step.stepType, - hasUi: step.hasUi, - ...(step.display !== undefined && { display: step.display }), - }), - ).toBe(step.expected); - }); - } - - it('hides plumbing steps, leaving the five-step operator spine', () => { - const visible = deskSteps.filter((s) => - isStepVisible({ - stepType: s.stepType, - hasUi: s.hasUi, - ...(s.display !== undefined && { display: s.display }), - }), - ); - expect(visible.map((s) => s.slug)).toEqual([ - 'advise', - 'execute', - 'grade', - 'judge', - 'park_approved', - ]); - }); - - it('an unannotated llm step is a quiet gate, not hidden', () => { - expect(stepTreatment({ stepType: 'llm', hasUi: false })).toBe('gate'); - }); - - it('display:gate wins even on a plain action step', () => { - expect( - stepTreatment({ stepType: 'action', hasUi: true, display: 'gate' }), - ).toBe('gate'); - }); - - it('an unannotated non-plumbing step (e.g. sandbox) shows normally', () => { - expect(stepTreatment({ stepType: 'sandbox', hasUi: false })).toBe('normal'); - }); - - // Regression: react-to-mentions' unannotated `respond` step (agent - // run_on_task) collapsed out as plumbing, so the live-run view rendered - // nothing while the agent worked in the sandbox. - it('an unannotated agent run_on_task action is core work, not plumbing', () => { - expect( - stepTreatment({ - stepType: 'action', - hasUi: false, - actionType: 'agent', - actionOperation: 'run_on_task', - }), - ).toBe('normal'); - }); - - it('agent bookkeeping actions (reassign, budget check) stay hidden', () => { - for (const actionOperation of [ - 'reassign_or_unassign', - 'check_run_budget', - 'requeue_queued_runs', - ]) { - expect( - stepTreatment({ - stepType: 'action', - hasUi: false, - actionType: 'agent', - actionOperation, - }), - ).toBe('hidden'); - } - }); - - it('a non-agent action with an operation param stays hidden', () => { - expect( - stepTreatment({ - stepType: 'action', - hasUi: false, - actionType: 'task', - actionOperation: 'run_on_task', - }), - ).toBe('hidden'); - }); -}); - -describe('isAgentRunAction', () => { - it('true only for agent actions with a run operation', () => { - expect( - isAgentRunAction({ - stepType: 'action', - actionType: 'agent', - actionOperation: 'run_on_task', - }), - ).toBe(true); - expect( - isAgentRunAction({ - stepType: 'action', - actionType: 'agent', - actionOperation: 'check_run_budget', - }), - ).toBe(false); - expect( - isAgentRunAction({ - stepType: 'sandbox', - actionType: 'agent', - actionOperation: 'run_on_task', - }), - ).toBe(false); - expect(isAgentRunAction({ stepType: 'action' })).toBe(false); - }); -}); - -describe('dedupeSpineLanes', () => { - // The issue-desk v2.1 pattern: four park variants (approved / exhausted / - // failed-grade / replan-exhausted) share the markInReview labelKey; the - // work steps are singletons. - const lane = (labelKey: string | undefined, hasRun: boolean) => - ({ ...(labelKey !== undefined && { labelKey }), hasRun }) as SpineLaneInput; - const desk = (ran: string[]) => { - const slugs = [ - ['advise', 'issueDesk.advise'], - ['request_plan_review', 'issueDesk.planReview'], - ['execute', 'issueDesk.implement'], - ['grade', 'issueDesk.review'], - ['dream', 'issueDesk.dream'], - ['plan_review_exhausted', 'issueDesk.markInReview'], - ['grade_failed_park', 'issueDesk.markInReview'], - ['replan_exhausted', 'issueDesk.markInReview'], - ['park_approved', 'issueDesk.markInReview'], - ['loops_exhausted', 'issueDesk.markInReview'], - ] as const; - return { - slugs: slugs.map(([slug]) => slug), - lanes: slugs.map(([slug, key]) => lane(key, ran.includes(slug))), - }; - }; - - it('collapses un-run branch variants to one upcoming placeholder each', () => { - const { slugs, lanes } = desk(['advise']); - const kept = dedupeSpineLanes(lanes).map((i) => slugs[i]); - expect(kept).toEqual([ - 'advise', - 'request_plan_review', - 'execute', - 'grade', - 'dream', - 'plan_review_exhausted', - ]); - }); - - it('a ran variant replaces the placeholder and hides its siblings', () => { - const { slugs, lanes } = desk(['advise', 'park_approved']); - const kept = dedupeSpineLanes(lanes).map((i) => slugs[i]); - expect(kept).toContain('park_approved'); - expect(kept).not.toContain('plan_review_exhausted'); - expect(kept).not.toContain('loops_exhausted'); - }); - - it('keeps every variant the run actually touched', () => { - const { slugs, lanes } = desk(['grade_failed_park', 'park_approved']); - const kept = dedupeSpineLanes(lanes).map((i) => slugs[i]); - expect(kept).toContain('grade_failed_park'); - expect(kept).toContain('park_approved'); - expect(kept).not.toContain('plan_review_exhausted'); - }); - - it('never groups steps without a labelKey', () => { - const kept = dedupeSpineLanes([ - lane(undefined, false), - lane(undefined, false), - lane(undefined, true), - ]); - expect(kept).toEqual([0, 1, 2]); - }); -}); - -describe('bypassedLaneIndexes', () => { - const lanes = (entries: Array<[hasRun: boolean, defIndex: number]>) => - entries.map(([hasRun, defIndex]) => ({ hasRun, defIndex })); - - it('flags a skipped gate the run already moved past', () => { - // advise(2) ran, review gate(4) skipped, execute(6) running → - // lastTouched = 6; the gate is neither run nor ahead. - const bypassed = bypassedLaneIndexes( - lanes([ - [true, 2], - [false, 4], - [true, 6], - [false, 9], - ]), - 6, - ); - expect(bypassed).toEqual([1]); - }); - - it('flags nothing before anything ran (full preview stays "up next")', () => { - const bypassed = bypassedLaneIndexes( - lanes([ - [false, 2], - [false, 4], - [false, 6], - ]), - -1, - ); - expect(bypassed).toEqual([]); - }); - - it('never flags steps that ran, wherever progress sits', () => { - const bypassed = bypassedLaneIndexes( - lanes([ - [true, 2], - [true, 4], - [false, 3], - ]), - 8, - ); - expect(bypassed).toEqual([2]); - }); -}); diff --git a/services/platform/lib/shared/platform/step_display.ts b/services/platform/lib/shared/platform/step_display.ts deleted file mode 100644 index c3227acb13..0000000000 --- a/services/platform/lib/shared/platform/step_display.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Which workflow steps the user-facing surfaces SHOW, and how. The friendly - * process map (apps `WorkflowMap`) and the live run view (operator) must agree on - * this, so the rule lives once here — both features already import this `shared` - * folder (render_kinds / part_state), so neither reaches into the other. - * - * A workflow's wire form carries plumbing the user does not care to watch — - * structural endpoints, routing `condition`s, and bare status-bump `action`s. The - * principle: an author OPTS A STEP IN by giving it a `ui` annotation; everything - * unannotated that is pure plumbing collapses out, so the process reads as its - * meaningful spine rather than a wall of identical "Status" tiles. Decision - * points (the merge judge) stay visible but DE-EMPHASIZED via the `gate` - * treatment, chosen explicitly with `ui.params.display: 'gate'` or implied by an - * unannotated `llm` step. - */ - -export type StepTreatment = 'hidden' | 'gate' | 'normal'; - -/** Step types that are pure structure, never shown. */ -const STRUCTURAL_TYPES = new Set(['start', 'trigger', 'output']); - -/** Agent-action operations that RUN an agent on a subject — live core work - * with a durable sandbox transcript the user watches, unlike the roster and - * bookkeeping operations (budget checks, reassignment, requeues) that stay - * plumbing. Without this carve-out an unannotated `respond` step (the pack - * mention/assignment reaction) collapses out and the run view shows nothing - * while the agent works. */ -const AGENT_RUN_OPERATIONS = new Set(['run_on_task', 'decompose_task']); - -interface StepDisplayInput { - stepType: string; - /** Whether the step carries a `ui` annotation (the opt-in to be shown). */ - hasUi: boolean; - /** The step's `ui.params.display`, when present (e.g. `'gate'`). */ - display?: string; - /** For `action` steps: the action's `config.type` (e.g. `'agent'`). */ - actionType?: string; - /** For `action` steps: the action's `parameters.operation`. */ - actionOperation?: string; -} - -/** An `action` step that RUNS an agent (vs agent bookkeeping) — shown as core - * work by `stepTreatment` and rendered as a `stream` transcript by the run - * view even without a `ui` annotation. */ -export function isAgentRunAction(input: { - stepType: string; - actionType?: string; - actionOperation?: string; -}): boolean { - return ( - input.stepType === 'action' && - input.actionType === 'agent' && - AGENT_RUN_OPERATIONS.has(input.actionOperation ?? '') - ); -} - -/** - * How a step renders in the friendly surfaces: `hidden` (collapsed plumbing), - * `gate` (a visible-but-quiet decision checkpoint), or `normal`. - */ -export function stepTreatment({ - stepType, - hasUi, - display, - actionType, - actionOperation, -}: StepDisplayInput): StepTreatment { - if (STRUCTURAL_TYPES.has(stepType)) return 'hidden'; - if (display === 'gate') return 'gate'; - if (hasUi) return 'normal'; - // Unannotated: routing conditions and bare status-bump actions are plumbing; - // an LLM decision step stays as a quiet gate; anything else (a harness - // turn, an agent-running action) is core work and shows normally. - if (stepType === 'condition') return 'hidden'; - if (stepType === 'action') { - return isAgentRunAction({ stepType, actionType, actionOperation }) - ? 'normal' - : 'hidden'; - } - if (stepType === 'llm') return 'gate'; - return 'normal'; -} - -/** True when the step should appear at all (not pure plumbing). */ -export function isStepVisible(input: StepDisplayInput): boolean { - return stepTreatment(input) !== 'hidden'; -} - -/** - * Indexes of un-run lanes the run has already moved PAST. A conditional lane - * the gate skipped (e.g. a plan review the advisor never flagged) would - * otherwise sit at "Up next" forever while LATER steps run — a lie about - * what's coming. Callers MARK these lanes as `skipped` rather than hiding - * them: the operator should see the review was bypassed, not wonder where it - * went. A lane is "passed" when it has no node but some later-ordered - * definition step does; the mark clears the moment a loop actually runs it - * (it then has a node). `lastTouchedIndex` is the max DEFINITION index with a - * node, computed over ALL steps (hidden plumbing included) so branch progress - * counts. Returns the bypassed indexes, in original order. - */ -export function bypassedLaneIndexes( - steps: readonly { hasRun: boolean; defIndex: number }[], - lastTouchedIndex: number, -): number[] { - return steps - .map((_, i) => i) - .filter((i) => { - const step = steps[i]; - if (step === undefined) return false; - return !step.hasRun && step.defIndex <= lastTouchedIndex; - }); -} - -export interface SpineLaneInput { - /** The step's `ui.labelKey` — the grouping key. Absent ⇒ never grouped. */ - labelKey?: string; - /** Whether the run has touched this step (a live node state exists). */ - hasRun: boolean; -} - -/** - * Collapse mutually-exclusive BRANCH VARIANTS of one concept into a single - * spine lane. Steps that share a `ui.labelKey` are alternatives of the same - * user-facing step (a round-0/round-1 review gate, one dream step per judge - * verdict): rendering each un-run variant as its own "Up next" lane reads as - * pending work that will never happen. Keep every variant the run actually - * touched (real history), and — only when none has run yet — the FIRST as the - * lane's single upcoming placeholder. Steps without a `labelKey` always keep - * their own lane. Returns the kept indexes, in original order. - */ -export function dedupeSpineLanes(steps: readonly SpineLaneInput[]): number[] { - const groups = new Map(); - steps.forEach((step, i) => { - if (step.labelKey === undefined) return; - const members = groups.get(step.labelKey) ?? []; - members.push(i); - groups.set(step.labelKey, members); - }); - const dropped = new Set(); - for (const members of groups.values()) { - if (members.length < 2) continue; - const ran = members.filter((i) => steps[i]?.hasRun); - const kept = ran.length > 0 ? new Set(ran) : new Set([members[0]]); - for (const i of members) { - if (!kept.has(i)) dropped.add(i); - } - } - return steps.map((_, i) => i).filter((i) => !dropped.has(i)); -} diff --git a/services/platform/lib/shared/platform/step_modes.ts b/services/platform/lib/shared/platform/step_modes.ts deleted file mode 100644 index 8eee417046..0000000000 --- a/services/platform/lib/shared/platform/step_modes.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Step modes — advisory projection hints distinct from the engine's `stepType` - * (the mechanism: start/llm/condition/action/loop/output/sandbox). A mode tells - * the operator UI how to frame a step ("what's happening"); the engine ignores - * it. Maps onto existing waiting states rather than introducing runtime - * behavior. - */ -export const STEP_MODES = [ - 'automated', // no human in the loop - 'review_gate', // pauses for a human sign-off - 'human_input', // collects structured input from a human - 'terminal', // produces the final artifact / output -] as const; - -type StepMode = (typeof STEP_MODES)[number]; - -const STEP_MODE_SET = new Set(STEP_MODES); - -export function isStepMode(value: string): value is StepMode { - return STEP_MODE_SET.has(value); -} diff --git a/services/platform/lib/shared/platform/vocabulary.test.ts b/services/platform/lib/shared/platform/vocabulary.test.ts deleted file mode 100644 index 814c7ef3a3..0000000000 --- a/services/platform/lib/shared/platform/vocabulary.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { FIELD_TYPES, isFieldType } from './field_types'; -import { PART_STATES, SLA_ACTIONS, isPartState } from './part_state'; -import { - ARTIFACT_DISPLAYS, - COLLECTION_LAYOUTS, - RENDER_KINDS, - RENDER_KIND_META, - REVIEW_CARDINALITIES, - REVIEW_MODES, - STREAM_ENTRY_KINDS, - SURFACES, - isRenderKind, - isSurface, - resolveSurface, -} from './render_kinds'; -import { STEP_MODES, isStepMode } from './step_modes'; - -const noDuplicates = (arr: readonly string[]) => - new Set(arr).size === arr.length; - -describe('platform render-kinds vocabulary', () => { - it('is the expected frozen set of 10 kinds', () => { - expect([...RENDER_KINDS]).toEqual([ - 'status', - 'ingest', - 'transform', - 'validation', - 'reconciliation', - 'diff', - 'collection', - 'artifact', - 'stream', - 'review', - ]); - }); - - it('has no duplicate kinds', () => { - expect(noDuplicates(RENDER_KINDS)).toBe(true); - }); - - it('declares metadata for exactly every kind, with a matching labelKeyPrefix', () => { - expect(Object.keys(RENDER_KIND_META).sort()).toEqual( - [...RENDER_KINDS].sort(), - ); - for (const kind of RENDER_KINDS) { - expect(RENDER_KIND_META[kind].labelKeyPrefix).toBe( - `platform.render.${kind}`, - ); - } - }); - - it('guards membership', () => { - expect(isRenderKind('review')).toBe(true); - expect(isRenderKind('not_a_kind')).toBe(false); - }); - - it('keeps composition params as closed, non-empty, duplicate-free sets', () => { - for (const params of [ - ARTIFACT_DISPLAYS, - COLLECTION_LAYOUTS, - STREAM_ENTRY_KINDS, - REVIEW_MODES, - REVIEW_CARDINALITIES, - SURFACES, - ]) { - expect(params.length).toBeGreaterThan(0); - expect(noDuplicates(params)).toBe(true); - } - }); - - it('freezes operator surfaces and degrades unknown values to process', () => { - expect([...SURFACES]).toEqual(['outcome', 'process']); - expect(isSurface('outcome')).toBe(true); - expect(isSurface('nope')).toBe(false); - expect(resolveSurface('outcome')).toBe('outcome'); - expect(resolveSurface(undefined)).toBe('process'); - expect(resolveSurface('nope')).toBe('process'); - }); -}); - -describe('platform state / mode / field / role vocabularies', () => { - it('freezes the lifecycle state axis (incl. waiting + empty)', () => { - expect([...PART_STATES]).toEqual([ - 'upcoming', - 'skipped', - 'loading', - 'running', - 'queued_capacity', - 'output_available', - 'output_error', - 'waiting_human', - 'waiting_external', - 'empty', - ]); - expect(isPartState('waiting_external')).toBe(true); - expect(isPartState('nope')).toBe(false); - }); - - it('declares the deferred SLA annotation tokens', () => { - expect([...SLA_ACTIONS]).toEqual([ - 'timeout', - 'escalate', - 'delegate', - 'reminder', - ]); - }); - - it('freezes step modes', () => { - expect([...STEP_MODES]).toEqual([ - 'automated', - 'review_gate', - 'human_input', - 'terminal', - ]); - expect(isStepMode('review_gate')).toBe(true); - expect(isStepMode('nope')).toBe(false); - }); - - it('freezes field types', () => { - expect(FIELD_TYPES).toContain('currency'); - expect(noDuplicates(FIELD_TYPES)).toBe(true); - expect(isFieldType('currency')).toBe(true); - expect(isFieldType('nope')).toBe(false); - }); -}); diff --git a/services/platform/lib/shared/schemas/format-error.test.ts b/services/platform/lib/shared/schemas/format-error.test.ts index 9f0d370713..3cb6e29cb4 100644 --- a/services/platform/lib/shared/schemas/format-error.test.ts +++ b/services/platform/lib/shared/schemas/format-error.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - formatZodError, - formatZodErrorFull, - zodErrorMessage, -} from './format-error'; +import { formatZodError, zodErrorMessage } from './format-error'; import { piiConfigSchema } from './pii'; import { skillFrontmatterSchema } from './skills'; @@ -70,18 +66,6 @@ describe('formatZodError', () => { }); }); -describe('formatZodErrorFull', () => { - it('renders every issue (zod/v4 prettifyError), never the raw dump', () => { - const result = piiConfigSchema.safeParse({}); - expect(result.success).toBe(false); - if (result.success) throw new Error('unreachable'); - - const message = formatZodErrorFull(result.error); - expectNoRawDump(message); - expect(message.length).toBeGreaterThan(0); - }); -}); - describe('zodErrorMessage', () => { it('prefixes the formatted summary with the given label', () => { const result = piiConfigSchema.safeParse({ diff --git a/services/platform/lib/shared/schemas/format-error.ts b/services/platform/lib/shared/schemas/format-error.ts index 5dfc52a915..502013bb60 100644 --- a/services/platform/lib/shared/schemas/format-error.ts +++ b/services/platform/lib/shared/schemas/format-error.ts @@ -45,16 +45,6 @@ export function formatZodError( return `${summary}${more}`; } -/** - * The full, multi-line, every-issue rendering (zod/v4's own pretty-printer) — - * for contexts that can afford more than one line (e.g. a CLI gate's console - * output), where truncation would hide problems the operator needs to fix in - * one pass. - */ -export function formatZodErrorFull(error: z.ZodError): string { - return z.prettifyError(error); -} - /** * `formatZodError` prefixed with a caller-supplied label — the common shape * for a thrown `Error`/`AppError` message: `${label}: ${formatZodError()}`. diff --git a/services/platform/lib/shared/text-matching/index.ts b/services/platform/lib/shared/text-matching/index.ts index 0f725858fe..ea29da001e 100644 --- a/services/platform/lib/shared/text-matching/index.ts +++ b/services/platform/lib/shared/text-matching/index.ts @@ -12,9 +12,6 @@ * Pure + zero-IO + runtime-agnostic so it bundles into the Convex V8 runtime. */ -/** How a locale's terms are bounded when matched. */ -export type BoundaryMode = 'word' | 'substring'; - interface MatcherSpec { /** Terms matched with Unicode word boundaries (Latin / Cyrillic / Greek …). */ wordTerms: Iterable; diff --git a/services/platform/lib/shared/utils/canonicalize-config.test.ts b/services/platform/lib/shared/utils/canonicalize-config.test.ts index 363d0622a2..ca147f154e 100644 --- a/services/platform/lib/shared/utils/canonicalize-config.test.ts +++ b/services/platform/lib/shared/utils/canonicalize-config.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - canonicalizeAgentConfig, - canonicalizeWorkflowConfig, - sortObjectKeysDeep, - sortStringArrayFields, -} from './canonicalize-config'; +import { sortObjectKeysDeep } from './canonicalize-config'; describe('sortObjectKeysDeep', () => { it('sorts keys of nested objects', () => { @@ -29,87 +24,3 @@ describe('sortObjectKeysDeep', () => { expect(sortObjectKeysDeep(null)).toBe(null); }); }); - -describe('sortStringArrayFields', () => { - it('sorts and dedupes only the named string-array fields', () => { - const out = sortStringArrayFields( - { tags: ['b', 'a', 'b'], order: ['z', 'a'] }, - ['tags'], - ); - expect(out.tags).toEqual(['a', 'b']); - // untouched field keeps its order - expect(out.order).toEqual(['z', 'a']); - }); - - it('ignores absent or non-string-array fields', () => { - const out = sortStringArrayFields({ nums: [3, 1, 2] }, ['nums', 'missing']); - expect(out.nums).toEqual([3, 1, 2]); - }); -}); - -describe('canonicalizeAgentConfig', () => { - it('sorts set-like arrays but preserves ordered ones', () => { - const out = canonicalizeAgentConfig({ - toolNames: ['web', 'calc', 'calc'], - skillBindings: ['c', 'a', 'b'], - supportedModels: ['gpt-4', 'haiku'], // fallback chain — preserved - conversationStarters: ['Hi', 'Bye'], // display order — preserved - }); - expect(out.toolNames).toEqual(['calc', 'web']); - expect(out.skillBindings).toEqual(['a', 'b', 'c']); - expect(out.supportedModels).toEqual(['gpt-4', 'haiku']); - expect(out.conversationStarters).toEqual(['Hi', 'Bye']); - }); - - it('does not mutate the input', () => { - const input = { toolNames: ['b', 'a'] }; - canonicalizeAgentConfig(input); - expect(input.toolNames).toEqual(['b', 'a']); - }); - - // Regression: the agent-type switcher writes the resolved literal back - // (`primaryBehavior: 'chat'`) while legacy files omit the optional key — - // the two are the same effective config (every reader does `?? 'chat'`), - // so an explicit default must not read as an unsaved change on revert. - it("drops an explicit primaryBehavior 'chat' so it equals the absent key", () => { - expect(canonicalizeAgentConfig({ primaryBehavior: 'chat' })).toEqual({}); - }); - - it('keeps a non-default primaryBehavior', () => { - expect( - canonicalizeAgentConfig({ primaryBehavior: 'external-agent' }), - ).toEqual({ primaryBehavior: 'external-agent' }); - }); -}); - -describe('canonicalizeWorkflowConfig', () => { - it('sorts steps by stepSlug (execution order is order/nextSteps-driven)', () => { - const out = canonicalizeWorkflowConfig({ - steps: [ - { stepSlug: 'zeta', order: 0 }, - { stepSlug: 'alpha', order: 1 }, - ], - }); - expect(out.steps.map((s: { stepSlug: string }) => s.stepSlug)).toEqual([ - 'alpha', - 'zeta', - ]); - }); - - it('sorts requires.connectors by name and their operations', () => { - const out = canonicalizeWorkflowConfig({ - requires: { - connectors: [ - { name: 'slack', operations: ['send', 'archive'] }, - { name: 'github' }, - ], - }, - }); - const connectors = out.requires.connectors as Array<{ - name: string; - operations?: string[]; - }>; - expect(connectors.map((i) => i.name)).toEqual(['github', 'slack']); - expect(connectors[1].operations).toEqual(['archive', 'send']); - }); -}); diff --git a/services/platform/lib/shared/utils/canonicalize-config.ts b/services/platform/lib/shared/utils/canonicalize-config.ts index 0d60c996c7..4e65fa874f 100644 --- a/services/platform/lib/shared/utils/canonicalize-config.ts +++ b/services/platform/lib/shared/utils/canonicalize-config.ts @@ -1,23 +1,14 @@ /** - * Canonical-form helpers for JSON config files. + * Canonical-form helper for JSON config files. * - * Two concerns, kept separate because their safety profiles differ: + * Sorting object keys is *always* safe (JSON key order is never semantic) and + * makes on-disk diffs deterministic. Arrays are never sorted: their order is + * frequently semantic (a model fallback chain, the display order of + * conversation starters), so element order is preserved and only the objects + * *inside* arrays are recursed. * - * - **Object keys** — sorting them is *always* safe (JSON key order is never - * semantic) and makes both on-disk diffs and dirty-state comparison - * deterministic. {@link sortObjectKeysDeep} sorts every nested plain - * object. - * - **Arrays** — order is frequently semantic (a model fallback chain, the - * display order of conversation starters), so we *never* sort arrays - * blindly. {@link sortStringArrayFields} sorts only the explicitly named - * set-like fields (membership matters, order doesn't), and the per-domain - * canonicalizers below name exactly which fields qualify. - * - * Used in two places that must agree: - * - serialization (`serializeJson` + `serialize{Agent,Workflow}Json`) so the - * on-disk file is canonical, and - * - the editors' dirty-state equality so a reordered-but-equivalent config - * never reads as a false-positive unsaved change. + * Used by `serializeJson` (`backend/core/lib/file_io.ts`) so every JSON config + * file the backend writes is canonical. */ function isPlainObject(value: unknown): value is Record { @@ -47,124 +38,3 @@ export function sortObjectKeysDeep(value: T): T { } return value; } - -/** - * Deterministic, locale-independent string order (UTF-16 code unit) — matches - * `Object.keys().sort()` in {@link sortObjectKeysDeep} so canonical output is - * byte-identical across machines and runtimes. `localeCompare` is not: its - * ordering depends on the host ICU locale/version. - */ -function byCodeUnit(a: string, b: string): number { - return a < b ? -1 : a > b ? 1 : 0; -} - -/** Lexicographic (code-unit) sort + dedupe of a string array. */ -function sortStrings(values: readonly string[]): string[] { - return [...new Set(values)].sort(byCodeUnit); -} - -/** - * Return a shallow clone of `config` with the named top-level fields sorted - * (and deduped) when they hold string arrays. Fields that are absent or not - * string arrays are left untouched. Use ONLY for set-like fields where the - * order carries no meaning. - */ -export function sortStringArrayFields( - config: T, - fields: readonly string[], -): T { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- record reflection over a known object - const next = { ...config } as Record; - for (const field of fields) { - const value = next[field]; - if ( - Array.isArray(value) && - value.every((entry) => typeof entry === 'string') - ) { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- guarded by every() - next[field] = sortStrings(value as string[]); - } - } - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- same shape, sorted fields - return next as T; -} - -/** - * Agent config fields whose arrays are sets (membership matters, order does - * not). Deliberately EXCLUDES `supportedModels` (fallback priority chain) and - * `conversationStarters` (display order) — sorting those would change runtime - * or display behavior. - */ -const AGENT_SET_ARRAY_FIELDS = [ - 'toolNames', - 'connectorBindings', - 'workflows', - 'skillBindings', -] as const; - -/** - * Canonicalize an agent config: sort the set-like string arrays and drop an - * explicit `primaryBehavior: 'chat'` (the field is optional and every reader - * resolves it as `config.primaryBehavior ?? 'chat'`, so the absent key and the - * explicit default are the same effective config). Without this, the agent-type - * switcher writing the resolved literal back reads as a permanent unsaved - * change on legacy agents whose file never carried the key. Does not touch - * ordered arrays. Pure — returns a new object. - */ -export function canonicalizeAgentConfig(config: T): T { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- record reflection over a known object - const next = sortStringArrayFields(config, AGENT_SET_ARRAY_FIELDS) as Record< - string, - unknown - >; - if (next.primaryBehavior === 'chat') { - delete next.primaryBehavior; - } - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- same shape minus a redundant default - return next as T; -} - -/** - * Canonicalize a workflow config: sort `steps` by `stepSlug` and - * `requires.connectors` by `name` (with each connector's `operations` - * sorted). Execution order is driven by each step's `order`/`nextSteps`, not - * its array index, so sorting the steps array is safe and yields stable - * diffs. Pure — returns a new object; absent fields are left as-is. - */ -export function canonicalizeWorkflowConfig(config: T): T { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- record reflection over a known object - const next = { ...config } as Record; - - const steps = next.steps; - if (Array.isArray(steps)) { - next.steps = [...steps].sort((a, b) => { - const aSlug = isPlainObject(a) ? String(a.stepSlug ?? '') : ''; - const bSlug = isPlainObject(b) ? String(b.stepSlug ?? '') : ''; - return byCodeUnit(aSlug, bSlug); - }); - } - - const requires = next.requires; - if (isPlainObject(requires) && Array.isArray(requires.connectors)) { - const connectors = requires.connectors.map((dep) => { - if (isPlainObject(dep) && Array.isArray(dep.operations)) { - return { - ...dep, - operations: sortStrings( - dep.operations.filter((op): op is string => typeof op === 'string'), - ), - }; - } - return dep; - }); - connectors.sort((a, b) => { - const aName = isPlainObject(a) ? String(a.name ?? '') : ''; - const bName = isPlainObject(b) ? String(b.name ?? '') : ''; - return byCodeUnit(aName, bName); - }); - next.requires = { ...requires, connectors }; - } - - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- same shape, sorted fields - return next as T; -} diff --git a/services/platform/lib/shared/utils/expand-model-variants.test.ts b/services/platform/lib/shared/utils/expand-model-variants.test.ts deleted file mode 100644 index 8f1db80ae8..0000000000 --- a/services/platform/lib/shared/utils/expand-model-variants.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - expandModelVariants, - getVariantBadgeLabel, -} from './expand-model-variants'; - -const QUANTS: Record = { - 'z-ai/glm-5.1': ['fp8', 'fp4'], - 'deepseek/deepseek-v4-pro': ['fp8', 'fp4'], -}; -const lookup = (id: string): string[] | undefined => QUANTS[id]; - -describe('expandModelVariants', () => { - it('returns an empty array when given an empty input', () => { - expect(expandModelVariants([], lookup)).toEqual([]); - }); - - it('keeps a model with no quantizations unchanged', () => { - expect( - expandModelVariants(['openrouter:anthropic/claude-opus-4.6'], lookup), - ).toEqual(['openrouter:anthropic/claude-opus-4.6']); - }); - - it('expands a base ref into one entry per quantization in declared order', () => { - expect(expandModelVariants(['openrouter:z-ai/glm-5.1'], lookup)).toEqual([ - 'openrouter:z-ai/glm-5.1@fp8', - 'openrouter:z-ai/glm-5.1@fp4', - ]); - }); - - it('keeps an already-pinned variant ref verbatim (no re-expansion)', () => { - expect( - expandModelVariants(['openrouter:z-ai/glm-5.1@fp4'], lookup), - ).toEqual(['openrouter:z-ai/glm-5.1@fp4']); - }); - - it('dedupes two identical pinned-variant refs', () => { - expect( - expandModelVariants( - ['openrouter:z-ai/glm-5.1@fp8', 'openrouter:z-ai/glm-5.1@fp8'], - lookup, - ), - ).toEqual(['openrouter:z-ai/glm-5.1@fp8']); - }); - - it('preserves order when two distinct pinned variants of the same base are given', () => { - expect( - expandModelVariants( - ['openrouter:z-ai/glm-5.1@fp4', 'openrouter:z-ai/glm-5.1@fp8'], - lookup, - ), - ).toEqual(['openrouter:z-ai/glm-5.1@fp4', 'openrouter:z-ai/glm-5.1@fp8']); - }); - - it('dedupes when input mixes a base ref with one of its variants', () => { - expect( - expandModelVariants( - ['openrouter:z-ai/glm-5.1@fp8', 'openrouter:z-ai/glm-5.1'], - lookup, - ), - ).toEqual(['openrouter:z-ai/glm-5.1@fp8', 'openrouter:z-ai/glm-5.1@fp4']); - }); - - it('dedupes duplicate base refs', () => { - expect( - expandModelVariants( - ['openrouter:z-ai/glm-5.1', 'openrouter:z-ai/glm-5.1'], - lookup, - ), - ).toEqual(['openrouter:z-ai/glm-5.1@fp8', 'openrouter:z-ai/glm-5.1@fp4']); - }); - - it('treats an empty quantizations array as no-variant', () => { - expect( - expandModelVariants(['openrouter:foo/bar'], (id) => - id === 'foo/bar' ? [] : undefined, - ), - ).toEqual(['openrouter:foo/bar']); - }); - - it('preserves the missing provider prefix when expanding unqualified refs', () => { - expect(expandModelVariants(['z-ai/glm-5.1'], lookup)).toEqual([ - 'z-ai/glm-5.1@fp8', - 'z-ai/glm-5.1@fp4', - ]); - }); - - it('keeps unknown bare ids as-is', () => { - expect(expandModelVariants(['openrouter:unknown/model'], lookup)).toEqual([ - 'openrouter:unknown/model', - ]); - }); - - it('handles multiple distinct models in one input list', () => { - expect( - expandModelVariants( - [ - 'openrouter:z-ai/glm-5.1', - 'openrouter:anthropic/claude-opus-4.6', - 'openrouter:deepseek/deepseek-v4-pro@fp4', - ], - lookup, - ), - ).toEqual([ - 'openrouter:z-ai/glm-5.1@fp8', - 'openrouter:z-ai/glm-5.1@fp4', - 'openrouter:anthropic/claude-opus-4.6', - 'openrouter:deepseek/deepseek-v4-pro@fp4', - ]); - }); -}); - -describe('getVariantBadgeLabel', () => { - it('uppercases the token', () => { - expect(getVariantBadgeLabel('fp8')).toBe('FP8'); - expect(getVariantBadgeLabel('bf16')).toBe('BF16'); - expect(getVariantBadgeLabel('int8')).toBe('INT8'); - }); -}); diff --git a/services/platform/lib/shared/utils/expand-model-variants.ts b/services/platform/lib/shared/utils/expand-model-variants.ts deleted file mode 100644 index 8e579885bb..0000000000 --- a/services/platform/lib/shared/utils/expand-model-variants.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { formatModelRef, parseModelRef } from './model-ref'; - -/** - * Expands a list of model refs into per-quantization variants. - * - * For each ref: - * - If the ref already pins a quantization (e.g. `openrouter:z-ai/glm-5.1@fp8`), - * it's kept as-is. - * - If the bare model has a non-empty `quantizations` array (looked up via - * `getQuantizations(bareId)`), the ref is replaced by one variant ref per - * quantization in declared order. The unsplit base entry is dropped — the - * UX rule is "force an explicit variant pick when quantizations exist". - * - Otherwise, the ref is kept as-is. - * - * Output is deduplicated while preserving first-occurrence order. - */ -export function expandModelVariants( - refs: readonly string[], - getQuantizations: (bareModelId: string) => readonly string[] | undefined, -): string[] { - const seen = new Set(); - const out: string[] = []; - const push = (ref: string): void => { - if (seen.has(ref)) return; - seen.add(ref); - out.push(ref); - }; - - for (const ref of refs) { - const parsed = parseModelRef(ref); - if (parsed.quantization) { - push(ref); - continue; - } - const variants = getQuantizations(parsed.modelId); - if (variants && variants.length > 0) { - for (const q of variants) { - push(formatModelRef({ ...parsed, quantization: q })); - } - } else { - push(ref); - } - } - return out; -} - -/** Render a quantization token as a UI badge label, e.g. `'fp8'` → `'FP8'`. */ -export function getVariantBadgeLabel(quantization: string): string { - return quantization.toUpperCase(); -} diff --git a/services/platform/lib/shared/utils/model-list.test.ts b/services/platform/lib/shared/utils/model-list.test.ts deleted file mode 100644 index 6ef136de70..0000000000 --- a/services/platform/lib/shared/utils/model-list.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - getFirstModel, - getFirstModelOrThrow, - parseModelList, -} from './model-list'; - -describe('parseModelList', () => { - it('returns empty array for undefined', () => { - expect(parseModelList(undefined)).toEqual([]); - }); - - it('returns empty array for null', () => { - expect(parseModelList(null)).toEqual([]); - }); - - it('returns empty array for empty string', () => { - expect(parseModelList('')).toEqual([]); - }); - - it('parses a single model', () => { - expect(parseModelList('model-a')).toEqual(['model-a']); - }); - - it('parses multiple models', () => { - expect(parseModelList('model-a,model-b,model-c')).toEqual([ - 'model-a', - 'model-b', - 'model-c', - ]); - }); - - it('trims whitespace around models', () => { - expect(parseModelList(' model-a , model-b ')).toEqual([ - 'model-a', - 'model-b', - ]); - }); - - it('filters out empty segments', () => { - expect(parseModelList('model-a,,model-b')).toEqual(['model-a', 'model-b']); - }); - - it('handles trailing comma', () => { - expect(parseModelList('model-a,')).toEqual(['model-a']); - }); - - it('preserves model paths with slashes', () => { - expect(parseModelList('openai/gpt-5.2,anthropic/claude-opus')).toEqual([ - 'openai/gpt-5.2', - 'anthropic/claude-opus', - ]); - }); -}); - -describe('getFirstModel', () => { - it('returns undefined for undefined input', () => { - expect(getFirstModel(undefined)).toBeUndefined(); - }); - - it('returns undefined for empty string', () => { - expect(getFirstModel('')).toBeUndefined(); - }); - - it('returns the first model from a single value', () => { - expect(getFirstModel('model-a')).toBe('model-a'); - }); - - it('returns the first model from a comma-separated list', () => { - expect(getFirstModel('model-a,model-b')).toBe('model-a'); - }); - - it('trims whitespace', () => { - expect(getFirstModel(' model-a , model-b ')).toBe('model-a'); - }); -}); - -describe('getFirstModelOrThrow', () => { - it('returns the first model when available', () => { - expect(getFirstModelOrThrow('model-a,model-b', 'OPENAI_MODEL')).toBe( - 'model-a', - ); - }); - - it('throws for undefined', () => { - expect(() => getFirstModelOrThrow(undefined, 'OPENAI_MODEL')).toThrow( - 'OPENAI_MODEL', - ); - }); - - it('throws for empty string', () => { - expect(() => getFirstModelOrThrow('', 'OPENAI_MODEL')).toThrow( - 'OPENAI_MODEL', - ); - }); - - it('throws for whitespace-only string', () => { - expect(() => getFirstModelOrThrow(' , ', 'OPENAI_MODEL')).toThrow( - 'OPENAI_MODEL', - ); - }); -}); diff --git a/services/platform/lib/shared/utils/model-list.ts b/services/platform/lib/shared/utils/model-list.ts deleted file mode 100644 index f363b1580d..0000000000 --- a/services/platform/lib/shared/utils/model-list.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Utilities for parsing comma-separated model lists from environment variables. - * - * Supports `OPENAI_MODEL=model-a,model-b,model-c` format. - * Used by both Convex backend and frontend code. - */ - -/** - * Parse a comma-separated model list into an array of trimmed, non-empty strings. - */ -export function parseModelList(value: string | undefined | null): string[] { - if (!value) return []; - return value - .split(',') - .map((m) => m.trim()) - .filter(Boolean); -} - -/** - * Get the first model from a comma-separated model list. - */ -export function getFirstModel( - value: string | undefined | null, -): string | undefined { - const models = parseModelList(value); - return models[0]; -} - -/** - * Get the first model from a comma-separated model list, or throw if none available. - */ -export function getFirstModelOrThrow( - value: string | undefined | null, - envVarName: string, -): string { - const model = getFirstModel(value); - if (!model) { - throw new Error( - `[Environment] ${envVarName} is not set or contains no valid models.`, - ); - } - return model; -} diff --git a/services/platform/lib/shared/utils/resolve-provider-locale.test.ts b/services/platform/lib/shared/utils/resolve-provider-locale.test.ts deleted file mode 100644 index b0ec73e355..0000000000 --- a/services/platform/lib/shared/utils/resolve-provider-locale.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - resolveModelLocale, - resolveProviderLocale, -} from './resolve-provider-locale'; - -const i18nFirstProvider = { - displayName: 'OpenRouter', - description: 'Top-level fallback description', - i18n: { - en: { - displayName: 'OpenRouter', - description: 'English description', - }, - de: { - displayName: 'OpenRouter DE', - description: 'Deutsche Beschreibung', - }, - fr: { - // partial: only displayName - displayName: 'OpenRouter FR', - }, - }, -}; - -const legacyProvider = { - displayName: 'Legacy', - description: 'Old format', -}; - -describe('resolveProviderLocale', () => { - it('returns i18n[locale] values when fully present', () => { - const result = resolveProviderLocale(i18nFirstProvider, 'de'); - expect(result.displayName).toBe('OpenRouter DE'); - expect(result.description).toBe('Deutsche Beschreibung'); - }); - - it('falls back field-by-field to i18n.en when locale has partial overrides', () => { - const result = resolveProviderLocale(i18nFirstProvider, 'fr'); - expect(result.displayName).toBe('OpenRouter FR'); - expect(result.description).toBe('English description'); - }); - - it('falls back to i18n.en for unknown locale, never uses i18n.de', () => { - const result = resolveProviderLocale(i18nFirstProvider, 'es'); - expect(result.displayName).toBe('OpenRouter'); - expect(result.description).toBe('English description'); - }); - - it('resolves legacy provider (no i18n) to top-level fields', () => { - const result = resolveProviderLocale(legacyProvider, 'de'); - expect(result.displayName).toBe('Legacy'); - expect(result.description).toBe('Old format'); - }); - - it('falls through to top-level when neither i18n[locale] nor i18n.en have the field', () => { - const provider = { - displayName: 'Top-level Name', - description: 'Top-level Description', - i18n: { - de: { displayName: 'DE Name' }, - }, - }; - const result = resolveProviderLocale(provider, 'fr'); - expect(result.displayName).toBe('Top-level Name'); - expect(result.description).toBe('Top-level Description'); - }); - - it('returns empty displayName when no source has one', () => { - const result = resolveProviderLocale({}, 'en'); - expect(result.displayName).toBe(''); - expect(result.description).toBeUndefined(); - }); - - // --- BCP-47 narrowing --- - - it('narrows de-CH to i18n.de when only de is populated', () => { - const provider = { i18n: { de: { displayName: 'Deutsch' } } }; - expect(resolveProviderLocale(provider, 'de-CH').displayName).toBe( - 'Deutsch', - ); - }); - - it('prefers a direct locale match over its narrowed base', () => { - const provider = { - i18n: { - de: { displayName: 'Deutsch' }, - 'de-CH': { displayName: 'Schweizerdeutsch' }, - }, - }; - expect(resolveProviderLocale(provider, 'de-CH').displayName).toBe( - 'Schweizerdeutsch', - ); - }); - - it('falls through to app-default when narrowed base is missing too', () => { - const provider = { i18n: { en: { displayName: 'EN' } } }; - expect(resolveProviderLocale(provider, 'fr-FR').displayName).toBe('EN'); - }); - - // --- Empty-value treatment --- - - it('skips empty-string i18n value to the next layer', () => { - const provider = { - displayName: 'Top', - i18n: { en: { displayName: '' } }, - }; - expect(resolveProviderLocale(provider, 'en').displayName).toBe('Top'); - }); - - it('skips whitespace-only i18n value to the next layer', () => { - const provider = { - description: 'Top', - i18n: { de: { description: ' ' } }, - }; - expect(resolveProviderLocale(provider, 'de').description).toBe('Top'); - }); - - it('uses top-level only when i18n entry exists but is empty', () => { - const provider = { - displayName: 'Top', - i18n: { en: {} }, - }; - expect(resolveProviderLocale(provider, 'en').displayName).toBe('Top'); - }); -}); - -const providerWithModelTranslations = { - displayName: 'OpenRouter', - i18n: { - de: { - models: { - 'anthropic/claude-opus-4.6': { - description: 'Leistungsstärkstes Modell', - }, - 'openai/gpt-5.2': { - displayName: 'GPT-5.2 (DE)', - description: 'OpenAI Flaggschiff', - }, - }, - }, - fr: { - models: { - 'anthropic/claude-opus-4.6': { - // empty — should fall through to en, then top-level - }, - }, - }, - }, -}; - -describe('resolveModelLocale', () => { - const claudeOpus = { - id: 'anthropic/claude-opus-4.6', - displayName: 'Claude Opus 4.6', - description: 'Anthropic flagship', - }; - - const gpt = { - id: 'openai/gpt-5.2', - displayName: 'GPT-5.2', - description: 'OpenAI flagship', - }; - - it('returns the locale-specific model description', () => { - const result = resolveModelLocale( - claudeOpus, - providerWithModelTranslations.i18n, - 'de', - ); - expect(result.description).toBe('Leistungsstärkstes Modell'); - // displayName not overridden in de.models — falls through to top-level - expect(result.displayName).toBe('Claude Opus 4.6'); - }); - - it('returns both displayName and description when both are overridden', () => { - const result = resolveModelLocale( - gpt, - providerWithModelTranslations.i18n, - 'de', - ); - expect(result.displayName).toBe('GPT-5.2 (DE)'); - expect(result.description).toBe('OpenAI Flaggschiff'); - }); - - it('falls through to top-level for unknown model id', () => { - const unknown = { id: 'foo/bar', displayName: 'Foo', description: 'Bar' }; - const result = resolveModelLocale( - unknown, - providerWithModelTranslations.i18n, - 'de', - ); - expect(result.displayName).toBe('Foo'); - expect(result.description).toBe('Bar'); - }); - - it('falls through to top-level when no provider i18n is present', () => { - expect(resolveModelLocale(claudeOpus, undefined, 'de')).toEqual({ - displayName: 'Claude Opus 4.6', - description: 'Anthropic flagship', - }); - }); - - it('narrows de-CH to de for model overrides', () => { - const result = resolveModelLocale( - claudeOpus, - providerWithModelTranslations.i18n, - 'de-CH', - ); - expect(result.description).toBe('Leistungsstärkstes Modell'); - }); - - it('falls through empty model entry to top-level', () => { - const result = resolveModelLocale( - claudeOpus, - providerWithModelTranslations.i18n, - 'fr', - ); - expect(result.displayName).toBe('Claude Opus 4.6'); - expect(result.description).toBe('Anthropic flagship'); - }); - - it('returns empty displayName when neither overrides nor top-level have one', () => { - const result = resolveModelLocale({ id: 'x/y' }, undefined, 'en'); - expect(result.displayName).toBe(''); - expect(result.description).toBeUndefined(); - }); -}); diff --git a/services/platform/lib/shared/utils/resolve-provider-locale.ts b/services/platform/lib/shared/utils/resolve-provider-locale.ts deleted file mode 100644 index c4c10c1d5b..0000000000 --- a/services/platform/lib/shared/utils/resolve-provider-locale.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { defaultLocale as appDefaultLocale } from '../../i18n/config'; -import { narrowBcp47 } from './narrow-bcp47'; -import { pickField } from './pick-field'; - -interface ProviderModelI18nOverride { - displayName?: string; - description?: string; -} - -interface ProviderI18nOverride { - displayName?: string; - description?: string; - models?: Record; -} - -type ProviderI18nMap = Record; - -interface LocalizableProvider { - displayName?: string; - description?: string; - i18n?: ProviderI18nMap; -} - -interface LocalizableModel { - id: string; - displayName?: string; - description?: string; -} - -interface ResolvedProviderFields { - displayName: string; - description?: string; -} - -interface ResolvedModelFields { - displayName: string; - description?: string; -} - -/** - * Resolves locale-specific provider fields with i18n-first precedence: - * 1. `i18n[requestedLocale].` - * 2. `i18n[baseLanguage].` — e.g. `de-CH` narrows to `de` - * 3. `i18n[appDefault='en'].` - * 4. top-level `` (legacy fallback for pre-i18n providers) - * - * Mirrors `resolveAgentLocale` so provider configs and agent configs share - * the same fallback semantics. Empty/whitespace overrides are skipped via - * `pickField` so disk-state and runtime-fallback agree on "empty". - */ -export function resolveProviderLocale( - provider: LocalizableProvider, - locale: string, -): ResolvedProviderFields { - const base = narrowBcp47(locale); - - const direct = provider.i18n?.[locale]; - const baseI18n = base ? provider.i18n?.[base] : undefined; - const fallbackI18n = - locale !== appDefaultLocale && base !== appDefaultLocale - ? provider.i18n?.[appDefaultLocale] - : undefined; - - return { - displayName: - pickField([ - direct?.displayName, - baseI18n?.displayName, - fallbackI18n?.displayName, - provider.displayName, - ]) ?? '', - description: pickField([ - direct?.description, - baseI18n?.description, - fallbackI18n?.description, - provider.description, - ]), - }; -} - -/** - * Resolves locale-specific fields for a single model entry. Walks the same - * three i18n layers but reads per-model overrides via - * `providerI18n[locale].models[model.id]`. Falls back to the model's - * top-level fields. Decoupled from `resolveProviderLocale` so callers that - * only need provider chrome (e.g. the providers table) don't iterate models. - */ -export function resolveModelLocale( - model: LocalizableModel, - providerI18n: ProviderI18nMap | undefined, - locale: string, -): ResolvedModelFields { - const base = narrowBcp47(locale); - - const direct = providerI18n?.[locale]?.models?.[model.id]; - const baseI18n = base ? providerI18n?.[base]?.models?.[model.id] : undefined; - const fallbackI18n = - locale !== appDefaultLocale && base !== appDefaultLocale - ? providerI18n?.[appDefaultLocale]?.models?.[model.id] - : undefined; - - return { - displayName: - pickField([ - direct?.displayName, - baseI18n?.displayName, - fallbackI18n?.displayName, - model.displayName, - ]) ?? '', - description: pickField([ - direct?.description, - baseI18n?.description, - fallbackI18n?.description, - model.description, - ]), - }; -} From 1d334a24f018ee98e47fa95e16577c6d9ad5fa8d Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sun, 6 Sep 2026 09:46:13 +0800 Subject: [PATCH 02/10] refactor(platform): retire the [TAG] system-message vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/shared/constants/system-message-tags.ts declared a `[TAG]` prefix contract for system-role chat rows that no 0.5 module writes: the six body format/parse helpers had no caller at all, and the only reader of SYSTEM_MSG_TAG / parseSystemMessageTag / getSystemMessageDisplay was system-notice.tsx, whose own header admitted "nothing on this branch writes those tags yet". The needs the tags once served were rebuilt on another channel (StepLimitNotice reads usage.stepLimitHit; the arena copies an untagged system row), and the mock's `[human_input_response]` clause simulated a request_human_input resume the 0.5 chat has no tool for. Delete the module, its test, SystemNotice (+ test) — a system row now renders through MessageParts like every untagged row already did — the mock clause with the contract test that only existed to exercise it, and the knip parking line. Finding: lib-shared-rest-4. --- knip.config.ts | 1 - .../features/chat/components/message-item.tsx | 3 - .../chat/components/system-notice.test.tsx | 94 --------- .../chat/components/system-notice.tsx | 190 ----------------- .../lib/mocks/contract/openai-compat.test.ts | 34 --- .../lib/mocks/overrides/chat-completions.ts | 12 +- .../constants/system-message-tags.test.ts | 168 --------------- .../shared/constants/system-message-tags.ts | 195 ------------------ 8 files changed, 3 insertions(+), 694 deletions(-) delete mode 100644 services/platform/app/features/chat/components/system-notice.test.tsx delete mode 100644 services/platform/app/features/chat/components/system-notice.tsx delete mode 100644 services/platform/lib/shared/constants/system-message-tags.test.ts delete mode 100644 services/platform/lib/shared/constants/system-message-tags.ts diff --git a/knip.config.ts b/knip.config.ts index 2f309f1636..9a6a10fcd1 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -119,7 +119,6 @@ export default { 'lib/shared/constants/agents.ts', 'lib/shared/schemas/skills.ts', 'lib/shared/config/registry.ts', - 'lib/shared/constants/system-message-tags.ts', 'lib/shared/file-types.ts', 'lib/shared/providers/attribution.ts', 'lib/shared/schemas/agents.ts', diff --git a/services/platform/app/features/chat/components/message-item.tsx b/services/platform/app/features/chat/components/message-item.tsx index 124260d722..ee13a40fb6 100644 --- a/services/platform/app/features/chat/components/message-item.tsx +++ b/services/platform/app/features/chat/components/message-item.tsx @@ -58,7 +58,6 @@ import { MessageParts } from './message-parts'; import { MessageToolbar } from './message-toolbar'; import { SourceCards } from './source-cards'; import { StepLimitNotice, stepLimitHit } from './step-limit-notice'; -import { SystemNotice } from './system-notice'; import { ThinkingDots } from './thinking-dots'; import { ThoughtTimeline } from './thought-timeline'; import { VoiceOutputIndicator } from './voice-output-indicator'; @@ -180,8 +179,6 @@ function MessageItemComponent({ voicePillForced={voicePillForced} isFreshSinceMount={isFreshSinceMount} /> - ) : message.role === 'system' ? ( - ) : ( )} diff --git a/services/platform/app/features/chat/components/system-notice.test.tsx b/services/platform/app/features/chat/components/system-notice.test.tsx deleted file mode 100644 index 52fea833b1..0000000000 --- a/services/platform/app/features/chat/components/system-notice.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -// @vitest-environment jsdom -import '@testing-library/jest-dom/vitest'; -import { describe, expect, it, vi } from 'vitest'; - -import { SYSTEM_MSG_TAG } from '@/lib/shared/constants/system-message-tags'; -import { render, screen } from '@/tests/utils/render'; - -import type { MessagePart } from '../types'; -import { SystemNotice } from './system-notice'; - -// The untagged path falls through to MessageParts, whose image-attachment -// branch resolves URLs through a Convex query; no provider here. -vi.mock('@/app/features/shared/files/use-file-url', () => ({ - useFileUrl: () => ({ data: null }), - useFileUrls: () => ({ data: [] }), -})); - -const textParts = (text: string): MessagePart[] => [{ type: 'text', text }]; - -describe('SystemNotice', () => { - it('renders a pill-tagged body as the confirmation pill', () => { - const body = 'Provided the requested details'; - render( - , - ); - - const label = screen.getByText(body); - expect(label.closest('div')).toHaveClass('rounded-full'); - expect(screen.queryByRole('alert')).toBeNull(); - expect(screen.queryByRole('status')).toBeNull(); - }); - - it('renders an error-tagged long body as the collapsible box with role=alert', () => { - // Long enough to pass the inline-row threshold; single line, so the - // whole body is the preview and there is nothing left to expand. - const body = - 'The automation run failed while calling the external endpoint, ' + - 'the response returned a non-recoverable status and the run was ' + - 'abandoned after the final retry attempt.'; - render( - , - ); - - const alert = screen.getByRole('alert'); - expect(alert).toHaveAttribute('aria-live', 'assertive'); - expect(alert).toHaveTextContent(body); - }); - - it('reveals the folded remainder of a multi-line body on expand', async () => { - const body = 'First line of the notice\nSecond line\nThe folded detail'; - const { user } = render( - , - ); - - expect(screen.queryByText('The folded detail')).toBeNull(); - await user.click(screen.getByRole('button', { expanded: false })); - expect(screen.getByText('The folded detail')).toBeInTheDocument(); - }); - - it('renders a short warning-tagged body as the inline annotation row', () => { - const body = 'Response was interrupted'; - render( - , - ); - - const row = screen.getByRole('alert'); - expect(row).toHaveTextContent(body); - expect(row).toHaveClass('text-warning'); - expect(screen.queryByRole('button')).toBeNull(); - }); - - it('passes an untagged message through to the parts renderer', () => { - const text = 'A plain system note without any tag'; - render(); - - // MessageParts renders text parts as plain paragraphs — no notice - // chrome, no live-region semantics. - expect(screen.getByText(text).tagName).toBe('P'); - expect(screen.queryByRole('alert')).toBeNull(); - expect(screen.queryByRole('status')).toBeNull(); - }); -}); diff --git a/services/platform/app/features/chat/components/system-notice.tsx b/services/platform/app/features/chat/components/system-notice.tsx deleted file mode 100644 index ce4b7bc86b..0000000000 --- a/services/platform/app/features/chat/components/system-notice.tsx +++ /dev/null @@ -1,190 +0,0 @@ -'use client'; - -/** - * The system-role rows of the transcript, routed by their `[TAG]`. - * - * The backend prefixes system messages with a `SYSTEM_MSG_TAG` and the - * display map assigns each tag a presentation: 'pill' reads as a compact - * right-aligned confirmation (a human-input answer landing in the flow), - * short warnings/errors read as a one-line annotation, and everything else - * folds into the collapsible box below — first lines visible, the rest - * behind a chevron, with alert/status semantics for screen readers. - * - * An untagged system message falls through to the plain parts renderer - * unchanged. The specialized notices main grew for structured bodies - * (model-fallback, generation-incomplete, step-limit) are deliberately NOT - * ported: nothing on this branch writes those tags yet, so they render via - * the generic display-map path until a writer exists. - */ - -import { Row } from '@tale/ui/layout'; -import { cva } from 'class-variance-authority'; -import { - AlertTriangle, - CheckCircle2, - ChevronDown, - Info, - XCircle, -} from 'lucide-react'; -import { memo, useCallback, useState } from 'react'; - -import { - getSystemMessageDisplay, - parseSystemMessageTag, - type SystemMessageDisplay, -} from '@/lib/shared/constants/system-message-tags'; -import { cn } from '@/lib/utils/cn'; - -import type { MessagePart } from '../types'; -import { MessageParts } from './message-parts'; - -type CollapsibleVariant = Exclude; - -/** A warning/error this short renders as a single annotation line instead - * of the boxed treatment — one line of prose, no fold. */ -const INLINE_NOTICE_MAX_CHARS = 120; - -const containerVariants = cva('overflow-hidden rounded-lg border text-xs', { - variants: { - variant: { - info: 'bg-muted/50 text-muted-foreground border-transparent', - success: 'bg-success/10 text-success border-success/30', - warning: 'bg-warning/10 text-warning border-warning/30', - error: 'bg-destructive/10 text-destructive border-destructive/30', - }, - }, - defaultVariants: { - variant: 'info', - }, -}); - -const VARIANT_ICONS = { - info: Info, - success: CheckCircle2, - warning: AlertTriangle, - error: XCircle, -} as const; - -/** The boxed treatment: the first two non-empty lines as a preview, the - * rest behind an expand chevron. Warnings/errors announce as alerts. */ -const CollapsibleSystemMessage = memo(function CollapsibleSystemMessage({ - content, - variant = 'info', -}: { - content: string; - variant?: CollapsibleVariant; -}) { - const [expanded, setExpanded] = useState(false); - const toggle = useCallback(() => setExpanded((prev) => !prev), []); - - const lines = content.split('\n'); - const nonEmptyLines = lines.filter((l) => l.trim() !== ''); - const previewLines = nonEmptyLines.slice(0, 2); - const preview = previewLines.join(' '); - const lastPreviewIdx = - previewLines.length > 0 - ? lines.indexOf(previewLines[previewLines.length - 1]) - : 0; - const rest = lines - .slice(lastPreviewIdx + 1) - .join('\n') - .trimStart(); - const hasMore = rest.length > 0; - - const Icon = VARIANT_ICONS[variant]; - const isAlertRole = variant === 'warning' || variant === 'error'; - - return ( -
-
- - {expanded && ( -
- {rest} -
- )} -
-
- ); -}); - -/** - * Route one system message to its presentation. `text` is the message's - * precomputed plain text ("[TAG] body"); `parts` carry the untagged - * fallback so a message no tag claims renders exactly as before. - */ -export function SystemNotice({ - text, - parts, -}: { - text: string; - parts: readonly MessagePart[]; -}) { - const { tag, body } = parseSystemMessageTag(text); - - if (tag === null) { - return ; - } - - const display = getSystemMessageDisplay(tag); - - if (display === 'pill') { - return ( - - - - - ); - } - - const isShortInline = - (display === 'warning' || display === 'error') && - !body.includes('\n') && - body.length < INLINE_NOTICE_MAX_CHARS; - - if (isShortInline) { - return ( -
-
- ); - } - - return ; -} diff --git a/services/platform/lib/mocks/contract/openai-compat.test.ts b/services/platform/lib/mocks/contract/openai-compat.test.ts index d62d9ae72e..35519fba90 100644 --- a/services/platform/lib/mocks/contract/openai-compat.test.ts +++ b/services/platform/lib/mocks/contract/openai-compat.test.ts @@ -240,40 +240,6 @@ describe('chat/completions override', () => { expect(resumeText).not.toContain('reasoning_content'); }); - test('a tool-scripted docs phrase acks after a human-response resume', async () => { - // The request_human_input pause: the resume conversation's LAST user - // message is the injected wrapper, not the docs phrase. - // The newest-first conversation scan must still find the entry, or the - // approval ack falls back to the canned reply mid-scene. - const scripted = DOCS_REPLIES.find( - (entry) => entry.tool?.name === 'request_human_input', - ); - if (!scripted) throw new Error('expected a request_human_input script'); - // The REAL resume shape (traced from the app): a rebuilt conversation — - // system messages only, the on-camera prompt embedded in a history - // block, and a single [HUMAN_INPUT_RESPONSE] user line. - const res = await post('/v1/chat/completions', { - model: 'm', - stream: true, - messages: [ - { role: 'system', content: 'You are an AI assistant for the org.' }, - { - role: 'system', - content: `
Historyuser: Please ${scripted.match}.
`, - }, - { - role: 'user', - content: '[HUMAN_INPUT_RESPONSE] Final adjustments: Looks good.', - }, - ], - }); - const text = await res.text(); - expect(text).not.toContain('"finish_reason":"tool_calls"'); - const ackWord = scripted.reply.split(/\s+/)[0]; - expect(text).toContain(ackWord); - expect(text).not.toContain(CANNED_REPLY.split(' ')[0]); - }); - test('a tool-scripted docs phrase stays text-only on the non-stream path', async () => { // Thread-title generation is a non-streamed call carrying the user's first // message — it must get the plain `reply`, never tool markup. diff --git a/services/platform/lib/mocks/overrides/chat-completions.ts b/services/platform/lib/mocks/overrides/chat-completions.ts index 478a6beccc..e0ba09d105 100644 --- a/services/platform/lib/mocks/overrides/chat-completions.ts +++ b/services/platform/lib/mocks/overrides/chat-completions.ts @@ -221,9 +221,8 @@ function mockTitleFor(body: ChatCompletionRequest): string { /** * The docs entry for this conversation: every message text scanned - * NEWEST-FIRST — system messages included. A human-input RESUME arrives as a - * REBUILT conversation whose only user message is the - * `[HUMAN_INPUT_RESPONSE]` line; the on-camera prompt that owns the script + * NEWEST-FIRST — system messages included, because a resume can arrive as a + * REBUILT conversation where the on-camera prompt that owns the script * survives only inside a system message's embedded history block. Docs * phrases are distinctive full clauses, so scanning system text cannot * shadow the e2e paths. @@ -255,12 +254,7 @@ function isToolResume(messages: ParsedMessage[]): boolean { (message) => message.role === 'tool' || message.hasToolCalls || - message.text.includes('human_response') || - // The human-input RESUME rebuilds the conversation; its user line is - // "[HUMAN_INPUT_RESPONSE] : " (note: NOT a substring of - // 'human_response'). Without this, a tool-scripted docs entry would - // re-emit its tool call and pause forever. - message.text.toLowerCase().includes('[human_input_response]'), + message.text.includes('human_response'), ); } diff --git a/services/platform/lib/shared/constants/system-message-tags.test.ts b/services/platform/lib/shared/constants/system-message-tags.test.ts deleted file mode 100644 index 9ed5f9a54f..0000000000 --- a/services/platform/lib/shared/constants/system-message-tags.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - SYSTEM_MSG_TAG, - formatGenerationIncompleteBody, - formatStepLimitBody, - getSystemMessageDisplay, - parseGenerationIncompleteBody, - parseStepLimitBody, - parseSystemMessageTag, -} from './system-message-tags'; - -describe('parseSystemMessageTag', () => { - it('parses known tags from content start', () => { - const result = parseSystemMessageTag( - '[WORKFLOW_COMPLETED]\nWorkflow done.', - ); - expect(result.tag).toBe(SYSTEM_MSG_TAG.WORKFLOW_COMPLETED); - expect(result.body).toBe('Workflow done.'); - }); - - it('trims leading whitespace from body', () => { - const result = parseSystemMessageTag('[WORKFLOW_FAILED] Error occurred.'); - expect(result.tag).toBe(SYSTEM_MSG_TAG.WORKFLOW_FAILED); - expect(result.body).toBe('Error occurred.'); - }); - - it('returns null tag for content without tag prefix', () => { - const result = parseSystemMessageTag('Just a plain message'); - expect(result.tag).toBeNull(); - expect(result.body).toBe('Just a plain message'); - }); - - it('returns null tag for empty content', () => { - const result = parseSystemMessageTag(''); - expect(result.tag).toBeNull(); - expect(result.body).toBe(''); - }); - - it('returns null tag for unknown bracket tags', () => { - const result = parseSystemMessageTag('[UNKNOWN_TAG] Some content'); - expect(result.tag).toBeNull(); - expect(result.body).toBe('[UNKNOWN_TAG] Some content'); - }); - - it('ignores tags not at the start of content', () => { - const result = parseSystemMessageTag( - 'Some text [WORKFLOW_COMPLETED] more text', - ); - expect(result.tag).toBeNull(); - expect(result.body).toBe('Some text [WORKFLOW_COMPLETED] more text'); - }); - - it('parses all known tags', () => { - for (const tag of Object.values(SYSTEM_MSG_TAG)) { - const result = parseSystemMessageTag(`${tag} body`); - expect(result.tag).toBe(tag); - expect(result.body).toBe('body'); - } - }); - - it('handles tag with no body', () => { - const result = parseSystemMessageTag('[TIMEOUT_RECOVERY]'); - expect(result.tag).toBe(SYSTEM_MSG_TAG.TIMEOUT_RECOVERY); - expect(result.body).toBe(''); - }); -}); - -describe('getSystemMessageDisplay', () => { - it('returns pill for HUMAN_INPUT_RESPONSE', () => { - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.HUMAN_INPUT_RESPONSE)).toBe( - 'pill', - ); - }); - - it('returns info for workflow completion tags', () => { - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.WORKFLOW_COMPLETED)).toBe( - 'info', - ); - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.WORKFLOW_CREATED)).toBe( - 'info', - ); - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.WORKFLOW_UPDATED)).toBe( - 'info', - ); - }); - - it('returns warning for interruption tags', () => { - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.RESPONSE_INTERRUPTED)).toBe( - 'warning', - ); - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.TIMEOUT_RECOVERY)).toBe( - 'warning', - ); - }); - - it('returns error for WORKFLOW_FAILED', () => { - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.WORKFLOW_FAILED)).toBe( - 'error', - ); - }); - - it('returns info for user-initiated actions', () => { - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.APPROVAL_REJECTED)).toBe( - 'info', - ); - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.WORKFLOW_CANCELLED)).toBe( - 'info', - ); - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.WORKFLOW_STARTED)).toBe( - 'info', - ); - }); - - it('returns info for null tag', () => { - expect(getSystemMessageDisplay(null)).toBe('info'); - }); - - it('returns warning for GENERATION_INCOMPLETE', () => { - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.GENERATION_INCOMPLETE)).toBe( - 'warning', - ); - }); - - it('returns info for step-limit tags — capacity stops are not failures', () => { - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.STEP_LIMIT_CONTINUED)).toBe( - 'info', - ); - expect(getSystemMessageDisplay(SYSTEM_MSG_TAG.STEP_LIMIT_REACHED)).toBe( - 'info', - ); - }); -}); - -describe('generation-incomplete body round-trip', () => { - it('round-trips tool names, including ones needing encoding', () => { - const body = formatGenerationIncompleteBody({ - tools: ['delegate_researcher', 'request_human_input', 'a,b'], - }); - expect(parseGenerationIncompleteBody(body).tools).toEqual([ - 'delegate_researcher', - 'request_human_input', - 'a,b', - ]); - }); - - it('formats an empty tool list to an empty body and parses it back', () => { - expect(formatGenerationIncompleteBody({})).toBe(''); - expect(parseGenerationIncompleteBody('').tools).toBeUndefined(); - }); -}); - -describe('step-limit body round-trip', () => { - it('round-trips the continuation round', () => { - expect(parseStepLimitBody(formatStepLimitBody({ round: 2 })).round).toBe(2); - }); - - it('formats a missing/zero round to an empty body and parses it back', () => { - expect(formatStepLimitBody({})).toBe(''); - expect(formatStepLimitBody({ round: 0 })).toBe(''); - expect(parseStepLimitBody('').round).toBeUndefined(); - }); - - it('ignores malformed round values', () => { - expect(parseStepLimitBody('round=abc').round).toBeUndefined(); - expect(parseStepLimitBody('rounds=3').round).toBeUndefined(); - }); -}); diff --git a/services/platform/lib/shared/constants/system-message-tags.ts b/services/platform/lib/shared/constants/system-message-tags.ts deleted file mode 100644 index 07314936e4..0000000000 --- a/services/platform/lib/shared/constants/system-message-tags.ts +++ /dev/null @@ -1,195 +0,0 @@ -export const SYSTEM_MSG_TAG = { - APPROVAL_REJECTED: '[APPROVAL_REJECTED]', - WORKFLOW_CANCELLED: '[WORKFLOW_CANCELLED]', - WORKFLOW_COMPLETED: '[WORKFLOW_COMPLETED]', - WORKFLOW_FAILED: '[WORKFLOW_FAILED]', - WORKFLOW_CREATED: '[WORKFLOW_CREATED]', - WORKFLOW_STARTED: '[WORKFLOW_STARTED]', - WORKFLOW_UPDATED: '[WORKFLOW_UPDATED]', - HUMAN_INPUT_RESPONSE: '[HUMAN_INPUT_RESPONSE]', - LOCATION_RESPONSE: '[LOCATION_RESPONSE]', - RESPONSE_INTERRUPTED: '[RESPONSE_INTERRUPTED]', - TIMEOUT_RECOVERY: '[TIMEOUT_RECOVERY]', - CONNECTOR_OPERATION_COMPLETED: '[CONNECTOR_OPERATION_COMPLETED]', - CONNECTOR_OPERATION_FAILED: '[CONNECTOR_OPERATION_FAILED]', - MODEL_FALLBACK: '[MODEL_FALLBACK]', - GENERATION_INCOMPLETE: '[GENERATION_INCOMPLETE]', - STEP_LIMIT_CONTINUED: '[STEP_LIMIT_CONTINUED]', - STEP_LIMIT_REACHED: '[STEP_LIMIT_REACHED]', -} as const; - -export type SystemMsgTag = (typeof SYSTEM_MSG_TAG)[keyof typeof SYSTEM_MSG_TAG]; - -const TAG_REGEX = /^\[([A-Z][A-Z_]+)\]/; -const KNOWN_TAGS = new Set(Object.values(SYSTEM_MSG_TAG)); - -export function parseSystemMessageTag(content: string): { - tag: SystemMsgTag | null; - body: string; -} { - const match = content.match(TAG_REGEX); - if (!match) return { tag: null, body: content }; - const raw = `[${match[1]}]`; - if (!KNOWN_TAGS.has(raw)) return { tag: null, body: content }; - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- validated via KNOWN_TAGS set lookup - return { - tag: raw as SystemMsgTag, - body: content.slice(match[0].length).trimStart(), - }; -} - -export type SystemMessageDisplay = - | 'pill' - | 'success' - | 'warning' - | 'error' - | 'info'; - -const DISPLAY_MAP: Record = { - [SYSTEM_MSG_TAG.HUMAN_INPUT_RESPONSE]: 'pill', - [SYSTEM_MSG_TAG.LOCATION_RESPONSE]: 'pill', - [SYSTEM_MSG_TAG.WORKFLOW_COMPLETED]: 'info', - [SYSTEM_MSG_TAG.WORKFLOW_CREATED]: 'info', - [SYSTEM_MSG_TAG.WORKFLOW_UPDATED]: 'info', - [SYSTEM_MSG_TAG.RESPONSE_INTERRUPTED]: 'warning', - [SYSTEM_MSG_TAG.TIMEOUT_RECOVERY]: 'warning', - [SYSTEM_MSG_TAG.WORKFLOW_FAILED]: 'error', - [SYSTEM_MSG_TAG.APPROVAL_REJECTED]: 'info', - [SYSTEM_MSG_TAG.WORKFLOW_CANCELLED]: 'info', - [SYSTEM_MSG_TAG.WORKFLOW_STARTED]: 'info', - [SYSTEM_MSG_TAG.CONNECTOR_OPERATION_COMPLETED]: 'info', - [SYSTEM_MSG_TAG.CONNECTOR_OPERATION_FAILED]: 'error', - [SYSTEM_MSG_TAG.MODEL_FALLBACK]: 'warning', - [SYSTEM_MSG_TAG.GENERATION_INCOMPLETE]: 'warning', - // Step-cap continuations are expected capacity stops on tool-heavy turns, - // not failures — rendered as neutral info, never a warning. - [SYSTEM_MSG_TAG.STEP_LIMIT_CONTINUED]: 'info', - [SYSTEM_MSG_TAG.STEP_LIMIT_REACHED]: 'info', -}; - -export function getSystemMessageDisplay( - tag: SystemMsgTag | null, -): SystemMessageDisplay { - if (!tag) return 'info'; - return DISPLAY_MAP[tag]; -} - -/** - * Structured payload carried in a `[MODEL_FALLBACK]` system-message body. - * - * The backend writes it machine-readably (URL-encoded values, no localized - * prose) so the chat UI can render a localized line and the model auto-switch - * can read `to` reliably — instead of regex-scraping an English sentence. - */ -interface ModelFallbackBody { - /** The model ref that just failed. */ - from?: string; - /** The next model ref being attempted ('default' for the tag-default). */ - to?: string; - /** A `ChatErrorCode`-style reason the previous model failed. */ - reason?: string; -} - -export function formatModelFallbackBody(body: ModelFallbackBody): string { - const parts: string[] = []; - if (body.from) parts.push(`from=${encodeURIComponent(body.from)}`); - if (body.to) parts.push(`to=${encodeURIComponent(body.to)}`); - if (body.reason) parts.push(`reason=${encodeURIComponent(body.reason)}`); - return parts.join(' '); -} - -/** - * Parse a `[MODEL_FALLBACK]` body. Returns an empty object for legacy bodies - * (the previous English-sentence format), so callers can fall back gracefully. - */ -export function parseModelFallbackBody(body: string): ModelFallbackBody { - const result: ModelFallbackBody = {}; - for (const token of body.trim().split(/\s+/)) { - const eq = token.indexOf('='); - if (eq <= 0) continue; - const key = token.slice(0, eq); - const rawValue = token.slice(eq + 1); - let value: string; - try { - value = decodeURIComponent(rawValue); - } catch { - value = rawValue; - } - if (key === 'from') result.from = value; - else if (key === 'to') result.to = value; - else if (key === 'reason') result.reason = value; - } - return result; -} - -/** - * Structured payload carried in a `[GENERATION_INCOMPLETE]` system-message - * body — written when a turn exhausts its retries without producing a final - * answer. Machine-readable (URL-encoded tool names, no localized prose) so the - * chat UI can render a localized warning instead of an English sentence - * masquerading as the assistant's own words. - */ -interface GenerationIncompleteBody { - /** Names of the tools the model called during the incomplete turn. */ - tools?: string[]; -} - -export function formatGenerationIncompleteBody( - body: GenerationIncompleteBody, -): string { - return body.tools && body.tools.length > 0 - ? `tools=${body.tools.map((t) => encodeURIComponent(t)).join(',')}` - : ''; -} - -/** - * Structured payload carried in `[STEP_LIMIT_CONTINUED]` / - * `[STEP_LIMIT_REACHED]` system-message bodies. Machine-readable (no - * localized prose) so the chat UI renders a localized neutral line. - * `round` is the 1-based continuation round for CONTINUED, and the number of - * continuation rounds the turn used for REACHED. - */ -interface StepLimitBody { - round?: number; -} - -export function formatStepLimitBody(body: StepLimitBody): string { - return body.round !== undefined && body.round > 0 - ? `round=${body.round}` - : ''; -} - -export function parseStepLimitBody(body: string): StepLimitBody { - const result: StepLimitBody = {}; - for (const token of body.trim().split(/\s+/)) { - const eq = token.indexOf('='); - if (eq <= 0) continue; - if (token.slice(0, eq) !== 'round') continue; - const value = Number.parseInt(token.slice(eq + 1), 10); - if (Number.isFinite(value) && value > 0) result.round = value; - } - return result; -} - -export function parseGenerationIncompleteBody( - body: string, -): GenerationIncompleteBody { - const result: GenerationIncompleteBody = {}; - for (const token of body.trim().split(/\s+/)) { - const eq = token.indexOf('='); - if (eq <= 0) continue; - if (token.slice(0, eq) !== 'tools') continue; - result.tools = token - .slice(eq + 1) - .split(',') - .filter((t) => t.length > 0) - .map((t) => { - try { - return decodeURIComponent(t); - } catch { - return t; - } - }); - } - return result; -} From 9f6bf9260a960fed2320d59e72673e0d9f9747ee Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sun, 6 Sep 2026 09:49:01 +0800 Subject: [PATCH 03/10] refactor(platform): describe the config registry as the 0.5 file tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/shared/config/registry.ts still narrated the 0.4 Convex split: a `readContext` (node-direct / v8-action / v8-sync), a `dataModel`, a `watcher` spec nothing ever populated, `V8_SYNC_DOMAINS`, `getV8SyncSpec` and a `configCache` mirror table no migration creates. The one live reader (organizations/scaffold.ts) only uses `name`, `scaffoldKind` and the per-key schema lookup — a "which seeded file gets which schema" table the `v8Sync` name misrepresented. Drop the retired fields, types and helpers, rename `v8Sync` to `seedSchemas`, rewrite the header and entry comments onto the file-tree contract, un-export what nothing imports, and remove the knip parking line so the module is audited again. scaffold.ts follows the rename and loses the sentence that pointed at a deleted `getConfigDomain` caller. Finding: lib-shared-rest-5. --- knip.config.ts | 1 - .../backend/core/organizations/scaffold.ts | 41 ++-- .../platform/lib/shared/config/registry.ts | 198 +++++------------- 3 files changed, 77 insertions(+), 163 deletions(-) diff --git a/knip.config.ts b/knip.config.ts index 9a6a10fcd1..257062c648 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -118,7 +118,6 @@ export default { // above (schemas, provider catalog shapes). Same debt, same exit. 'lib/shared/constants/agents.ts', 'lib/shared/schemas/skills.ts', - 'lib/shared/config/registry.ts', 'lib/shared/file-types.ts', 'lib/shared/providers/attribution.ts', 'lib/shared/schemas/agents.ts', diff --git a/services/platform/backend/core/organizations/scaffold.ts b/services/platform/backend/core/organizations/scaffold.ts index f9faf4f3d0..c3eb3f8112 100644 --- a/services/platform/backend/core/organizations/scaffold.ts +++ b/services/platform/backend/core/organizations/scaffold.ts @@ -102,8 +102,8 @@ export type DomainResult = { // - 'flat' = one file per item, no subdirs. Today that's just `governance`. // override:true overwrites per-file via atomicWrite; user-added files // survive, secrets sidecars + `.history/` at the dir level survive. A -// catalog `.yml`/`.json` file that matches one of the domain's `v8Sync` -// keys is schema-validated before being written (corrupt files are +// catalog `.yml`/`.json` file that matches one of the domain's +// `seedSchemas` keys is schema-validated before being written (corrupt files are // skipped, never copied); anything else (e.g. `retention.yml`) copies // unchecked. // `bundle` (skills/connectors/automations pre-rewrite) and `tree` @@ -172,9 +172,9 @@ export async function pathsOverlap(a: string, b: string): Promise { /** * The schema for a single catalog config file (`.yml` or `.json`), keyed by - * matching its basename (no extension) against the domain's `v8Sync` keys - * via `fileBaseFor` — the same mapping the file→`configCache` mirror uses. - * Returns `undefined` when the domain has no `v8Sync` spec, or when no key + * matching its basename (no extension) against the domain's `seedSchemas` + * keys via `fileBaseFor`. + * Returns `undefined` when the domain has no `seedSchemas` spec, or when no key * maps to this filename (e.g. governance's `retention.yml`, which is not a * policy) — the caller then copies the file unchecked, same as the old * per-domain `domainCatalogFileSchema` fallback. @@ -183,12 +183,12 @@ function schemaForCatalogFile( domain: ConfigDomain, fileName: string, ): z.ZodType | undefined { - const v8Sync = domain.v8Sync; - if (!v8Sync) return undefined; + const seedSchemas = domain.seedSchemas; + if (!seedSchemas) return undefined; const base = fileName.replace(/\.(yml|json)$/, ''); - for (const key of v8Sync.keys) { - if (v8Sync.fileBaseFor(key) === base) { - return v8Sync.schemaFor(key); + for (const key of seedSchemas.keys) { + if (seedSchemas.fileBaseFor(key) === base) { + return seedSchemas.schemaFor(key); } } return undefined; @@ -198,8 +198,8 @@ function schemaForCatalogFile( * A single catalog config file that fails its domain schema is SKIPPED * (warn + return false) rather than copied — corrupt bytes must never reach * a new org's disk. `.yml` parses through the shared safe loader, `.json` - * through `JSON.parse`. Files with no matching `v8Sync` key (or a domain - * with no `v8Sync` spec at all) keep copying unchecked (the CI + * through `JSON.parse`. Files with no matching `seedSchemas` key (or a domain + * with no `seedSchemas` spec at all) keep copying unchecked (the CI * catalog-validation gate is the exhaustive check; this is only "catch the * common case"). */ @@ -276,7 +276,7 @@ export async function writeFileFromCatalog( * cross-tenant content — skip with a warning rather than recurse. * * `domain` is threaded down to `writeFileFromCatalog` so each `.json` file - * is schema-checked (via its `v8Sync` mapping, when it has one) before being + * is schema-checked (via its `seedSchemas` mapping, when it has one) before being * written — omit it to copy unchecked. */ export async function copyTree( @@ -339,15 +339,12 @@ export async function copyTree( * domains. There is deliberately no `default`/org level and no fallback to any * org's live dir: every org is seeded only from the built-in catalog. * - * MINIMAL interim: only `scaffoldKind: 'flat'` is implemented. `bundle` and - * `tree` domains throw rather than attempt copy semantics this module no - * longer carries — a silent no-op or a naive flat copy would either strand an - * operator expecting a real seed or corrupt a nested bundle/tree layout. The - * only caller that can currently reach a non-flat domain is the pre-rewrite - * `v0_3_4/33` migration (via `getConfigDomain('automations')`), which only - * matters on a deployment still mid-upgrade from before this rewrite — - * failing loud there is correct: it tells the operator to land a pre-rewrite - * release first rather than silently mis-seeding. + * `scaffoldKind: 'flat'` and `'bundle'` are implemented. A `tree` domain + * throws rather than attempt copy semantics this module does not carry — a + * silent no-op or a naive flat copy would either strand an operator expecting + * a real seed or corrupt a nested tree layout. No registered domain is a + * `tree` today, so the throw is a guard for a future registration, not a + * reachable path. * * A missing `/` source dir degrades to `{ok:true}` with * nothing seeded (see the file header) rather than the deploy-misconfig diff --git a/services/platform/lib/shared/config/registry.ts b/services/platform/lib/shared/config/registry.ts index f41b2b6a1c..3bb7230e3f 100644 --- a/services/platform/lib/shared/config/registry.ts +++ b/services/platform/lib/shared/config/registry.ts @@ -1,22 +1,18 @@ /** - * Config-domain registry — Layer A (pure data, V8-safe). + * Config-domain registry — pure data, importable from anywhere (the + * backend, Bun scripts, vitest, the browser). NO `node:*`: a domain's + * on-disk directory is resolved in `backend/core/lib/config_store/ + * resolvers.ts`, the one place that value-imports the per-domain + * `file_utils` modules. * - * A Convex query/mutation runs in a V8 sandbox that cannot import `node:*`, - * so anything filesystem-flavored lives in Layer B - * (`convex/lib/config_store/resolvers.ts` + the per-domain `file_utils.ts` - * modules, all `'use node'`). This module declares WHAT the config domains - * are as pure data — Zod schemas, key lists, copy semantics — and may be - * imported from anywhere: V8 Convex code, node actions, Bun scripts, vitest, - * and the browser. NO `node:*`, NO `convex/_generated`, NO `'use node'`. - * - * This is the seed of the config-system rewrite registry. The rebuilt - * AI-backend domains (agents, automations, connectors, …) re-register here - * as their phases land. The default on-disk format is YAML-first with a - * `.json` fallback per file (the shared reader in - * `convex/lib/config_store/read_domain_file.ts`): a versioned node migration - * converts org trees in place, and both formats read correctly while any - * tree is still unconverted. `skills` is the one domain whose files are - * markdown with YAML frontmatter, because a skill is a document. + * Per-org configuration is a file tree, never a database row: + * `$TALE_CONFIG_DIR///…`. This module declares WHAT the + * domains are — the dir name, how the org scaffolder seeds a domain from + * the builtin catalog (`configs/platform/custom//`), and which + * seeded files carry a Zod schema the copy validates against. The default + * on-disk format is YAML-first with a `.json` fallback per file; `skills` is + * the one domain whose files are markdown with YAML frontmatter, because a + * skill is a document. */ import type { z } from 'zod/v4'; @@ -42,91 +38,49 @@ import { * - `tree` — per-file overwrite recursing into subdirectories, never `rm`; * user-only folders survive. */ -export type ScaffoldKind = 'flat' | 'bundle' | 'tree'; +type ScaffoldKind = 'flat' | 'bundle' | 'tree'; /** - * How a domain's config is READ at runtime — the crux of the architecture: - * - `node-direct` — read from the filesystem inside a `'use node'` action. - * - `v8-action` — a V8 action delegates to a `'use node'` action via - * `ctx.runAction`. - * - `v8-sync` — read from V8 queries/mutations/auth-hooks (which cannot - * touch the filesystem), so files are mirrored into the - * derived `configCache` table and read from there. + * The catalog files the scaffolder validates before copying them into an + * org's tree: `fileBaseFor(key)` names the file (no extension) under the + * domain dir, `schemaFor(key)` the schema its contents must satisfy. A + * catalog file no key maps to copies unchecked. */ -export type ReadContext = 'node-direct' | 'v8-action' | 'v8-sync'; - -/** - * Where the AUTHORITATIVE copy lives: - * - `config` — the file is the source of truth; the DB only ever holds - * a re-derivable cache (`configCache`). - * - `runtime-state` — the file holds the definition; the DB holds only - * per-org runtime state (install rows, trigger rows). - * (The pre-rewrite `seeded-user-data` model — the prompt library — is - * retired; no v2 domain may reintroduce a DB-authoritative config.) - */ -export type ConfigDataModel = 'config' | 'runtime-state'; - -/** - * Drives the generic file→`configCache` mirror for `v8-sync` domains. Pure - * (no fs): the `'use node'` sync action joins `fileBaseFor(key)` onto the - * domain dir (resolved via Layer B) to locate each file. - */ -export interface V8SyncSpec { - /** Stable cache keys for this domain (the `key` column of `configCache`). */ +interface SeedSchemaSpec { + /** Stable keys for this domain's schema-checked files. */ readonly keys: readonly string[]; - /** key → Zod schema, validated before mirroring AND on read. */ + /** key → Zod schema, validated before the file is seeded. */ schemaFor: (key: string) => z.ZodType; /** key → on-disk filename base (no extension), relative to the domain dir. */ fileBaseFor: (key: string) => string; } -/** - * Drives the dev file-watcher → frontend SSE cache invalidation for domains - * read via Convex ACTIONS (not reactive, so a file edit needs an explicit - * signal). `v8-sync` domains don't need one — their readers are reactive - * queries on `configCache`. - */ -export interface DomainWatcherSpec { - /** SSE event `type` emitted to the frontend for a change in this domain. */ - readonly eventType: string; - /** Emit only when the changed path (relative to the domain dir) matches. */ - emitsFor: (relPathWithinDomain: string) => boolean; - /** Derive the change `slug` from the path segments below the domain dir. */ - slugFromRest: (rest: readonly string[]) => string | undefined; -} - export interface ConfigDomain { /** Catalog dir name AND on-disk domain dir: `//`. */ readonly name: string; - readonly readContext: ReadContext; - readonly dataModel: ConfigDataModel; /** * Present iff this domain is independently scaffolded from the builtin * catalog. Absent ⇒ created on demand by an admin action (e.g. `sso`), so * the scaffolder skips it instead of failing on a missing catalog dir. */ readonly scaffoldKind?: ScaffoldKind; - /** Present iff `readContext === 'v8-sync'`. */ - readonly v8Sync?: V8SyncSpec; - /** Present iff dev edits to this domain need a frontend SSE invalidation. */ - readonly watcher?: DomainWatcherSpec; + /** Present iff some of the domain's files are schema-checked when seeded. */ + readonly seedSchemas?: SeedSchemaSpec; } /** * The canonical domain list. Order matters: org scaffolding seeds domains in - * this order — keep it stable when phase-1+ re-registers the rebuilt domains. + * this order — keep it stable. */ export const CONFIG_DOMAINS: readonly ConfigDomain[] = [ - // Governance policies — enforced from V8 queries/mutations/auth-hooks, so - // mirrored into `configCache`. One `.yml` per policy (kebab - // filename for the snake_case type; `.json` readable pre-conversion) plus - // `*.secrets.json` sidecars. + // Governance policies — one `.yml` per policy (kebab filename + // for the snake_case type; `.json` readable pre-conversion) plus + // `*.secrets.json` sidecars. Every seeded policy file is validated against + // its policy schema before it is copied. { name: 'governance', - readContext: 'v8-sync', - dataModel: 'config', scaffoldKind: 'flat', - v8Sync: { + seedSchemas: { keys: FILE_POLICY_TYPES, schemaFor: (key) => { if (!isFilePolicyType(key)) { @@ -142,95 +96,59 @@ export const CONFIG_DOMAINS: readonly ConfigDomain[] = [ }, }, }, - // Enterprise SSO connection — v8-sync like governance (sign-in hooks read - // the `configCache` mirror) but NOT catalog-scaffolded: the single + // Enterprise SSO connection — NOT catalog-scaffolded: the single // `connection.yml` is created on demand by the admin SSO form. Its on-disk - // dir is nested under governance (`/governance/sso/`, resolved in - // Layer B) so it never collides with the flat policy files. + // dir is nested under governance (`/governance/sso/`, see + // resolvers.ts) so it never collides with the flat policy files. { name: SSO_CONFIG_DOMAIN, - readContext: 'v8-sync', - dataModel: 'config', - v8Sync: { + seedSchemas: { keys: [SSO_CONNECTION_KEY], schemaFor: () => ssoConnectionFileSchema, fileBaseFor: () => SSO_CONNECTION_KEY, }, }, // Custom AI-provider connectors — one `.yml` per org-defined - // connector (a vLLM/Ollama box, an internal gateway), read directly from - // `'use node'` actions (`convex/lib/providers/org_providers.ts`) wherever - // provider resolution runs. Not catalog-scaffolded: there is no builtin - // seed — the domain dir is created on demand when an org authors its first - // custom connector. Credentials for custom connectors live in the same - // `providerCredentials` table as the shipped ones. + // connector (a vLLM/Ollama box, an internal gateway), read by the + // provider-resolution modules (`backend/core/lib/providers/ + // org_providers.ts`) wherever provider resolution runs. Not + // catalog-scaffolded: there is no builtin seed — the domain dir is created + // on demand when an org authors its first custom connector. Credentials + // for custom connectors live in the same `providerCredentials` table as + // the shipped ones. { name: 'providers', - readContext: 'node-direct', - dataModel: 'config', }, // Skills — one bundle directory per skill (`/skills//SKILL.md` // plus small assets). A skill is a knowledge pack an agent expands, never - // something the platform runs, so the files are read from `'use node'` code - // at the two places that consume them: staging a sandbox workspace and - // answering the skill tools during a turn. Sharing lives in the file itself - // — `visibility: private | org` with an `owner` — so there is nothing to - // mirror into a table and no cross-org surface to scope. Catalog-scaffolded - // (`bundle`: a whole `/` directory tree per skill) so a fresh org ships - // with the builtin skills under `configs/platform/custom/skills/` — e.g. the - // baked `visual-aspect-analyzer`. An org still authors or imports its own - // skills alongside them. The org-facing editing surface is a V8 action - // delegating to the same node layer (`convex/skills/`). + // something the platform runs, so the files are read at the two places + // that consume them: staging a sandbox workspace and answering the skill + // tools during a turn. Sharing lives in the file itself — `visibility: + // private | org` with an `owner` — so there is nothing to mirror into a + // table and no cross-org surface to scope. Catalog-scaffolded (`bundle`: a + // whole `/` directory tree per skill) so a fresh org ships with the + // builtin skills under `configs/platform/custom/skills/` — e.g. the baked + // `visual-aspect-analyzer`. An org still authors or imports its own skills + // alongside them; the org-facing editor reads and writes the same files. { name: 'skills', - readContext: 'node-direct', - dataModel: 'config', scaffoldKind: 'bundle', }, // Agents — one `/agents/.yml` per agent. An agent is a persona: // a name, instructions, what it may reach for, and who may use it. It says // nothing about how a turn executes (no model, no ceiling, no harness, no // credentials), so there is no runtime state to keep beside the file and - // nothing to mirror into a table. Read from `'use node'` code at the two - // places that consume it — the org-facing editor and the turn that resolves - // the agent answering — so `node-direct` like skills and providers. Sharing - // lives in the file (`visibility: private | org` with an `owner`), which is - // also why nothing here is shared across organizations: a file only exists - // inside one org's tree. Catalog-scaffolded (`flat`: one `.yml` per - // agent) so a fresh org ships with the builtin agents under - // `configs/platform/custom/agents/` — e.g. the Coding Agent, which lists the - // baked `visual-aspect-analyzer` skill in its `skills:` allowlist. + // nothing to mirror into a table. Read at the two places that consume it + // — the org-facing editor and the turn that resolves the agent answering. + // Sharing lives in the file (`visibility: private | org` with an `owner`), + // which is also why nothing here is shared across organizations: a file + // only exists inside one org's tree. Catalog-scaffolded (`flat`: one + // `.yml` per agent) so a fresh org ships with the builtin agents + // under `configs/platform/custom/agents/` — e.g. the Coding Agent, which + // lists the baked `visual-aspect-analyzer` skill in its `skills:` + // allowlist. { name: 'agents', - readContext: 'node-direct', - dataModel: 'config', scaffoldKind: 'flat', }, ]; - -const CONFIG_DOMAINS_BY_NAME: ReadonlyMap = new Map( - CONFIG_DOMAINS.map((domain) => [domain.name, domain]), -); - -/** Domains whose config must be mirrored into `configCache` for V8 reads. */ -export const V8_SYNC_DOMAINS: readonly ConfigDomain[] = CONFIG_DOMAINS.filter( - (domain) => domain.readContext === 'v8-sync', -); - -/** Look up a domain by name, throwing if it is not registered. */ -export function getConfigDomain(name: string): ConfigDomain { - const domain = CONFIG_DOMAINS_BY_NAME.get(name); - if (!domain) { - throw new Error(`Unknown config domain: ${name}`); - } - return domain; -} - -/** The `V8SyncSpec` for a `v8-sync` domain, throwing otherwise. */ -export function getV8SyncSpec(name: string): V8SyncSpec { - const spec = getConfigDomain(name).v8Sync; - if (!spec) { - throw new Error(`Config domain "${name}" is not a v8-sync domain`); - } - return spec; -} From bc2e1bac492b231777e00f74581fd8b2046b8248 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sun, 6 Sep 2026 09:51:33 +0800 Subject: [PATCH 04/10] refactor(platform): drop the unread MICROSOFT_AUTH_ENABLED client flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.ts and the Vite inject-env plugin both computed `MICROSOFT_AUTH_ENABLED: !!process.env.AUTH_MICROSOFT_ENTRA_ID_ID` into window.__ENV__, lib/env.ts typed it with its own getEnv overload and default branch, and vite-env.d.ts declared a VITE_ twin — yet nothing reads any of them: the Microsoft sign-in button is gated by the org's SSO config (`ssoConfig?.enabled`), not by this flag. An operator reading the servers would wrongly conclude AUTH_MICROSOFT_ENTRA_ID_ID toggles the client. Remove the field from every injection site, the env accessor and the two test fixtures. backend/auth keeps reading AUTH_MICROSOFT_ENTRA_ID_ID. Finding: lib-shared-rest-6. --- services/platform/lib/env.ts | 9 +-------- services/platform/observatory.test.ts | 1 - services/platform/server.test.ts | 1 - services/platform/server.ts | 2 -- services/platform/vite-env.d.ts | 1 - services/platform/vite-plugins/inject-env.ts | 2 -- 6 files changed, 1 insertion(+), 15 deletions(-) diff --git a/services/platform/lib/env.ts b/services/platform/lib/env.ts index df7d8dc645..7678bc615f 100644 --- a/services/platform/lib/env.ts +++ b/services/platform/lib/env.ts @@ -3,7 +3,6 @@ declare global { __ENV__?: { SITE_URL?: string; BASE_PATH?: string; - MICROSOFT_AUTH_ENABLED?: boolean; TRUSTED_HEADERS_ENABLED?: boolean; FILE_EVENTS_ENABLED?: boolean; SENTRY_DSN?: string; @@ -17,7 +16,6 @@ declare global { export function getEnv(key: 'SITE_URL'): string; export function getEnv(key: 'BASE_PATH'): string; -export function getEnv(key: 'MICROSOFT_AUTH_ENABLED'): boolean; export function getEnv(key: 'TRUSTED_HEADERS_ENABLED'): boolean; export function getEnv(key: 'FILE_EVENTS_ENABLED'): boolean; export function getEnv(key: 'SENTRY_DSN'): string | undefined; @@ -28,7 +26,6 @@ export function getEnv( key: | 'SITE_URL' | 'BASE_PATH' - | 'MICROSOFT_AUTH_ENABLED' | 'TRUSTED_HEADERS_ENABLED' | 'FILE_EVENTS_ENABLED' | 'SENTRY_DSN' @@ -41,11 +38,7 @@ export function getEnv( if (key === 'BASE_PATH') { return ''; } - if ( - key === 'MICROSOFT_AUTH_ENABLED' || - key === 'TRUSTED_HEADERS_ENABLED' || - key === 'FILE_EVENTS_ENABLED' - ) { + if (key === 'TRUSTED_HEADERS_ENABLED' || key === 'FILE_EVENTS_ENABLED') { return false; } if ( diff --git a/services/platform/observatory.test.ts b/services/platform/observatory.test.ts index 1758932758..48806e4d66 100644 --- a/services/platform/observatory.test.ts +++ b/services/platform/observatory.test.ts @@ -42,7 +42,6 @@ import { createApp } from './server'; const baseEnv = { SITE_URL: 'https://tale.example.com', BASE_PATH: '', - MICROSOFT_AUTH_ENABLED: false, TRUSTED_HEADERS_ENABLED: false, FILE_EVENTS_ENABLED: true, SENTRY_DSN: undefined, diff --git a/services/platform/server.test.ts b/services/platform/server.test.ts index 5c3e96bb10..d1772fa840 100644 --- a/services/platform/server.test.ts +++ b/services/platform/server.test.ts @@ -16,7 +16,6 @@ import { const baseEnv = { SITE_URL: 'https://tale.example.com', BASE_PATH: '', - MICROSOFT_AUTH_ENABLED: false, TRUSTED_HEADERS_ENABLED: false, FILE_EVENTS_ENABLED: true, SENTRY_DSN: undefined, diff --git a/services/platform/server.ts b/services/platform/server.ts index 675a6235d9..62aa883ec4 100644 --- a/services/platform/server.ts +++ b/services/platform/server.ts @@ -193,7 +193,6 @@ function escapeHtmlAttr(value: string) { interface EnvConfig { SITE_URL: string | undefined; BASE_PATH: string; - MICROSOFT_AUTH_ENABLED: boolean; TRUSTED_HEADERS_ENABLED: boolean; FILE_EVENTS_ENABLED: boolean; SENTRY_DSN: string | undefined; @@ -333,7 +332,6 @@ function getEnvConfig(): EnvConfig { return { SITE_URL: process.env.SITE_URL, BASE_PATH: getBasePath(), - MICROSOFT_AUTH_ENABLED: !!process.env.AUTH_MICROSOFT_ENTRA_ID_ID, TRUSTED_HEADERS_ENABLED: process.env.TRUSTED_HEADERS_ENABLED === 'true', FILE_EVENTS_ENABLED: fileEventsEnabled, SENTRY_DSN: process.env.SENTRY_DSN, diff --git a/services/platform/vite-env.d.ts b/services/platform/vite-env.d.ts index 24f188ac59..8f1e195a97 100644 --- a/services/platform/vite-env.d.ts +++ b/services/platform/vite-env.d.ts @@ -5,7 +5,6 @@ interface ImportMetaEnv { readonly VITE_CONVEX_URL: string; readonly VITE_SITE_URL: string; - readonly VITE_MICROSOFT_AUTH_ENABLED?: string; } interface ImportMeta { diff --git a/services/platform/vite-plugins/inject-env.ts b/services/platform/vite-plugins/inject-env.ts index 65ac4b9eb5..9c8e7687f3 100644 --- a/services/platform/vite-plugins/inject-env.ts +++ b/services/platform/vite-plugins/inject-env.ts @@ -8,7 +8,6 @@ import { parseSessionIdleTimeoutMinutes } from '../lib/shared/session-idle'; interface EnvConfig { SITE_URL: string; BASE_PATH: string; - MICROSOFT_AUTH_ENABLED: boolean; FILE_EVENTS_ENABLED: boolean; SENTRY_DSN?: string; SENTRY_TRACES_SAMPLE_RATE: number; @@ -23,7 +22,6 @@ function getEnvConfig(): EnvConfig { return { SITE_URL: process.env.SITE_URL, BASE_PATH: (process.env.BASE_PATH ?? '').replace(/\/$/, ''), - MICROSOFT_AUTH_ENABLED: !!process.env.AUTH_MICROSOFT_ENTRA_ID_ID, FILE_EVENTS_ENABLED: process.env.TALE_FILE_EVENTS === 'true', SENTRY_DSN: process.env.SENTRY_DSN, SENTRY_TRACES_SAMPLE_RATE: parseFloat( From a43c948d3448c4f71b258b87b37e903bf43d2b18 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sun, 6 Sep 2026 09:57:19 +0800 Subject: [PATCH 05/10] fix(platform): watch the config tree again for /events/file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/config-watcher.ts had been a permanent no-op since the backend rewrite: createConfigWatcher logged "stubbed" and never invoked a callback. Everything downstream was still live and switched on — TALE_FILE_EVENTS=true in every dev compose, server.ts mounting the authenticated per-org SSE door and raising Bun's idleTimeout for it, the Vite plugin serving the same door, and use-file-events.ts holding a reconnecting EventSource in every tab — so each tab paid for a stream that could never carry an event, and the promised on-disk edit → cache invalidation never fired. Restore the producer with chokidar (already a dependency): watch $TALE_CONFIG_DIR recursively, map `//…` onto the event the frontend cache keys on ({ type: , orgSlug, slug }), skip dot entries (.history/, atomic-write temp files) at the watch level, coalesce the burst one write produces into one event, and tear the watcher down on close(). The domain is the dir name rather than a registry lookup because live surfaces such as branding/ are not registered. Every event carries an orgSlug, which the server's default-deny fan-out requires. The Vite plugin drops its dead `examples` fallback and says so when TALE_CONFIG_DIR is absent instead of watching nothing silently. The env reference documented this switch under the wrong name (FILE_EVENTS_ENABLED is the window.__ENV__ field) with a wrong description ("OneDrive-sync connector"); the row now names TALE_FILE_EVENTS and what it does, in en/de/fr. Finding: lib-shared-rest-2. --- .../configuration/environment-reference.md | 2 +- .../configuration/environment-reference.md | 2 +- .../configuration/environment-reference.md | 2 +- knip.config.ts | 1 - services/platform/lib/config-watcher.test.ts | 130 ++++++++++++++ services/platform/lib/config-watcher.ts | 164 +++++++++++++++--- .../platform/vite-plugins/watch-examples.ts | 47 +++-- 7 files changed, 303 insertions(+), 45 deletions(-) create mode 100644 services/platform/lib/config-watcher.test.ts diff --git a/docs/de/self-hosted/configuration/environment-reference.md b/docs/de/self-hosted/configuration/environment-reference.md index b541066666..fc66e21182 100644 --- a/docs/de/self-hosted/configuration/environment-reference.md +++ b/docs/de/self-hosted/configuration/environment-reference.md @@ -155,7 +155,7 @@ Optionale Schalter für Features, die standardmässig nicht aktiviert sind. Jede | `TRUSTED_HEADERS_ENABLED` | `false` | Aktiviert den Trusted-Headers-Auth-Modus (Identität vom Reverse-Proxy geliefert). | | `TRUSTED_HEADERS_INTERNAL_SECRET` | nicht gesetzt | Shared Secret, das der authentifizierende Proxy mit jeder Trusted-Headers-Anfrage schicken muss. Pflicht, sobald der Modus an ist — ohne Secret verweigert der Endpunkt den Dienst. | | `TRUSTED_SECRET_HEADER` | `Remote-Internal-Secret` | Name des Request-Headers, der das interne Secret trägt. | -| `FILE_EVENTS_ENABLED` | `false` | Aktiviert Datei-Watching-Events für die OneDrive-Sync-Connector. | +| `TALE_FILE_EVENTS` | `false` | Streamt Änderungen an Config-Dateien unter `TALE_CONFIG_DIR` an offene Browser-Tabs (`/events/file`): Eine auf der Platte bearbeitete Agent-, Skill- oder Branding-Datei erscheint ohne Reload. Im Dev-Compose an, in Produktion aus. | | `TALE_DEPLOYMENT_CONFIG_ADMINS` | unset | Kommagetrennte E-Mail-Allowlist der Operatoren, die die Datenresidenz bearbeiten dürfen. Leer/nicht gesetzt = nur lesend für alle Admins. | ## RAG-Retrieval-Tuning diff --git a/docs/en/self-hosted/configuration/environment-reference.md b/docs/en/self-hosted/configuration/environment-reference.md index ad4f923a45..28ac257f15 100644 --- a/docs/en/self-hosted/configuration/environment-reference.md +++ b/docs/en/self-hosted/configuration/environment-reference.md @@ -155,7 +155,7 @@ Optional toggles for features not enabled by default. Each flag turns one featur | `TRUSTED_HEADERS_ENABLED` | `false` | Enables the trusted-headers auth mode (identity supplied by the reverse proxy). | | `TRUSTED_HEADERS_INTERNAL_SECRET` | unset | Shared secret the authenticating proxy must send with every trusted-headers request. Required when the mode is enabled — the endpoint refuses to run without it. | | `TRUSTED_SECRET_HEADER` | `Remote-Internal-Secret` | Name of the request header carrying the internal secret. | -| `FILE_EVENTS_ENABLED` | `false` | Enables file-watching events for the OneDrive-sync connector. | +| `TALE_FILE_EVENTS` | `false` | Streams config-file changes under `TALE_CONFIG_DIR` to open browser tabs (`/events/file`), so an agent, skill, or branding file edited on disk shows up without a reload. On in the dev compose; production leaves it off. | | `TALE_DEPLOYMENT_CONFIG_ADMINS` | unset | Comma-separated email allowlist of operators allowed to edit deployment data residency. Empty/unset = read-only for all admins. | ## RAG retrieval tuning diff --git a/docs/fr/self-hosted/configuration/environment-reference.md b/docs/fr/self-hosted/configuration/environment-reference.md index f56ddb4148..a2e0632b95 100644 --- a/docs/fr/self-hosted/configuration/environment-reference.md +++ b/docs/fr/self-hosted/configuration/environment-reference.md @@ -155,7 +155,7 @@ Bascules optionnelles pour des fonctionnalités non activées par défaut. Chaqu | `TRUSTED_HEADERS_ENABLED` | `false` | Active le mode auth par trusted headers (identité fournie par le reverse proxy). | | `TRUSTED_HEADERS_INTERNAL_SECRET` | non défini | Secret partagé que le proxy authentifiant doit envoyer avec chaque requête trusted headers. Obligatoire dès que le mode est actif — sans lui, l'endpoint refuse de fonctionner. | | `TRUSTED_SECRET_HEADER` | `Remote-Internal-Secret` | Nom de l'en-tête de requête qui porte le secret interne. | -| `FILE_EVENTS_ENABLED` | `false` | Active les événements de surveillance de fichiers pour le connector OneDrive-sync. | +| `TALE_FILE_EVENTS` | `false` | Diffuse les changements des fichiers de config sous `TALE_CONFIG_DIR` aux onglets ouverts (`/events/file`) : un fichier d’agent, de skill ou de branding modifié sur le disque apparaît sans recharger. Actif dans le compose de dev, désactivé en production. | | `TALE_DEPLOYMENT_CONFIG_ADMINS` | non défini | Allowlist de courriels (séparés par des virgules) des opérateurs autorisés à modifier la résidence des données du déploiement. Vide/non défini = lecture seule pour tous les admins. | ## Réglage du retrieval RAG diff --git a/knip.config.ts b/knip.config.ts index 257062c648..d2f35f8ff8 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -156,7 +156,6 @@ export default { '@types/seedrandom', '@types/turndown', 'bcryptjs', - 'chokidar', 'cron-parser', 'diff', 'hast-util-to-html', diff --git a/services/platform/lib/config-watcher.test.ts b/services/platform/lib/config-watcher.test.ts new file mode 100644 index 0000000000..fe452976b2 --- /dev/null +++ b/services/platform/lib/config-watcher.test.ts @@ -0,0 +1,130 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + type ConfigChangeEvent, + type ConfigWatcher, + createConfigWatcher, + parseConfigChange, +} from './config-watcher'; + +const ROOT = '/data'; + +describe('parseConfigChange', () => { + it('maps a flat file to its domain and slug', () => { + expect(parseConfigChange(ROOT, `${ROOT}/acme/agents/coder.yml`)).toEqual({ + type: 'agents', + orgSlug: 'acme', + slug: 'coder', + }); + }); + + it('maps a secrets sidecar onto the item it belongs to', () => { + expect( + parseConfigChange(ROOT, `${ROOT}/acme/governance/pii.secrets.json`), + ).toEqual({ type: 'governance', orgSlug: 'acme', slug: 'pii' }); + }); + + it('maps a file deep inside a bundle to the bundle slug', () => { + expect( + parseConfigChange(ROOT, `${ROOT}/acme/skills/pdf/scripts/fill.py`), + ).toEqual({ type: 'skills', orgSlug: 'acme', slug: 'pdf' }); + }); + + it('reports a domain dir coming or going without a slug', () => { + expect(parseConfigChange(ROOT, `${ROOT}/acme/branding`)).toEqual({ + type: 'branding', + orgSlug: 'acme', + }); + }); + + it('ignores the org dir, the root and anything outside the tree', () => { + expect(parseConfigChange(ROOT, `${ROOT}/acme`)).toBeNull(); + expect(parseConfigChange(ROOT, ROOT)).toBeNull(); + expect(parseConfigChange(ROOT, '/etc/passwd')).toBeNull(); + expect(parseConfigChange(ROOT, `${ROOT}/../other/agents/x.yml`)).toBeNull(); + }); + + it('ignores dot entries: .history snapshots and atomic-write temp files', () => { + expect( + parseConfigChange(ROOT, `${ROOT}/acme/agents/.history/coder/1.yml`), + ).toBeNull(); + expect( + parseConfigChange(ROOT, `${ROOT}/acme/agents/.coder.yml.1712.ab12.tmp`), + ).toBeNull(); + }); + + it('refuses a malformed org slug or domain dir', () => { + expect(parseConfigChange(ROOT, `${ROOT}/Acme/agents/coder.yml`)).toBeNull(); + expect( + parseConfigChange(ROOT, `${ROOT}/acme/Agents Copy/coder.yml`), + ).toBeNull(); + }); +}); + +describe('createConfigWatcher', () => { + let dir: string; + let watcher: ConfigWatcher | undefined; + + afterEach(async () => { + await watcher?.close(); + watcher = undefined; + await rm(dir, { recursive: true, force: true }); + }); + + /** A tree with one org + domain dir already present, then a live watcher + * on it: the writes below exercise change reporting, not dir discovery. */ + async function start(coalesceMs: number): Promise { + dir = await mkdtemp(join(tmpdir(), 'tale-config-watcher-')); + await mkdir(join(dir, 'acme', 'agents', '.history'), { recursive: true }); + watcher = createConfigWatcher(dir, { coalesceMs }); + const events: ConfigChangeEvent[] = []; + watcher.onChange((event) => events.push(event)); + await watcher.ready; + return events; + } + + it('reports a config write as one event for the item', async () => { + // A wide window so every fs event of the write (add, change, the + // atomic-write rename) provably lands inside it — one invalidation. + const events = await start(500); + const file = join(dir, 'acme', 'agents', 'coder.yml'); + await writeFile(file, 'name: Coder\n'); + await writeFile(file, 'name: Coder\ndescription: x\n'); + + await vi.waitFor( + () => + expect(events).toContainEqual({ + type: 'agents', + orgSlug: 'acme', + slug: 'coder', + }), + { timeout: 10_000 }, + ); + await new Promise((resolve) => setTimeout(resolve, 600)); + expect(events.filter((e) => e.slug === 'coder')).toHaveLength(1); + }); + + it('never emits for dot entries, and stops after close', async () => { + const events = await start(50); + await writeFile(join(dir, 'acme', 'agents', '.history', 'old.yml'), 'x\n'); + await writeFile(join(dir, 'acme', 'agents', '.coder.yml.1.tmp'), 'x\n'); + // A real file after the dot entries proves the watcher saw the burst. + await writeFile(join(dir, 'acme', 'agents', 'coder.yml'), 'name: Coder\n'); + await vi.waitFor( + () => expect(events.some((e) => e.slug === 'coder')).toBe(true), + { timeout: 10_000 }, + ); + expect(events.every((e) => e.slug === 'coder')).toBe(true); + + await watcher?.close(); + watcher = undefined; + const seen = events.length; + await writeFile(join(dir, 'acme', 'agents', 'later.yml'), 'x\n'); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(events).toHaveLength(seen); + }); +}); diff --git a/services/platform/lib/config-watcher.ts b/services/platform/lib/config-watcher.ts index a93c83cf6c..b02985f8a4 100644 --- a/services/platform/lib/config-watcher.ts +++ b/services/platform/lib/config-watcher.ts @@ -1,36 +1,156 @@ -// The real config watcher (chokidar-backed, registry-driven -// event parsing) is retired. It -// depended on `CONFIG_DOMAINS_BY_NAME`/`NESTED_SINGLE_FILE_WATCHERS`, which no -// longer exist on the rewritten `lib/shared/config/registry.ts` (that module -// now exports `CONFIG_DOMAINS`/`getConfigDomain`/`getV8SyncSpec` instead). This -// stub keeps `server.ts` and `vite-plugins/watch-examples.ts` compiling and -// running by never watching anything and never invoking callbacks — config -// changes simply won't push a live SSE invalidation to open browser tabs until -// the rewrite restores this. Callers already treat the watcher as fire-and- -// forget display plumbing (a manual page refresh still picks up the change), -// so a silent no-op is safe here. - -interface ConfigChangeEvent { +/** + * Live config-file events for open browser tabs. + * + * Per-org configuration is a file tree, `$TALE_CONFIG_DIR///…` + * (see lib/shared/config/registry.ts), edited by the app, the CLI, a `git + * pull` on the volume, or an operator with a text editor. This watcher turns + * each on-disk change into the event the frontend cache keys on — + * `{ type: , orgSlug, slug }` maps onto the TanStack Query key + * `['config', type, organizationId, …]` (app/hooks/use-file-events.ts) — so + * an edit shows up without a reload. server.ts fans the events out over the + * authenticated `/events/file` SSE door, per-org (shouldDeliverSseEvent); + * vite-plugins/watch-examples.ts serves the same door in `vite dev`. Every + * event carries an `orgSlug`: the fan-out is default-deny without one. + * + * The domain is the dir name, not a registry lookup: the registry lists the + * catalog-seeded domains, while live surfaces such as `branding/` are + * unregistered yet still cache under `['config', 'branding', orgId]`. Dot + * entries never emit — `.history/` snapshots, `*.secrets` never leave the + * server as a name either way, and the `....tmp` files of + * `atomicWrite` are how every config write lands, so the rename then reads + * as one change of the real file. + */ + +import path from 'node:path'; + +import { watch } from 'chokidar'; + +import { isValidOrgSlug } from './shared/constants/org-slug'; + +export interface ConfigChangeEvent { + /** The domain dir under the org — the `type` the frontend cache keys on. */ type: string; - orgSlug?: string; + orgSlug: string; + /** + * The item under the domain dir: a flat file's base name (`coder.yml` and + * its `coder.secrets.json` sidecar both read `coder`) or a bundle's dir + * (`skills//…`). Absent when the domain dir itself came or went. + */ slug?: string; } -interface ConfigWatcher { +export interface ConfigWatcher { onChange: (callback: (event: ConfigChangeEvent) => void) => void; + /** Resolves once the initial scan is done and changes are being reported. */ + ready: Promise; close: () => Promise; } -export function createConfigWatcher(_configDir: string): ConfigWatcher { - console.debug( - '[config-watcher] stubbed while the platform AI backend is rewritten; no file-change events will be emitted', - ); +/** A domain dir is a lowercase kebab/snake name; anything else is not config. */ +const DOMAIN_DIR_REGEX = /^[a-z][a-z0-9_-]*$/; + +/** + * Repeated events for one item inside this window collapse into one: an + * atomic write is an `unlink` + `add` of the same path, a bundle replace is + * one event per file, and the browser only needs one invalidation. + */ +const DEFAULT_COALESCE_MS = 50; + +export interface ConfigWatcherOptions { + /** Coalescing window per item, in ms (tests widen it to pin the collapse). */ + coalesceMs?: number; +} + +/** Path segments below `configDir`, or null when `changedPath` is outside it. */ +function segmentsWithin( + configDir: string, + changedPath: string, +): string[] | null { + const rel = path.relative(configDir, changedPath); + if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null; + return rel.split(path.sep); +} + +/** Dot entries: `.history/`, atomic-write temp files, editor swap files. */ +function hasDotSegment(segments: readonly string[]): boolean { + return segments.some((segment) => segment.startsWith('.')); +} + +/** + * The event a changed path stands for, or null when the path is not an org + * config item (outside the tree, a dot entry, a malformed org or domain + * segment, or the org dir itself). + */ +export function parseConfigChange( + configDir: string, + changedPath: string, +): ConfigChangeEvent | null { + const segments = segmentsWithin(configDir, changedPath); + if (segments === null || hasDotSegment(segments)) return null; + const [orgSlug, domain, item] = segments; + if (orgSlug === undefined || domain === undefined) return null; + if (!isValidOrgSlug(orgSlug) || !DOMAIN_DIR_REGEX.test(domain)) return null; + if (item === undefined) return { type: domain, orgSlug }; + const slug = item.replace(/(\.secrets)?\.[^.]+$/, ''); + return slug === '' ? null : { type: domain, orgSlug, slug }; +} + +export function createConfigWatcher( + configDir: string, + options: ConfigWatcherOptions = {}, +): ConfigWatcher { + const coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS; + const callbacks = new Set<(event: ConfigChangeEvent) => void>(); + const pending = new Map>(); + + const emit = (event: ConfigChangeEvent): void => { + const key = JSON.stringify(event); + const scheduled = pending.get(key); + if (scheduled !== undefined) clearTimeout(scheduled); + pending.set( + key, + setTimeout(() => { + pending.delete(key); + for (const callback of callbacks) { + try { + callback(event); + } catch (err) { + console.warn('[config-watcher] onChange callback failed', err); + } + } + }, coalesceMs), + ); + }; + + const watcher = watch(configDir, { + ignoreInitial: true, + // Skip dot entries at the watch level too, so `.history/` trees cost no + // inotify watches and temp files never reach the parser. + ignored: (candidate) => { + const segments = segmentsWithin(configDir, candidate); + return segments !== null && hasDotSegment(segments); + }, + }); + watcher.on('all', (_eventName, changedPath) => { + const event = parseConfigChange(configDir, changedPath); + if (event !== null) emit(event); + }); + watcher.on('error', (err) => { + console.warn(`[config-watcher] watch error under ${configDir}`, err); + }); + const ready = new Promise((resolve) => { + watcher.once('ready', () => resolve()); + }); + return { - onChange() { - // No-op: never invoked, since nothing is watched. + onChange(callback) { + callbacks.add(callback); }, + ready, async close() { - // No-op: nothing to tear down. + for (const scheduled of pending.values()) clearTimeout(scheduled); + pending.clear(); + await watcher.close(); }, }; } diff --git a/services/platform/vite-plugins/watch-examples.ts b/services/platform/vite-plugins/watch-examples.ts index 1c53970da3..b958377425 100644 --- a/services/platform/vite-plugins/watch-examples.ts +++ b/services/platform/vite-plugins/watch-examples.ts @@ -1,38 +1,47 @@ +import { existsSync } from 'node:fs'; import type { ServerResponse } from 'node:http'; -import path from 'node:path'; import { type Plugin } from 'vite'; import { createConfigWatcher } from '../lib/config-watcher'; /** - * Vite plugin that watches the config directory for JSON changes and serves - * an SSE endpoint at /events/file — the same path used by server.ts in - * production, so the frontend code is identical in dev and prod. + * Vite plugin that watches the org config tree (`TALE_CONFIG_DIR`) and + * serves the config-file SSE endpoint at /events/file — the same path + * server.ts serves in production, so the frontend code is identical in dev + * and prod. Without a config dir there is nothing to watch: the door still + * answers (the client's EventSource must not error-loop) but only ever says + * `connected`. */ export function watchExamples(): Plugin { - const configDir = - process.env.TALE_CONFIG_DIR || - path.resolve(__dirname, '..', '..', '..', 'examples'); - + const configDir = process.env.TALE_CONFIG_DIR; const clients = new Set(); return { name: 'watch-examples', apply: 'serve', configureServer(server) { - const watcher = createConfigWatcher(configDir); - watcher.onChange((event) => { - const payload = `data: ${JSON.stringify(event)}\n\n`; - for (const client of clients) { - try { - client.write(payload); - } catch (err) { - console.warn('SSE write failed; dropping client', err); - clients.delete(client); + if (configDir && existsSync(configDir)) { + const watcher = createConfigWatcher(configDir); + watcher.onChange((event) => { + const payload = `data: ${JSON.stringify(event)}\n\n`; + for (const client of clients) { + try { + client.write(payload); + } catch (err) { + console.warn('SSE write failed; dropping client', err); + clients.delete(client); + } } - } - }); + }); + server.httpServer?.once('close', () => { + void watcher.close(); + }); + } else { + console.warn( + `[watch-examples] TALE_CONFIG_DIR ${configDir ? `(${configDir}) does not exist` : 'is not set'}; config-file events are off`, + ); + } // Serve SSE at /events/file in the Vite dev server server.middlewares.use('/events/file', (_req, res) => { From 1a2fabfe7cb40e0055672d3194e073ac193de161 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sun, 6 Sep 2026 09:59:48 +0800 Subject: [PATCH 06/10] fix(platform): follow a 303 (or a POSTed 301/302) with GET in safeFetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit safeFetch and safeFetchBinary each carried their own copy of the redirect loop, and both replayed the original method, body and content headers against every Location: a POST answered with 303 — the common "POST /things → 303 /things/123" pattern — re-executed the POST with its JSON body (Authorization still attached on same-host hops), performing the mutation twice or handing a body to a read endpoint. RFC 9110 §15.4.4 requires GET after a 303; 301/302 to a POST switch to GET in every mainstream client. Real non-GET callers exist (tts/service.ts, connectors/live-host.ts). No test covered redirects at all. Fold the two loops into one fetchFollowingRedirects; per hop, a 303 (any method but HEAD) or a 301/302 to a POST continues as GET with no body and without Content-Type/Length/Encoding, while 307/308 keep method and body. Cross-host credential stripping and per-hop re-validation are unchanged. Tests pin POST→303, POST→301/302, POST→307/308, GET→302, HEAD→303 and the binary sibling with a scripted fetch. Finding: lib-shared-rest-7. --- services/platform/lib/net/safe-fetch.test.ts | 128 +++++++- services/platform/lib/net/safe-fetch.ts | 322 +++++++++---------- 2 files changed, 275 insertions(+), 175 deletions(-) diff --git a/services/platform/lib/net/safe-fetch.test.ts b/services/platform/lib/net/safe-fetch.test.ts index e8b265ad7a..c91d44ee39 100644 --- a/services/platform/lib/net/safe-fetch.test.ts +++ b/services/platform/lib/net/safe-fetch.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { isPrivateIp, safeFetch, SafeFetchError } from './safe-fetch'; +import { + isPrivateIp, + safeFetch, + SafeFetchError, + safeFetchBinary, +} from './safe-fetch'; describe('lib/http/safe_fetch.isPrivateIp', () => { it.each([ @@ -89,3 +94,124 @@ describe('lib/http/safe_fetch.signal', () => { expect(fetchSpy).toHaveBeenCalledTimes(0); }); }); + +describe('lib/net/safe-fetch redirects', () => { + const ORIGIN = 'https://api.example.com'; + + /** A fetch stub answering the scripted responses in order and recording + * every request it saw (method, url, body, headers). */ + function scriptFetch(responses: Response[]) { + const calls: { + method: string; + url: string; + body: unknown; + headers: Record; + }[] = []; + const spy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input, init) => { + calls.push({ + method: init?.method ?? 'GET', + url: + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url, + body: init?.body, + headers: (init?.headers as Record) ?? {}, + }); + const next = responses.shift(); + if (!next) throw new Error('unexpected extra fetch'); + return next; + }); + return { calls, spy }; + } + + const redirect = (status: number, location: string) => + new Response(null, { status, headers: { Location: location } }); + const ok = (body = 'done') => new Response(body, { status: 200 }); + + const postOptions = { + method: 'POST' as const, + headers: { + Authorization: 'Bearer secret', + 'Content-Type': 'application/json', + 'Content-Length': '9', + }, + body: '{"a":1}', + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('follows a 303 to a POST with GET, no body and no body headers', async () => { + const { calls } = scriptFetch([ + redirect(303, `${ORIGIN}/things/123`), + ok(), + ]); + const res = await safeFetch(`${ORIGIN}/things`, postOptions); + + expect(res.status).toBe(200); + expect(res.finalUrl).toBe(`${ORIGIN}/things/123`); + expect(calls).toHaveLength(2); + expect(calls[1].method).toBe('GET'); + expect(calls[1].body).toBeUndefined(); + expect(Object.keys(calls[1].headers)).toEqual(['Authorization']); + }); + + it('switches a 301/302 answered to a POST to GET', async () => { + for (const status of [301, 302]) { + const { calls, spy } = scriptFetch([ + redirect(status, `${ORIGIN}/moved`), + ok(), + ]); + await safeFetch(`${ORIGIN}/things`, postOptions); + expect(calls[1].method).toBe('GET'); + expect(calls[1].body).toBeUndefined(); + spy.mockRestore(); + } + }); + + it('keeps the method and body across a 307/308', async () => { + for (const status of [307, 308]) { + const { calls, spy } = scriptFetch([ + redirect(status, `${ORIGIN}/things-v2`), + ok(), + ]); + await safeFetch(`${ORIGIN}/things`, postOptions); + expect(calls[1].method).toBe('POST'); + expect(calls[1].body).toBe('{"a":1}'); + expect(calls[1].headers['Content-Type']).toBe('application/json'); + spy.mockRestore(); + } + }); + + it('leaves a GET → 302 and a HEAD → 303 unchanged', async () => { + const get = scriptFetch([redirect(302, `${ORIGIN}/elsewhere`), ok()]); + await safeFetch(`${ORIGIN}/things`); + expect(get.calls.map((c) => c.method)).toEqual(['GET', 'GET']); + get.spy.mockRestore(); + + const head = scriptFetch([redirect(303, `${ORIGIN}/elsewhere`), ok('')]); + await safeFetch(`${ORIGIN}/things`, { method: 'HEAD' }); + expect(head.calls.map((c) => c.method)).toEqual(['HEAD', 'HEAD']); + }); + + it('applies the same switch in safeFetchBinary', async () => { + const { calls } = scriptFetch([ + redirect(303, `${ORIGIN}/things/123`), + new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { 'Content-Type': 'audio/mpeg' }, + }), + ]); + const res = await safeFetchBinary(`${ORIGIN}/things`, postOptions); + + expect(res.body.type).toBe('audio/mpeg'); + expect(res.finalUrl).toBe(`${ORIGIN}/things/123`); + expect(calls[1].method).toBe('GET'); + expect(calls[1].body).toBeUndefined(); + }); +}); diff --git a/services/platform/lib/net/safe-fetch.ts b/services/platform/lib/net/safe-fetch.ts index d84b6df723..6b0b2d6d27 100644 --- a/services/platform/lib/net/safe-fetch.ts +++ b/services/platform/lib/net/safe-fetch.ts @@ -309,19 +309,64 @@ function stripCrossHostSensitiveHeaders( return out; } -export async function safeFetch( +/** Request headers that describe a body; they leave with it. */ +const BODY_HEADERS: ReadonlySet = new Set([ + 'content-type', + 'content-length', + 'content-encoding', +]); + +function stripBodyHeaders( + headers: Record, +): Record { + const out: Record = {}; + for (const [name, value] of Object.entries(headers)) { + if (BODY_HEADERS.has(name.toLowerCase())) continue; + out[name] = value; + } + return out; +} + +/** + * Whether following this redirect switches the request to GET (RFC 9110 + * §15.4): a 303 always does (except for HEAD, which stays HEAD), and a + * 301/302 answered to a POST does — the convention every mainstream client + * (browsers, undici, curl) implements. 307/308 keep method and body by + * definition. + */ +function redirectSwitchesToGet(status: number, method: string): boolean { + const upper = method.toUpperCase(); + if (upper === 'HEAD') return false; + if (status === 303) return true; + return (status === 301 || status === 302) && upper === 'POST'; +} + +/** + * The shared request loop of `safeFetch` and `safeFetchBinary`: derive the + * allowlist, validate the URL, follow redirects manually re-validating every + * hop, and hand back the final non-redirect response with the URL it came + * from. `signal` is the caller's abort controller (timeout + the caller's + * own `options.signal`; it also covers the body read that follows). + * + * A redirect may rewrite the request, not only its URL: credential headers + * are dropped on cross-host hops, and a 303 (or 301/302 to a POST) is + * followed with GET and no body — replaying a POST body against the + * Location would perform the mutation twice, or hand a JSON body to a read + * endpoint with the Authorization header still attached. + */ +async function fetchFollowingRedirects( rawUrl: string, - options: SafeFetchOptions = {}, -): Promise { + options: SafeFetchOptions, + signal: AbortSignal, + timeoutMs: number, +): Promise<{ response: Response; finalUrl: string }> { const { method = 'GET', headers = {}, body, - timeoutMs = DEFAULT_TIMEOUT_MS, - maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, maxRedirects = DEFAULT_MAX_REDIRECTS, allowedHosts: callerAllowedHosts, - signal, + signal: callerSignal, } = options; // When the caller doesn't supply an allowlist, auto-derive it from the @@ -350,89 +395,112 @@ export async function safeFetch( validateUrl(rawUrl, allowedHosts, callerAllowedHosts); - if (signal?.aborted) { + if (callerSignal?.aborted) { throw new SafeFetchError( 'aborted', 'Request aborted by the caller before it started', ); } - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - const onCallerAbort = (): void => controller.abort(); - signal?.addEventListener('abort', onCallerAbort, { once: true }); - try { - let currentUrl = rawUrl; - let currentHeaders = headers; - let redirectsFollowed = 0; - let response: Response; + let currentUrl = rawUrl; + let currentMethod: string = method; + let currentHeaders = headers; + let currentBody = body; + let redirectsFollowed = 0; - while (true) { - try { - response = await fetch(currentUrl, { - method, - headers: currentHeaders, - body, - redirect: 'manual', - signal: controller.signal, - }); - } catch (error) { - if (error instanceof SafeFetchError) throw error; - if ( - error instanceof Error && - (error.name === 'AbortError' || error.name === 'TimeoutError') - ) { - if (signal?.aborted) { - throw new SafeFetchError( - 'aborted', - 'Request aborted by the caller before it completed', - ); - } + while (true) { + let response: Response; + try { + response = await fetch(currentUrl, { + method: currentMethod, + headers: currentHeaders, + body: currentBody, + redirect: 'manual', + signal, + }); + } catch (error) { + if (error instanceof SafeFetchError) throw error; + if ( + error instanceof Error && + (error.name === 'AbortError' || error.name === 'TimeoutError') + ) { + if (callerSignal?.aborted) { throw new SafeFetchError( - 'timeout', - `Request timed out after ${timeoutMs}ms`, + 'aborted', + 'Request aborted by the caller before it completed', ); } - const message = error instanceof Error ? error.message : 'unknown'; - throw new SafeFetchError('network_error', `fetch failed: ${message}`); - } - - if (!REDIRECT_STATUSES.has(response.status)) { - // 304/305/306 land here too — they carry no Location header, so - // returning them to the caller is correct. - break; - } - - const location = response.headers.get('Location'); - if (!location) { throw new SafeFetchError( - 'redirect_missing_location', - `Redirect ${response.status} missing Location header`, - response.status, + 'timeout', + `Request timed out after ${timeoutMs}ms`, ); } + const message = error instanceof Error ? error.message : 'unknown'; + throw new SafeFetchError('network_error', `fetch failed: ${message}`); + } - redirectsFollowed += 1; - if (redirectsFollowed > maxRedirects) { - throw new SafeFetchError( - 'redirect_limit_exceeded', - `Exceeded ${maxRedirects} redirects`, - ); - } + if (!REDIRECT_STATUSES.has(response.status)) { + // 304/305/306 land here too — they carry no Location header, so + // returning them to the caller is correct. + return { response, finalUrl: currentUrl }; + } - const nextUrl = new URL(location, currentUrl); - validateUrl(nextUrl.toString(), allowedHosts, callerAllowedHosts); - // Drop credential-carrying headers on cross-host hops so an - // attacker who controls a redirect target on a second allowlisted - // host can't harvest the upstream provider's bearer token. - if ( - nextUrl.host.toLowerCase() !== new URL(currentUrl).host.toLowerCase() - ) { - currentHeaders = stripCrossHostSensitiveHeaders(currentHeaders); - } - currentUrl = nextUrl.toString(); + const location = response.headers.get('Location'); + if (!location) { + throw new SafeFetchError( + 'redirect_missing_location', + `Redirect ${response.status} missing Location header`, + response.status, + ); + } + + redirectsFollowed += 1; + if (redirectsFollowed > maxRedirects) { + throw new SafeFetchError( + 'redirect_limit_exceeded', + `Exceeded ${maxRedirects} redirects`, + ); + } + + const nextUrl = new URL(location, currentUrl); + validateUrl(nextUrl.toString(), allowedHosts, callerAllowedHosts); + // Drop credential-carrying headers on cross-host hops so an + // attacker who controls a redirect target on a second allowlisted + // host can't harvest the upstream provider's bearer token. + if (nextUrl.host.toLowerCase() !== new URL(currentUrl).host.toLowerCase()) { + currentHeaders = stripCrossHostSensitiveHeaders(currentHeaders); } + if (redirectSwitchesToGet(response.status, currentMethod)) { + currentMethod = 'GET'; + currentBody = undefined; + currentHeaders = stripBodyHeaders(currentHeaders); + } + currentUrl = nextUrl.toString(); + } +} + +export async function safeFetch( + rawUrl: string, + options: SafeFetchOptions = {}, +): Promise { + const { + timeoutMs = DEFAULT_TIMEOUT_MS, + maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, + signal, + } = options; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const onCallerAbort = (): void => controller.abort(); + signal?.addEventListener('abort', onCallerAbort, { once: true }); + try { + const { response, finalUrl } = await fetchFollowingRedirects( + rawUrl, + options, + controller.signal, + timeoutMs, + ); const bodyText = await readBodyWithCap(response, maxResponseBytes); return { @@ -440,7 +508,7 @@ export async function safeFetch( statusText: response.statusText, headers: response.headers, body: bodyText, - finalUrl: currentUrl, + finalUrl, }; } finally { clearTimeout(timeout); @@ -465,118 +533,24 @@ export async function safeFetchBinary( options: SafeFetchOptions & { defaultContentType?: string } = {}, ): Promise { const { - method = 'GET', - headers = {}, - body, timeoutMs = DEFAULT_TIMEOUT_MS, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, - maxRedirects = DEFAULT_MAX_REDIRECTS, - allowedHosts: callerAllowedHosts, defaultContentType, signal, } = options; - let allowedHosts = callerAllowedHosts; - if (allowedHosts === undefined) { - try { - const ownHost = new URL(rawUrl).hostname.toLowerCase(); - if (ownHost) allowedHosts = [ownHost]; - } catch (err) { - // Intentional swallow: `validateUrl` below produces the canonical - // `invalid_url` SafeFetchError for malformed URLs. The debug log - // keeps a forensic trail per CLAUDE.md's no-silent-swallow rule - // without trying to recover here. - console.debug( - '[safe_fetch] auto-allowlist URL parse failed; deferring to validateUrl', - err, - ); - } - } - - validateUrl(rawUrl, allowedHosts, callerAllowedHosts); - - if (signal?.aborted) { - throw new SafeFetchError( - 'aborted', - 'Request aborted by the caller before it started', - ); - } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); const onCallerAbort = (): void => controller.abort(); signal?.addEventListener('abort', onCallerAbort, { once: true }); try { - let currentUrl = rawUrl; - let currentHeaders = headers; - let redirectsFollowed = 0; - let response: Response; - - while (true) { - try { - response = await fetch(currentUrl, { - method, - headers: currentHeaders, - body, - redirect: 'manual', - signal: controller.signal, - }); - } catch (error) { - if (error instanceof SafeFetchError) throw error; - if ( - error instanceof Error && - (error.name === 'AbortError' || error.name === 'TimeoutError') - ) { - if (signal?.aborted) { - throw new SafeFetchError( - 'aborted', - 'Request aborted by the caller before it completed', - ); - } - throw new SafeFetchError( - 'timeout', - `Request timed out after ${timeoutMs}ms`, - ); - } - const message = error instanceof Error ? error.message : 'unknown'; - throw new SafeFetchError('network_error', `fetch failed: ${message}`); - } - - if (!REDIRECT_STATUSES.has(response.status)) { - // 304/305/306 land here too — they carry no Location header, so - // returning them to the caller is correct. - break; - } - - const location = response.headers.get('Location'); - if (!location) { - throw new SafeFetchError( - 'redirect_missing_location', - `Redirect ${response.status} missing Location header`, - response.status, - ); - } - - redirectsFollowed += 1; - if (redirectsFollowed > maxRedirects) { - throw new SafeFetchError( - 'redirect_limit_exceeded', - `Exceeded ${maxRedirects} redirects`, - ); - } - - const nextUrl = new URL(location, currentUrl); - validateUrl(nextUrl.toString(), allowedHosts, callerAllowedHosts); - // Drop credential-carrying headers on cross-host hops — see - // `safeFetch` above for the threat model. - if ( - nextUrl.host.toLowerCase() !== new URL(currentUrl).host.toLowerCase() - ) { - currentHeaders = stripCrossHostSensitiveHeaders(currentHeaders); - } - currentUrl = nextUrl.toString(); - } - + const { response, finalUrl } = await fetchFollowingRedirects( + rawUrl, + options, + controller.signal, + timeoutMs, + ); const { buffer, contentType } = await readBinaryBodyWithCap( response, maxResponseBytes, @@ -590,7 +564,7 @@ export async function safeFetchBinary( statusText: response.statusText, headers: response.headers, body: blob, - finalUrl: currentUrl, + finalUrl, }; } finally { clearTimeout(timeout); From 5ece070008ff52214281acfa797283d0346f6690 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sun, 6 Sep 2026 10:03:32 +0800 Subject: [PATCH 07/10] fix(platform): drop the chat-filter toggle nothing reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chatFilterConfigSchema.preferNonStreamingForFiltering was a live control in Settings > Governance > Content safety — toggled, saved into the org's chat-filter policy, seeded in the builtin catalog and the docs-demo fixture — and no backend module ever read it: the guardrail chain (lib/chat/guardrails.ts) streams the same way whatever the value. An admin control that promises to change streaming and changes nothing is a misleading control. Remove the field from the schema, the toggle row with its state and save wiring from the form, the contentSafety.preferNonStreaming* strings from en/de/fr, and the key from the catalog file and the e2e fixture. The schema strips unknown keys, so an org file that still carries the key keeps parsing. Finding: lib-shared-schemas-7. --- .../custom/governance/chat-filter.yml | 1 - .../components/chat-filter-config.tsx | 39 +------------------ .../platform/lib/shared/schemas/governance.ts | 1 - services/platform/messages/de.yml | 4 -- services/platform/messages/en.yml | 4 -- services/platform/messages/fr.yml | 4 -- .../docs-demo/governance/chat-filter.json | 1 - 7 files changed, 1 insertion(+), 53 deletions(-) diff --git a/configs/platform/custom/governance/chat-filter.yml b/configs/platform/custom/governance/chat-filter.yml index 3b28a6e06c..13b2ab6665 100644 --- a/configs/platform/custom/governance/chat-filter.yml +++ b/configs/platform/custom/governance/chat-filter.yml @@ -2,6 +2,5 @@ enabled: false maskReplacement: '[BLOCKED]' appliesTo: - input -preferNonStreamingForFiltering: false configVersion: 1 categories: [] diff --git a/services/platform/app/features/settings/governance/components/chat-filter-config.tsx b/services/platform/app/features/settings/governance/components/chat-filter-config.tsx index 52c8181db1..64c002ce82 100644 --- a/services/platform/app/features/settings/governance/components/chat-filter-config.tsx +++ b/services/platform/app/features/settings/governance/components/chat-filter-config.tsx @@ -29,7 +29,6 @@ import { SettingsFieldRow, } from '@/app/features/settings/components/settings-field-list'; import { SettingsSection } from '@/app/features/settings/components/settings-section'; -import { SettingsToggleRow } from '@/app/features/settings/components/settings-toggle-row'; import { useAbility } from '@/app/hooks/use-ability'; import { useToast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; @@ -65,7 +64,6 @@ interface ChatFilterDraft { maskReplacement: string; appliesToInput: boolean; appliesToOutput: boolean; - preferNonStreaming: boolean; categories: ChatFilterCategory[]; } @@ -74,7 +72,6 @@ const DEFAULT_DRAFT: ChatFilterDraft = { maskReplacement: '[BLOCKED]', appliesToInput: true, appliesToOutput: false, - preferNonStreaming: false, categories: [], }; @@ -95,7 +92,6 @@ function deriveDraft(policy: ChatFilterPolicy): ChatFilterDraft { maskReplacement: config.maskReplacement ?? '[BLOCKED]', appliesToInput: config.appliesTo?.includes('input') ?? true, appliesToOutput: config.appliesTo?.includes('output') ?? false, - preferNonStreaming: config.preferNonStreamingForFiltering ?? false, categories: config.categories ?? [], }; } @@ -140,9 +136,6 @@ export function ChatFilterConfigView({ const [appliesToOutput, setAppliesToOutput] = useState( initial.appliesToOutput, ); - const [preferNonStreaming, setPreferNonStreaming] = useState( - initial.preferNonStreaming, - ); const [categories, setCategories] = useState(initial.categories); const [editorIndex, setEditorIndex] = useState(null); @@ -160,7 +153,6 @@ export function ChatFilterConfigView({ setMaskReplacement(initial.maskReplacement); setAppliesToInput(initial.appliesToInput); setAppliesToOutput(initial.appliesToOutput); - setPreferNonStreaming(initial.preferNonStreaming); setCategories(initial.categories); } @@ -170,7 +162,6 @@ export function ChatFilterConfigView({ maskReplacement?: string; appliesToInput?: boolean; appliesToOutput?: boolean; - preferNonStreaming?: boolean; categories?: ChatFilterCategory[]; }): ChatFilterConfig => { const nextInput = overrides.appliesToInput ?? appliesToInput; @@ -184,20 +175,11 @@ export function ChatFilterConfigView({ enabled: overrides.enabled ?? enabled, maskReplacement: overrides.maskReplacement ?? maskReplacement, appliesTo, - preferNonStreamingForFiltering: - overrides.preferNonStreaming ?? preferNonStreaming, configVersion: 1, categories: overrides.categories ?? categories, }; }, - [ - enabled, - maskReplacement, - appliesToInput, - appliesToOutput, - preferNonStreaming, - categories, - ], + [enabled, maskReplacement, appliesToInput, appliesToOutput, categories], ); const saveWith = useCallback( @@ -287,14 +269,6 @@ export function ChatFilterConfigView({ [buildConfig, saveWith], ); - const handlePreferNonStreaming = useCallback( - (checked: boolean) => { - setPreferNonStreaming(checked); - void saveWith(buildConfig({ preferNonStreaming: checked })); - }, - [buildConfig, saveWith], - ); - return ( - - {/* A toggle row is already a settings row — it joins the list - so it shares the same divider and vertical rhythm. */} - Date: Sun, 6 Sep 2026 10:03:34 +0800 Subject: [PATCH 08/10] fix(platform): drop the harness pinnedVersion nothing checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit harnessDefinitionSchema.pinnedVersion was declared in all nine harness.yml files, yet no loader, test, image build or runtime read it: the versions actually installed are the independent ARGs in services/sandbox-runtime/Dockerfile, and nothing cross-checked the two. qwen-code shows how such a copy rots — its yml pinned 0.10.3 while the image installs no qwen CLI at all. A pin nothing validates misleads an operator into believing a mismatch would be caught. Remove the field from the schema, the nine ymls (with qwen's "MUST be re-verified" note) and the test fixture; the Dockerfile ARGs stay the single source of the installed versions. Finding: lib-shared-schemas-9. --- configs/platform/system/harnesses/claude-code/harness.yml | 1 - configs/platform/system/harnesses/codex/harness.yml | 1 - configs/platform/system/harnesses/cursor/harness.yml | 1 - configs/platform/system/harnesses/gemini/harness.yml | 1 - configs/platform/system/harnesses/hermes/harness.yml | 1 - configs/platform/system/harnesses/openclaw/harness.yml | 1 - configs/platform/system/harnesses/opencode/harness.yml | 1 - configs/platform/system/harnesses/pi/harness.yml | 1 - configs/platform/system/harnesses/qwen-code/harness.yml | 4 ---- services/platform/lib/shared/schemas/providers.test.ts | 6 ------ services/platform/lib/shared/schemas/providers.ts | 2 -- 11 files changed, 20 deletions(-) diff --git a/configs/platform/system/harnesses/claude-code/harness.yml b/configs/platform/system/harnesses/claude-code/harness.yml index 3f18a8011c..7b985ba690 100644 --- a/configs/platform/system/harnesses/claude-code/harness.yml +++ b/configs/platform/system/harnesses/claude-code/harness.yml @@ -155,4 +155,3 @@ subscription: kind: env tokenVar: ANTHROPIC_AUTH_TOKEN baseUrlVar: ANTHROPIC_BASE_URL -pinnedVersion: 2.1.173 diff --git a/configs/platform/system/harnesses/codex/harness.yml b/configs/platform/system/harnesses/codex/harness.yml index d6a298f800..bcc606c6f5 100644 --- a/configs/platform/system/harnesses/codex/harness.yml +++ b/configs/platform/system/harnesses/codex/harness.yml @@ -118,4 +118,3 @@ exec: CODEX_HOME: /agent/.runtime/home/.codex managed: TALE_GATEWAY_TOKEN: '${gateway.token}' -pinnedVersion: 0.142.5 diff --git a/configs/platform/system/harnesses/cursor/harness.yml b/configs/platform/system/harnesses/cursor/harness.yml index f62b164cc1..67af1854e7 100644 --- a/configs/platform/system/harnesses/cursor/harness.yml +++ b/configs/platform/system/harnesses/cursor/harness.yml @@ -32,7 +32,6 @@ capabilities: # would silently drop. Declared off until the exec vocabulary carries a # delivery channel for it. mcp: false -pinnedVersion: 2026.03.20-44cb435 parser: cursor-jsonl exec: bin: agent diff --git a/configs/platform/system/harnesses/gemini/harness.yml b/configs/platform/system/harnesses/gemini/harness.yml index 7ff82f992b..e22ad56a1a 100644 --- a/configs/platform/system/harnesses/gemini/harness.yml +++ b/configs/platform/system/harnesses/gemini/harness.yml @@ -107,4 +107,3 @@ exec: subscription: kind: staged-file path: .runtime/home/.gemini/oauth_creds.json -pinnedVersion: 0.49.0 diff --git a/configs/platform/system/harnesses/hermes/harness.yml b/configs/platform/system/harnesses/hermes/harness.yml index 9e24e9a59c..7c948b9c04 100644 --- a/configs/platform/system/harnesses/hermes/harness.yml +++ b/configs/platform/system/harnesses/hermes/harness.yml @@ -60,4 +60,3 @@ subscription: kind: env tokenVar: OPENAI_API_KEY baseUrlVar: OPENAI_BASE_URL -pinnedVersion: 0.18.0 diff --git a/configs/platform/system/harnesses/openclaw/harness.yml b/configs/platform/system/harnesses/openclaw/harness.yml index 31c4607f5b..2712fa67f8 100644 --- a/configs/platform/system/harnesses/openclaw/harness.yml +++ b/configs/platform/system/harnesses/openclaw/harness.yml @@ -146,4 +146,3 @@ exec: OPENCLAW_STATE_DIR: /agent/.runtime/home/.openclaw managed: TALE_GATEWAY_TOKEN: '${gateway.token}' -pinnedVersion: 2026.6.11 diff --git a/configs/platform/system/harnesses/opencode/harness.yml b/configs/platform/system/harnesses/opencode/harness.yml index 1c4be36ee7..76277d634f 100644 --- a/configs/platform/system/harnesses/opencode/harness.yml +++ b/configs/platform/system/harnesses/opencode/harness.yml @@ -121,4 +121,3 @@ exec: bridgeEnv: TALE_CONNECTORS_URL: '${bridgeUrl}' TALE_CONNECTORS_TOKEN: '{env:TALE_GATEWAY_TOKEN}' -pinnedVersion: 1.17.3 diff --git a/configs/platform/system/harnesses/pi/harness.yml b/configs/platform/system/harnesses/pi/harness.yml index 2dc98c6a55..35b6f1d162 100644 --- a/configs/platform/system/harnesses/pi/harness.yml +++ b/configs/platform/system/harnesses/pi/harness.yml @@ -86,4 +86,3 @@ exec: env: managed: TALE_GATEWAY_TOKEN: '${gateway.token}' -pinnedVersion: 0.80.3 diff --git a/configs/platform/system/harnesses/qwen-code/harness.yml b/configs/platform/system/harnesses/qwen-code/harness.yml index b00f3b9663..78b5a4b6e5 100644 --- a/configs/platform/system/harnesses/qwen-code/harness.yml +++ b/configs/platform/system/harnesses/qwen-code/harness.yml @@ -85,7 +85,3 @@ exec: OPENAI_BASE_URL: '${gateway.baseUrl}/openai/v1' OPENAI_API_KEY: '${gateway.token}' TALE_GATEWAY_TOKEN: '${gateway.token}' -# Pin chosen from the fork's release line at authoring time; the pin MUST be -# re-verified against the qwen-code releases before the sandbox image bakes -# the CLI in. -pinnedVersion: 0.10.3 diff --git a/services/platform/lib/shared/schemas/providers.test.ts b/services/platform/lib/shared/schemas/providers.test.ts index f804e0fe68..eb15f34bf3 100644 --- a/services/platform/lib/shared/schemas/providers.test.ts +++ b/services/platform/lib/shared/schemas/providers.test.ts @@ -89,7 +89,6 @@ const VALID_HARNESS = { stdin: { mode: 'ndjson-user-message' }, env: { managed: { ANTHROPIC_AUTH_TOKEN: '${gateway.token}' } }, }, - pinnedVersion: '2.1.173', } as const; describe('SECRETS_ENV prefix gate', () => { @@ -597,11 +596,6 @@ describe('harnessDefinitionSchema', () => { expect(harnessDefinitionSchema.safeParse(VALID_HARNESS).success).toBe(true); }); - it('accepts a harness without a pinned version', () => { - const { pinnedVersion: _pinnedVersion, ...unpinned } = VALID_HARNESS; - expect(harnessDefinitionSchema.safeParse(unpinned).success).toBe(true); - }); - it('accepts one-sided credential policies', () => { expect( harnessDefinitionSchema.safeParse({ diff --git a/services/platform/lib/shared/schemas/providers.ts b/services/platform/lib/shared/schemas/providers.ts index 56d59d03ed..731c2d8f16 100644 --- a/services/platform/lib/shared/schemas/providers.ts +++ b/services/platform/lib/shared/schemas/providers.ts @@ -1047,8 +1047,6 @@ export const harnessDefinitionSchema = z /** Subscription-key delivery, for harnesses a subscription credential * can force (absent = no subscription path). */ subscription: harnessSubscriptionSchema.optional(), - /** The CLI version baked into the sandbox image, when pinned. */ - pinnedVersion: z.string().min(1).max(64).optional(), }) .strict() .superRefine((provider, ctx) => { From 31d75ed49be5f367cb2b454114d4406356449830 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sun, 6 Sep 2026 10:03:37 +0800 Subject: [PATCH 09/10] refactor(platform): drop unreachable SSO schema leftovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enterprise_sso.ts kept a private emptySsoConnectionFile() nothing in the file (or anywhere else) called, and ssoUserInfoSchema declared companyName and customAttributes that no OIDC/OAuth2/Graph adapter ever populates and no provisioning code reads — identity fields the boundary promised and no protocol front-half produced. Delete the helper and the two fields; the adapters' SsoUserInfo values are unchanged. Finding: lib-shared-schemas-10. --- services/platform/lib/shared/schemas/enterprise_sso.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/services/platform/lib/shared/schemas/enterprise_sso.ts b/services/platform/lib/shared/schemas/enterprise_sso.ts index 851aecec62..4020fe6e30 100644 --- a/services/platform/lib/shared/schemas/enterprise_sso.ts +++ b/services/platform/lib/shared/schemas/enterprise_sso.ts @@ -69,8 +69,6 @@ export const ssoUserInfoSchema = z.object({ location: z.string().optional(), country: z.string().optional(), city: z.string().optional(), - companyName: z.string().optional(), - customAttributes: z.record(z.string(), z.string()).optional(), groups: z.array(z.string()).optional(), appRoles: z.array(z.string()).optional(), rawClaims: z.record(z.string(), z.unknown()).optional(), @@ -279,11 +277,6 @@ export const ssoConnectionSecretsSchema = z.object({ }); export type SsoConnectionSecrets = z.infer; -/** Effective, defaulted connection used when the org has no `connection.json`. */ -function emptySsoConnectionFile(): SsoConnectionFile { - return ssoConnectionFileSchema.parse({}); -} - /** * `configCache` coordinates for the connection — V8-safe constants so queries / * mutations / auth-hooks can read the file-derived mirror. The `'use node'` From 2834daa624f941225b716e835c79615959cb96ef Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sun, 6 Sep 2026 10:27:35 +0800 Subject: [PATCH 10/10] feat(platform): show a pack's declared icon and labels on the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit automationPresentationSchema.icon and .labels were copied from every pack manifest into automations.presentation on upload and on provisioning, and returned by the list route, but no surface read them: the automations list showed name + slug only, so the glyph and the catalog chips all nine shipped packs declare were stored and never seen. Both the manifest and the presentation schema are strict, so dropping the two fields would have refused every existing pack carrying them and needed a migration for the stored rows; the doc-comments name the surface they were meant for. Complete the path instead: the presentation module gains automationDisplayIcon (the Iconify id ConfigIcon resolves offline) and automationDisplayLabels beside automationDisplayName, and the list's name cell renders the glyph like the skills table does — neutral fallback for canvas-authored automations — with the labels as slate chips beside the name. Docs (en/de/fr) name the two additions. Finding: lib-shared-schemas-12. --- docs/de/platform/automations/catalog.md | 2 +- docs/en/platform/automations/catalog.md | 2 +- docs/fr/platform/automations/catalog.md | 2 +- .../components/automations-list.test.tsx | 36 +++++++++++++ .../components/automations-list.tsx | 50 +++++++++++++++---- .../schemas/automation_presentation.test.ts | 24 +++++++++ .../shared/schemas/automation_presentation.ts | 24 ++++++++- 7 files changed, 125 insertions(+), 15 deletions(-) diff --git a/docs/de/platform/automations/catalog.md b/docs/de/platform/automations/catalog.md index 7e2dc51232..6385348107 100644 --- a/docs/de/platform/automations/catalog.md +++ b/docs/de/platform/automations/catalog.md @@ -15,7 +15,7 @@ Diese Seite behandelt, woher Automatisierungen kommen und was ein hochgeladenes ## Was die Liste zeigt -Jede Zeile ist eine Automatisierung: ihr Name, wie viele Versionen sie hat, und entweder die Live-Version oder **Nicht live**. Die Suche filtert die Liste. **Neue Automatisierung** sitzt in der Tabellenleiste wie auf den anderen Listen — **Aus einem Ziel**, **Leer (Trigger + Agent)** oder **Paket hochladen**. Das Zeilenmenü bietet **Löschen**, ohne den Editor zu öffnen. Die Org-Seite listet Automatisierungen auf Organisationsebene; eine Automatisierung, die zu einem Projekt gehört, lebt stattdessen im **Automatisierungen**-Tab dieses Projekts — wo eine Automatisierung erscheint, entscheidet ihr erster Save, und danach zieht sie nie um. Klicke eine Zeile an und du landest auf der Seite der Automatisierung, wie [Der Workflow-Editor](/de/platform/automations/editor) sie beschreibt. +Jede Zeile ist eine Automatisierung: ihr Name — samt Symbol und Katalog-Chips, wenn ihr Paket welche mitbringt —, wie viele Versionen sie hat, und entweder die Live-Version oder **Nicht live**. Die Suche filtert die Liste. **Neue Automatisierung** sitzt in der Tabellenleiste wie auf den anderen Listen — **Aus einem Ziel**, **Leer (Trigger + Agent)** oder **Paket hochladen**. Das Zeilenmenü bietet **Löschen**, ohne den Editor zu öffnen. Die Org-Seite listet Automatisierungen auf Organisationsebene; eine Automatisierung, die zu einem Projekt gehört, lebt stattdessen im **Automatisierungen**-Tab dieses Projekts — wo eine Automatisierung erscheint, entscheidet ihr erster Save, und danach zieht sie nie um. Klicke eine Zeile an und du landest auf der Seite der Automatisierung, wie [Der Workflow-Editor](/de/platform/automations/editor) sie beschreibt. **Automatisierung erstellen** bietet zwei Wege, bei null zu starten: **Aus einem Ziel** übergibt deine Beschreibung dem Builder, der die Nodes für dich baut; **Leer (Trigger + Agent)** legt eine Ein-Agent-Automatisierung an, die du selbst verdrahtest — benenne sie, wähle das Modell des Agenten, und den Rest (Prompt, gewährte Tools und Secrets, Trigger) setzt du auf dem Canvas. Die mitgelieferten Packs brauchen gar keinen Installationsschritt: Jede Organisation wird bei ihrer Anlage damit ausgestattet, bereit zum Deployen. diff --git a/docs/en/platform/automations/catalog.md b/docs/en/platform/automations/catalog.md index 9362473435..c75179ec65 100644 --- a/docs/en/platform/automations/catalog.md +++ b/docs/en/platform/automations/catalog.md @@ -15,7 +15,7 @@ This page covers where automations come from and what an uploaded package may co ## What the list shows -Each row is one automation: its name, how many versions it has, and either the live version or **Not deployed**. Search filters the list. **New automation** sits in the table toolbar with the other list pages — **From a goal**, **Blank (trigger + agent)**, or **Upload package**. The row menu offers **Delete** without opening the editor. The org page lists organization-level automations; an automation that belongs to a project lives on that project's **Automations** tab instead — where an automation appears is decided once, by its first save, and never moves. Click a row to land on the automation's page and work with it as [The workflow editor](/platform/automations/editor) describes. +Each row is one automation: its name — with the glyph and the catalog chips its pack declared, when it came from one — how many versions it has, and either the live version or **Not deployed**. Search filters the list. **New automation** sits in the table toolbar with the other list pages — **From a goal**, **Blank (trigger + agent)**, or **Upload package**. The row menu offers **Delete** without opening the editor. The org page lists organization-level automations; an automation that belongs to a project lives on that project's **Automations** tab instead — where an automation appears is decided once, by its first save, and never moves. Click a row to land on the automation's page and work with it as [The workflow editor](/platform/automations/editor) describes. **Create automation** offers two ways to start from scratch: **From a goal** hands your description to the builder, which authors the nodes for you; **Blank (trigger + agent)** scaffolds a one-agent automation you wire yourself — name it, pick the agent's model, and the rest (the prompt, the granted tools and secrets, the trigger) is yours to set on the canvas. The shipped packs need no install step at all: every organization is seeded with them at creation, ready to deploy. diff --git a/docs/fr/platform/automations/catalog.md b/docs/fr/platform/automations/catalog.md index a56ae71bb0..7e55c34670 100644 --- a/docs/fr/platform/automations/catalog.md +++ b/docs/fr/platform/automations/catalog.md @@ -15,7 +15,7 @@ Cette page couvre la provenance des automatisations et ce qu’un paquet télév ## Ce que montre la liste -Chaque ligne est une automatisation : son nom, son nombre de versions, et soit la version en service, soit **Pas en service**. La recherche filtre la liste. **Nouvelle automatisation** se trouve dans la barre d’outils du tableau, comme sur les autres listes — **À partir d’un objectif**, **Vierge (trigger + agent)** ou **Téléverser un paquet**. Le menu de la ligne propose **Supprimer** sans ouvrir l’éditeur. La page de l’org liste les automatisations au niveau de l’organisation ; une automatisation qui appartient à un projet vit dans l’onglet **Automatisations** de ce projet — l’endroit où elle apparaît se décide une fois, à son premier enregistrement, et ne bouge plus. Clique une ligne pour arriver sur la page de l’automatisation, que décrit [L’éditeur de workflow](/fr/platform/automations/editor). +Chaque ligne est une automatisation : son nom — avec l’icône et les puces de catalogue que son paquet déclare, s’il en déclare —, son nombre de versions, et soit la version en service, soit **Pas en service**. La recherche filtre la liste. **Nouvelle automatisation** se trouve dans la barre d’outils du tableau, comme sur les autres listes — **À partir d’un objectif**, **Vierge (trigger + agent)** ou **Téléverser un paquet**. Le menu de la ligne propose **Supprimer** sans ouvrir l’éditeur. La page de l’org liste les automatisations au niveau de l’organisation ; une automatisation qui appartient à un projet vit dans l’onglet **Automatisations** de ce projet — l’endroit où elle apparaît se décide une fois, à son premier enregistrement, et ne bouge plus. Clique une ligne pour arriver sur la page de l’automatisation, que décrit [L’éditeur de workflow](/fr/platform/automations/editor). **Créer une automatisation** propose deux façons de partir de zéro : **À partir d’un objectif** confie ta description au builder, qui construit les nœuds pour toi ; **Vierge (trigger + agent)** échafaude une automatisation à un seul agent que tu câbles toi-même — nomme-la, choisis le modèle de l’agent, et le reste (le prompt, les outils et secrets accordés, le trigger) est à toi de le poser sur le canvas. Les packs livrés ne demandent aucune installation : chaque organisation en est équipée à sa création, prêts à déployer. diff --git a/services/platform/app/features/automations/components/automations-list.test.tsx b/services/platform/app/features/automations/components/automations-list.test.tsx index 3d589c424e..89615e870a 100644 --- a/services/platform/app/features/automations/components/automations-list.test.tsx +++ b/services/platform/app/features/automations/components/automations-list.test.tsx @@ -52,6 +52,7 @@ let automationsData: latest: number; projectIds: string[]; deployedVersion?: number; + presentation?: unknown; }> | undefined; vi.mock('../hooks/queries', () => ({ @@ -139,6 +140,41 @@ describe('AutomationsList', () => { }); }); +describe('AutomationsList presentation', () => { + it('shows the pack s declared glyph and catalog chips beside the name', () => { + automationsData = [ + { + name: 'gmail/triage-inbox', + latest: 1, + projectIds: [], + presentation: { + name: 'Triage the Gmail inbox', + icon: 'mail', + labels: ['Email', 'Gmail'], + }, + }, + ]; + const { container } = render(); + + expect(screen.getByText('Triage the Gmail inbox')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + expect(screen.getByText('Gmail')).toBeInTheDocument(); + // The bundled lucide set resolves offline: the declared glyph renders as + // an Iconify svg, not the neutral fallback. + expect(container.querySelector('svg.iconify--lucide')).not.toBeNull(); + expect(container.querySelector('svg.lucide-sparkles')).toBeNull(); + }); + + it('falls back to the neutral glyph and no chips without a presentation', () => { + automationsData = [{ name: 'weekly-report', latest: 1, projectIds: [] }]; + const { container } = render(); + + expect(screen.getByText('Weekly report')).toBeInTheDocument(); + expect(container.querySelector('svg.iconify--lucide')).toBeNull(); + expect(container.querySelector('svg.lucide-sparkles')).not.toBeNull(); + }); +}); + describe('AutomationsList create menu', () => { it('offers the create lanes from the toolbar button when the list is empty', async () => { automationsData = []; diff --git a/services/platform/app/features/automations/components/automations-list.tsx b/services/platform/app/features/automations/components/automations-list.tsx index c498571cd4..71d7521345 100644 --- a/services/platform/app/features/automations/components/automations-list.tsx +++ b/services/platform/app/features/automations/components/automations-list.tsx @@ -16,6 +16,7 @@ import { } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; +import { ConfigIcon } from '@/app/components/catalog/config-icon'; import { ACTIONS_COLUMN_SIZE } from '@/app/components/ui/data-table/column-builders'; import { DataTable } from '@/app/components/ui/data-table/data-table'; import { useProjects } from '@/app/features/projects/hooks/queries'; @@ -23,7 +24,11 @@ import { useAbility } from '@/app/hooks/use-ability'; import { useListPage } from '@/app/hooks/use-list-page'; import { usePreloadRoute } from '@/app/hooks/use-preload-route'; import { useT } from '@/lib/i18n/client'; -import { automationDisplayName } from '@/lib/shared/schemas/automation_presentation'; +import { + automationDisplayIcon, + automationDisplayLabels, + automationDisplayName, +} from '@/lib/shared/schemas/automation_presentation'; import { useAutomations } from '../hooks/queries'; import { automationErrorMessage } from '../lib/errors'; @@ -36,6 +41,10 @@ import { UploadAutomationDialog } from './upload-automation-dialog'; interface AutomationListRow { name: string; displayName: string; + /** Iconify id of the pack's declared glyph; absent → the neutral fallback. */ + icon?: string; + /** The pack's catalog chips, in declaration order. */ + labels: string[]; latest: number; projectIds: string[]; deployedVersion?: number; @@ -92,6 +101,7 @@ export function AutomationsList({ a.name.localeCompare(b.name), ); return listed.map((automation) => { + const icon = automationDisplayIcon(automation.presentation); const row: AutomationListRow = { name: automation.name, displayName: automationDisplayName( @@ -99,10 +109,12 @@ export function AutomationsList({ automation.name, locale, ), + labels: automationDisplayLabels(automation.presentation), latest: automation.latest, projectIds: automation.projectIds, presentation: automation.presentation, }; + if (icon !== undefined) row.icon = icon; if ( 'deployedVersion' in automation && automation.deployedVersion !== undefined @@ -120,16 +132,34 @@ export function AutomationsList({ header: t('list.columnName'), size: 280, cell: ({ row }) => ( - - - {row.original.displayName} - - {/* The slug stays visible on the admin surface: it is what the - store, the CLI and the run log address. */} - - {row.original.name} + + {/* The pack's declared glyph, like a skill's on its list; a + canvas-authored automation gets the same neutral fallback. */} + + + + + {row.original.displayName} + + {/* Catalog chips the pack declared — proper nouns, so they + read the same in every locale. */} + {row.original.labels.map((label) => ( + + {label} + + ))} + + {/* The slug stays visible on the admin surface: it is what the + store, the CLI and the run log address. */} + + {row.original.name} + - + ), }, ]; diff --git a/services/platform/lib/shared/schemas/automation_presentation.test.ts b/services/platform/lib/shared/schemas/automation_presentation.test.ts index 7ac7530079..51fc132315 100644 --- a/services/platform/lib/shared/schemas/automation_presentation.test.ts +++ b/services/platform/lib/shared/schemas/automation_presentation.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { automationDisplayDescription, + automationDisplayIcon, + automationDisplayLabels, automationDisplayName, parseAutomationPresentation, titleFromSlug, @@ -86,3 +88,25 @@ describe('automationDisplayDescription', () => { expect(automationDisplayDescription(null, 'en')).toBeUndefined(); }); }); + +describe('automationDisplayIcon', () => { + it('speaks the Iconify id the renderer resolves offline', () => { + expect(automationDisplayIcon(PACK)).toBe('lucide:file-check'); + }); + + it('leaves the renderer to its fallback when nothing was declared', () => { + expect(automationDisplayIcon({ name: 'Weekly report' })).toBeUndefined(); + expect(automationDisplayIcon(undefined)).toBeUndefined(); + }); +}); + +describe('automationDisplayLabels', () => { + it('keeps the declared chips in declaration order', () => { + expect(automationDisplayLabels(PACK)).toEqual(['Review', 'Documents']); + }); + + it('reads no chips from an undeclared or unusable presentation', () => { + expect(automationDisplayLabels({ name: 'Weekly report' })).toEqual([]); + expect(automationDisplayLabels({ labels: 'Review' })).toEqual([]); + }); +}); diff --git a/services/platform/lib/shared/schemas/automation_presentation.ts b/services/platform/lib/shared/schemas/automation_presentation.ts index 9186df274a..5be966a5ff 100644 --- a/services/platform/lib/shared/schemas/automation_presentation.ts +++ b/services/platform/lib/shared/schemas/automation_presentation.ts @@ -27,9 +27,11 @@ export const automationPresentationSchema = z /** Display name, authored in English. */ name: z.string().min(1).max(200), description: z.string().max(2000).optional(), - /** Lucide icon name for the automation's card/badge. */ + /** Lucide icon name, shown before the name on the automations list + * (see {@link automationDisplayIcon}). */ icon: z.string().min(1).optional(), - /** Catalog chips — proper nouns, left untranslated on purpose. */ + /** Catalog chips beside the name — proper nouns, left untranslated on + * purpose (see {@link automationDisplayLabels}). */ labels: z.array(z.string().min(1)).max(6).optional(), /** Per-locale overrides; an absent locale falls back to the English above. */ i18n: z @@ -118,3 +120,21 @@ export function automationDisplayDescription( const parsed = parseAutomationPresentation(presentation); return parsed === null ? undefined : localized(parsed, locale).description; } + +/** + * The declared icon as the Iconify id `ConfigIcon` resolves offline + * (`lucide:`) — the manifest names a lucide glyph, the renderer speaks + * Iconify. `undefined` when nothing was declared, so the renderer shows its + * neutral fallback instead of an empty slot. + */ +export function automationDisplayIcon( + presentation: unknown, +): string | undefined { + const parsed = parseAutomationPresentation(presentation); + return parsed?.icon === undefined ? undefined : `lucide:${parsed.icon}`; +} + +/** The catalog chips a pack declared, in declaration order; empty when none. */ +export function automationDisplayLabels(presentation: unknown): string[] { + return parseAutomationPresentation(presentation)?.labels ?? []; +}