From 7dd12ec6e59a5ab75cb3e1d86eca95db59b3e6e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 17:36:24 +0000 Subject: [PATCH 1/3] feat(cli): announce seed settlement on serve's ipc channel and forward it from dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `✓ Server is ready` is true about the HTTP server and silent about the app. Past the inline seed budget the rest of the seed runs detached, so the banner — and any parent that waits for it — can be eighty seconds ahead of a hundred-line error wall, and nothing a parent can observe distinguishes that boot from one that seeded cleanly. `serve` now sends a second ipc message, `objectstack:seed-settled`, beside `objectstack:listening`, latched so it can never precede it; `os dev` relays it verbatim to its own parent when one holds the channel. The producer is not new: the runtime already publishes the tally under the spec's `seed-settlement` contract, and this is the hop outward. Keyed on `inFlight === 0`, not `pending === 0`: multi-tenant replay and `skipSeedData` keep `pending` above zero for the life of the boot, so a `pending`-keyed message would never be sent there and its absence would be indistinguishable from a boot still writing. Those boots get the message with `suppressed` reasons instead. The banner's over-budget path now says `Seeds: pending` and that seeding continues in the background, so the transcript is no longer byte-identical to a boot that declared no seeds. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- packages/cli/src/commands/dev.ts | 48 +++++ packages/cli/src/commands/serve.ts | 300 +++++++++++++++++++++++++++++ packages/cli/src/utils/format.ts | 84 ++++++++ 3 files changed, 432 insertions(+) diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 0d9b5f2921..d1c781285f 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -131,6 +131,50 @@ export function printMcpConnectHint( console.log(chalk.dim(' Disable OS_MCP_SERVER_ENABLED=false')); } +/** + * The hop outward: relay the `serve` child's `objectstack:seed-settled` + * announcement to `os dev`'s OWN parent (#17329). + * + * ## Why the hop exists at all + * + * `os dev` is a spawner. It runs `serve --dev` over + * `stdio: ['inherit','inherit','inherit','ipc']`, so the child's settle + * announcement lands HERE and stops — while the consumer that needs it (a demo + * script, a test harness, anything that spawns `os dev` and wants to print one + * line after the boot) holds a channel to `os dev`, not to a grandchild process + * it did not start and cannot name. One hop is the whole of the missing piece: + * the producer already exists, and the child already announces. + * + * ## Relayed verbatim, deliberately + * + * ⛔ Nothing here re-derives, re-summarises or re-grades the message. The child + * read the settlement tally off the kernel that did the seeding; this process + * has no kernel and could only guess. Passing the object through means `os + * dev`'s parent and the `serve` child can never be made to say two different + * things about one boot — the same rule the `MCP:` row above follows for the + * origin, and for the same reason. + * + * ## An IPC channel stays OPTIONAL for this command + * + * ⛔ A parent that holds no channel must be unaffected, and is: `process.send` + * is `undefined` under an ordinary terminal `os dev`, so this returns having + * done nothing, printed nothing, and changed no byte of that transcript. The + * `serve` child's own `announceListening` is best-effort for exactly this + * reason and this is its mirror — ⛔ this message does not make an IPC channel + * a requirement of running a published command. + * + * @returns `true` when the message was a settle announcement (handled here, and + * the caller should stop) — `false` for every other message, which the + * caller's own branches still own. + */ +export function forwardSeedSettledToParent(msg: unknown): boolean { + if ((msg as { type?: unknown } | null | undefined)?.type !== 'objectstack:seed-settled') return false; + try { + if (typeof process.send === 'function') process.send(msg); + } catch { /* the parent's channel closed — best-effort, exactly like the child's */ } + return true; +} + export default class Dev extends Command { static override description = 'Start development mode — watch sources, rebuild the artifact, and restart the server on change'; @@ -566,6 +610,10 @@ export default class Dev extends Command { // its HTTP server is up. We surface it so the printed URL is correct // even when the port was auto-shifted (e.g. 3000 busy → 3001). child.on('message', (msg: any) => { + // #17329 — the hop outward. Handled first and exclusively: a settle + // announcement carries no port and has nothing to do with the block + // below. See {@link forwardSeedSettledToParent}. + if (forwardSeedSettledToParent(msg)) return; if (msg?.type === 'objectstack:listening' && msg.port) { const actual = String(msg.port); if (actual !== requestedPort) { diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 8afd9f401e..8fa02a659e 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -28,6 +28,19 @@ import { PLATFORM_CAPABILITY_TOKENS, PLATFORM_ALWAYS_ON_CAPABILITIES } from '@ob // boot gate's fix list enumerates the accepted values, and a second literal // list would be free to drift the day a posture is added. import { TENANCY_POSTURES, type TenancyPosture } from '@objectstack/spec/security'; +// #17329 — the PUBLISHED settlement contract, READ rather than re-derived, so +// the `objectstack:seed-settled` announcement below asks the one component that +// knows whether this boot's seeding has come to rest. The service is registered +// by the runtime this command itself boots (`@objectstack/runtime`'s +// `declareSeedSource`); `packages/cli` never registers or mutates it — the +// contract is deliberately read-only, because a consumer that could edit the +// tally could certify itself. +import { + SEED_SETTLEMENT_SERVICE, + type ISeedSettlementService, + type SeedSettlementSnapshot, + type SeedSuppressionReason, +} from '@objectstack/spec/contracts'; import { missingProviderMessage } from '../utils/capability-preflight.js'; // The mail provider vocabulary, read from the package that materialises the // transports rather than restated here (#5132) — `resolveEmailCapabilityArg` @@ -497,6 +510,192 @@ export interface ListeningMessage { url: string; } +/** + * The `objectstack:seed-settled` IPC message — "this boot's seeding has come to + * rest" (#17329). + * + * ## The defect this closes + * + * `✓ Server is ready` is true about the HTTP SERVER and says nothing about the + * APP. `AppPlugin` races its inline seed against a soft budget + * (`OS_INLINE_SEED_BUDGET_MS`, default 8s); over budget it hands the rest to a + * detached promise and the kernel starts anyway. So the banner can print, the + * parent can proceed, and eighty seconds later the continuation emits its error + * wall — and a parent that merely spawned the child has no readable signal that + * distinguishes that boot from one that seeded cleanly before the banner. Which + * side wins is decided by whether the seed fits its budget on a contended box, + * so the same command on the same corpus disagrees between two containers. + * + * Every signal that WOULD distinguish them — the over-budget warning, the error + * wall, the completion summary — arrives on the child's inherited stdio. Reading + * it costs the boot its TTY. This message is the same fact on the channel the + * parent already holds. + * + * ## When it is sent, and the one guarantee that makes it waitable + * + * Exactly once per boot, and never before {@link ListeningMessage}: seeding that + * settles DURING `runtime.start()` (the ordinary in-budget boot) is latched and + * released after {@link publishBoundPort} has driven its three channels, so a + * parent that waits for `objectstack:listening` first can never miss this one by + * being late. See {@link createSeedSettlementAnnouncer}. + * + * ## ⭐ `suppressed` is what keeps a waiting parent from hanging forever + * + * Two deployment shapes register a seed source and then deliberately never run + * it, so `app:seeded` never fires and the contract's `pending` stays above zero + * for the life of the process: **multi-tenant** (seeds replay per organization + * on `sys_organization` insert) and **`skipSeedData`** (an `os migrate` planning + * boot that must not write). A message keyed on `pending === 0` would never be + * sent in either, and its absence would be indistinguishable from a boot still + * writing — the exact ambiguity this card exists to end, one level up. + * + * So the predicate is {@link SeedSettlementSnapshot.inFlight}, not `pending`: + * *nothing is still writing*. Both modes reach `inFlight === 0` inside Phase 2 + * `start()` (`suppress()` decrements the in-flight tally and records the reason), + * so the message is sent on those boots too — carrying the reasons, so a + * consumer can say **why** no rows landed instead of waiting for rows that were + * never coming. An empty `suppressed` with an empty `sources` is the third + * honest answer: this kernel has no seed pipeline at all. + */ +export interface SeedSettledMessage { + type: 'objectstack:seed-settled'; + /** + * Whether every source that REPORTED an outcome reported a clean one — no + * rejected records, no dropped references, no install that came up empty. + * + * ⛔ Read it against {@link sources}, never alone: it is a verdict on what the + * seed summary contains, and a source that finished by throwing may record no + * outcome at all. `true` over an empty {@link sources} means "nothing reported + * a problem", which on a suppressed boot is simply "nothing ran". + */ + ok: boolean; + /** + * One entry per source this boot deliberately did not run. Non-empty means + * rows these datasets describe were never written by this process, and no + * later signal is coming for them. + */ + suppressed: readonly SeedSuppressionReason[]; + /** Per-source counts — the same array the banner's `Seeds:` row renders. */ + sources: SeedSourceSummary[]; +} + +/** + * Read the published settlement tally off the kernel this command booted. + * + * `undefined` means no seed pipeline registered on this kernel — a FACT by + * `kernel:ready`, since every source is declared in Phase 2 `start()`, and + * therefore an answer ("nothing to wait for") rather than a not-yet. ⚠️ + * `getService` THROWS on an unregistered name rather than returning undefined, + * hence the `try`. + */ +export function readSeedSettlement( + kernel: { getService?: (name: string) => unknown } | undefined, +): SeedSettlementSnapshot | undefined { + try { + const svc = kernel?.getService?.(SEED_SETTLEMENT_SERVICE) as ISeedSettlementService | undefined; + if (!svc || typeof svc.snapshot !== 'function') return undefined; + const snapshot = svc.snapshot(); + // A tally that does not answer the one question is the same as no tally. + if (typeof snapshot?.inFlight !== 'number') return undefined; + return snapshot; + } catch { + return undefined; + } +} + +/** + * Has this boot's seeding come to rest? + * + * ⛔ NOT `pending === 0`. `pending` counts suppressed sources forever by design + * (see {@link SeedSettledMessage}), so keying on it would hang every + * multi-tenant and `skipSeedData` boot. The question a parent is actually asking + * is "is anything still writing", and that is `inFlight`. + * + * An absent snapshot is settled: no seed pipeline registered, so nothing is + * outstanding and nothing more is coming. + */ +export function seedingHasSettled(snapshot: SeedSettlementSnapshot | undefined): boolean { + return !snapshot || snapshot.inFlight === 0; +} + +/** Build the message from the two things the kernel already knows. */ +export function composeSeedSettledMessage( + snapshot: SeedSettlementSnapshot | undefined, + sources: SeedSourceSummary[] | undefined, +): SeedSettledMessage { + const list = sources ?? []; + return { + type: 'objectstack:seed-settled', + ok: list.every((s) => s.rejected === 0 && (s.droppedRefs ?? 0) === 0 && !s.emptyInstall), + suppressed: snapshot ? [...snapshot.suppressed] : [], + sources: list, + }; +} + +/** What {@link createSeedSettlementAnnouncer} needs from its host. */ +export interface SeedSettlementChannels { + /** The live tally — re-read per use, never cached (the contract says so). */ + readSettlement: () => SeedSettlementSnapshot | undefined; + /** The per-source counts the banner renders, or `undefined` if none ran. */ + readSummary: () => SeedSourceSummary[] | undefined; + /** Sends {@link SeedSettledMessage}, when an IPC channel is open. */ + announceSettled: (message: SeedSettledMessage) => void; +} + +/** + * The latch that turns "seeding settled" into ONE message, in the one order it + * is safe to send it in. + * + * ## Why a latch rather than a call at the settle point + * + * The two clocks this card is about run in EITHER order, and both have to work: + * + * ```text + * in-budget seed settles ─▶ kernel:ready ─▶ publishBoundPort ─▶ (release) ─▶ send + * over-budget kernel:ready ─▶ publishBoundPort ─▶ (release) … ─▶ app:seeded ─▶ send + * ``` + * + * {@link check} is called from both the `app:seeded` and `kernel:ready` hooks; + * {@link release} is called once, after {@link publishBoundPort} has driven its + * three channels. Before the release nothing is sent, so a settle that happened + * during `runtime.start()` cannot overtake `objectstack:listening`; after it, + * the first `check` that finds nothing in flight sends and closes the latch. + * + * ⛔ The `kernel:ready` leg is not redundant with `app:seeded`. A suppressed + * boot (multi-tenant, `skipSeedData`) NEVER fires `app:seeded` — `emitSeedSettled` + * lives only on the branch that actually seeds — so `kernel:ready` is the only + * hook that runs on those boots, and dropping it is exactly how a waiting parent + * would hang forever there. + * + * ⛔ And it is not a poll. Every leg is an event the kernel already emits; the + * latch only decides which of them is allowed to be the one that speaks. + */ +export function createSeedSettlementAnnouncer(channels: SeedSettlementChannels): { + /** Re-evaluate: send iff released, not yet sent, and nothing in flight. */ + check: () => void; + /** Open the gate — call AFTER the bound-port channels have been driven. */ + release: () => void; +} { + let released = false; + let sent = false; + + const check = () => { + if (!released || sent) return; + const snapshot = channels.readSettlement(); + if (!seedingHasSettled(snapshot)) return; + sent = true; + channels.announceSettled(composeSeedSettledMessage(snapshot, channels.readSummary())); + }; + + return { + check, + release: () => { + released = true; + check(); + }, + }; +} + /** * The three channels {@link publishBoundPort} drives — declared in the ONE * order it is safe to drive them in, which is also the order the fields are @@ -709,6 +908,40 @@ export function runtimeBoundPortChannels(printBanner: () => void): BoundPortChan }; } +/** + * The real {@link SeedSettlementChannels} for a booted kernel (#17329). + * + * Kept OUT of {@link BoundPortChannels} deliberately. Those three are one + * ordered publication of ONE number and {@link publishBoundPort} exists to drive + * them in the one safe order; this is a different fact arriving on a different + * clock, often minutes later. Folding it in would have made the seam either + * asynchronous or a liar. + * + * Best-effort like its sibling: a closed or absent IPC channel is the ordinary + * `os serve` case, not an error, and a throw here would be a supervision + * nicety taking a healthy server down. + */ +export function runtimeSeedSettlementChannels( + kernel: { getService?: (name: string) => unknown } | undefined, +): SeedSettlementChannels { + return { + readSettlement: () => readSeedSettlement(kernel), + readSummary: () => { + try { + const s: unknown = kernel?.getService?.('seed-summary'); + return Array.isArray(s) && s.length > 0 ? (s as SeedSourceSummary[]) : undefined; + } catch { + return undefined; + } + }, + announceSettled: (message) => { + try { + if (typeof process.send === 'function') process.send(message); + } catch { /* IPC channel closed — best-effort */ } + }, + }; +} + /** * The IDENTITIES a capability provider registers under: full `plugin.name` ids * (`com.objectstack.mcp`) and/or exported class names (`MCPServerPlugin`). @@ -4705,6 +4938,50 @@ export default class Serve extends Command { trackPlugin('ArtifactBootMigrationGate'); } + // ── Seed-settlement announcer (#17329) ───────────────────────── + // `✓ Server is ready` is true about the HTTP server and says nothing + // about the app: past the inline seed budget the rest of the seed runs + // detached, so the banner, and any parent that waits for it, can be + // eighty seconds ahead of a hundred-line error wall. This is the hop that + // makes the settle readable OFF the child's stdio — a second IPC message + // beside `objectstack:listening`, on the channel `os dev` already holds. + // + // ⛔ The producer is NOT new and NOT ours: `@objectstack/runtime` already + // declares every seed source and settles it at the exact moment its + // boot-time write is done, publishing the tally under the spec's + // `seed-settlement` contract. This registers no service and mutates + // nothing — it subscribes to two hooks the kernel already fires and reads + // a snapshot it already publishes. + // + // ⭐ Registered as a plugin because `hook` is reachable from a plugin + // context and nowhere else — `ObjectKernel.context` is private, and + // sniffing past that to reach it would be the coupling the published + // contract exists to avoid. Same shape as the artifact gate above. + const seedSettlement = createSeedSettlementAnnouncer(runtimeSeedSettlementChannels(kernel)); + await kernel.use({ + name: 'com.objectstack.cli.seed-settlement-announcer', + version: '1.0.0', + init: async (ctx: any) => { + // Both legs are load-bearing and neither is redundant: + // `app:seeded` — the ONLY signal on the over-budget path, where the + // settle lands after the banner (and after + // `kernel:ready`). Fires once per config app, so it + // asks the tally rather than counting itself. + // `kernel:ready`— the only hook that runs at all on a multi-tenant + // or `skipSeedData` boot, where the source is + // SUPPRESSED and `app:seeded` never fires. Also the + // in-budget fast path, where seeding was already + // done before this line. + ctx.hook('app:seeded', () => { seedSettlement.check(); }); + ctx.hook('kernel:ready', () => { seedSettlement.check(); }); + }, + } as any); + // ⛔ Deliberately NOT `trackPlugin`ed. That list feeds the banner's + // `Plugins:` count and name row, and this subscriber is an internal wiring + // detail of the CLI's own announcement — putting it there would grow a + // published banner surface on every boot to report a thing an operator + // cannot act on. + // Boot the runtime await runtime.start(); @@ -4947,6 +5224,15 @@ export default class Serve extends Command { seededAdmin, automation: automationSummary, seeds: seedSummary, + // #17329 — read HERE, inside the banner thunk, so it is the tally as of + // the moment the banner prints rather than one sampled earlier in the + // boot. On the over-budget path `seedSummary` above is `undefined` — + // the summary is recorded only when the load FINISHES — so without this + // the transcript of a boot whose seed is still writing is + // byte-identical to one that declared no seeds at all, which is how + // this defect hid: the louder the seed went on to fail, the more + // complete the omission looked at the moment it was read. + seedSettlement: readSeedSettlement(kernel), // #16630 — what the kernel already knows about this boot, so the ready // line can say what state it is ready in. `undefined` on a healthy // boot, where the ready block prints exactly what it always has. @@ -4986,6 +5272,20 @@ export default class Serve extends Command { // old order lost, and the reason the repair is not reader-side polling. publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner), boundProtocol); + // ── …and one beat later, whether the APP is ready too (#17329) ── + // ⭐ AFTER the line above, and that is the whole of the ordering + // contract: a parent that waits for `objectstack:listening` and only then + // starts listening for the settle can never miss it, because nothing is + // sent before this call. On the ordinary in-budget boot seeding settled + // minutes of CPU ago and this releases the latched message immediately; + // past the budget it arms and the `app:seeded` hook speaks later. + // + // ⛔ NOT a fourth channel inside `publishBoundPort`. Those three are one + // publication of ONE number, ordered so the file exists before anything + // names it; this is a different fact on a different clock that frequently + // has not happened yet. + seedSettlement.release(); + // ── Watch the served database file's identity ────────────────── // Deleting the data directory under a running server (`rm -rf // .objectstack/data`, what `demo:reset` does) unlinks the inode without diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 780c6cd11d..e3dda5c853 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -4,6 +4,11 @@ import chalk from 'chalk'; import type { ZodError } from 'zod'; import { formatZodIssue, type ConversionNotice } from '@objectstack/spec'; import type { TenancyPosture } from '@objectstack/spec/security'; +// #17329 — the published settlement contract's own shape. Read, never restated: +// a second local copy of `{ pending, inFlight, suppressed }` would be free to +// drift the day a suppression reason is added, and the whole point of asking +// through the contract is that the two sides cannot disagree. +import type { SeedSettlementSnapshot } from '@objectstack/spec/contracts'; import { writeStdoutDirect } from './json-stdout.js'; import { authoringRuleUnionStack } from './stack-collections.js'; @@ -777,6 +782,33 @@ export interface ServerReadyOptions { * rejections and empty installs are loud, a clean seed prints one dim line. */ seeds?: SeedSourceSummary[]; + /** + * The kernel's live seed-settlement tally as of the instant the banner prints + * (#17329) — `undefined` when no seed pipeline registered on this kernel. + * + * ## The omission this closes + * + * {@link seeds} is fed by the `seed-summary` service, and a source only + * records its outcome when its load FINISHES. Past the inline seed budget the + * load has not finished when the banner prints, so `seeds` is `undefined` and + * the `Seeds:` row is ABSENT — making the transcript of a boot that is still + * writing byte-indistinguishable from one that declared no seeds at all: + * + * ```text + * ✓ Server is ready + * … + * Press Ctrl+C to stop ← and 82 seconds later, 120 ERROR lines + * ``` + * + * A reader who sees `Press Ctrl+C to stop` reasonably believes nothing more + * is coming. They believe it because the banner in front of them says, by + * omission, that seeding is not part of this boot. + * + * ⛔ Human-facing only. It does NOT replace the `objectstack:seed-settled` + * IPC message a parent process waits on — a shell script still cannot wait on + * prose, which is why holding the banner was refused as the repair. + */ + seedSettlement?: SeedSettlementSnapshot; /** * Boot-phase kernel-logger diagnostics replayed from the boot-quiet stdout * window (#4012). `ObjectLogger` writes `warn` to stdout, so that window @@ -1040,6 +1072,10 @@ export function printServerReady(opts: ServerReadyOptions) { } if (opts.automation) printAutomationSummary(opts.automation); if (opts.seeds) printSeedSummary(opts.seeds); + // #17329 — AFTER the settled summary, never instead of it: a bundle with two + // config apps can have one finished (a real `Seeds:` row) and one still + // writing, and reporting only the first is the omission this closes. + if (opts.seedSettlement) printSeedsStillWriting(opts.seedSettlement); if (opts.bootDiagnostics) printBootDiagnostics(opts.bootDiagnostics); console.error(''); console.error(chalk.dim(' Press Ctrl+C to stop')); @@ -1202,6 +1238,54 @@ function printSeedSummary(sources: SeedSourceSummary[]) { console.error(chalk.dim(` Seeds: ${line}`)); } +/** + * Say that seeding is still running, when it is (#17329). + * + * ## The transcript this repairs + * + * `printSeedSummary` above can only render sources that FINISHED — the + * `seed-summary` service is written by `recordSeedOutcome`, which the seeder + * calls at the end of a load. Past the inline seed budget (`AppPlugin` races it + * against `OS_INLINE_SEED_BUDGET_MS`, default 8s, then detaches the rest) the + * load has not finished when the banner prints, so there is nothing to read and + * the row simply does not appear. Measured on one showcase boot at + * `OS_INLINE_SEED_BUDGET_MS=1`: a probe read `{"pending":1,"inFlight":1}` at + * banner time, and the transcript carried **zero** `Seeds:` rows — identical, + * byte for byte, to an app that declares no seeds. Eighty-two seconds later + * that same boot emitted 120 `ERROR` lines. + * + * So the row's absence was carrying a claim ("seeding is not part of this + * boot") that the boot went on to contradict. This says *pending* instead. + * + * ## Why suppressed sources are named rather than counted as pending + * + * A multi-tenant or `skipSeedData` boot keeps `pending > 0` for the life of the + * process ON PURPOSE — those rows are written per organization later, or not at + * all. Rendering that as "still writing" would promise a completion that is + * never coming, which is the same defect pointed the other way. `inFlight` is + * the only half that means work is outstanding; `suppressed` gets its own + * sentence saying nothing further is due. + */ +function printSeedsStillWriting(settlement: SeedSettlementSnapshot) { + const { inFlight, suppressed } = settlement; + + if (inFlight > 0) { + const n = `${inFlight} source${inFlight === 1 ? '' : 's'}`; + console.error(chalk.yellow(` ⚠ Seeds: pending — ${n} still writing`)); + // The line the ruling asks for in as many words: the banner is not the end + // of this boot's output, and a reader at `Press Ctrl+C to stop` needs to + // know that before the wall arrives rather than after. + console.error(chalk.dim(' seeding continues in the background; its result prints after this banner')); + } + + if (suppressed.length > 0) { + // Deduplicated: two config apps suppressed for the same cause is one fact + // about this deployment, not two. + const reasons = [...new Set(suppressed)].join(', '); + console.error(chalk.dim(` Seeds: not run this boot (${reasons})`)); + } +} + export function printMetadataStats(stats: MetadataStats) { const sections: Array<{ label: string; From a81eb6c3d4532065b44f9145c688475f5741b506 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 17:46:39 +0000 Subject: [PATCH 2/3] test(cli): pin the seed-settled ipc message, its ordering, and the banner rider 64 assertions across three files: the message reaches process.send and stays silent without a channel; the latch cannot let it overtake `objectstack:listening` and cannot send twice; the over-budget path withholds then speaks; both suppressed modes announce with their reason rather than hanging; a kernel with no seed pipeline counts as settled; `os dev` relays verbatim and claims only its own message; and the banner's over-budget row, with the ablation showing the same boot without the reading is the byte-identical no-seeds transcript. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- .../commands/dev-seed-settled-forward.test.ts | 106 +++++++ .../serve-bound-port-publication.test.ts | 278 ++++++++++++++++++ .../cli/src/utils/format.seed-pending.test.ts | 151 ++++++++++ 3 files changed, 535 insertions(+) create mode 100644 packages/cli/src/commands/dev-seed-settled-forward.test.ts create mode 100644 packages/cli/src/utils/format.seed-pending.test.ts diff --git a/packages/cli/src/commands/dev-seed-settled-forward.test.ts b/packages/cli/src/commands/dev-seed-settled-forward.test.ts new file mode 100644 index 0000000000..bd5ba4ac7b --- /dev/null +++ b/packages/cli/src/commands/dev-seed-settled-forward.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { forwardSeedSettledToParent } from './dev.js'; + +/** + * #17329 — `os dev` relays the `serve` child's settle announcement to its OWN + * parent, and does nothing at all when no parent holds the channel. + * + * ## Why the hop is the card + * + * The producer already exists and is published: `@objectstack/runtime` declares + * every seed source and settles it at the moment its boot-time write is done, + * under the spec's `seed-settlement` contract. `serve` now announces that on the + * ipc channel. But the consumer — a demo script, a test harness, anything that + * spawns a dev server and wants to print one line after the boot — spawns + * `os dev`, not `serve`; `os dev` runs the child over + * `stdio: ['inherit','inherit','inherit','ipc']`, so without this the message + * lands in the middle process and stops. One hop is the whole of what was + * missing. + * + * ⚠️ Under vitest's `forks` pool `process.send` is the RUNNER's own control + * channel. Every swap below is synchronous, spans one call, and is undone in + * `finally` — a real message must never reach it. + */ +describe('#17329 `os dev` forwards `objectstack:seed-settled` outward', () => { + /** Drive `fn` with `process.send` replaced by a recorder. */ + const recording = (fn: () => void): unknown[] => { + const sent: unknown[] = []; + const prior = process.send; + (process as { send?: unknown }).send = (m: unknown) => { sent.push(m); return true; }; + try { fn(); } finally { (process as { send?: unknown }).send = prior; } + return sent; + }; + + /** Drive `fn` with NO ipc channel — the ordinary terminal `os dev`. */ + const withoutChannel = (fn: () => T): T => { + const prior = process.send; + (process as { send?: unknown }).send = undefined; + try { return fn(); } finally { (process as { send?: unknown }).send = prior; } + }; + + const settled = { + type: 'objectstack:seed-settled', + ok: false, + suppressed: [], + sources: [{ source: 'showcase', inserted: 24, updated: 0, skipped: 0, rejected: 14 }], + }; + + it('relays the message VERBATIM, not a re-derivation of it', () => { + // ⛔ This process has no kernel and could only guess. Passing the object + // through is what keeps `os dev`'s parent and the `serve` child from being + // made to say two different things about one boot. + const sent = recording(() => { + expect(forwardSeedSettledToParent(settled)).toBe(true); + }); + expect(sent).toEqual([settled]); + expect(sent[0], 'the message was rebuilt rather than relayed').toBe(settled); + }); + + it('⛔ a parent with no ipc channel is UNAFFECTED — no throw, no send', () => { + // An ipc channel must not become a requirement of running a published + // command. `process.send` is undefined under a terminal `os dev`. + withoutChannel(() => { + expect(() => forwardSeedSettledToParent(settled)).not.toThrow(); + expect(forwardSeedSettledToParent(settled), 'the message is still HANDLED here').toBe(true); + }); + }); + + it('survives a parent channel that has already closed', () => { + // Best-effort, exactly like the child's own `announceListening`: a + // supervision nicety must never take a healthy dev server down. + const prior = process.send; + (process as { send?: unknown }).send = () => { throw new Error('channel closed'); }; + try { + expect(() => forwardSeedSettledToParent(settled)).not.toThrow(); + } finally { + (process as { send?: unknown }).send = prior; + } + }); + + describe('⛔ and it claims ONLY its own message', () => { + it.each([ + ['the listening announcement', { type: 'objectstack:listening', port: 3001, url: 'http://localhost:3001' }], + ['an unrelated type', { type: 'something:else' }], + ['no type at all', { port: 3001 }], + ['null', null], + ['undefined', undefined], + ['a string', 'objectstack:seed-settled'], + ])('%s is left to the caller', (_label, msg) => { + // Returning `true` here would swallow `objectstack:listening` and take + // the bound-port readout and the MCP connect hint down with it. + const sent = recording(() => { + expect(forwardSeedSettledToParent(msg)).toBe(false); + }); + expect(sent, 'a message that is not ours was forwarded anyway').toEqual([]); + }); + + it('…and the positive control on the same path still fires', () => { + // So the zeros above are readings rather than a function that forwards + // nothing at all. + const sent = recording(() => { forwardSeedSettledToParent(settled); }); + expect(sent).toHaveLength(1); + }); + }); +}); diff --git a/packages/cli/src/commands/serve-bound-port-publication.test.ts b/packages/cli/src/commands/serve-bound-port-publication.test.ts index 8d7e841757..cc84c71437 100644 --- a/packages/cli/src/commands/serve-bound-port-publication.test.ts +++ b/packages/cli/src/commands/serve-bound-port-publication.test.ts @@ -47,12 +47,22 @@ import { fileURLToPath } from 'node:url'; import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; import { + composeSeedSettledMessage, + createSeedSettlementAnnouncer, publishBoundPort, + readSeedSettlement, resolveBoundPort, runtimeBoundPortChannels, + runtimeSeedSettlementChannels, runtimeStateFileName, + seedingHasSettled, type BoundPortChannels, + type SeedSettledMessage, } from './serve.js'; +// #17329 — the published contract's own shapes, so the fixtures below cannot +// drift from what the runtime actually hands the CLI. +import type { SeedSettlementSnapshot, SeedSuppressionReason } from '@objectstack/spec/contracts'; +import type { SeedSourceSummary } from '../utils/format.js'; import { MAX_PORT } from '../utils/port-contract.js'; /** …/packages/cli/src/commands — seeded from `import.meta.url`. */ @@ -449,3 +459,271 @@ describe('#13062 all THREE channels publish that one number', () => { }); }); }); + +/** + * #17329 — the SECOND ipc message: "this boot's seeding has come to rest". + * + * ## The defect, and the two clocks that hid it + * + * `✓ Server is ready` is true about the HTTP server and says nothing about the + * app. `AppPlugin` races its inline seed against `OS_INLINE_SEED_BUDGET_MS` + * (default 8s) and past it hands the rest to a detached promise, so the banner + * prints, a parent proceeds, and the continuation's error wall lands later — + * measured once at 82 seconds later, 120 `ERROR` lines. Which side wins is + * decided by whether the seed fits its budget on a contended box, so the same + * command on the same corpus disagrees between two containers. + * + * Everything that WOULD distinguish them arrives on the child's inherited + * stdio, and reading that costs the boot its TTY. So the fact moves to the + * channel the `os dev` parent already holds, beside `objectstack:listening`. + * + * ## ⭐ Why `inFlight`, and why that is the whole acceptance of the card + * + * The obvious predicate — `pending === 0` — is WRONG, and wrong in the + * direction that reproduces the defect one level up. Two shapes register a seed + * source and deliberately never run it (multi-tenant per-org replay, + * `skipSeedData` planning boots); both keep `pending` above zero for the life + * of the process by design. A `pending`-keyed message would never be sent on + * either, and its ABSENCE would be indistinguishable from a boot still writing + * — which is exactly how this defect hid the first time. + */ +describe('#17329 the `objectstack:seed-settled` ipc message', () => { + /** A settlement snapshot, with `pending` kept consistent by construction. */ + const snap = (inFlight: number, suppressed: SeedSuppressionReason[] = []): SeedSettlementSnapshot => ({ + pending: inFlight + suppressed.length, + inFlight, + suppressed, + }); + + /** An announcer over a mutable tally, so a boot's clock can be driven. */ + const harness = (initial: SeedSettlementSnapshot | undefined) => { + const state = { snapshot: initial, summary: undefined as SeedSourceSummary[] | undefined }; + const sent: SeedSettledMessage[] = []; + const announcer = createSeedSettlementAnnouncer({ + readSettlement: () => state.snapshot, + readSummary: () => state.summary, + announceSettled: (m) => { sent.push(m); }, + }); + return { state, sent, announcer }; + }; + + const s = (o: Partial & { source: string }): SeedSourceSummary => ({ + inserted: 0, updated: 0, skipped: 0, rejected: 0, ...o, + }); + + it('really reaches `process.send`, like the first message', () => { + // Pinned by OBSERVING the send, not by grepping for the call — the same + // rule the `objectstack:listening` leg above follows. + const channels = runtimeSeedSettlementChannels(undefined); + const sent = recordingProcessSend(() => { + channels.announceSettled({ + type: 'objectstack:seed-settled', ok: true, suppressed: [], sources: [], + }); + }); + expect(sent).toEqual([{ + type: 'objectstack:seed-settled', ok: true, suppressed: [], sources: [], + }]); + }); + + it('and stays silent, rather than throwing, when no IPC channel is open', () => { + // The ordinary `os serve` case: no parent, no fd 3. ⛔ This message must + // not make an IPC channel a requirement of a published command. + const channels = runtimeSeedSettlementChannels(undefined); + const prior = process.send; + (process as { send?: unknown }).send = undefined; + try { + expect(() => channels.announceSettled({ + type: 'objectstack:seed-settled', ok: true, suppressed: [], sources: [], + })).not.toThrow(); + } finally { + (process as { send?: unknown }).send = prior; + } + }); + + describe('the ordering contract — never before `objectstack:listening`', () => { + it('a settle during `runtime.start()` is LATCHED until the release', () => { + // The ordinary in-budget boot: seeding finished long before the bound + // port was published. A parent that waits for `objectstack:listening` + // and only then listens for the settle would MISS a message sent during + // the boot, so nothing may be sent until the release. + const { sent, announcer } = harness(snap(0)); + announcer.check(); // `app:seeded`, mid-boot + announcer.check(); // `kernel:ready`, still mid-boot + expect(sent, 'a settle overtook the listening announcement').toEqual([]); + + announcer.release(); // …after publishBoundPort drove its three + expect(sent).toHaveLength(1); + expect(sent[0].type).toBe('objectstack:seed-settled'); + }); + + it('and the release is what sends it — not a later hook that may never fire', () => { + // ⛔ Ablation of the leg above: if the release only opened a gate and + // waited for the NEXT hook, an in-budget boot (whose hooks have all + // already fired) would never announce at all. + const { sent, announcer } = harness(snap(0)); + announcer.release(); + expect(sent, 'the release did not evaluate the tally it just un-gated').toHaveLength(1); + }); + }); + + describe('the over-budget path — the clock the card is about', () => { + it('withholds while a source is still writing, then speaks when it settles', () => { + const { state, sent, announcer } = harness(snap(1)); + announcer.release(); + expect(sent, 'announced settled over a seed that was still writing').toEqual([]); + + // The detached continuation finishes: the runtime settles the source and + // fires `app:seeded`, which is the only signal on this path. + state.snapshot = snap(0); + state.summary = [s({ source: 'showcase', inserted: 132 })]; + announcer.check(); + + expect(sent).toHaveLength(1); + expect(sent[0].sources).toEqual([s({ source: 'showcase', inserted: 132 })]); + expect(sent[0].ok).toBe(true); + }); + + it('sends exactly ONCE, however many times the hooks fire', () => { + // `app:seeded` fires once per config app, and `kernel:ready` fires beside + // it; a bundle with several apps would otherwise announce several times + // and a parent reading one message would act on the first source's tally. + const { sent, announcer } = harness(snap(0)); + announcer.release(); + announcer.check(); + announcer.check(); + announcer.check(); + expect(sent).toHaveLength(1); + }); + + it('reports failure as settled too — a failed seed has still come to rest', () => { + // ⛔ Not "announce only on success". A parent waiting for the boot to + // stop moving must be released by a seed that failed just as much as by + // one that worked; withholding here recreates the hang this closes. + const { state, sent, announcer } = harness(snap(1)); + announcer.release(); + state.snapshot = snap(0); + state.summary = [s({ source: 'showcase', inserted: 24, rejected: 14 })]; + announcer.check(); + + expect(sent).toHaveLength(1); + expect(sent[0].ok, 'a seed that dropped 14 records reported ok').toBe(false); + expect(sent[0].sources[0].rejected).toBe(14); + }); + }); + + describe('⭐ ruled item 3 — the two modes that report `pending > 0` FOREVER', () => { + // 「multi-tenant replay and `skipSeedData` report `pending > 0` for the whole + // boot — a consumer waiting on the new message must not hang forever there; + // the message's contract states what it means in those modes.」 + it.each([ + ['multi-tenant-replay' as const], + ['skip-seed-data' as const], + ])('%s: announces, carrying the reason — it does NOT hang', (reason) => { + // `suppress()` moves the source out of `inFlight` and records why, inside + // Phase 2 `start()`. So `kernel:ready` — the only hook that runs on these + // boots, since `app:seeded` never fires — finds nothing in flight. + const { sent, announcer } = harness(snap(0, [reason])); + announcer.release(); + + expect(sent, 'a consumer on this boot would wait for a message never coming').toHaveLength(1); + expect(sent[0].suppressed).toEqual([reason]); + expect(sent[0].sources, 'no rows were written by this boot').toEqual([]); + }); + + it('⛔ and `pending` is NOT the predicate — the ablation that proves it', () => { + // The discriminating reading. Both boots below report `pending: 1`; only + // one of them has work outstanding. A `pending`-keyed message would treat + // them identically and hang on both. + const suppressed = snap(0, ['multi-tenant-replay']); + const writing = snap(1); + expect(suppressed.pending, 'the two modes must be indistinguishable BY PENDING').toBe(writing.pending); + + expect(seedingHasSettled(suppressed)).toBe(true); + expect(seedingHasSettled(writing)).toBe(false); + }); + }); + + describe('a kernel with no seed pipeline at all', () => { + it('counts as settled — an absent service is an answer, not a not-yet', () => { + // Every source is declared in Phase 2 `start()`, which completes before + // `kernel:ready`, so "no service" is the fact "this kernel does not seed". + // ⛔ Treating it as unsettled would hang every app that ships no seeds. + expect(seedingHasSettled(undefined)).toBe(true); + const { sent, announcer } = harness(undefined); + announcer.release(); + expect(sent).toHaveLength(1); + expect(sent[0]).toEqual({ + type: 'objectstack:seed-settled', ok: true, suppressed: [], sources: [], + }); + }); + + it('and `readSeedSettlement` survives a kernel whose `getService` THROWS', () => { + // `getService` throws on an unregistered name rather than returning + // undefined — the same trap `resolveBoundPort` documents above. + const throwing = { getService: (n: string) => { throw new Error(`Service '${n}' not found`); } }; + expect(readSeedSettlement(throwing)).toBeUndefined(); + expect(readSeedSettlement(undefined)).toBeUndefined(); + // A service present but answering nothing usable is the same as absent. + const garbage = { getService: () => ({ snapshot: () => ({}) }) }; + expect(readSeedSettlement(garbage)).toBeUndefined(); + // …and the positive control, so those zeros are readings: a real tracker + // IS read back through the same accessor. + const real = { getService: () => ({ snapshot: () => snap(2) }) }; + expect(readSeedSettlement(real)).toEqual(snap(2)); + }); + }); + + describe('`ok` is a verdict on what the summary CONTAINS', () => { + it.each([ + ['a clean source', [{ source: 'showcase', inserted: 132 }], true], + ['rejected records', [{ source: 'showcase', inserted: 24, rejected: 14 }], false], + ['dropped references', [{ source: 'showcase', inserted: 42, droppedRefs: 3 }], false], + ['an empty install', [{ source: 'hotcrm', emptyInstall: true }], false], + ['one clean and one broken', [{ source: 'a', inserted: 9 }, { source: 'b', rejected: 1 }], false], + ])('%s', (_label, sources, expected) => { + const message = composeSeedSettledMessage( + snap(0), + (sources as Array & { source: string }>).map(s), + ); + expect(message.ok).toBe(expected); + }); + + it('an absent summary is `ok` with an empty `sources` — "nothing reported a problem"', () => { + // Documented rather than smoothed over: on a suppressed boot this reads + // "nothing ran", which is why the contract says to read `ok` against + // `sources` and `suppressed` rather than alone. + const message = composeSeedSettledMessage(snap(0, ['skip-seed-data']), undefined); + expect(message).toEqual({ + type: 'objectstack:seed-settled', + ok: true, + suppressed: ['skip-seed-data'], + sources: [], + }); + }); + }); + + describe('the wiring inside `run()`, which is still un-enterable in-process', () => { + // Same reason the bound-port wiring above is a source pin: `run()` is one + // ~3000-line method that needs a whole kernel to enter. Comments are MASKED + // by the same separator, so a sentence about the release can never answer + // for code that does not perform it. + it('releases the latch AFTER the bound-port channels have been driven', () => { + const publish = SERVE.indexOf('publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner), boundProtocol);'); + const release = SERVE.indexOf('seedSettlement.release();'); + expect(publish, 'the bound-port publish site moved or was renamed').toBeGreaterThan(-1); + expect(release, 'the settle latch is never released — the message can never be sent').toBeGreaterThan(-1); + expect(release, 'the settle announcement can now overtake `objectstack:listening`').toBeGreaterThan(publish); + }); + + it('subscribes BOTH hooks — `kernel:ready` is the suppressed boot\'s only one', () => { + expect(SERVE).toContain("ctx.hook('app:seeded', () => { seedSettlement.check(); });"); + expect(SERVE).toContain("ctx.hook('kernel:ready', () => { seedSettlement.check(); });"); + }); + + it('⛔ and the bound-port seam still has exactly ONE call site', () => { + // The #13062 pin, re-read here because this card added a publication + // beside it: a second `publishBoundPort` call is that defect returning. + expect(SERVE.match(/publishBoundPort\(/g) ?? []).toHaveLength(2); + }); + }); +}); diff --git a/packages/cli/src/utils/format.seed-pending.test.ts b/packages/cli/src/utils/format.seed-pending.test.ts new file mode 100644 index 0000000000..b87554d8ac --- /dev/null +++ b/packages/cli/src/utils/format.seed-pending.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { SeedSettlementSnapshot, SeedSuppressionReason } from '@objectstack/spec/contracts'; +import { printServerReady, type ServerReadyOptions, type SeedSourceSummary } from './format.js'; + +/** + * #17329 rider — the over-budget banner says seeding is still running, instead + * of saying nothing at all. + * + * ## The omission this closes, measured + * + * `printSeedSummary` can only render sources that FINISHED: the `seed-summary` + * service is written by `recordSeedOutcome`, which the seeder calls at the end + * of a load. Past the inline seed budget the load has not finished when the + * banner prints, so `seeds` is `undefined` and the row is ABSENT. Measured on a + * showcase boot at `OS_INLINE_SEED_BUDGET_MS=1`, with a probe reading the + * kernel at the instant the banner fired: + * + * ```text + * [PROBE] seed-settlement at banner time: {"pending":1,"inFlight":1,"suppressed":[]} + * ✓ Server is ready + * … ← zero `Seeds:` rows + * Press Ctrl+C to stop + * ``` + * + * …which is byte-identical to a boot of an app that declares no seeds at all. + * The row's absence was carrying a claim the boot then contradicted, and the + * louder the seed went on to fail, the more complete the omission looked at the + * moment it was read. + * + * ⛔ Human-facing only. It does NOT replace the `objectstack:seed-settled` ipc + * message — a shell script cannot wait on prose, which is exactly why holding + * the banner until seeding settles was refused as the repair. + */ +describe('printServerReady seed-settlement row (#17329)', () => { + const base: ServerReadyOptions = { + externalBaseOrigin: 'http://localhost:3000', + configFile: 'objectstack.config.ts', + isDev: true, + pluginCount: 1, + }; + let lines: string[]; + let spy: ReturnType; + + beforeEach(() => { + lines = []; + // stderr, not stdout (#7915) — the whole banner is a diagnostic. + spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + lines.push(args.join(' ')); + }); + }); + afterEach(() => spy.mockRestore()); + + const seedLines = () => lines.filter((l) => l.includes('Seeds:')); + const snap = (inFlight: number, suppressed: SeedSuppressionReason[] = []): SeedSettlementSnapshot => ({ + pending: inFlight + suppressed.length, inFlight, suppressed, + }); + const s = (o: Partial & { source: string }): SeedSourceSummary => ({ + inserted: 0, updated: 0, skipped: 0, rejected: 0, ...o, + }); + + it('⭐ the over-budget boot no longer prints a transcript with NO seed row', () => { + // The exact reading above: a source in flight and no summary to render. + printServerReady({ ...base, seeds: undefined, seedSettlement: snap(1) }); + + expect(seedLines(), 'the banner is still silent about a seed that is still writing').toHaveLength(1); + expect(seedLines()[0]).toContain('pending'); + expect(seedLines()[0]).toContain('1 source still writing'); + expect( + lines.some((l) => l.includes('seeding continues in the background')), + 'nothing tells the reader more output is coming after `Press Ctrl+C to stop`', + ).toBe(true); + }); + + it('⛔ ABLATION — the same boot WITHOUT the reading is the byte-identical transcript', () => { + // The control that makes the leg above a measurement: drop the settlement + // reading and the banner reverts to the defect — no seed row at all, and + // therefore indistinguishable from an app that declares no seeds. + printServerReady({ ...base, seeds: undefined, seedSettlement: undefined }); + expect(seedLines()).toHaveLength(0); + expect(lines.some((l) => l.includes('seeding continues'))).toBe(false); + // …and it really is the "declared no seeds" transcript, not an empty run. + expect(lines.some((l) => l.includes('Press Ctrl+C to stop'))).toBe(true); + }); + + it('pluralises honestly when several sources are still writing', () => { + printServerReady({ ...base, seedSettlement: snap(3) }); + expect(seedLines()[0]).toContain('3 sources still writing'); + }); + + it('stays out of the way on the ordinary settled boot', () => { + // The in-budget path: everything landed before the banner, the existing + // `Seeds:` row says so, and this rider must add nothing. + printServerReady({ + ...base, + seeds: [s({ source: 'showcase', inserted: 132 })], + seedSettlement: snap(0), + }); + expect(seedLines()).toHaveLength(1); + expect(seedLines()[0]).toContain('showcase 132 rows'); + expect(seedLines()[0]).not.toContain('pending'); + expect(lines.some((l) => l.includes('seeding continues'))).toBe(false); + }); + + it('reports BOTH when one source finished and another is still writing', () => { + // ⛔ Not either/or. A bundle with two config apps can be half-done, and + // printing only the finished half is the same omission in miniature. + printServerReady({ + ...base, + seeds: [s({ source: 'showcase', inserted: 132 })], + seedSettlement: snap(1), + }); + expect(seedLines()).toHaveLength(2); + expect(seedLines()[0]).toContain('showcase 132 rows'); + expect(seedLines()[1]).toContain('pending'); + }); + + describe('suppressed sources are named, never reported as pending', () => { + it.each([ + ['multi-tenant-replay' as const], + ['skip-seed-data' as const], + ])('%s says nothing further is due', (reason) => { + // ⛔ "still writing" would promise a completion that is never coming — + // the same defect pointed the other way. These rows are written per org + // later, or not at all. + printServerReady({ ...base, seedSettlement: snap(0, [reason]) }); + expect(seedLines()).toHaveLength(1); + expect(seedLines()[0]).toContain('not run this boot'); + expect(seedLines()[0]).toContain(reason); + expect(seedLines()[0]).not.toContain('pending'); + expect(lines.some((l) => l.includes('seeding continues'))).toBe(false); + }); + + it('deduplicates a cause shared by several sources', () => { + printServerReady({ + ...base, + seedSettlement: snap(0, ['multi-tenant-replay', 'multi-tenant-replay']), + }); + expect(seedLines()).toHaveLength(1); + expect(seedLines()[0].match(/multi-tenant-replay/g) ?? []).toHaveLength(1); + }); + + it('and a boot that is BOTH suppressed and still writing says both', () => { + printServerReady({ ...base, seedSettlement: snap(2, ['skip-seed-data']) }); + expect(seedLines()).toHaveLength(2); + expect(seedLines()[0]).toContain('2 sources still writing'); + expect(seedLines()[1]).toContain('not run this boot (skip-seed-data)'); + }); + }); +}); From 225ad588f7ac6ff238f6e770d8ab5ee59cf2d9de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 18:20:27 +0000 Subject: [PATCH 3/3] docs(cli): document the two ipc messages and the parent-spawn pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `Waiting for the boot from a parent process` section to the `os dev` page: what each message means, the spawn-with-ipc snippet, the asymmetry (`os dev` consumes `objectstack:listening` and relays only the settle message), and what `objectstack:seed-settled` promises — including why it is sent on suppressed boots with a reason rather than withheld. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- .changeset/17329-seed-settled-ipc-message.md | 17 ++++++ content/docs/deployment/cli.mdx | 61 ++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 .changeset/17329-seed-settled-ipc-message.md diff --git a/.changeset/17329-seed-settled-ipc-message.md b/.changeset/17329-seed-settled-ipc-message.md new file mode 100644 index 0000000000..2d44c38e3e --- /dev/null +++ b/.changeset/17329-seed-settled-ipc-message.md @@ -0,0 +1,17 @@ +--- +"@objectstack/cli": minor +--- + +`os serve` now announces **`objectstack:seed-settled`** on its existing ipc channel when this boot's seeding has come to rest, and `os dev` forwards it to its own parent process when one holds the channel. A script that spawns a dev server can finally wait for the boot to finish without reading the child's output. + +`✓ Server is ready` is true about the HTTP server and says nothing about the app. Seeding races a soft budget (`OS_INLINE_SEED_BUDGET_MS`, default 8s) and past it finishes in the background, so the banner can be a minute ahead of the seed's own result — measured downstream at **82 seconds of silence after the banner, then 120 `ERROR` lines**. The same command on the same corpus settles before the banner on a machine where the seed fits its budget, so the defect is invisible on exactly the boxes that would have caught it. Everything that distinguishes the two cases arrives on the child's inherited stdio, and reading that costs the boot its TTY. + +- **The producer is not new.** `@objectstack/runtime` already declares every seed source and settles it at the moment its boot-time write is done, publishing the tally under `@objectstack/spec`'s `seed-settlement` contract. This is the hop outward: the CLI subscribes to two hooks the kernel already fires and reads a snapshot it already publishes. No service is registered and no tally is mutated — the contract is read-only by design. +- **Sent once, and never before `objectstack:listening`.** Seeding that settles during `runtime.start()` is latched and released after the bound port is published, so a parent that waits for the listening message and only then listens for the settle cannot miss it. +- ⛔ **Keyed on `inFlight`, not `pending`.** Multi-tenant replay and `skipSeedData` register a seed source and deliberately never run it, keeping `pending` above zero for the life of the process. A `pending`-keyed message would never be sent on those boots, and its absence would be indistinguishable from a boot still writing — the same ambiguity this closes, one level up. Those boots get the message with `suppressed` reasons attached instead, so a consumer can say *why* no rows landed. +- **Failure settles too.** A seed that failed has still come to rest; withholding there would recreate the hang. `ok` is a verdict on the per-source counts the boot recorded, and the message carries those counts. +- **The over-budget banner no longer omits seeding.** `Seeds:` is fed by outcomes recorded when a load *finishes*, so past the budget the row was ABSENT and the transcript was byte-identical to an app that declares no seeds — which is how the defect hid. It now reads `pending — N sources still writing`, with a line saying seeding continues in the background; suppressed sources are named rather than reported as pending. + +⛔ An ipc channel is **not** made a requirement of either command: `process.send` is undefined under an ordinary terminal boot, both sends are no-ops there, and no byte of that transcript changes. Nothing in the existing `objectstack:listening` publication moves. + +Note that `os dev` consumes `objectstack:listening` itself (it is how the bound-port readout and the MCP connect hint learn the real port) and relays only `objectstack:seed-settled`. Spawn `os serve` directly to receive both in one place. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 1bb4e17f6f..ffbe20acbc 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -234,6 +234,67 @@ audit) lands there instead of the business DB (ADR-0057). Opt out with `OS_TELEMETRY_DB=0`, or point it elsewhere (any mode, including `serve`) with `OS_TELEMETRY_DB=`. +##### Waiting for the boot from a parent process + +`✓ Server is ready` is true about the **HTTP server**, and deliberately says +nothing about the app's data. Seeding races a soft budget +(`OS_INLINE_SEED_BUDGET_MS`, default `8000`); when it runs long the kernel +starts anyway and the rest of the seed finishes **in the background** — so the +banner, and anything that waits for it, can be a minute ahead of the seed's own +result. On a machine where the seed fits its budget the same command settles +before the banner. Both are normal, and which one you get depends on the box. + +So a script that spawns a dev server and wants to act **after the boot has come +to rest** should not wait on the banner, and should not need to read the child's +output at all. Spawn with an `ipc` channel and wait for a message: + +| Message | Sent by | Means | +|---|---|---| +| `objectstack:listening` | `os serve` | The HTTP server is bound. Carries `{ port, url }` — the port actually bound, which in dev may differ from the one requested. | +| `objectstack:seed-settled` | `os serve` | Nothing is still seeding. Carries `{ ok, suppressed, sources }`. Sent once per boot, always **after** `objectstack:listening`. | + +```js +import { spawn } from 'node:child_process'; + +const child = spawn('os', ['dev'], { stdio: ['inherit', 'inherit', 'inherit', 'ipc'] }); + +child.on('message', (msg) => { + if (msg?.type !== 'objectstack:seed-settled') return; + if (msg.suppressed.length > 0) { + console.log(`boot complete — seeds not run this boot (${msg.suppressed.join(', ')})`); + } else if (!msg.ok) { + console.log('boot complete — but some seed records did not land; see the log above'); + } else { + console.log('boot complete — the app is ready to use'); + } +}); +``` + +**`os dev` spawns `os serve`, and the two channels are not symmetric.** `os dev` +consumes `objectstack:listening` itself — it is how the `↪ server bound to port` +line and the MCP connect hint learn the real port — and does **not** relay it. +It forwards `objectstack:seed-settled` to its own parent verbatim. Spawn +`os serve` directly if you need both messages in one place. + +An `ipc` channel is optional: without one, both sends are no-ops and nothing +about the command changes. There is no polling to do — if you did not open the +channel, the messages simply are not sent. + + +It is sent when **nothing is still writing** — on success *and* on failure, since +a seed that failed has still come to rest. Read `ok` together with `sources` +rather than alone: `ok` is a verdict on the per-source counts the boot recorded, +and a source that finished by throwing may record no counts at all. + +`suppressed` is non-empty when this boot registered a seed source and +deliberately never ran it — `multi-tenant-replay` (rows are written per +organization on `sys_organization` insert) or `skip-seed-data` (a planning boot +that writes nothing). Those sources never settle and no further signal is +coming for them, which is exactly why the message is sent anyway with the reason +attached: a consumer that waited for *every* source to finish would wait +forever. + + #### `os serve` Starts the ObjectStack server with automatic plugin discovery: