Skip to content

Commit abd2545

Browse files
fix(cli): print the deployment-policy unbound-flow class under a dim ℹ glyph (#22409)
Fixes #22255 Clause-②: no ## What changes The startup banner's `Flows:` section prints one line per unbound-flow class (trigger type, reason), and every class printed as a yellow `⚠`. A class whose reason is the deployment's scheduled-work sentence (flows left unbound only because package-authored scheduled work is off, the documented default) now prints **dim, under `ℹ`**. It keeps its count, trigger type, first sentence and flow list. Every other reason keeps its yellow `⚠`: a missing trigger, a binding failure, anything else. The unknown-target-object and shadowed-flow lines are untouched. This applies the routing of record (triage `6057431712`), which executes the maintainer's direction on #22160, quoted verbatim: 「预期中的降级记 info」. - `packages/cli/src/utils/format.ts`: a new `isDeploymentPolicyClass(reason)` and the class loop in `printAutomationSummary`. Only the glyph and the color depend on the class. The line's text, the class order and the records handed back to *Boot diagnostics* (`restatedAbove`) are unchanged. - ⛔ Not changed: the automation plugin's binding, the scheduled-work switch, `@objectstack/service-automation`'s own log level, `serve.ts` and `packages/spec`. ## How the class is told apart: identity, not prose `isDeploymentPolicyClass` is `reason === SCHEDULED_WORK_DISABLED_REASON` (`@objectstack/types`). Measured on `origin/main` at `11d119ab1`: - The engine records `scheduledWorkDisabledReason(policy)` from the policy reading that refused the bind (`activateFlowTrigger`). `describeUnboundReason` reads that record back for `getTriggerBindingAudit`, and `collectAutomationSummary` passes it through unchanged. - For every policy the environment resolves, that function returns the constant byte for byte: `resolveScheduledWorkPolicy` never sets `hostDisabledReason` (pinned in `env.test.ts`). - The test file's real-producer leg boots the real `AutomationServicePlugin` on a `LiteKernel` and reads the summary through `collectAutomationSummary`. Its schedule line is now asserted to start with `ℹ`, so the identity is pinned against what the producer actually records. If the producer is reworded, the line goes back to `⚠`, which is the loud direction. - The check does not call `scheduledWorkDisabledReason(resolveScheduledWorkPolicy())`. On that reading the call returns the same constant by construction, and the resolver throws on an unrecognised `OS_TENANCY_POSTURE`, which a printer must not do. - No structured kind was needed. So neither `serve.ts` (held by PR #22381) nor the spec contract is touched. ## Pins (`format.boot-warning-classes.test.ts`) - **Real boot** (eight scheduled flows, switch off): the one schedule line now starts with ` ℹ 8 flows declare `. - **Policy class:** prints dim (opening SGR `ESC[2m`, no yellow) under `ℹ`, with its flows and first sentence. Controls: a missing trigger and a binding failure keep a yellow (`ESC[33m`) `⚠`. - **⛔ Identity, not words:** three reasons that only quote the policy all keep `⚠`. They are a binding failure carrying the policy's first sentence, the whole sentence inside a longer record (the schedule trigger's own refusal shape), and the first sentence alone. - **`restatedAbove` is the same under both glyphs:** a policy flow, a missing-trigger flow and a binding-failure flow, each with its producer audit record. Every flow is named once, and no *Boot diagnostics* block prints. - The existing first-sentence pin moves from `⚠` to `ℹ`. ## Evidence (head `62c86cba9`, branched from `11d119ab1`) **Public door.** `objectstack dev --seed-admin --fresh -p 38421` on `examples/app-showcase`, with the CLI built from this head. The Flows section, SGR stripped and the shared sentence cut to an ellipsis: ```text Flows: 30 flow(s) 20 bound to triggers (record_change, schedule, time_relative, api) · 7 draft ℹ 1 flow declares a 'time_relative' trigger but is NOT bound — disabled by deployment policy — … : showcase_task_due_reminder ℹ 1 flow declares a 'schedule' trigger but is NOT bound — disabled by deployment policy — … : showcase_scheduled_digest reasons cut to their first sentence — --log-level debug prints each flow's full reason Seeds: com.example.showcase 132 rows ℹ Boot diagnostics — 1 informational (2 more already listed above): ``` Both class lines open with the dim SGR (`ESC[2m`) in the raw capture, and *Boot diagnostics* still withholds the two restated records. That boot printed two other `⚠` lines, both about this worktree having no console build (`packages/console/dist` and the SDUI manifest). They come from the environment, not from this change. **Tests and gates**, all at `62c86cba9`: | Check | Result | |:---|:---| | `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` | 270 files, 3971 tests passed, lock `VERDICT command-exit 0` | | `format.boot-warning-classes.test.ts` alone | 21 of 21 passed | | `pnpm --filter @objectstack/cli typecheck` | exit 0 (`tsc --noEmit`, then `check:test-typecheck` over `tsconfig.test.json`) | | `pnpm lint` (full: `eslint . --no-inline-config`) | exit 0, no findings | | derived gate families | `dispatch-gates --ran`: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero, every exit code recorded) | The integration layer is declared to CI: the diff reaches no integration file and no spawn entry. **Ablation.** Run on the committed fix with `scripts/ablation-replace.mjs`. In each leg the anchor went from 1 hit to 0, and the restore brought the blob back to `5804f510beed`, equal to HEAD, with `git diff HEAD` empty. - **Leg 1**, identity forced to `false`: 3 failed, 18 passed. The failures are the real-boot `ℹ` pin, the first-sentence pin and the dim/`ℹ` pin. - **Leg 2**, identity replaced by `reason.includes('disabled by deployment policy')`: 1 failed, 20 passed. The failure is the identity pin. The test imports `./format.js`, which vitest resolves to `src/utils/format.ts`, so neither leg depended on a `dist` rebuild. ## Acceptance notes - **Host-injected policy.** A per-kernel `ScheduledWorkPolicy` that carries its own `hostDisabledReason` keeps `⚠`. That sentence is the host's, not the documented default, and the printer cannot know it. For this banner the case is dormant: `git grep` finds 0 non-test producers of `hostDisabledReason:` in the tree, against a positive control of 4 hits in test files. Telling that class apart would need a structured kind on the audit row (the spec contract plus `serve.ts`), which this PR does not add. - **Contract wording.** `FlowRuntimeState.reason` in the spec contract says consumers render the reason and do not parse it. An equality check against the exported producer constant is not parsing, and no prose match was added. - **Stale prose this change makes inaccurate, left unedited** (outside the claim's file surface): - `docs/qa/platform-checklist/areas/platform-core.json` acceptance[2].verify describes the policy lines as `⚠ … disabled by deployment policy`. Its clause (fail on the misauthored `⚠` classes) still holds, and is now easier to apply. - The still-pending `.changeset/22073-boot-warning-one-line-per-class.md` quotes the class with `⚠`. This PR's changeset states the move to `ℹ`, and the two would ship in the same release. - **Line order is unchanged** (first-seen). A dim policy line can still print above a `⚠` class: the one-line-per-class order belongs to the earlier ruling and was not re-ruled. --- _Generated by [Claude Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e75dced commit abd2545

3 files changed

Lines changed: 173 additions & 7 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
The startup banner's `Flows:` section prints flows left unbound by the deployment's scheduled-work switch as information, not as a warning.
6+
7+
Clause-②: no
8+
9+
- **Information, not a warning.** Flows that declare a time trigger but are not bound because package-authored scheduled work is off on this deployment (`OS_AUTOMATION_SCHEDULED_WORK_ENABLED` unset — the default) now print dim, under `ℹ`: `ℹ 8 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — … (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms …: flow_a, flow_b, …`. The line's text, its flow list and its place among the other lines are unchanged. Before, it printed as a yellow `⚠`, though nothing about those flows needs fixing.
10+
- **Every other reason keeps `⚠`.** A binding failure, a missing trigger, an unknown target object and a shadowed flow name still print as yellow warnings. The class is recognised only by the exact sentence the automation engine records for the deployment switch, so a reason that merely mentions the policy stays a warning. A host that turns scheduled work off with its own reason sentence also keeps `⚠`.
11+
- ⛔ Nothing you author changes. Which flows bind, the scheduled-work switch, the level `@objectstack/service-automation` logs at, *Boot diagnostics* and every public key, export and parameter are unchanged.

‎packages/cli/src/utils/format.boot-warning-classes.test.ts‎

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import chalk from 'chalk';
45
import { LiteKernel, ObjectLogger, type Plugin, type PluginContext } from '@objectstack/core';
56
import { runActionGovernanceInventory } from '@objectstack/objectql';
67
import { AutomationServicePlugin } from '@objectstack/service-automation';
@@ -93,17 +94,22 @@ const BASE: ServerReadyOptions = {
9394
};
9495

9596
let transcript: string[];
97+
/** The same lines with their SGR kept — for the pins that read a line's color. */
98+
let rawTranscript: string[];
9699
let errSpy: ReturnType<typeof vi.spyOn>;
97100

98101
/** Strip SGR so assertions hold whether or not chalk colors this run. */
99102
const plain = (s: string) => s.replace(/\u001b\[[0-9;]*m/g, '');
100103

101104
const linesWith = (needle: string) => transcript.filter((line) => line.includes(needle));
105+
const rawLinesWith = (needle: string) => rawTranscript.filter((line) => plain(line).includes(needle));
102106

103107
beforeEach(() => {
104108
transcript = [];
109+
rawTranscript = [];
105110
errSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
106111
for (const line of plain(args.join(' ')).split('\n')) transcript.push(line);
112+
rawTranscript.push(...args.join(' ').split('\n'));
107113
});
108114
});
109115

