From d4331222ac05e8dc1903fe84ae3bd08b16b22e3b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:09:29 +0800 Subject: [PATCH] fix(showcase): seed all five projects through the FSM, and surface seed outcomes in the boot banner (#3415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project seed wrote terminal statuses directly on insert, but project_status_flow gates inserts to initialStates: ['planned'] — so 4/5 projects, 9/10 master-detail tasks and 3/3 memberships were rejected on every boot, silently: SeedLoader's logs sit under the default warn level AND inside serve's boot-quiet stdout window. Fixture: seed projects in three phases. Phase 1 inserts all five as 'planned' (explicitly — seed inserts do not apply select defaults) with mode 'ignore' so replays leave walked rows untouched; phases 2-3 then walk them along legal transitions (planned→active→on_hold/completed), doubling as a live FSM demo. A new 'completed → active' reopen transition (a real PM affordance — cancelled could already be revived) keeps the walk legal on replay. Fresh boot: 5 projects across four statuses, 10 tasks, 3 memberships, zero rejections; replay preserves walked states. Surfacing: AppPlugin stashes per-boot seed counters on the kernel ('seed-summary'); the serve banner prints 'Seeds: X inserted · Y updated · Z skipped', escalating to a yellow '⚠ … N REJECTED' line when rows dropped — before the fix that line read '114 inserted · 30 REJECTED', after it reads '130 inserted · 6 updated'. Gates: seed.test.ts now statically replays every FSM-gated object's seed phases (two rounds — fresh boot AND replay) against initialStates and transitions; format.seed-summary.test.ts covers the banner contract. Both proven red against the old fixture / a dead-end terminal state. Known pre-existing follow-up (#3434): mode:'insert' datasets (memberships) duplicate on every replay boot — masked until now because the rows never inserted at all. Closes #3415 Co-Authored-By: Claude Fable 5 --- .changeset/seed-summary-banner.md | 6 ++ .../src/data/objects/project.object.ts | 6 +- examples/app-showcase/src/data/seed/index.ts | 50 ++++++++++++++-- examples/app-showcase/test/seed.test.ts | 60 +++++++++++++++++++ packages/cli/src/commands/serve.ts | 12 ++++ .../cli/src/utils/format.seed-summary.test.ts | 52 ++++++++++++++++ packages/cli/src/utils/format.ts | 40 +++++++++++++ packages/runtime/src/app-plugin.ts | 20 +++++++ 8 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 .changeset/seed-summary-banner.md create mode 100644 packages/cli/src/utils/format.seed-summary.test.ts diff --git a/.changeset/seed-summary-banner.md b/.changeset/seed-summary-banner.md new file mode 100644 index 0000000000..2e3bb05830 --- /dev/null +++ b/.changeset/seed-summary-banner.md @@ -0,0 +1,6 @@ +--- +'@objectstack/runtime': patch +'@objectstack/cli': patch +--- + +Surface seed outcomes in the `os dev` / `os serve` boot banner (#3415). Seeds run inside the boot-quiet stdout window and SeedLoader's logs sit under the default warn level, so a fixture could silently lose most of its rows — the showcase shipped 1 of 5 projects with zero terminal signal. AppPlugin now stashes the per-boot seed counters on the kernel (`seed-summary` service) and the banner prints `Seeds: X inserted · Y updated · Z skipped`, escalating to a yellow `⚠ … N REJECTED` line when records were dropped. diff --git a/examples/app-showcase/src/data/objects/project.object.ts b/examples/app-showcase/src/data/objects/project.object.ts index 323472639c..0b7beb1569 100644 --- a/examples/app-showcase/src/data/objects/project.object.ts +++ b/examples/app-showcase/src/data/objects/project.object.ts @@ -135,7 +135,11 @@ export const Project = ObjectSchema.create({ planned: ['active', 'cancelled'], active: ['on_hold', 'completed', 'cancelled'], on_hold: ['active', 'cancelled'], - completed: [], + // `completed → active` is reopen — a real PM affordance (cancelled can + // already be revived via `planned`). It also keeps the seed's FSM walk + // (#3415) replayable: the walk re-runs on every boot, and a dead-end + // terminal state would reject the hop back through `active`. + completed: ['active'], cancelled: ['planned'], }, }, diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 84e7c06fb4..e49d66ae7c 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -100,15 +100,53 @@ const contacts = defineSeed(Contact, { ], }); +/** + * Projects seed in THREE phases because `project_status_flow` (#3165) gates + * inserts to `initialStates: ['planned']` and seeds deliberately run + * validation. Writing the target status directly rejected 4 of 5 projects on + * every boot — and their master-detail tasks/memberships with them (#3415). + * + * Phase 1 inserts every project as `planned` — explicitly, because seed + * inserts do NOT apply select defaults (see the Accounts note above) — using + * `mode: 'ignore'` so replays leave already-walked rows completely untouched. + * Phases 2-3 then walk the records along LEGAL transitions, doubling as a + * live demo of the state machine the seed used to violate: + * planned → active (phase 2) + * active → on_hold / completed (phase 3) + * Same-object datasets run in declaration order (stable topological sort). + * On replay, phase 1 skips wholesale (ignore), single-hop rows no-op skip, + * and the two 2-hop rows re-walk `active → terminal` — legal on both edges + * thanks to the `completed → active` reopen transition. Zero rejections. + */ const projects = defineSeed(Project, { - mode: 'upsert', + mode: 'ignore', externalId: 'name', records: [ - { name: 'Website Relaunch', account: 'Northwind', status: 'active', health: 'green', budget: 150_000, spent: 60_000, owner: 'ada@example.com', start_date: cel`daysAgo(30)`, end_date: cel`daysFromNow(60)` }, - { name: 'Data Platform', account: 'Contoso', status: 'active', health: 'yellow', budget: 600_000, spent: 420_000, owner: 'linus@example.com', start_date: cel`daysAgo(90)`, end_date: cel`daysFromNow(120)` }, - { name: 'Compliance Audit', account: 'Fabrikam', status: 'on_hold', health: 'red', budget: 90_000, spent: 88_000, owner: 'grace@example.com', start_date: cel`daysAgo(15)`, end_date: cel`daysFromNow(30)` }, + { name: 'Website Relaunch', account: 'Northwind', status: 'planned', health: 'green', budget: 150_000, spent: 60_000, owner: 'ada@example.com', start_date: cel`daysAgo(30)`, end_date: cel`daysFromNow(60)` }, + { name: 'Data Platform', account: 'Contoso', status: 'planned', health: 'yellow', budget: 600_000, spent: 420_000, owner: 'linus@example.com', start_date: cel`daysAgo(90)`, end_date: cel`daysFromNow(120)` }, + { name: 'Compliance Audit', account: 'Fabrikam', status: 'planned', health: 'red', budget: 90_000, spent: 88_000, owner: 'grace@example.com', start_date: cel`daysAgo(15)`, end_date: cel`daysFromNow(30)` }, { name: 'Mobile App', account: 'Contoso', status: 'planned', health: 'green', budget: 200_000, spent: 0, owner: 'ada@example.com', start_date: cel`daysFromNow(14)`, end_date: cel`daysFromNow(140)` }, - { name: 'Legacy Sunset', account: 'Northwind', status: 'completed', health: 'green', budget: 50_000, spent: 48_000, owner: 'linus@example.com', start_date: cel`daysAgo(180)`, end_date: cel`daysAgo(20)` }, + { name: 'Legacy Sunset', account: 'Northwind', status: 'planned', health: 'green', budget: 50_000, spent: 48_000, owner: 'linus@example.com', start_date: cel`daysAgo(180)`, end_date: cel`daysAgo(20)` }, + ], +}); + +const projectsActivate = defineSeed(Project, { + mode: 'upsert', + externalId: 'name', + records: [ + { name: 'Website Relaunch', status: 'active' }, + { name: 'Data Platform', status: 'active' }, + { name: 'Compliance Audit', status: 'active' }, + { name: 'Legacy Sunset', status: 'active' }, + ], +}); + +const projectsSettle = defineSeed(Project, { + mode: 'upsert', + externalId: 'name', + records: [ + { name: 'Compliance Audit', status: 'on_hold' }, + { name: 'Legacy Sunset', status: 'completed' }, ], }); @@ -395,4 +433,4 @@ const announcements = defineSeed(Announcement, { ], }); -export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements]; +export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, projectsActivate, projectsSettle, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements]; diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts index 6e08679cfd..56d478bd48 100644 --- a/examples/app-showcase/test/seed.test.ts +++ b/examples/app-showcase/test/seed.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import stack from '../objectstack.config.js'; +import { ShowcaseSeedData } from '../src/data/seed/index.js'; /** * Smoke test — the stack loads and registers the expected breadth of @@ -33,3 +34,62 @@ describe('showcase stack', () => { expect((stack.agents ?? []).length).toBe(0); // AI agents are an enterprise (service-ai) feature; the open showcase ships none }); }); + +/** + * Static shadow of what SeedLoader + validation do at boot (#3415): for every + * object whose state_machine gates INSERT (`initialStates`), replay the seed + * datasets in declaration order and assert each record enters through a legal + * initial state and only moves along declared transitions. The fixture that + * silently lost 4/5 projects (target status written directly on insert) can + * never come back green. + */ +describe('seed data vs state machines (#3415)', () => { + const gated = (stack.objects ?? []).flatMap((o: any) => + (o.validations ?? []) + .filter( + (v: any) => + v.type === 'state_machine' && + (v.events ?? []).includes('insert') && + Array.isArray(v.initialStates) && + v.severity !== 'warning', + ) + .map((v: any) => ({ object: o, rule: v })), + ); + + it('covers the project status flow (the #3415 gate)', () => { + expect(gated.map((g: any) => `${g.object.name}.${g.rule.field}`)).toContain('showcase_project.status'); + }); + + for (const { object, rule } of gated) { + it(`${object.name}: seeded '${rule.field}' respects initialStates and transitions — including on replay`, () => { + const datasets = ShowcaseSeedData.filter((d: any) => d.object === object.name); + expect(datasets.length).toBeGreaterThan(0); + const current = new Map(); + // Round 1 = fresh boot; round 2 = replay against the walked state. + // Replay must also be violation-free (#3415 follow-up): `ignore` + // datasets skip existing rows wholesale, and re-walked hops must be + // legal transitions (which is what the reopen edge guarantees). + for (const round of [1, 2]) { + for (const ds of datasets as any[]) { + for (const rec of ds.records as any[]) { + const key = String(rec[ds.externalId ?? 'name']); + const next = rec[rule.field]; + if (!current.has(key)) { + // First appearance = INSERT. Seed inserts do NOT apply select + // defaults, so a gated field must be explicit AND legal. + expect(next, `'${key}' (round ${round}) must seed '${rule.field}' explicitly`).toBeDefined(); + expect(rule.initialStates, `'${key}' enters as '${next}'`).toContain(next); + current.set(key, next); + } else { + if (ds.mode === 'ignore') continue; // existing rows untouched + if (next === undefined || next === current.get(key)) continue; // no-op replay skips + const from = current.get(key)!; + expect(rule.transitions?.[from] ?? [], `'${key}' (round ${round}) ${from} → ${next}`).toContain(next); + current.set(key, next); + } + } + } + } + }); + } +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 0eb806e854..d10b8a6d9a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -2427,6 +2427,17 @@ export default class Serve extends Command { Array.isArray((config as any)?.flows) ? (config as any).flows.length : 0, ); + // ── Seed outcome summary (#3415) ─────────────────────────────── + // Seeds run inside the boot-quiet window too, and SeedLoader's own + // logs sit under the default warn level — a fixture could lose 90% + // of its rows with zero terminal signal. AppPlugin stashes the + // counters on the kernel; print them here, loudly when rows dropped. + let seedSummary: { inserted: number; updated: number; skipped: number; rejected: number } | undefined; + try { + const s: any = kernel.getService?.('seed-summary'); + if (s && typeof s.inserted === 'number') seedSummary = s; + } catch { /* no seeds ran — nothing to show */ } + // ── Clean startup summary ────────────────────────────────────── printServerReady({ port, @@ -2441,6 +2452,7 @@ export default class Serve extends Command { multiTenant: resolveMultiOrgEnabled(), seededAdmin, automation: automationSummary, + seeds: seedSummary, // #3167 — surface the default-on MCP endpoint in the dev loop, where an // AI client can connect to operate the running app. Same decision point // that auto-loads the plugin + gates the route, so the banner never diff --git a/packages/cli/src/utils/format.seed-summary.test.ts b/packages/cli/src/utils/format.seed-summary.test.ts new file mode 100644 index 0000000000..88281233c9 --- /dev/null +++ b/packages/cli/src/utils/format.seed-summary.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { printServerReady, type ServerReadyOptions } from './format.js'; + +/** + * #3415 — the boot banner is the ONE place a developer reliably sees seed + * outcomes (SeedLoader's own logs are level-filtered and swallowed by the + * serve boot-quiet window). Assert the Seeds line prints, screams on + * rejections, and stays silent when nothing was seeded. + */ +describe('printServerReady seed summary (#3415)', () => { + const base: ServerReadyOptions = { + port: 3000, + configFile: 'objectstack.config.ts', + isDev: true, + pluginCount: 1, + }; + let lines: string[]; + let spy: ReturnType; + + beforeEach(() => { + lines = []; + spy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + lines.push(args.join(' ')); + }); + }); + afterEach(() => spy.mockRestore()); + + const seedLines = () => lines.filter((l) => l.includes('Seeds:')); + + it('prints a quiet one-liner for a clean seed', () => { + printServerReady({ ...base, seeds: { inserted: 42, updated: 6, skipped: 3, rejected: 0 } }); + expect(seedLines()).toHaveLength(1); + expect(seedLines()[0]).toContain('42 inserted'); + expect(seedLines()[0]).toContain('6 updated'); + expect(seedLines()[0]).not.toContain('REJECTED'); + }); + + it('screams when records were rejected', () => { + printServerReady({ ...base, seeds: { inserted: 24, updated: 0, skipped: 0, rejected: 14 } }); + expect(seedLines()).toHaveLength(1); + expect(seedLines()[0]).toContain('14 REJECTED'); + expect(seedLines()[0]).toContain('OS_LOG_LEVEL=info'); + }); + + it('stays silent when no summary was collected or nothing ran', () => { + printServerReady({ ...base }); + printServerReady({ ...base, seeds: { inserted: 0, updated: 0, skipped: 0, rejected: 0 } }); + expect(seedLines()).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index c261b68860..fb71d1bec7 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -203,6 +203,14 @@ export interface ServerReadyOptions { * armed. Collected from the live engine after runtime.start(). */ automation?: AutomationReadySummary; + /** + * Seed outcome for this boot (#3415). Seeds run inside the boot-quiet + * stdout window and SeedLoader's own logs sit under the default warn + * level, so without this line a fixture can silently lose most of its + * rows (the showcase shipped 1 of 5 projects for weeks). Rejections are + * loud; a clean seed prints one dim line. + */ + seeds?: SeedReadySummary; /** * Whether the MCP server surface (`/api/v1/mcp`) is on (#3167). Default-on * core capability, but nothing in the dev loop surfaces it — an AI client @@ -213,6 +221,14 @@ export interface ServerReadyOptions { mcpEnabled?: boolean; } +export interface SeedReadySummary { + inserted: number; + updated: number; + skipped: number; + /** Records dropped by validation/reference errors — the silent-loss case. */ + rejected: number; +} + export interface AutomationReadySummary { /** Whether the automation service is registered at all. */ enabled: boolean; @@ -268,6 +284,7 @@ export function printServerReady(opts: ServerReadyOptions) { console.log(chalk.dim(` ${opts.pluginNames.join(', ')}`)); } if (opts.automation) printAutomationSummary(opts.automation); + if (opts.seeds) printSeedSummary(opts.seeds); console.log(''); console.log(chalk.dim(' Press Ctrl+C to stop')); console.log(''); @@ -312,6 +329,29 @@ function printAutomationSummary(a: AutomationReadySummary) { } } +/** + * One-glance answer to "did my seed rows actually land?" (#3415). Follows + * printAutomationSummary's contract: quiet when everything is fine, yellow + * with a count when rows were dropped — a fixture contradiction (e.g. seed + * status vs a state_machine's initialStates) must not pass silently again. + */ +function printSeedSummary(s: SeedReadySummary) { + const total = s.inserted + s.updated + s.skipped + s.rejected; + if (total === 0) return; + const parts = [`${s.inserted} inserted`]; + if (s.updated > 0) parts.push(`${s.updated} updated`); + if (s.skipped > 0) parts.push(`${s.skipped} skipped`); + if (s.rejected > 0) { + console.log( + chalk.yellow( + ` ⚠ Seeds: ${parts.join(' · ')} · ${s.rejected} REJECTED — run with OS_LOG_LEVEL=info to see each reason`, + ), + ); + return; + } + console.log(chalk.dim(` Seeds: ${parts.join(' · ')}`)); +} + export function printMetadataStats(stats: MetadataStats) { const sections: Array<{ label: string; items: Array<[string, number]> }> = [ { diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index cb192f1122..c8be1caded 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -842,6 +842,26 @@ export class AppPlugin implements Plugin { }); const result = await seedLoader.load(request); const { totalInserted, totalUpdated, totalSkipped, totalErrored } = result.summary; + // #3415: stash the outcome on the kernel so the CLI boot + // banner can print a Seeds line. The logs below never + // reach `os dev` output — info is under the default warn + // level, and the serve boot-quiet window swallows stdout + // — so without this a fixture can lose most of its rows + // with no signal at all. Accumulates across apps. + try { + const kernelRef: any = (ctx as any).kernel; + const prev = (() => { + try { return kernelRef?.getService?.('seed-summary') as any; } catch { return undefined; } + })(); + const summary = { + inserted: (prev?.inserted ?? 0) + totalInserted, + updated: (prev?.updated ?? 0) + totalUpdated, + skipped: (prev?.skipped ?? 0) + totalSkipped, + rejected: (prev?.rejected ?? 0) + totalErrored, + }; + if (kernelRef?.registerService) kernelRef.registerService('seed-summary', summary); + else if (typeof (ctx as any).registerService === 'function') (ctx as any).registerService('seed-summary', summary); + } catch { /* banner summary is best-effort */ } if (result.success) { ctx.logger.info('[Seeder] Seed loading complete', { inserted: totalInserted,