From 493423e55dbdc6eb9d363bf9bae7cd30c8268de9 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:12:54 -0700 Subject: [PATCH 1/3] feat(cards): catalogue revision 2 with a server-stamped Source (TASK-607) Revision 2 adds one component, Source(connector, fetchedAt), naming where a card's data came from. connector is linear, sentry or atlassian, drawn with the bundled connector-tile marks; there is no URL or remote image in it. Revision 1 is untouched and still what agents author. The validator now keeps one parser per revision, accepts either tuple, and refuses any agent-written Source under both with invalid_ui_card. stampCardSource() is the only writer: it holds the input to the agent rules, appends Source to the top-level Card, reprints it as positional OpenUI and revalidates it as revision 2. Nothing calls it until TASK-337. The reader accepts revisions 1 and 2 and keeps the unsupported note for anything else. Source renders in the card footer as the mark, the connector name and "fetched 2 min ago", with the absolute time as the title. Text uses the card's existing v2 token aliases only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQmb6BbxDrAtw45bgN --- .../agentsRuntime.cardKindBoundary.test.js | 16 + .../services/uiCardCatalogueRevision.test.js | 24 + .../unit/services/uiCardSource.test.js | 103 ++++ .../unit/services/uiCardValidation.test.js | 2 +- backend/config/uiCardCatalogue.rev2.json | 456 ++++++++++++++++++ .../config/uiCardCatalogueRevision.rev2.json | 5 + backend/services/uiCardValidation.ts | 175 +++++-- frontend/scripts/sync-ui-card-catalogue.mjs | 5 +- frontend/scripts/ui-card-catalogue.test.mjs | 38 +- .../v2/__tests__/v2-layout-invariants.test.ts | 16 + frontend/src/v2/cards/V2UICard.tsx | 11 +- .../src/v2/cards/__tests__/V2UICard.test.tsx | 42 +- .../src/v2/cards/__tests__/cardViews.test.tsx | 9 +- frontend/src/v2/cards/cardRuntime.tsx | 39 +- frontend/src/v2/cards/cardViews.tsx | 19 + frontend/src/v2/cards/uiCard.css | 6 + .../src/v2/cards/uiCardCatalogue.rev2.json | 456 ++++++++++++++++++ .../cards/uiCardCatalogueRevision.rev2.json | 5 + frontend/src/v2/cards/uiCardSource.ts | 56 ++- 19 files changed, 1397 insertions(+), 86 deletions(-) create mode 100644 backend/__tests__/unit/services/uiCardSource.test.js create mode 100644 backend/config/uiCardCatalogue.rev2.json create mode 100644 backend/config/uiCardCatalogueRevision.rev2.json create mode 100644 frontend/src/v2/cards/uiCardCatalogue.rev2.json create mode 100644 frontend/src/v2/cards/uiCardCatalogueRevision.rev2.json diff --git a/backend/__tests__/service/agentsRuntime.cardKindBoundary.test.js b/backend/__tests__/service/agentsRuntime.cardKindBoundary.test.js index 3c31af6745..0b7cdd8505 100644 --- a/backend/__tests__/service/agentsRuntime.cardKindBoundary.test.js +++ b/backend/__tests__/service/agentsRuntime.cardKindBoundary.test.js @@ -22,6 +22,7 @@ const User = require('../../models/User'); const Pod = require('../../models/Pod'); const { AgentRegistry, AgentInstallation } = require('../../models/AgentRegistry'); const catalogueRevision = require('../../config/uiCardCatalogueRevision.json'); +const catalogueRevision2 = require('../../config/uiCardCatalogueRevision.rev2.json'); jest.mock('../../services/agentEventService', () => ({ enqueue: jest.fn(async () => ({ _id: 'stub-event' })), @@ -170,4 +171,19 @@ describe('agent write path accepts only payload kind "ui" (TASK-657)', () => { expect(res.body.refused).toBeUndefined(); expect(await storedMessageCount()).toBe(before + 1); }); + + // TASK-607: Source names a vendor origin, so only the server may write it. + it.each([['revision 1', catalogueRevision], ['revision 2', catalogueRevision2]])( + 'refuses a hand-written Source under %s with invalid_ui_card and stores nothing', + async (_label, pin) => { + const before = await storedMessageCount(); + const res = await postCard(uiPayload({ + ...pin, source: 'root = Card("Issues", [Text("x"), Source("linear", "2026-11-01T09:30:00.000Z")])', + })); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toContain('invalid_ui_card'); + expect(await storedMessageCount()).toBe(before); + }, + ); }); diff --git a/backend/__tests__/unit/services/uiCardCatalogueRevision.test.js b/backend/__tests__/unit/services/uiCardCatalogueRevision.test.js index 92e6bba51d..e307ffa4f5 100644 --- a/backend/__tests__/unit/services/uiCardCatalogueRevision.test.js +++ b/backend/__tests__/unit/services/uiCardCatalogueRevision.test.js @@ -23,3 +23,27 @@ describe('Commonly UI catalogue revision 1', () => { expect(hash).toBe(revision.catalogHash); }); }); + +// Revision 2 (TASK-607) is revision 1 plus the server-stamped Source. Same rule: +// never edit these expectations; a change to the grammar is revision 3. +describe('Commonly UI catalogue revision 2', () => { + const catalogue2 = require('../../../config/uiCardCatalogue.rev2.json'); + const revision2 = require('../../../config/uiCardCatalogueRevision.rev2.json'); + it('pins all positional property orders', () => { + expect(Object.fromEntries(Object.entries(catalogue2.$defs) + .map(([name, def]) => [name, Object.keys(def.properties)]))).toEqual({ + Card: ['title', 'children', 'meta', 'summary'], Text: ['body'], + List: ['items', 'ordered'], Steps: ['steps'], Step: ['label', 'state', 'detail'], + DiffSummary: ['files'], File: ['path', 'added', 'removed'], + TaskStatus: ['taskId', 'note'], KeyValue: ['pairs'], Pair: ['key', 'value'], + Table: ['columns', 'rows'], Col: ['label', 'type'], Badge: ['label', 'tone'], + Link: ['label', 'url', 'as'], RunTrace: ['limit'], Source: ['connector', 'fetchedAt'], + }); + }); + it('pins the complete schema, identity and revision hash', () => { + const hash = `sha256:${createHash('sha256').update(JSON.stringify(catalogue2)).digest('hex')}`; + expect(revision2).toEqual({ catalogId: 'commonly.ui', catalogRev: 2, + catalogHash: 'sha256:1bbf106e81f08dde540996d53b32e8ac12f661ecdc1409e7ceb2c8a26655b7a1' }); + expect(hash).toBe(revision2.catalogHash); + }); +}); diff --git a/backend/__tests__/unit/services/uiCardSource.test.js b/backend/__tests__/unit/services/uiCardSource.test.js new file mode 100644 index 0000000000..d9c9810932 --- /dev/null +++ b/backend/__tests__/unit/services/uiCardSource.test.js @@ -0,0 +1,103 @@ +/** + * TASK-607: catalogue revision 2 adds Source(connector, fetchedAt). It names a + * vendor origin, so only the server writes it; an agent can never claim one. + */ +const revision1 = require('../../../config/uiCardCatalogueRevision.json'); +const revision2 = require('../../../config/uiCardCatalogueRevision.rev2.json'); +const catalogue1 = require('../../../config/uiCardCatalogue.json'); +const catalogue2 = require('../../../config/uiCardCatalogue.rev2.json'); +const { + validateAgentCardPayload, stampCardSource, UI_CARD_SOURCE_CATALOGUE, UI_CARD_SOURCE_CONNECTORS, +} = require('../../../services/uiCardValidation'); + +const envelope = (source, pin = revision1) => ({ + kind: 'ui', v: 1, lang: 'openui@1', source, ...pin, +}); +const agent = (payload) => validateAgentCardPayload('card', payload, 'Text fallback'); +const issues = 'root = Card("Open issues", [Text("Two need a reply")], "linear")'; +const fetchedAt = '2026-11-01T09:30:00.000Z'; +const refused = (message) => expect.objectContaining({ statusCode: 400, code: 'invalid_ui_card', message: expect.stringMatching(message) }); + +describe('catalogue revision 2', () => { + it('adds only Source to revision 1, leaving every revision 1 component unchanged', () => { + const added = Object.keys(catalogue2.$defs).filter((name) => !(name in catalogue1.$defs)); + expect(added).toEqual(['Source']); + expect(Object.keys(catalogue2.properties)).toEqual([...Object.keys(catalogue1.properties), 'Source']); + Object.keys(catalogue1.$defs).filter((n) => n !== 'Card').forEach((name) => { + expect(catalogue2.$defs[name]).toEqual(catalogue1.$defs[name]); + }); + expect(catalogue2.$defs.Card.properties.children.items.anyOf) + .toEqual([...catalogue1.$defs.Card.properties.children.items.anyOf, { $ref: '#/$defs/Source' }]); + // Positional ABI: connector first, then fetchedAt. + expect(Object.keys(catalogue2.$defs.Source.properties)).toEqual(['connector', 'fetchedAt']); + expect(revision2).toEqual(expect.objectContaining({ catalogId: revision1.catalogId, catalogRev: 2 })); + expect(revision2.catalogHash).not.toBe(revision1.catalogHash); + expect(UI_CARD_SOURCE_CATALOGUE).toEqual(revision2); + expect(UI_CARD_SOURCE_CONNECTORS).toEqual(['linear', 'sentry', 'atlassian']); + }); + it('accepts an agent card under either revision when it has no Source', () => { + expect(() => agent(envelope(issues))).not.toThrow(); + expect(() => agent(envelope(issues, revision2))).not.toThrow(); + }); + it.each([{ catalogRev: 3 }, { catalogRev: 2, catalogHash: revision1.catalogHash }, { catalogRev: 1, catalogHash: revision2.catalogHash }])( + 'refuses an unknown or mismatched revision tuple %#', + (fields) => expect(() => agent({ ...envelope(issues), ...fields })).toThrow(refused(/catalog/)), + ); +}); + +describe('agent-written Source', () => { + it.each([ + ['revision 2, in a Card', 'root = Card("Issues", [Text("x"), Source("linear", "2026-11-01T09:30:00.000Z")])', revision2], + ['revision 2, as a dormant statement', 'root = Text("x")\nlater = Source("sentry", "2026-11-01T09:30:00.000Z")', revision2], + ['revision 2, by reference', 's = Source("linear", "2026-11-01T09:30:00.000Z")\nroot = Card("Issues", [Text("x"), s])', revision2], + ['revision 1', 'root = Card("Issues", [Text("x"), Source("linear", "2026-11-01T09:30:00.000Z")])', revision1], + ])('is refused: %s', (_label, source, pin) => { + expect(() => agent(envelope(source, pin))).toThrow(refused(/Source is stamped by the server/)); + }); + it('is refused on a stamped envelope resubmitted through the agent path', () => { + const stamped = stampCardSource(issues, 'Two open issues', { connector: 'linear', fetchedAt }); + expect(() => agent(stamped)).toThrow(refused(/Source is stamped by the server/)); + }); +}); + +describe('stampCardSource', () => { + it('appends Source to the top-level Card and returns a revision 2 envelope', () => { + const stamped = stampCardSource(issues, 'Two open issues', { connector: 'linear', fetchedAt: new Date(fetchedAt) }); + expect(stamped).toEqual({ + kind: 'ui', + v: 1, + lang: 'openui@1', + ...revision2, + source: 'root = Card("Open issues", [Text("Two need a reply"), Source("linear", "2026-11-01T09:30:00.000Z")], "linear")', + }); + }); + it('keeps quoted prose, references, links and helpers intact through the reprint', () => { + const source = [ + 'pair = Pair("Owner", "Sam \\"S\\" X")', + 'root = Card("Sentry", [Text(["Line one\\nline two é ", Link("open", "https://sentry.io/x", "text")]), KeyValue([pair, pair]), Table([Col("n", "number")], [[1], [2]])], "2 issues")', + ].join('\n'); + const { source: out } = stampCardSource(source, 'fallback', { connector: 'sentry', fetchedAt }); + expect(out).toBe('root = Card("Sentry", [Text(["Line one\\nline two é ", Link("open", "https://sentry.io/x", "text")]), ' + + 'KeyValue([Pair("Owner", "Sam \\"S\\" X"), Pair("Owner", "Sam \\"S\\" X")]), Table([Col("n", "number")], [[1], [2]]), ' + + 'Source("sentry", "2026-11-01T09:30:00.000Z")], "2 issues")'); + }); + it.each(['github', 'Linear', '', undefined])('refuses an unknown connector %p', (connector) => { + expect(() => stampCardSource(issues, 'fallback', { connector, fetchedAt })).toThrow(refused(/connector/)); + }); + it.each(['yesterday', '', new Date(Number.NaN), '+275760-09-13T00:00:00.000Z'])('refuses an invalid fetchedAt %p', (value) => { + expect(() => stampCardSource(issues, 'fallback', { connector: 'linear', fetchedAt: value })).toThrow(refused(/fetchedAt|catalogue/)); + }); + it('holds the input to the agent rules, so vendor data cannot carry its own Source', () => { + const smuggled = 'root = Card("x", [Text("y"), Source("linear", "2026-11-01T09:30:00.000Z")])'; + expect(() => stampCardSource(smuggled, 'fallback', { connector: 'sentry', fetchedAt })).toThrow(refused(/Source is stamped/)); + expect(() => stampCardSource('root = Query("secret")', 'fallback', { connector: 'linear', fetchedAt })).toThrow(refused(/not allowed/)); + }); + it('stamps only a Card root, with room for one more child', () => { + expect(() => stampCardSource('root = Text("bare")', 'fallback', { connector: 'linear', fetchedAt })).toThrow(refused(/Card root/)); + const full = `root = Card("full", [${Array.from({ length: 6 }, (_, i) => `Text("${i}")`).join(', ')}])`; + expect(() => stampCardSource(full, 'fallback', { connector: 'linear', fetchedAt })).toThrow(refused(/catalogue/)); + }); + it('requires a text fallback, as every card does', () => { + expect(() => stampCardSource(issues, '', { connector: 'linear', fetchedAt })).toThrow(refused(/fallback/)); + }); +}); diff --git a/backend/__tests__/unit/services/uiCardValidation.test.js b/backend/__tests__/unit/services/uiCardValidation.test.js index 9891649cd7..5c0468f296 100644 --- a/backend/__tests__/unit/services/uiCardValidation.test.js +++ b/backend/__tests__/unit/services/uiCardValidation.test.js @@ -8,7 +8,7 @@ const validate = (payload = card(), content = 'Text fallback', type = 'card') => describe('untrusted UI card write validation', () => { it.each([ - { catalogId: 'other.ui' }, { catalogRev: 2 }, { catalogRev: '1' }, + { catalogId: 'other.ui' }, { catalogRev: 2 }, { catalogRev: 3 }, { catalogRev: '1' }, { catalogHash: 'sha256:forged' }, { catalogRev: undefined }, { catalogId: undefined }, { catalogHash: undefined }, ])('visibly refuses an unsupported catalogue tuple %#', (fields) => { diff --git a/backend/config/uiCardCatalogue.rev2.json b/backend/config/uiCardCatalogue.rev2.json new file mode 100644 index 0000000000..f2e6787841 --- /dev/null +++ b/backend/config/uiCardCatalogue.rev2.json @@ -0,0 +1,456 @@ +{ + "$defs": { + "Card": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 80 + }, + "children": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Card" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/List" + }, + { + "$ref": "#/$defs/Steps" + }, + { + "$ref": "#/$defs/DiffSummary" + }, + { + "$ref": "#/$defs/TaskStatus" + }, + { + "$ref": "#/$defs/KeyValue" + }, + { + "$ref": "#/$defs/Table" + }, + { + "$ref": "#/$defs/Badge" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/RunTrace" + }, + { + "$ref": "#/$defs/Source" + } + ] + }, + "minItems": 1, + "maxItems": 6 + }, + "meta": { + "type": "string", + "maxLength": 32 + }, + "summary": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "title", + "children" + ], + "additionalProperties": false + }, + "Text": { + "type": "object", + "properties": { + "body": { + "anyOf": [ + { + "type": "string", + "maxLength": 600 + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "maxLength": 600 + }, + { + "$ref": "#/$defs/Link" + } + ] + } + } + ] + } + }, + "required": [ + "body" + ], + "additionalProperties": false + }, + "List": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string", + "maxLength": 120 + }, + "minItems": 1, + "maxItems": 8 + }, + "ordered": { + "type": "boolean", + "default": false + } + }, + "required": [ + "items" + ], + "additionalProperties": false + }, + "Steps": { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/Step" + }, + "minItems": 2, + "maxItems": 8 + } + }, + "required": [ + "steps" + ], + "additionalProperties": false + }, + "Step": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 60 + }, + "state": { + "type": "string", + "enum": [ + "pending", + "active", + "done", + "failed", + "skipped" + ] + }, + "detail": { + "type": "string", + "maxLength": 120 + } + }, + "required": [ + "label", + "state" + ], + "additionalProperties": false + }, + "DiffSummary": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/$defs/File" + } + } + }, + "required": [ + "files" + ], + "additionalProperties": false + }, + "File": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "added": { + "type": "integer", + "minimum": 0 + }, + "removed": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "path", + "added", + "removed" + ], + "additionalProperties": false + }, + "TaskStatus": { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + }, + "KeyValue": { + "type": "object", + "properties": { + "pairs": { + "type": "array", + "items": { + "$ref": "#/$defs/Pair" + } + } + }, + "required": [ + "pairs" + ], + "additionalProperties": false + }, + "Pair": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "key", + "value" + ], + "additionalProperties": false + }, + "Table": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/$defs/Col" + }, + "minItems": 1 + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + } + }, + "required": [ + "columns", + "rows" + ], + "additionalProperties": false + }, + "Col": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "Badge": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 24 + }, + "tone": { + "type": "string", + "enum": [ + "neutral", + "ink", + "done", + "failed", + "pending" + ] + } + }, + "required": [ + "label", + "tone" + ], + "additionalProperties": false + }, + "Link": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 40 + }, + "url": { + "type": "string" + }, + "as": { + "type": "string", + "enum": [ + "text", + "button", + "primary" + ] + } + }, + "required": [ + "label", + "url", + "as" + ], + "additionalProperties": false + }, + "RunTrace": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "default": 3 + } + }, + "required": [ + "limit" + ], + "additionalProperties": false + }, + "Source": { + "type": "object", + "properties": { + "connector": { + "type": "string", + "enum": [ + "linear", + "sentry", + "atlassian" + ] + }, + "fetchedAt": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + } + }, + "required": [ + "connector", + "fetchedAt" + ], + "additionalProperties": false + } + }, + "properties": { + "Card": { + "$ref": "#/$defs/Card" + }, + "Text": { + "$ref": "#/$defs/Text" + }, + "List": { + "$ref": "#/$defs/List" + }, + "Steps": { + "$ref": "#/$defs/Steps" + }, + "Step": { + "$ref": "#/$defs/Step" + }, + "DiffSummary": { + "$ref": "#/$defs/DiffSummary" + }, + "File": { + "$ref": "#/$defs/File" + }, + "TaskStatus": { + "$ref": "#/$defs/TaskStatus" + }, + "KeyValue": { + "$ref": "#/$defs/KeyValue" + }, + "Pair": { + "$ref": "#/$defs/Pair" + }, + "Table": { + "$ref": "#/$defs/Table" + }, + "Col": { + "$ref": "#/$defs/Col" + }, + "Badge": { + "$ref": "#/$defs/Badge" + }, + "Link": { + "$ref": "#/$defs/Link" + }, + "RunTrace": { + "$ref": "#/$defs/RunTrace" + }, + "Source": { + "$ref": "#/$defs/Source" + } + } +} diff --git a/backend/config/uiCardCatalogueRevision.rev2.json b/backend/config/uiCardCatalogueRevision.rev2.json new file mode 100644 index 0000000000..f7586fa0ad --- /dev/null +++ b/backend/config/uiCardCatalogueRevision.rev2.json @@ -0,0 +1,5 @@ +{ + "catalogId": "commonly.ui", + "catalogRev": 2, + "catalogHash": "sha256:1bbf106e81f08dde540996d53b32e8ac12f661ecdc1409e7ceb2c8a26655b7a1" +} diff --git a/backend/services/uiCardValidation.ts b/backend/services/uiCardValidation.ts index 1ea75eea3d..ca3b8bb563 100644 --- a/backend/services/uiCardValidation.ts +++ b/backend/services/uiCardValidation.ts @@ -6,17 +6,20 @@ import Ajv from 'ajv'; import { createHash } from 'crypto'; import catalogue from '../config/uiCardCatalogue.json'; import revision from '../config/uiCardCatalogueRevision.json'; +import catalogueRev2 from '../config/uiCardCatalogue.rev2.json'; +import revisionRev2 from '../config/uiCardCatalogueRevision.rev2.json'; // A revision is immutable: property insertion order is OpenUI's positional ABI. +// Agents author revision 1 (the MCP snapshot serves it). Revision 2 adds only +// Source, which the server stamps; an agent-written Source is always refused. export const UI_CARD_CATALOGUE = Object.freeze({ ...revision }); -const catalogueHash = `sha256:${createHash('sha256').update(JSON.stringify(catalogue)).digest('hex')}`; -if (catalogueHash !== revision.catalogHash) throw new Error('UI catalogue revision hash mismatch'); +export const UI_CARD_SOURCE_CATALOGUE = Object.freeze({ ...revisionRev2 }); +export const UI_CARD_SOURCE_CONNECTORS: readonly string[] = Object.freeze( + [...catalogueRev2.$defs.Source.properties.connector.enum], +); export const UI_CARD_MAX_BYTES = 32 * 1024; export const UI_CARD_MAX_EXPANDED_NODES = 2048; -const schema = catalogue as LibraryJSONSchema; -const parser = createParser(schema); -const componentNames = new Set(Object.keys(schema.properties || {})); const bracketTokens = tokenize('()[]{}'); const closingTokens = new Map([ [bracketTokens[0].t, bracketTokens[1].t], @@ -24,34 +27,60 @@ const closingTokens = new Map([ [bracketTokens[4].t, bracketTokens[5].t], ]); const openParenToken = bracketTokens[0].t; -const allowedCall = (name: string) => componentNames.has(name) || ['Action', 'OpenUrl'].includes(name); -const helpers = new Set(['Step', 'File', 'Pair', 'Col']); -// lang-core validates prop types but ignores JSON Schema bounds (maxLength, -// minItems, minimum, etc.). Validate its materialized tree as well. References -// name element wrappers so helper/child identity is checked, not only prop shape. -const validateTree = new Ajv({ strict: false }).compile({ - $defs: Object.fromEntries(Object.entries(catalogue.$defs).map(([name, props]) => [name, { - type: 'object', - properties: { - type: { const: 'element' }, typeName: { const: name }, props, - partial: { type: 'boolean' }, hasDynamicProps: { type: 'boolean' }, statementId: { type: 'string' }, - }, - required: ['type', 'typeName', 'props'], - additionalProperties: false, - }])), - anyOf: Object.keys(catalogue.properties).filter((name) => !helpers.has(name)) - .map((name) => ({ $ref: `#/$defs/${name}` })), -}); +// Components that only ever appear inside a parent, never as the card root. +const notRoot = new Set(['Step', 'File', 'Pair', 'Col', 'Source']); + +type Grammar = typeof catalogue; +interface CardRevision { + pin: typeof revision; + grammar: Grammar; + parser: ReturnType; + componentNames: Set; + validateTree: ReturnType; +} +function loadRevision(grammar: Grammar, pin: typeof revision): CardRevision { + const hash = `sha256:${createHash('sha256').update(JSON.stringify(grammar)).digest('hex')}`; + if (hash !== pin.catalogHash) throw new Error(`UI catalogue revision ${pin.catalogRev} hash mismatch`); + const schema = grammar as LibraryJSONSchema; + return { + pin, + grammar, + parser: createParser(schema), + componentNames: new Set(Object.keys(schema.properties || {})), + // lang-core validates prop types but ignores JSON Schema bounds (maxLength, + // minItems, minimum, etc.). Validate its materialized tree as well. References + // name element wrappers so helper/child identity is checked, not only prop shape. + validateTree: new Ajv({ strict: false }).compile({ + $defs: Object.fromEntries(Object.entries(grammar.$defs).map(([name, props]) => [name, { + type: 'object', + properties: { + type: { const: 'element' }, typeName: { const: name }, props, + partial: { type: 'boolean' }, hasDynamicProps: { type: 'boolean' }, statementId: { type: 'string' }, + }, + required: ['type', 'typeName', 'props'], + additionalProperties: false, + }])), + anyOf: Object.keys(grammar.properties).filter((name) => !notRoot.has(name)) + .map((name) => ({ $ref: `#/$defs/${name}` })), + }), + }; +} +const revisions = [loadRevision(catalogue, revision), loadRevision(catalogueRev2 as Grammar, revisionRev2)]; +const allowedCall = (rev: CardRevision, name: string) => ( + rev.componentNames.has(name) || ['Action', 'OpenUrl'].includes(name) +); function validatePresentation(root: ElementNode): void { let buttons = 0; let primary = 0; + let sources = 0; function visit(value: unknown): void { if (Array.isArray(value)) { value.forEach(visit); return; } if (!value || typeof value !== 'object') return; const node = value as ElementNode; if (node.type !== 'element') return; const { props } = node; + if (node.typeName === 'Source') sources += 1; if (node.typeName === 'Link') { const url = new URL(props.url as string); if (url.protocol !== 'https:' || url.username || url.password) refuse('Link requires a credential-free https URL'); @@ -81,6 +110,9 @@ function validatePresentation(root: ElementNode): void { } visit(root); if (buttons > 2 || primary > 1) refuse('UI card permits at most two buttons and one primary link'); + const topLevelSources = root.typeName === 'Card' + ? (root.props.children as ElementNode[]).filter((child) => child.typeName === 'Source').length : 0; + if (sources > 1 || sources !== topLevelSources) refuse('UI card permits one Source, on the top-level Card'); } @@ -139,10 +171,19 @@ export function validateAgentCardPayload( payload: unknown, content: unknown, ): void { + validateCardPayload(messageType, payload, content, false); +} + +function validateCardPayload( + messageType: unknown, + payload: unknown, + content: unknown, + serverStamped: boolean, +): ElementNode | undefined { if (messageType !== undefined && messageType !== 'text' && messageType !== 'card') { refuse('Agent messageType must be text or card'); } - if (payload === undefined && messageType !== 'card') return; + if (payload === undefined && messageType !== 'card') return undefined; if (messageType !== 'card') refuse('UI payload requires messageType card'); if (typeof content !== 'string' || !content.trim()) refuse('UI card requires a content text fallback'); if (!payload || typeof payload !== 'object' || Array.isArray(payload)) refuse('UI card payload must be an object'); @@ -153,13 +194,23 @@ export function validateAgentCardPayload( refuse('Expected {kind:"ui",v:1,lang:"openui@1",catalogId,catalogRev,catalogHash,source}'); } if (envelope.catalogId !== revision.catalogId) refuse('Unsupported UI catalogId'); - if (envelope.catalogRev !== revision.catalogRev) refuse('Unsupported UI catalogRev'); - if (envelope.catalogHash !== revision.catalogHash) refuse('UI catalogHash does not match catalogRev'); + const rev = revisions.find((candidate) => candidate.pin.catalogRev === envelope.catalogRev); + if (!rev) refuse('Unsupported UI catalogRev'); + if (envelope.catalogHash !== rev.pin.catalogHash) refuse('UI catalogHash does not match catalogRev'); if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > UI_CARD_MAX_BYTES) { refuse('UI card payload exceeds 32 KB'); } + return validateCardSource(envelope.source as string, rev, serverStamped); +} + +function validateCardSource(source: string, rev: CardRevision, serverStamped: boolean): ElementNode { + // Source names a vendor origin. Only stampCardSource writes it, so an agent + // cannot claim a card came from Linear or Sentry, under any revision. + const callable = (name: string) => { + if (name === 'Source' && !serverStamped) refuse('UI card Source is stamped by the server, not written by agents'); + return allowedCall(rev, name); + }; try { - const source = envelope.source as string; // The lexer drops unknown characters. Reject those in the original source, // while leaving quoted prose (including Unicode and escapes) untouched. let quote = ''; @@ -190,7 +241,7 @@ export function validateAgentCardPayload( refuse('UI card contains unmatched brackets'); } if (typeof token.v === 'string' && tokens[index + 1]?.t === openParenToken - && parseExpression([token]).k !== 'Str' && !allowedCall(token.v)) { + && parseExpression([token]).k !== 'Str' && !callable(token.v)) { refuse(`UI card call is not allowed: ${token.v}`); } }); @@ -220,7 +271,7 @@ export function validateAgentCardPayload( expressions.set(statement.id, expression); walkAST(expression, (node) => { if (node.k === 'Comp') { - if (!allowedCall(node.name)) { + if (!callable(node.name)) { refuse(`UI card call is not allowed: ${node.name}`); } if (node.fn) refuse('UI card functions are not allowed'); @@ -248,16 +299,78 @@ export function validateAgentCardPayload( }); }); validateExpansionBudget(expressions); - const result = parser.parse(source); + const result = rev.parser.parse(source); if (!result.root || result.meta.incomplete || result.meta.unresolved.length || result.meta.errors.length || result.queryStatements.length || result.mutationStatements.length || Object.keys(result.stateDeclarations).length) { refuse('UI card source is incomplete or invalid for the Commonly catalogue'); } - if (!validateTree(result.root)) refuse('UI card props violate the Commonly catalogue'); + if (!rev.validateTree(result.root)) refuse('UI card props violate the Commonly catalogue'); validatePresentation(result.root); + return result.root; } catch (error) { if (error instanceof UICardValidationError) throw error; refuse('UI card source could not be parsed'); } } + +export interface UICardEnvelope { + kind: 'ui'; + v: 1; + lang: 'openui@1'; + source: string; + catalogId: string; + catalogRev: number; + catalogHash: string; +} + +/** Print a validated, static element tree back to positional OpenUI source. */ +function printNode(value: unknown, grammar: Grammar): string { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return `[${value.map((item) => printNode(item, grammar)).join(', ')}]`; + const node = value as ElementNode; + const definition = (grammar.$defs as Record }>)[node.typeName]; + if (node.type !== 'element' || !definition) refuse('UI card Source stamping met an unknown node'); + const args = Object.keys(definition.properties).map((key) => node.props[key]); + while (args.length && args[args.length - 1] === undefined) args.pop(); + return `${node.typeName}(${args.map((arg) => printNode(arg, grammar)).join(', ')})`; +} + +/** + * Server-only: append Source(connector, fetchedAt) to a card built from a + * recorded connector call and return a revision 2 envelope. The input is held + * to the agent rules first, so vendor data cannot smuggle in its own Source. + */ +export function stampCardSource( + source: string, + content: string, + stamp: { connector: string; fetchedAt: Date | string }, +): UICardEnvelope { + const root = validateCardPayload('card', { + kind: 'ui', v: 1, lang: 'openui@1', source, ...UI_CARD_CATALOGUE, + }, content, false)!; + if (!UI_CARD_SOURCE_CONNECTORS.includes(stamp.connector)) refuse('Unknown UI card Source connector'); + const fetchedAt = new Date(stamp.fetchedAt); + if (Number.isNaN(fetchedAt.getTime())) refuse('UI card Source requires a valid fetchedAt'); + if (root.typeName !== 'Card') refuse('UI card Source is stamped on a Card root'); + const children = root.props.children as unknown[]; + const rev = revisions.find((candidate) => candidate.pin.catalogRev === UI_CARD_SOURCE_CATALOGUE.catalogRev)!; + const stamped: ElementNode = { + ...root, + props: { + ...root.props, + children: [...children, { + type: 'element', typeName: 'Source', props: { connector: stamp.connector, fetchedAt: fetchedAt.toISOString() }, + }], + }, + } as ElementNode; + const envelope: UICardEnvelope = { + kind: 'ui', v: 1, lang: 'openui@1', ...UI_CARD_SOURCE_CATALOGUE, + source: `root = ${printNode(stamped, rev.grammar)}`, + }; + // The Card's own bounds (six children, 32 KB) still apply after the stamp. + validateCardPayload('card', envelope, content, true); + return envelope; +} diff --git a/frontend/scripts/sync-ui-card-catalogue.mjs b/frontend/scripts/sync-ui-card-catalogue.mjs index 6db49196b7..2a2172d12d 100644 --- a/frontend/scripts/sync-ui-card-catalogue.mjs +++ b/frontend/scripts/sync-ui-card-catalogue.mjs @@ -1,7 +1,8 @@ import { readFileSync, writeFileSync } from 'node:fs'; -// Each Docker context ships its own byte-identical positional grammar and pin. -for (const file of ['uiCardCatalogue.json', 'uiCardCatalogueRevision.json']) { +// Each Docker context ships its own byte-identical positional grammar and pin, +// one pair per revision. A stored revision is never edited, only added beside. +for (const file of ['uiCardCatalogue.json', 'uiCardCatalogueRevision.json', 'uiCardCatalogue.rev2.json', 'uiCardCatalogueRevision.rev2.json']) { const source = readFileSync(new URL(`../../backend/config/${file}`, import.meta.url)); const generated = new URL(`../src/v2/cards/${file}`, import.meta.url); if (process.argv.includes('--check')) { diff --git a/frontend/scripts/ui-card-catalogue.test.mjs b/frontend/scripts/ui-card-catalogue.test.mjs index 33f99f87e2..ca3087b1ca 100644 --- a/frontend/scripts/ui-card-catalogue.test.mjs +++ b/frontend/scripts/ui-card-catalogue.test.mjs @@ -5,35 +5,41 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; -test('frontend card grammar is byte-identical to the server grammar', () => { - const canonical = readFileSync(new URL('../../backend/config/uiCardCatalogue.json', import.meta.url)); - const generated = readFileSync(new URL('../src/v2/cards/uiCardCatalogue.json', import.meta.url)); - assert.deepEqual(generated, canonical, 'Run node frontend/scripts/sync-ui-card-catalogue.mjs'); -}); +// One grammar + pin pair per revision; revision 1 is never edited in place. +const REVISIONS = [['uiCardCatalogue.json', 'uiCardCatalogueRevision.json'], ['uiCardCatalogue.rev2.json', 'uiCardCatalogueRevision.rev2.json']]; +const FILES = REVISIONS.flat(); -test('frontend revision pin matches the server and ordered grammar hash', async () => { - const { createHash } = await import('node:crypto'); - const canonical = readFileSync(new URL('../../backend/config/uiCardCatalogueRevision.json', import.meta.url)); - const generated = readFileSync(new URL('../src/v2/cards/uiCardCatalogueRevision.json', import.meta.url)); - assert.deepEqual(generated, canonical); - const grammar = JSON.parse(readFileSync(new URL('../src/v2/cards/uiCardCatalogue.json', import.meta.url))); - assert.equal(JSON.parse(generated).catalogHash, `sha256:${createHash('sha256').update(JSON.stringify(grammar)).digest('hex')}`); -}); +for (const [grammarFile, pinFile] of REVISIONS) { + test(`frontend card grammar ${grammarFile} is byte-identical to the server grammar`, () => { + const canonical = readFileSync(new URL(`../../backend/config/${grammarFile}`, import.meta.url)); + const generated = readFileSync(new URL(`../src/v2/cards/${grammarFile}`, import.meta.url)); + assert.deepEqual(generated, canonical, 'Run node frontend/scripts/sync-ui-card-catalogue.mjs'); + }); + + test(`frontend revision pin ${pinFile} matches the server and ordered grammar hash`, async () => { + const { createHash } = await import('node:crypto'); + const canonical = readFileSync(new URL(`../../backend/config/${pinFile}`, import.meta.url)); + const generated = readFileSync(new URL(`../src/v2/cards/${pinFile}`, import.meta.url)); + assert.deepEqual(generated, canonical); + const grammar = JSON.parse(readFileSync(new URL(`../src/v2/cards/${grammarFile}`, import.meta.url))); + assert.equal(JSON.parse(generated).catalogHash, `sha256:${createHash('sha256').update(JSON.stringify(grammar)).digest('hex')}`); + }); +} -test('sync check refuses drift and write mode restores both copies', () => { +test('sync check refuses drift and write mode restores every copy', () => { const root = mkdtempSync(join(tmpdir(), 'card-catalogue-sync-')); try { for (const dir of ['frontend/scripts','frontend/src/v2/cards','backend/config']) mkdirSync(join(root,dir),{recursive:true}); const script = join(root,'frontend/scripts/sync-ui-card-catalogue.mjs'); copyFileSync(new URL('./sync-ui-card-catalogue.mjs',import.meta.url),script); - for (const file of ['uiCardCatalogue.json','uiCardCatalogueRevision.json']) { + for (const file of FILES) { copyFileSync(new URL(`../../backend/config/${file}`,import.meta.url),join(root,'backend/config',file)); writeFileSync(join(root,'frontend/src/v2/cards',file),'{}'); } const check = spawnSync(process.execPath,[script,'--check']); assert.notEqual(check.status,0); assert.match(check.stderr.toString(),/differs/); assert.equal(spawnSync(process.execPath,[script]).status,0); - for(const file of ['uiCardCatalogue.json','uiCardCatalogueRevision.json']) assert.deepEqual(readFileSync(join(root,'frontend/src/v2/cards',file)),readFileSync(join(root,'backend/config',file))); + for(const file of FILES) assert.deepEqual(readFileSync(join(root,'frontend/src/v2/cards',file)),readFileSync(join(root,'backend/config',file))); assert.equal(spawnSync(process.execPath,[script,'--check']).status,0); } finally { rmSync(root,{recursive:true,force:true}); } }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 27be8fab92..bc6664dffb 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -5274,6 +5274,22 @@ test('OpenUI cards keep the approved phone controls and table records', () => { expect(css).toContain('.ui.gone{border-style:dashed'); }); +test('the card Source line (TASK-607) uses v2 tokens only, one value per token, no dark-only colours', () => { + const css = read('../cards/uiCard.css'); + const rules = css.split('\n').filter(line => /\.ui-src/.test(line)); + expect(rules.length).toBeGreaterThanOrEqual(4); + for (const rule of rules) { + // Colour comes only from the card's v2 token aliases (--m muted, --s secondary). + expect(rule).not.toMatch(/#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(/i); + expect(rule).not.toMatch(/data-v2-theme|prefers-color-scheme/); + } + expect(css).toContain('.v2-ui-card .ui-src{display:inline-flex;align-items:center;gap:6px;margin-left:auto;min-width:0;font:500 11px/16px var(--mono);color:var(--m)}'); + expect(css).toContain('.v2-ui-card .ui-src-n{color:var(--s)}'); + expect(css).toContain('.v2-ui-card .ui-src-mk>*{display:block;width:16px;height:16px}'); + // The aliases resolve to the shared v2 tokens, which carry the theme themselves. + expect(css).toMatch(/--m:var\(--v2-text-muted\);--s:var\(--v2-text-secondary\)/); +}); + describe('v2 board on a phone (TASK-576)', () => { test('the board head wraps so the title keeps a full-width row above the actions', () => { const css = read('../v2.css'); diff --git a/frontend/src/v2/cards/V2UICard.tsx b/frontend/src/v2/cards/V2UICard.tsx index 43fa1d6204..25b0cbaa26 100644 --- a/frontend/src/v2/cards/V2UICard.tsx +++ b/frontend/src/v2/cards/V2UICard.tsx @@ -1,10 +1,9 @@ import React, { lazy, Suspense, useEffect, useState } from 'react'; import type { V2Message } from '../hooks/useV2PodDetail'; -import { parseCard } from './uiCardSource'; +import { cardRevision, parseCard } from './uiCardSource'; import { CardContext, TraceLink } from './cardViews'; import './uiCard.css'; import CardRenderBoundary from './CardRenderBoundary'; -import revision from './uiCardCatalogueRevision.json'; const Runtime = lazy(() => { // OpenUI's development entry auto-mounts an inspector by default. Commonly @@ -17,15 +16,15 @@ function CardBody({ message }: { message: V2Message }) { const [now, setNow] = useState(Date.now); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 60_000); return () => clearInterval(timer); }, []); const payload = message.payload; - const supported = payload?.catalogId === revision.catalogId && payload?.catalogRev === revision.catalogRev - && payload?.catalogHash === revision.catalogHash; - const valid = supported && payload?.v === 1 && payload?.lang === 'openui@1' && !!parseCard(payload?.source); + const rev = cardRevision(payload); + const supported = !!rev; + const valid = !!rev && payload?.v === 1 && payload?.lang === 'openui@1' && !!parseCard(payload?.source, rev); const expired = now >= new Date(message.created_at || message.createdAt || '').getTime() + 30 * 86400_000; const fallback = <>
{message.content}

{supported ? 'This card couldn’t be displayed.' : 'Unsupported card version.'}

; if (expired) return

{message.content}

Card expires after 30 days. Trace persists.

; if (!valid || failed) return
{fallback}
; return {message.content}}> - setFailed(true)} /> + setFailed(true)} /> ; } export default function V2UICard({ message, authorLabel, time }: { message: V2Message; authorLabel: string; time?: string }) { diff --git a/frontend/src/v2/cards/__tests__/V2UICard.test.tsx b/frontend/src/v2/cards/__tests__/V2UICard.test.tsx index 9584c04d4f..33502d38af 100644 --- a/frontend/src/v2/cards/__tests__/V2UICard.test.tsx +++ b/frontend/src/v2/cards/__tests__/V2UICard.test.tsx @@ -1,4 +1,5 @@ import revision from '../uiCardCatalogueRevision.json'; +import revision2 from '../uiCardCatalogueRevision.rev2.json'; import React from 'react'; import { TextEncoder } from 'util'; import { render, screen } from '@testing-library/react'; @@ -33,7 +34,7 @@ test('expires at 30 days without discarding the content or trusted trace link', expect(screen.getByRole('link', { name: 'Open trace' })).toHaveAttribute('href', '/v2/pods/111111111111111111111111/runs/012345678901234567890123'); }); -test.each([{ catalogRev: 2 }, { catalogId: 'other' }, { catalogHash: 'sha256:bad' }, { catalogRev: undefined }])('unknown catalogue tuple preserves text: %j', tuple => { +test.each([{ catalogRev: 2 }, { catalogRev: 3 }, { ...revision2, catalogRev: 3 }, { catalogId: 'other' }, { catalogHash: 'sha256:bad' }, { catalogRev: undefined }])('unknown catalogue tuple preserves text: %j', tuple => { const m = message('root = Text("Must not render")'); Object.assign(m.payload!, tuple); show(m); @@ -49,3 +50,42 @@ test('an expired forwarded card links to its server-bound source pod', () => { show(m); expect(screen.getByRole('link', { name: 'Open trace' })).toHaveAttribute('href', '/v2/pods/222222222222222222222222/runs/012345678901234567890123'); }); + +// TASK-607: catalogue revision 2 adds the server-stamped Source(connector, fetchedAt). +const stampedAt = new Date(Date.now() - 2 * 60_000).toISOString(); +const rev2 = (source: string) => { const m = message(source); Object.assign(m.payload!, revision2); return m; }; +test('revision 1 and revision 2 cards both render', async () => { + show(message('root = Card("Rev one", [Text("Still renders")])')); + expect(await screen.findByText('Still renders')).toBeInTheDocument(); + show(rev2(`root = Card("Rev two", [Text("Issues"), Source("linear", "${stampedAt}")])`)); + expect(await screen.findByText('Issues')).toBeInTheDocument(); +}); +test('a stamped Source shows the bundled mark, the connector and relative time with the absolute time as its title', async () => { + show(rev2(`root = Card("Open issues", [Text("Two need a reply"), Link("Open Linear", "https://linear.app/x", "button"), Source("linear", "${stampedAt}")])`)); + const time = await screen.findByText('fetched 2 min ago'); + expect(time.tagName).toBe('TIME'); + expect(time).toHaveAttribute('dateTime', stampedAt); + expect(time).toHaveAttribute('title', new Date(stampedAt).toLocaleString()); + expect(screen.getByText('Linear')).toBeInTheDocument(); + const source = time.closest('.ui-src')!; + expect(source.querySelector('svg.v2-cx-logo')).not.toBeNull(); + expect(source.querySelector('img, image')).toBeNull(); + // The origin sits in the footer beside the buttons, not in the body. + expect(source.closest('.ui-ft')).not.toBeNull(); + expect(source.closest('.ui-ft')!.textContent).toContain('Open Linear'); +}); +test('Source is unreadable under revision 1, so a rev 1 card can never carry an origin', () => { + show(message(`root = Card("Spoof", [Text("x"), Source("linear", "${stampedAt}")])`)); + expect(screen.getByText('This card couldn’t be displayed.')).toBeInTheDocument(); + expect(screen.queryByText('Linear')).not.toBeInTheDocument(); +}); +test.each([ + ['an unknown connector', `root = Card("x", [Text("y"), Source("github", "${stampedAt}")])`], + ['a Source at the root', `root = Source("linear", "${stampedAt}")`], + ['two Sources', `root = Card("x", [Text("y"), Source("linear", "${stampedAt}"), Source("sentry", "${stampedAt}")])`], + ['a nested Source', `root = Card("x", [Card("inner", [Text("y"), Source("linear", "${stampedAt}")])])`], + ['a fetchedAt that is not a stamp', 'root = Card("x", [Text("y"), Source("linear", "yesterday")])'], +])('the rev 2 reader refuses %s', (_label, source) => { + show(rev2(source)); + expect(screen.getByText('This card couldn’t be displayed.')).toBeInTheDocument(); +}); diff --git a/frontend/src/v2/cards/__tests__/cardViews.test.tsx b/frontend/src/v2/cards/__tests__/cardViews.test.tsx index f0554edc7c..626c5bf739 100644 --- a/frontend/src/v2/cards/__tests__/cardViews.test.tsx +++ b/frontend/src/v2/cards/__tests__/cardViews.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { act, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import { CardContext, TaskView, TraceView } from '../cardViews'; +import { CardContext, TaskView, TraceView, fetchedLabel } from '../cardViews'; const podId = '111111111111111111111111'; const runId = '012345678901234567890123'; const mockGet = jest.fn(); @@ -67,3 +67,10 @@ test('trace duration rounds across a minute boundary without 60 seconds', async expect(await screen.findByText('run · 0 calls · 1m 0s')).toBeInTheDocument(); expect(screen.queryByText(/0m 60s/)).not.toBeInTheDocument(); }); + +test.each([ + [-5000, 'fetched just now'], [59_000, 'fetched just now'], [2 * 60_000, 'fetched 2 min ago'], + [59 * 60_000, 'fetched 59 min ago'], [3 * 3600_000, 'fetched 3 h ago'], [2 * 86400_000, 'fetched 2 d ago'], +])('Source relative time for %d ms is "%s"', (ms, label) => { + expect(fetchedLabel(ms)).toBe(label); +}); diff --git a/frontend/src/v2/cards/cardRuntime.tsx b/frontend/src/v2/cards/cardRuntime.tsx index 51df238f5a..dee319ad15 100644 --- a/frontend/src/v2/cards/cardRuntime.tsx +++ b/frontend/src/v2/cards/cardRuntime.tsx @@ -2,17 +2,19 @@ import React from 'react'; import { createLibrary, defineComponent, Renderer } from '@openuidev/react-lang'; import { z } from 'zod/v4'; import type { LibraryJSONSchema } from '@openuidev/lang-core'; -import catalogue from './uiCardCatalogue.json'; -import { BadgeView, CardLink, DiffView, KeyValueView, ListView, StepsView, TableView, TaskView, TextView, TraceView } from './cardViews'; +import { BadgeView, CardLink, DiffView, KeyValueView, ListView, SourceView, StepsView, TableView, TaskView, TextView, TraceView } from './cardViews'; +import type { CardRevision } from './uiCardSource'; import type { Node } from './cardViews'; const views: Record, render: (v: unknown) => React.ReactNode) => React.ReactNode> = { Card: (p, render) => { const links = p.children.filter((n: Node) => n.typeName === 'Link' && n.props.as !== 'text'); - const body = p.children.filter((n: Node) => !links.includes(n)); + // The origin sits in the footer beside the buttons, never in the body. + const source = p.children.find((n: Node) => n.typeName === 'Source'); + const body = p.children.filter((n: Node) => !links.includes(n) && n !== source); return

{p.title}

{p.meta && {p.meta}}
{p.summary &&

{p.summary}

}
{body.map((n: Node, i: number) => {render(n)})}
- {!!links.length &&
{links.map((n: Node, i: number) => {render(n)})}
}
; + {(!!links.length || source) &&
{links.map((n: Node, i: number) => {render(n)})}{source && render(source)}
}; }, Text: (p, render) => , Badge: p => , @@ -24,18 +26,25 @@ const views: Record, render: (v: unknown) => Rea Link: p => , TaskStatus: p => , RunTrace: p => , + Source: p => , Step: () => null, File: () => null, Pair: () => null, Col: () => null, }; -const components = Object.entries(catalogue.$defs).map(([name, definition]) => defineComponent({ - name, description: name, - // The canonical JSON supplies the parser grammar, including bounds and refs. - // These prop slots preserve its wire order; no independently authored schema. - props: z.object(Object.fromEntries(Object.keys(definition.properties).map(key => [key, z.any()]))), - component: ({ props, renderNode }) => views[name](props, renderNode), -})); -const base = createLibrary({ components }); -const library = { ...base, toJSONSchema: () => catalogue as LibraryJSONSchema }; -export default function CardRuntime({ source, onError }: { source: string; onError: () => void }) { - return & { toJSONSchema: () => LibraryJSONSchema }>(); +function libraryFor(rev: CardRevision) { + if (!libraries.has(rev.catalogRev)) { + const components = Object.entries(rev.grammar.$defs).map(([name, definition]) => defineComponent({ + name, description: name, + // The canonical JSON supplies the parser grammar, including bounds and refs. + // These prop slots preserve its wire order; no independently authored schema. + props: z.object(Object.fromEntries(Object.keys(definition.properties).map(key => [key, z.any()]))), + component: ({ props, renderNode }) => views[name](props, renderNode), + })); + libraries.set(rev.catalogRev, { ...createLibrary({ components }), toJSONSchema: () => rev.grammar }); + } + return libraries.get(rev.catalogRev)!; +} +export default function CardRuntime({ source, rev, onError }: { source: string; rev: CardRevision; onError: () => void }) { + return { if (errors.some(e => e.severity !== 'warning')) onError(); }} />; } diff --git a/frontend/src/v2/cards/cardViews.tsx b/frontend/src/v2/cards/cardViews.tsx index 58c4d3381a..6e7aa02c1d 100644 --- a/frontend/src/v2/cards/cardViews.tsx +++ b/frontend/src/v2/cards/cardViews.tsx @@ -4,6 +4,7 @@ import { Link as RouterLink } from 'react-router-dom'; import { useV2Api } from '../hooks/useV2Api'; import { useSocket } from '../../context/SocketContext'; import { cardHref } from './cardLinks'; +import ConnectorLogo from '../connectors/ConnectorLogo'; export const CardContext = createContext<{ podId: string; runId: string; runPodId?: string }>({ podId: '', runId: '' }); export type Node = ElementNode; @@ -80,6 +81,24 @@ export function TaskView({ taskId, note }: { taskId: string; note?: string }) { {task?.title || (state.key === key ? 'Task unavailable' : 'Loading task…')}{taskId} {task?.status || ''}{note && ` · ${note}`}; } +const SOURCE_NAMES: Record = { linear: 'Linear', sentry: 'Sentry', atlassian: 'Atlassian' }; +/** "fetched 2 min ago"; a clock running behind the server reads as just now. */ +export function fetchedLabel(ms: number) { + const minutes = Math.floor(Math.max(0, ms) / 60_000); + if (minutes < 1) return 'fetched just now'; + if (minutes < 60) return `fetched ${minutes} min ago`; + if (minutes < 24 * 60) return `fetched ${Math.floor(minutes / 60)} h ago`; + return `fetched ${Math.floor(minutes / (24 * 60))} d ago`; +} +/** Server-stamped origin (TASK-607). The write path refuses it from agents. */ +export function SourceView({ connector, fetchedAt }: { connector: string; fetchedAt: string }) { + const [now, setNow] = useState(Date.now); + useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 60_000); return () => clearInterval(timer); }, []); + const at = new Date(fetchedAt); + return + {SOURCE_NAMES[connector] || connector} + ; +} export function TraceLink() { const { podId: messagePodId, runPodId, runId } = useContext(CardContext); const podId = runPodId ?? messagePodId; diff --git a/frontend/src/v2/cards/uiCard.css b/frontend/src/v2/cards/uiCard.css index 058a2e5baf..2f706ec7dc 100644 --- a/frontend/src/v2/cards/uiCard.css +++ b/frontend/src/v2/cards/uiCard.css @@ -11,6 +11,11 @@ .v2-ui-card .ui-sec>.ui-meta{margin:0} .v2-ui-card .ui-ft{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 14px;border-top:1px solid var(--d)} .v2-ui-card .ui-ft .ui-meta{margin-left:auto} +/* Source (rev 2, server-stamped): the bundled connector mark, then name and fetch time in v2 tokens */ +.v2-ui-card .ui-src{display:inline-flex;align-items:center;gap:6px;margin-left:auto;min-width:0;font:500 11px/16px var(--mono);color:var(--m)} +.v2-ui-card .ui-src-mk{display:block;flex:none;width:16px;height:16px} +.v2-ui-card .ui-src-mk>*{display:block;width:16px;height:16px} +.v2-ui-card .ui-src-n{color:var(--s)} /* Steps */ .v2-ui-card .ui-steps{margin:0;padding:0;list-style:none;display:grid;gap:2px} .v2-ui-card .ui-steps li{display:grid;grid-template-columns:16px minmax(0,1fr) auto;gap:8px;align-items:start;padding:3px 0} @@ -110,6 +115,7 @@ .v2-ui-card .ui .ui-ft{display:grid;gap:8px} .v2-ui-card .ui .ui-ft .ub{height:44px;justify-content:center} .v2-ui-card .ui .ui-ft .ui-meta{margin:0} +.v2-ui-card .ui .ui-ft .ui-src{margin:0} .v2-ui-card .ui .ui-h{flex-wrap:wrap;row-gap:0} .v2-ui-card .ui .ui-h .ui-meta{margin-left:0;flex-basis:100%} diff --git a/frontend/src/v2/cards/uiCardCatalogue.rev2.json b/frontend/src/v2/cards/uiCardCatalogue.rev2.json new file mode 100644 index 0000000000..f2e6787841 --- /dev/null +++ b/frontend/src/v2/cards/uiCardCatalogue.rev2.json @@ -0,0 +1,456 @@ +{ + "$defs": { + "Card": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 80 + }, + "children": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Card" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/List" + }, + { + "$ref": "#/$defs/Steps" + }, + { + "$ref": "#/$defs/DiffSummary" + }, + { + "$ref": "#/$defs/TaskStatus" + }, + { + "$ref": "#/$defs/KeyValue" + }, + { + "$ref": "#/$defs/Table" + }, + { + "$ref": "#/$defs/Badge" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/RunTrace" + }, + { + "$ref": "#/$defs/Source" + } + ] + }, + "minItems": 1, + "maxItems": 6 + }, + "meta": { + "type": "string", + "maxLength": 32 + }, + "summary": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "title", + "children" + ], + "additionalProperties": false + }, + "Text": { + "type": "object", + "properties": { + "body": { + "anyOf": [ + { + "type": "string", + "maxLength": 600 + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "maxLength": 600 + }, + { + "$ref": "#/$defs/Link" + } + ] + } + } + ] + } + }, + "required": [ + "body" + ], + "additionalProperties": false + }, + "List": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string", + "maxLength": 120 + }, + "minItems": 1, + "maxItems": 8 + }, + "ordered": { + "type": "boolean", + "default": false + } + }, + "required": [ + "items" + ], + "additionalProperties": false + }, + "Steps": { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/Step" + }, + "minItems": 2, + "maxItems": 8 + } + }, + "required": [ + "steps" + ], + "additionalProperties": false + }, + "Step": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 60 + }, + "state": { + "type": "string", + "enum": [ + "pending", + "active", + "done", + "failed", + "skipped" + ] + }, + "detail": { + "type": "string", + "maxLength": 120 + } + }, + "required": [ + "label", + "state" + ], + "additionalProperties": false + }, + "DiffSummary": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/$defs/File" + } + } + }, + "required": [ + "files" + ], + "additionalProperties": false + }, + "File": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "added": { + "type": "integer", + "minimum": 0 + }, + "removed": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "path", + "added", + "removed" + ], + "additionalProperties": false + }, + "TaskStatus": { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + }, + "KeyValue": { + "type": "object", + "properties": { + "pairs": { + "type": "array", + "items": { + "$ref": "#/$defs/Pair" + } + } + }, + "required": [ + "pairs" + ], + "additionalProperties": false + }, + "Pair": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "key", + "value" + ], + "additionalProperties": false + }, + "Table": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/$defs/Col" + }, + "minItems": 1 + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + } + }, + "required": [ + "columns", + "rows" + ], + "additionalProperties": false + }, + "Col": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "Badge": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 24 + }, + "tone": { + "type": "string", + "enum": [ + "neutral", + "ink", + "done", + "failed", + "pending" + ] + } + }, + "required": [ + "label", + "tone" + ], + "additionalProperties": false + }, + "Link": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 40 + }, + "url": { + "type": "string" + }, + "as": { + "type": "string", + "enum": [ + "text", + "button", + "primary" + ] + } + }, + "required": [ + "label", + "url", + "as" + ], + "additionalProperties": false + }, + "RunTrace": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "default": 3 + } + }, + "required": [ + "limit" + ], + "additionalProperties": false + }, + "Source": { + "type": "object", + "properties": { + "connector": { + "type": "string", + "enum": [ + "linear", + "sentry", + "atlassian" + ] + }, + "fetchedAt": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + } + }, + "required": [ + "connector", + "fetchedAt" + ], + "additionalProperties": false + } + }, + "properties": { + "Card": { + "$ref": "#/$defs/Card" + }, + "Text": { + "$ref": "#/$defs/Text" + }, + "List": { + "$ref": "#/$defs/List" + }, + "Steps": { + "$ref": "#/$defs/Steps" + }, + "Step": { + "$ref": "#/$defs/Step" + }, + "DiffSummary": { + "$ref": "#/$defs/DiffSummary" + }, + "File": { + "$ref": "#/$defs/File" + }, + "TaskStatus": { + "$ref": "#/$defs/TaskStatus" + }, + "KeyValue": { + "$ref": "#/$defs/KeyValue" + }, + "Pair": { + "$ref": "#/$defs/Pair" + }, + "Table": { + "$ref": "#/$defs/Table" + }, + "Col": { + "$ref": "#/$defs/Col" + }, + "Badge": { + "$ref": "#/$defs/Badge" + }, + "Link": { + "$ref": "#/$defs/Link" + }, + "RunTrace": { + "$ref": "#/$defs/RunTrace" + }, + "Source": { + "$ref": "#/$defs/Source" + } + } +} diff --git a/frontend/src/v2/cards/uiCardCatalogueRevision.rev2.json b/frontend/src/v2/cards/uiCardCatalogueRevision.rev2.json new file mode 100644 index 0000000000..f7586fa0ad --- /dev/null +++ b/frontend/src/v2/cards/uiCardCatalogueRevision.rev2.json @@ -0,0 +1,5 @@ +{ + "catalogId": "commonly.ui", + "catalogRev": 2, + "catalogHash": "sha256:1bbf106e81f08dde540996d53b32e8ac12f661ecdc1409e7ceb2c8a26655b7a1" +} diff --git a/frontend/src/v2/cards/uiCardSource.ts b/frontend/src/v2/cards/uiCardSource.ts index 22a0e99907..ea4d9dd04e 100644 --- a/frontend/src/v2/cards/uiCardSource.ts +++ b/frontend/src/v2/cards/uiCardSource.ts @@ -2,23 +2,49 @@ import { createParser, parseExpression, split, tokenize, walkAST } from '@openui import type { ElementNode, LibraryJSONSchema } from '@openuidev/lang-core'; import Ajv from 'ajv'; import catalogue from './uiCardCatalogue.json'; +import revision from './uiCardCatalogueRevision.json'; +import catalogueRev2 from './uiCardCatalogue.rev2.json'; +import revisionRev2 from './uiCardCatalogueRevision.rev2.json'; import { cardHref } from './cardLinks'; export { cardHref } from './cardLinks'; -const helpers = new Set(['Step', 'File', 'Pair', 'Col']); -const names = new Set(Object.keys(catalogue.properties)); -const parser = createParser(catalogue as LibraryJSONSchema); -const validate = new Ajv({ strict: false }).compile({ - $defs: Object.fromEntries(Object.entries(catalogue.$defs).map(([name, props]) => [name, { - type: 'object', properties: { type: { const: 'element' }, typeName: { const: name }, props, - partial: { type: 'boolean' }, hasDynamicProps: { type: 'boolean' }, statementId: { type: 'string' } }, - required: ['type', 'typeName', 'props'], additionalProperties: false, - }])), - anyOf: [...names].filter(name => !helpers.has(name)).map(name => ({ $ref: `#/$defs/${name}` })), -}); +const notRoot = new Set(['Step', 'File', 'Pair', 'Col', 'Source']); + +/** One reader per stored revision. Revision 2 only adds the server-stamped Source. */ +export interface CardRevision { + catalogId: string; catalogRev: number; catalogHash: string; + grammar: LibraryJSONSchema & { $defs: Record }> }; + names: Set; + parser: ReturnType; + validate: ReturnType; +} +const reader = (grammar: typeof catalogueRev2, pin: typeof revision): CardRevision => { + const names = new Set(Object.keys(grammar.properties)); + return { + ...pin, grammar: grammar as CardRevision['grammar'], names, + parser: createParser(grammar as LibraryJSONSchema), + validate: new Ajv({ strict: false }).compile({ + $defs: Object.fromEntries(Object.entries(grammar.$defs).map(([name, props]) => [name, { + type: 'object', properties: { type: { const: 'element' }, typeName: { const: name }, props, + partial: { type: 'boolean' }, hasDynamicProps: { type: 'boolean' }, statementId: { type: 'string' } }, + required: ['type', 'typeName', 'props'], additionalProperties: false, + }])), + anyOf: [...names].filter(name => !notRoot.has(name)).map(name => ({ $ref: `#/$defs/${name}` })), + }), + }; +}; +export const CARD_REVISIONS: readonly CardRevision[] = [ + reader(catalogue as typeof catalogueRev2, revision), reader(catalogueRev2, revisionRev2), +]; +/** The reader for a stored payload's exact tuple, or undefined: unknown revisions never mount. */ +export function cardRevision(payload: { catalogId?: unknown; catalogRev?: unknown; catalogHash?: unknown } | null | undefined) { + return CARD_REVISIONS.find(r => payload?.catalogId === r.catalogId && payload?.catalogRev === r.catalogRev + && payload?.catalogHash === r.catalogHash); +} /** Stored cards are complete static documents. Never mount a rejected source. */ -export function parseCard(source: unknown): ElementNode | null { +export function parseCard(source: unknown, rev: CardRevision = CARD_REVISIONS[0]): ElementNode | null { + const { names, parser, validate } = rev; if (typeof source !== 'string' || new TextEncoder().encode(source).length > 32 * 1024) return null; try { // The forgiving lexer drops characters such as # outside quoted prose. @@ -86,8 +112,9 @@ export function parseCard(source: unknown): ElementNode | null { if (!r.root || r.meta.incomplete || r.meta.errors.length || r.meta.unresolved.length || r.queryStatements.length || r.mutationStatements.length || Object.keys(r.stateDeclarations).length || !validate(r.root)) return null; - let buttons = 0; let primary = 0; + let buttons = 0; let primary = 0; let sources = 0; const inspect = (node: ElementNode) => { + if (node.typeName === 'Source') sources++; if (node.typeName === 'Link') { if (node.props.as !== 'text') buttons++; if (node.props.as === 'primary') primary++; @@ -102,6 +129,9 @@ export function parseCard(source: unknown): ElementNode | null { }; inspect(r.root); if (buttons > 2 || primary > 1) return null; + // As on the server: at most one Source, and only on the top-level Card. + const topSources = r.root.typeName === 'Card' ? (r.root.props.children as ElementNode[]).filter(n => n.typeName === 'Source').length : 0; + if (sources > 1 || sources !== topSources) return null; return r.root; } catch { return null; } } From 95ce8539b9e3fca5dcda03d51186a0ad847cc2ff Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:21:07 -0700 Subject: [PATCH 2/3] fix(cards): Source mark in tokens and a strict fetchedAt (TASK-607 review) The bundled marks' brand fills fail 3:1 on the dark card (Sentry #362D59 is 1.41:1 on #141922). The spec asks for the mark in v2 tokens, so the Source line redraws it in --v2-text-secondary with its white knockouts in --v2-bg: 7.69:1 light, 8.63:1 dark, measured in a headless browser at 1440 and 390. stampCardSource now takes a Date or the exact ISO stamp only. new Date() read a loose string such as "1" as 2001 and stamped it as valid. Adds a reprint case for negative and decimal numbers, null table cells and a defaulted RunTrace limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQmb6BbxDrAtw45bgN --- backend/__tests__/unit/services/uiCardSource.test.js | 9 ++++++++- backend/services/uiCardValidation.ts | 5 ++++- frontend/src/v2/__tests__/v2-layout-invariants.test.ts | 9 +++++++-- frontend/src/v2/cards/uiCard.css | 6 ++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/unit/services/uiCardSource.test.js b/backend/__tests__/unit/services/uiCardSource.test.js index d9c9810932..8334c672cf 100644 --- a/backend/__tests__/unit/services/uiCardSource.test.js +++ b/backend/__tests__/unit/services/uiCardSource.test.js @@ -81,10 +81,17 @@ describe('stampCardSource', () => { + 'KeyValue([Pair("Owner", "Sam \\"S\\" X"), Pair("Owner", "Sam \\"S\\" X")]), Table([Col("n", "number")], [[1], [2]]), ' + 'Source("sentry", "2026-11-01T09:30:00.000Z")], "2 issues")'); }); + it('reprints numbers, null table cells and defaulted props exactly', () => { + const source = 'root = Card("Counts", [Table([Col("n", "number"), Col("s")], [[-5, null], [1.5, "x"]]), RunTrace(), ' + + 'DiffSummary([File("a.ts", 0, 12)]), List(["one"], true)])'; + const { source: out } = stampCardSource(source, 'fallback', { connector: 'atlassian', fetchedAt }); + expect(out).toBe('root = Card("Counts", [Table([Col("n", "number"), Col("s")], [[-5, null], [1.5, "x"]]), RunTrace(3), ' + + 'DiffSummary([File("a.ts", 0, 12)]), List(["one"], true), Source("atlassian", "2026-11-01T09:30:00.000Z")])'); + }); it.each(['github', 'Linear', '', undefined])('refuses an unknown connector %p', (connector) => { expect(() => stampCardSource(issues, 'fallback', { connector, fetchedAt })).toThrow(refused(/connector/)); }); - it.each(['yesterday', '', new Date(Number.NaN), '+275760-09-13T00:00:00.000Z'])('refuses an invalid fetchedAt %p', (value) => { + it.each(['yesterday', '', '1', '2026-11-01', '2026-11-01T09:30:00Z', new Date(Number.NaN), '+275760-09-13T00:00:00.000Z'])('refuses an invalid fetchedAt %p', (value) => { expect(() => stampCardSource(issues, 'fallback', { connector: 'linear', fetchedAt: value })).toThrow(refused(/fetchedAt|catalogue/)); }); it('holds the input to the agent rules, so vendor data cannot carry its own Source', () => { diff --git a/backend/services/uiCardValidation.ts b/backend/services/uiCardValidation.ts index ca3b8bb563..6a09cf4804 100644 --- a/backend/services/uiCardValidation.ts +++ b/backend/services/uiCardValidation.ts @@ -352,7 +352,10 @@ export function stampCardSource( kind: 'ui', v: 1, lang: 'openui@1', source, ...UI_CARD_CATALOGUE, }, content, false)!; if (!UI_CARD_SOURCE_CONNECTORS.includes(stamp.connector)) refuse('Unknown UI card Source connector'); - const fetchedAt = new Date(stamp.fetchedAt); + // A Date from the server clock, or its exact ISO form: Date() would read a + // loose vendor string such as "1" as 2001 and stamp it as valid. + const fetchedAt = stamp.fetchedAt instanceof Date ? stamp.fetchedAt + : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(String(stamp.fetchedAt)) ? new Date(stamp.fetchedAt) : new Date(Number.NaN); if (Number.isNaN(fetchedAt.getTime())) refuse('UI card Source requires a valid fetchedAt'); if (root.typeName !== 'Card') refuse('UI card Source is stamped on a Card root'); const children = root.props.children as unknown[]; diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index bc6664dffb..71e265a240 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -5278,14 +5278,19 @@ test('the card Source line (TASK-607) uses v2 tokens only, one value per token, const css = read('../cards/uiCard.css'); const rules = css.split('\n').filter(line => /\.ui-src/.test(line)); expect(rules.length).toBeGreaterThanOrEqual(4); - for (const rule of rules) { - // Colour comes only from the card's v2 token aliases (--m muted, --s secondary). + for (const rule of rules.map(line => line.replace(/\[[^\]]*\]/g, ''))) { + // Colour comes only from v2 tokens (--m muted, --s secondary, --v2-bg). Attribute + // selectors name the bundled mark's own fills to replace them, so they are stripped first. expect(rule).not.toMatch(/#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(/i); expect(rule).not.toMatch(/data-v2-theme|prefers-color-scheme/); } expect(css).toContain('.v2-ui-card .ui-src{display:inline-flex;align-items:center;gap:6px;margin-left:auto;min-width:0;font:500 11px/16px var(--mono);color:var(--m)}'); expect(css).toContain('.v2-ui-card .ui-src-n{color:var(--s)}'); expect(css).toContain('.v2-ui-card .ui-src-mk>*{display:block;width:16px;height:16px}'); + // The bundled brand fills are replaced, not shown: Sentry's #362D59 is 1.4:1 on the dark card. + expect(css).toContain('.v2-ui-card .ui-src-mk svg *{fill:currentColor}'); + expect(css).toContain('.v2-ui-card .ui-src-mk svg [fill="#fff"]{fill:var(--v2-bg)}'); + expect(css).toContain('.v2-ui-card .ui-src-mk svg [stroke="#fff"]{stroke:var(--v2-bg);opacity:1}'); // The aliases resolve to the shared v2 tokens, which carry the theme themselves. expect(css).toMatch(/--m:var\(--v2-text-muted\);--s:var\(--v2-text-secondary\)/); }); diff --git a/frontend/src/v2/cards/uiCard.css b/frontend/src/v2/cards/uiCard.css index 2f706ec7dc..bc2ef2f7db 100644 --- a/frontend/src/v2/cards/uiCard.css +++ b/frontend/src/v2/cards/uiCard.css @@ -16,6 +16,12 @@ .v2-ui-card .ui-src-mk{display:block;flex:none;width:16px;height:16px} .v2-ui-card .ui-src-mk>*{display:block;width:16px;height:16px} .v2-ui-card .ui-src-n{color:var(--s)} +/* The bundled mark is redrawn in tokens: one colour (--s) with its white knockouts in --v2-bg, so it holds 3:1 in both themes. */ +.v2-ui-card .ui-src-mk{color:var(--s)} +.v2-ui-card .ui-src-mk svg *{fill:currentColor} +.v2-ui-card .ui-src-mk svg [fill="none"]{fill:none} +.v2-ui-card .ui-src-mk svg [fill="#fff"]{fill:var(--v2-bg)} +.v2-ui-card .ui-src-mk svg [stroke="#fff"]{stroke:var(--v2-bg);opacity:1} /* Steps */ .v2-ui-card .ui-steps{margin:0;padding:0;list-style:none;display:grid;gap:2px} .v2-ui-card .ui-steps li{display:grid;grid-template-columns:16px minmax(0,1fr) auto;gap:8px;align-items:start;padding:3px 0} From 40a5cc79b48f23cb5b1af3cfceda8b54cc21af11 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:31:15 -0700 Subject: [PATCH 3/3] fix(cards): refuse an impossible fetchedAt instead of rolling it over A string fetchedAt now has to survive the ISO round trip unchanged, so 2026-02-30 or T24:00 is refused rather than stamped as 03-02 or the next day. Found by CodeRabbit on #3196. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQmb6BbxDrAtw45bgN --- .../__tests__/unit/services/uiCardSource.test.js | 2 +- backend/services/uiCardValidation.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/backend/__tests__/unit/services/uiCardSource.test.js b/backend/__tests__/unit/services/uiCardSource.test.js index 8334c672cf..414f551780 100644 --- a/backend/__tests__/unit/services/uiCardSource.test.js +++ b/backend/__tests__/unit/services/uiCardSource.test.js @@ -91,7 +91,7 @@ describe('stampCardSource', () => { it.each(['github', 'Linear', '', undefined])('refuses an unknown connector %p', (connector) => { expect(() => stampCardSource(issues, 'fallback', { connector, fetchedAt })).toThrow(refused(/connector/)); }); - it.each(['yesterday', '', '1', '2026-11-01', '2026-11-01T09:30:00Z', new Date(Number.NaN), '+275760-09-13T00:00:00.000Z'])('refuses an invalid fetchedAt %p', (value) => { + it.each(['yesterday', '', '1', '2026-11-01', '2026-11-01T09:30:00Z', '2026-02-30T09:30:00.000Z', '2026-11-01T24:00:00.000Z', 1761989400000, new Date(Number.NaN), '+275760-09-13T00:00:00.000Z'])('refuses an invalid fetchedAt %p', (value) => { expect(() => stampCardSource(issues, 'fallback', { connector: 'linear', fetchedAt: value })).toThrow(refused(/fetchedAt|catalogue/)); }); it('holds the input to the agent rules, so vendor data cannot carry its own Source', () => { diff --git a/backend/services/uiCardValidation.ts b/backend/services/uiCardValidation.ts index 6a09cf4804..f00bfacfa2 100644 --- a/backend/services/uiCardValidation.ts +++ b/backend/services/uiCardValidation.ts @@ -352,11 +352,14 @@ export function stampCardSource( kind: 'ui', v: 1, lang: 'openui@1', source, ...UI_CARD_CATALOGUE, }, content, false)!; if (!UI_CARD_SOURCE_CONNECTORS.includes(stamp.connector)) refuse('Unknown UI card Source connector'); - // A Date from the server clock, or its exact ISO form: Date() would read a - // loose vendor string such as "1" as 2001 and stamp it as valid. - const fetchedAt = stamp.fetchedAt instanceof Date ? stamp.fetchedAt - : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(String(stamp.fetchedAt)) ? new Date(stamp.fetchedAt) : new Date(Number.NaN); - if (Number.isNaN(fetchedAt.getTime())) refuse('UI card Source requires a valid fetchedAt'); + // A Date from the server clock, or its exact ISO form. Date() reads a loose + // string such as "1" as 2001 and rolls 02-30 over to 03-02, so a string must + // survive the round trip unchanged. + const raw = stamp.fetchedAt; + const fetchedAt = raw instanceof Date ? raw : new Date(typeof raw === 'string' ? raw : Number.NaN); + if (Number.isNaN(fetchedAt.getTime()) || (!(raw instanceof Date) && fetchedAt.toISOString() !== raw)) { + refuse('UI card Source requires a valid fetchedAt'); + } if (root.typeName !== 'Card') refuse('UI card Source is stamped on a Card root'); const children = root.props.children as unknown[]; const rev = revisions.find((candidate) => candidate.pin.catalogRev === UI_CARD_SOURCE_CATALOGUE.catalogRev)!;