@@ -206,6 +212,10 @@ describe('a real automation boot with eight scheduled flows and scheduled work o
206212
// The class's short text is the reason's first sentence — cause and switch.
207213
expect(scheduleLines[0]).toContain('disabled by deployment policy');
208214
expect(scheduleLines[0]).toContain(SCHEDULED_WORK_ENV);
215+
// [#22255] Information, not a warning — and asserted against the REAL
216+
// producer's recorded reason, so the printer's identity with it is pinned
217+
// here: a reworded producer turns this line back into a `⚠`.
218+
expect(scheduleLines[0]).toMatch(/^ {2}ℹ 8 flows declare /);
209219
});
210220

211221
it('prints no warning twice — every flow named on exactly one line', async () => {
@@ -280,7 +290,7 @@ describe('one line per warning class (formatter)', () => {
280290

281291
const firstSentence = SCHEDULED_WORK_DISABLED_REASON.slice(0, SCHEDULED_WORK_DISABLED_REASON.indexOf('. This'));
282292
expect(linesWith('NOT bound')).toEqual([
283-
` ⚠ 1 flow declares a 'schedule' trigger but is NOT bound — ${firstSentence}: a_flow`,
293+
` ℹ 1 flow declares a 'schedule' trigger but is NOT bound — ${firstSentence}: a_flow`,
284294
]);
285295
expect(linesWith(LONG_EXPLANATION)).toEqual([]);
286296
expect(linesWith('--log-level debug')).toHaveLength(1);
@@ -298,6 +308,111 @@ describe('one line per warning class (formatter)', () => {
298308
});
299309
});
300310

311+
// ---------------------------------------------------------------------------
312+
// #22255 — the deployment-policy class is information
313+
// ---------------------------------------------------------------------------
314+
315+
/**
316+
* Flows left unbound because the deployment switched scheduled work off — its
317+
* documented default — are an expected degradation, so their class line
318+
* prints dim under `ℹ` (the maintainer's direction on #22160, 「预期中的降级记
319+
* info」). Every other unbound reason is the author's to fix and keeps its
320+
* yellow `⚠`. Only the glyph and color move: the text, the order and the
321+
* records the banner hands back to Boot diagnostics are #22073's, unchanged.
322+
*/
323+
describe('the deployment-policy class prints as information (#22255, formatter)', () => {
324+
const missingTrigger =
325+
"no 'schedule' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)";
326+
const bindingFailed = "trigger 'schedule' is registered but binding failed — see earlier warnings";
327+
const firstSentence = SCHEDULED_WORK_DISABLED_REASON.slice(0, SCHEDULED_WORK_DISABLED_REASON.indexOf('. This'));
328+
/** chalk's opening SGR for `dim` and for `yellow`. */
329+
const DIM = '\u001b[2m';
330+
const YELLOW = '\u001b[33m';
331+
332+
/** Print with color on whatever this run's stderr is: the color split is half the pin. */
333+
function printColored(opts: ServerReadyOptions): void {
334+
const prior = chalk.level;
335+
chalk.level = 1;
336+
try {
337+
printServerReady(opts);
338+
} finally {
339+
chalk.level = prior;
340+
}
341+
}
342+
343+
it('prints the policy class dim under ℹ with its flows and first sentence; a missing trigger and a binding failure keep a yellow ⚠', () => {
344+
printColored({
345+
...BASE,
346+
automation: summary({
347+
unbound: [
348+
...policyRefused(['a_flow', 'b_flow']),
349+
{ flowName: 'c_flow', triggerType: 'schedule', reason: missingTrigger },
350+
{ flowName: 'd_flow', triggerType: 'schedule', reason: bindingFailed },
351+
],
352+
}),
353+
});
354+
355+
expect(linesWith('NOT bound')).toEqual([
356+
` ℹ 2 flows declare a 'schedule' trigger but are NOT bound — ${firstSentence}: a_flow, b_flow`,
357+
` ⚠ 1 flow declares a 'schedule' trigger but is NOT bound — ${missingTrigger}: c_flow`,
358+
` ⚠ 1 flow declares a 'schedule' trigger but is NOT bound — ${bindingFailed}: d_flow`,
359+
]);
360+
const [policy, missing, failed] = rawLinesWith('NOT bound');
361+
expect(policy.startsWith(DIM), JSON.stringify(policy)).toBe(true);
362+
expect(policy).not.toContain(YELLOW);
363+
expect(missing.startsWith(YELLOW), JSON.stringify(missing)).toBe(true);
364+
expect(failed.startsWith(YELLOW), JSON.stringify(failed)).toBe(true);
365+
});
366+
367+
it('⛔ judges the class by identity with the producer, never by its words — a reason that only quotes the policy keeps ⚠', () => {
368+
const quoting = [
369+
// A binding failure whose text carries the policy's first sentence.
370+
`trigger 'schedule' is registered but binding failed — ${firstSentence}`,
371+
// The whole policy sentence inside a longer record — the shape the
372+
// schedule trigger's own refusal log takes.
373+
`[ScheduleTrigger] flow 'q1_flow' is not armed: ${SCHEDULED_WORK_DISABLED_REASON}`,
374+
// The first sentence alone, as a reworded producer could leave it.
375+
firstSentence,
376+
];
377+
printColored({
378+
...BASE,
379+
automation: summary({
380+
unbound: quoting.map((reason, i) => ({ flowName: `q${i}_flow`, triggerType: 'schedule', reason })),
381+
}),
382+
});
383+
384+
const lines = linesWith('NOT bound');
385+
expect(lines, transcript.join('\n')).toHaveLength(quoting.length);
386+
for (const line of lines) expect(line).toMatch(/^ {2}⚠ /);
387+
for (const line of rawLinesWith('NOT bound')) expect(line.startsWith(YELLOW), JSON.stringify(line)).toBe(true);
388+
});
389+
390+
it('hands back the same restated records under either glyph — each flow is named once, and Boot diagnostics prints nothing', () => {
391+
printServerReady({
392+
...BASE,
393+
automation: summary({
394+
unbound: [
395+
...policyRefused(['a_flow']),
396+
{ flowName: 'b_flow', triggerType: 'schedule', reason: missingTrigger },
397+
{ flowName: 'c_flow', triggerType: 'schedule', reason: bindingFailed },
398+
],
399+
}),
400+
bootDiagnostics: {
401+
lines: [
402+
auditRecord('a_flow'),
403+
auditRecord('b_flow', 'schedule', missingTrigger),
404+
auditRecord('c_flow', 'schedule', bindingFailed),
405+
],
406+
},
407+
});
408+
409+
for (const name of ['a_flow', 'b_flow', 'c_flow']) {
410+
expect(linesWith(name), transcript.join('\n')).toHaveLength(1);
411+
}
412+
expect(linesWith('Boot diagnostics')).toEqual([]);
413+
});
414+
});
415+
301416
describe('print-once: Boot diagnostics withholds what the banner restated (formatter)', () => {
302417
// A boot warning no banner section restates. Shaped as the runtime-assets
303418
// plugin's branding warning (`describeUnservedBrandingAssets` in

‎packages/cli/src/utils/format.ts‎

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import type { TenancyPosture } from '@objectstack/spec/security';
1111
import type { SeedSettlementSnapshot } from '@objectstack/spec/contracts';
1212
import type { DevLogin } from '@objectstack/spec/system';
1313
import { explainRule } from '@objectstack/lint/rule-explanations';
14+
// #22255 — the deployment's scheduled-work sentence, read as an IDENTITY: the
15+
// Flows section tells the policy class apart by equality with it, never by its
16+
// words. See `isDeploymentPolicyClass`.
17+
import { SCHEDULED_WORK_DISABLED_REASON } from '@objectstack/types';
1418
import { writeStdoutDirect } from './json-stdout.js';
1519
import { authoringRuleUnionStack } from './stack-collections.js';
1620
import { stripAnsi } from './boot-log-capture.js';
@@ -1587,6 +1591,38 @@ function unboundFlowClasses(
15871591
return [...classes.values()];
15881592
}
15891593

1594+
/**
1595+
* Whether one unbound-flow class is the deployment's scheduled-work switch
1596+
* (#22255) — flows refused because package-authored scheduled work is OFF,
1597+
* the documented default. That is an expected degradation, so its class line
1598+
* prints dim under `ℹ`, the way the maintainer's noise budget asks for one
1599+
* (「预期中的降级记 info」); every other class is the author's to fix and keeps
1600+
* its yellow `⚠`.
1601+
*
1602+
* Judged by IDENTITY with the producer's sentence, ⛔ never by its words. The
1603+
* engine records `scheduledWorkDisabledReason(policy)` of the reading that
1604+
* refused, and on every policy the environment resolves that is
1605+
* `SCHEDULED_WORK_DISABLED_REASON` byte for byte (`resolveScheduledWorkPolicy`
1606+
* never sets a `hostDisabledReason`). So equality is exact in both directions:
1607+
*
1608+
* - a binding failure, a missing trigger, or any reason that merely QUOTES the
1609+
* policy's words is not equal, and keeps `⚠`;
1610+
* - a reworded producer is not equal either, and drifts back to `⚠` — the
1611+
* loud direction. A substring or pattern match would drift the other way,
1612+
* dimming a real failure that happened to share a phrase.
1613+
*
1614+
* A host-injected per-kernel policy carrying its OWN `hostDisabledReason` is
1615+
* not this class: its sentence is the host's, not the documented default, and
1616+
* the printer cannot know it — it keeps `⚠`, as before.
1617+
*
1618+
* Not `scheduledWorkDisabledReason(resolveScheduledWorkPolicy())`: on that
1619+
* reading it answers this same constant by construction, and the resolver
1620+
* throws on an unrecognised `OS_TENANCY_POSTURE` — a printer must not.
1621+
*/
1622+
function isDeploymentPolicyClass(reason: string): boolean {
1623+
return reason === SCHEDULED_WORK_DISABLED_REASON;
1624+
}
1625+
15901626
/**
15911627
* One-glance answer to "did my flows actually arm?" — the question the
15921628
* boot-quiet stdout window otherwise makes unanswerable (the engine's own
@@ -1640,17 +1676,21 @@ function printAutomationSummary(a: AutomationReadySummary): string[] {
16401676
// eight package-authored scheduled flows on a deployment with scheduled work
16411677
// off used to print the same paragraph eight times here and eight more in
16421678
// Boot diagnostics; it now prints one line.
1679+
//
1680+
// [#22255] Only the glyph and color depend on the class: the deployment's
1681+
// scheduled-work switch is information (dim `ℹ`), every other reason a
1682+
// warning (yellow `⚠`). Text, order and the records handed back are the same
1683+
// for both.
16431684
let shortened = false;
16441685
for (const c of unboundFlowClasses(a.unbound)) {
16451686
const n = c.flowNames.length;
16461687
const short = leadSentence(c.reason);
16471688
if (short !== c.reason.trim().replace(/\.$/, '')) shortened = true;
1648-
console.error(
1649-
chalk.yellow(
1650-
` ⚠ ${n} flow${n === 1 ? ' declares' : 's declare'} a '${c.triggerType}' trigger but ` +
1651-
`${n === 1 ? 'is' : 'are'} NOT bound — ${short}: ${c.flowNames.join(', ')}`,
1652-
),
1653-
);
1689+
const expected = isDeploymentPolicyClass(c.reason);
1690+
const line =
1691+
` ${expected ? 'ℹ' : '⚠'} ${n} flow${n === 1 ? ' declares' : 's declare'} a '${c.triggerType}' trigger but ` +
1692+
`${n === 1 ? 'is' : 'are'} NOT bound — ${short}: ${c.flowNames.join(', ')}`;
1693+
console.error(expected ? chalk.dim(line) : chalk.yellow(line));
16541694
for (const flowName of c.flowNames) {
16551695
restated.push(`[Automation] flow '${flowName}' declares a '${c.triggerType}' trigger but is NOT bound`);
16561696
}

0 commit comments

Comments
 (0)