diff --git a/.changeset/22255-policy-unbound-info-glyph.md b/.changeset/22255-policy-unbound-info-glyph.md new file mode 100644 index 00000000000..4a947c2b6fa --- /dev/null +++ b/.changeset/22255-policy-unbound-info-glyph.md @@ -0,0 +1,11 @@ +--- +"@objectstack/cli": patch +--- + +The startup banner's `Flows:` section prints flows left unbound by the deployment's scheduled-work switch as information, not as a warning. + +Clause-②: no + +- **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. +- **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 `⚠`. +- ⛔ 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. diff --git a/packages/cli/src/utils/format.boot-warning-classes.test.ts b/packages/cli/src/utils/format.boot-warning-classes.test.ts index 112f5b78c3e..4c855299d32 100644 --- a/packages/cli/src/utils/format.boot-warning-classes.test.ts +++ b/packages/cli/src/utils/format.boot-warning-classes.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import chalk from 'chalk'; import { LiteKernel, ObjectLogger, type Plugin, type PluginContext } from '@objectstack/core'; import { runActionGovernanceInventory } from '@objectstack/objectql'; import { AutomationServicePlugin } from '@objectstack/service-automation'; @@ -93,17 +94,22 @@ const BASE: ServerReadyOptions = { }; let transcript: string[]; +/** The same lines with their SGR kept — for the pins that read a line's color. */ +let rawTranscript: string[]; let errSpy: ReturnType; /** Strip SGR so assertions hold whether or not chalk colors this run. */ const plain = (s: string) => s.replace(/\u001b\[[0-9;]*m/g, ''); const linesWith = (needle: string) => transcript.filter((line) => line.includes(needle)); +const rawLinesWith = (needle: string) => rawTranscript.filter((line) => plain(line).includes(needle)); beforeEach(() => { transcript = []; + rawTranscript = []; errSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { for (const line of plain(args.join(' ')).split('\n')) transcript.push(line); + rawTranscript.push(...args.join(' ').split('\n')); }); }); @@ -206,6 +212,10 @@ describe('a real automation boot with eight scheduled flows and scheduled work o // The class's short text is the reason's first sentence — cause and switch. expect(scheduleLines[0]).toContain('disabled by deployment policy'); expect(scheduleLines[0]).toContain(SCHEDULED_WORK_ENV); + // [#22255] Information, not a warning — and asserted against the REAL + // producer's recorded reason, so the printer's identity with it is pinned + // here: a reworded producer turns this line back into a `⚠`. + expect(scheduleLines[0]).toMatch(/^ {2}ℹ 8 flows declare /); }); it('prints no warning twice — every flow named on exactly one line', async () => { @@ -280,7 +290,7 @@ describe('one line per warning class (formatter)', () => { const firstSentence = SCHEDULED_WORK_DISABLED_REASON.slice(0, SCHEDULED_WORK_DISABLED_REASON.indexOf('. This')); expect(linesWith('NOT bound')).toEqual([ - ` ⚠ 1 flow declares a 'schedule' trigger but is NOT bound — ${firstSentence}: a_flow`, + ` ℹ 1 flow declares a 'schedule' trigger but is NOT bound — ${firstSentence}: a_flow`, ]); expect(linesWith(LONG_EXPLANATION)).toEqual([]); expect(linesWith('--log-level debug')).toHaveLength(1); @@ -298,6 +308,111 @@ describe('one line per warning class (formatter)', () => { }); }); +// --------------------------------------------------------------------------- +// #22255 — the deployment-policy class is information +// --------------------------------------------------------------------------- + +/** + * Flows left unbound because the deployment switched scheduled work off — its + * documented default — are an expected degradation, so their class line + * prints dim under `ℹ` (the maintainer's direction on #22160, 「预期中的降级记 + * info」). Every other unbound reason is the author's to fix and keeps its + * yellow `⚠`. Only the glyph and color move: the text, the order and the + * records the banner hands back to Boot diagnostics are #22073's, unchanged. + */ +describe('the deployment-policy class prints as information (#22255, formatter)', () => { + const missingTrigger = + "no 'schedule' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)"; + const bindingFailed = "trigger 'schedule' is registered but binding failed — see earlier warnings"; + const firstSentence = SCHEDULED_WORK_DISABLED_REASON.slice(0, SCHEDULED_WORK_DISABLED_REASON.indexOf('. This')); + /** chalk's opening SGR for `dim` and for `yellow`. */ + const DIM = '\u001b[2m'; + const YELLOW = '\u001b[33m'; + + /** Print with color on whatever this run's stderr is: the color split is half the pin. */ + function printColored(opts: ServerReadyOptions): void { + const prior = chalk.level; + chalk.level = 1; + try { + printServerReady(opts); + } finally { + chalk.level = prior; + } + } + + it('prints the policy class dim under ℹ with its flows and first sentence; a missing trigger and a binding failure keep a yellow ⚠', () => { + printColored({ + ...BASE, + automation: summary({ + unbound: [ + ...policyRefused(['a_flow', 'b_flow']), + { flowName: 'c_flow', triggerType: 'schedule', reason: missingTrigger }, + { flowName: 'd_flow', triggerType: 'schedule', reason: bindingFailed }, + ], + }), + }); + + expect(linesWith('NOT bound')).toEqual([ + ` ℹ 2 flows declare a 'schedule' trigger but are NOT bound — ${firstSentence}: a_flow, b_flow`, + ` ⚠ 1 flow declares a 'schedule' trigger but is NOT bound — ${missingTrigger}: c_flow`, + ` ⚠ 1 flow declares a 'schedule' trigger but is NOT bound — ${bindingFailed}: d_flow`, + ]); + const [policy, missing, failed] = rawLinesWith('NOT bound'); + expect(policy.startsWith(DIM), JSON.stringify(policy)).toBe(true); + expect(policy).not.toContain(YELLOW); + expect(missing.startsWith(YELLOW), JSON.stringify(missing)).toBe(true); + expect(failed.startsWith(YELLOW), JSON.stringify(failed)).toBe(true); + }); + + it('⛔ judges the class by identity with the producer, never by its words — a reason that only quotes the policy keeps ⚠', () => { + const quoting = [ + // A binding failure whose text carries the policy's first sentence. + `trigger 'schedule' is registered but binding failed — ${firstSentence}`, + // The whole policy sentence inside a longer record — the shape the + // schedule trigger's own refusal log takes. + `[ScheduleTrigger] flow 'q1_flow' is not armed: ${SCHEDULED_WORK_DISABLED_REASON}`, + // The first sentence alone, as a reworded producer could leave it. + firstSentence, + ]; + printColored({ + ...BASE, + automation: summary({ + unbound: quoting.map((reason, i) => ({ flowName: `q${i}_flow`, triggerType: 'schedule', reason })), + }), + }); + + const lines = linesWith('NOT bound'); + expect(lines, transcript.join('\n')).toHaveLength(quoting.length); + for (const line of lines) expect(line).toMatch(/^ {2}⚠ /); + for (const line of rawLinesWith('NOT bound')) expect(line.startsWith(YELLOW), JSON.stringify(line)).toBe(true); + }); + + it('hands back the same restated records under either glyph — each flow is named once, and Boot diagnostics prints nothing', () => { + printServerReady({ + ...BASE, + automation: summary({ + unbound: [ + ...policyRefused(['a_flow']), + { flowName: 'b_flow', triggerType: 'schedule', reason: missingTrigger }, + { flowName: 'c_flow', triggerType: 'schedule', reason: bindingFailed }, + ], + }), + bootDiagnostics: { + lines: [ + auditRecord('a_flow'), + auditRecord('b_flow', 'schedule', missingTrigger), + auditRecord('c_flow', 'schedule', bindingFailed), + ], + }, + }); + + for (const name of ['a_flow', 'b_flow', 'c_flow']) { + expect(linesWith(name), transcript.join('\n')).toHaveLength(1); + } + expect(linesWith('Boot diagnostics')).toEqual([]); + }); +}); + describe('print-once: Boot diagnostics withholds what the banner restated (formatter)', () => { // A boot warning no banner section restates. Shaped as the runtime-assets // plugin's branding warning (`describeUnservedBrandingAssets` in diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 40dcdfee995..5804f510bee 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -11,6 +11,10 @@ import type { TenancyPosture } from '@objectstack/spec/security'; import type { SeedSettlementSnapshot } from '@objectstack/spec/contracts'; import type { DevLogin } from '@objectstack/spec/system'; import { explainRule } from '@objectstack/lint/rule-explanations'; +// #22255 — the deployment's scheduled-work sentence, read as an IDENTITY: the +// Flows section tells the policy class apart by equality with it, never by its +// words. See `isDeploymentPolicyClass`. +import { SCHEDULED_WORK_DISABLED_REASON } from '@objectstack/types'; import { writeStdoutDirect } from './json-stdout.js'; import { authoringRuleUnionStack } from './stack-collections.js'; import { stripAnsi } from './boot-log-capture.js'; @@ -1587,6 +1591,38 @@ function unboundFlowClasses( return [...classes.values()]; } +/** + * Whether one unbound-flow class is the deployment's scheduled-work switch + * (#22255) — flows refused because package-authored scheduled work is OFF, + * the documented default. That is an expected degradation, so its class line + * prints dim under `ℹ`, the way the maintainer's noise budget asks for one + * (「预期中的降级记 info」); every other class is the author's to fix and keeps + * its yellow `⚠`. + * + * Judged by IDENTITY with the producer's sentence, ⛔ never by its words. The + * engine records `scheduledWorkDisabledReason(policy)` of the reading that + * refused, and on every policy the environment resolves that is + * `SCHEDULED_WORK_DISABLED_REASON` byte for byte (`resolveScheduledWorkPolicy` + * never sets a `hostDisabledReason`). So equality is exact in both directions: + * + * - a binding failure, a missing trigger, or any reason that merely QUOTES the + * policy's words is not equal, and keeps `⚠`; + * - a reworded producer is not equal either, and drifts back to `⚠` — the + * loud direction. A substring or pattern match would drift the other way, + * dimming a real failure that happened to share a phrase. + * + * A host-injected per-kernel policy carrying its OWN `hostDisabledReason` is + * not this class: its sentence is the host's, not the documented default, and + * the printer cannot know it — it keeps `⚠`, as before. + * + * Not `scheduledWorkDisabledReason(resolveScheduledWorkPolicy())`: on that + * reading it answers this same constant by construction, and the resolver + * throws on an unrecognised `OS_TENANCY_POSTURE` — a printer must not. + */ +function isDeploymentPolicyClass(reason: string): boolean { + return reason === SCHEDULED_WORK_DISABLED_REASON; +} + /** * One-glance answer to "did my flows actually arm?" — the question the * boot-quiet stdout window otherwise makes unanswerable (the engine's own @@ -1640,17 +1676,21 @@ function printAutomationSummary(a: AutomationReadySummary): string[] { // eight package-authored scheduled flows on a deployment with scheduled work // off used to print the same paragraph eight times here and eight more in // Boot diagnostics; it now prints one line. + // + // [#22255] Only the glyph and color depend on the class: the deployment's + // scheduled-work switch is information (dim `ℹ`), every other reason a + // warning (yellow `⚠`). Text, order and the records handed back are the same + // for both. let shortened = false; for (const c of unboundFlowClasses(a.unbound)) { const n = c.flowNames.length; const short = leadSentence(c.reason); if (short !== c.reason.trim().replace(/\.$/, '')) shortened = true; - console.error( - chalk.yellow( - ` ⚠ ${n} flow${n === 1 ? ' declares' : 's declare'} a '${c.triggerType}' trigger but ` + - `${n === 1 ? 'is' : 'are'} NOT bound — ${short}: ${c.flowNames.join(', ')}`, - ), - ); + const expected = isDeploymentPolicyClass(c.reason); + const line = + ` ${expected ? 'ℹ' : '⚠'} ${n} flow${n === 1 ? ' declares' : 's declare'} a '${c.triggerType}' trigger but ` + + `${n === 1 ? 'is' : 'are'} NOT bound — ${short}: ${c.flowNames.join(', ')}`; + console.error(expected ? chalk.dim(line) : chalk.yellow(line)); for (const flowName of c.flowNames) { restated.push(`[Automation] flow '${flowName}' declares a '${c.triggerType}' trigger but is NOT bound`); }