Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/22255-policy-unbound-info-glyph.md
Original file line number Diff line number Diff line change
@@ -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.
117 changes: 116 additions & 1 deletion packages/cli/src/utils/format.boot-warning-classes.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<typeof vi.spyOn>;

/** 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'));
});
});

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
52 changes: 46 additions & 6 deletions packages/cli/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`);
}
Expand Down
Loading