@sensigo/realm-cli 0.40.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/providers/agent-utils.d.ts +37 -13
- package/dist/agent/providers/agent-utils.d.ts.map +1 -1
- package/dist/agent/providers/agent-utils.js +92 -17
- package/dist/agent/providers/agent-utils.js.map +1 -1
- package/dist/agent/run-attach.d.ts.map +1 -1
- package/dist/agent/run-attach.js +8 -21
- package/dist/agent/run-attach.js.map +1 -1
- package/dist/commands/agent.d.ts +6 -0
- package/dist/commands/agent.d.ts.map +1 -1
- package/dist/commands/agent.js +28 -4
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/drain.d.ts.map +1 -1
- package/dist/commands/drain.js +60 -9
- package/dist/commands/drain.js.map +1 -1
- package/dist/commands/inspect.d.ts.map +1 -1
- package/dist/commands/inspect.js +20 -2
- package/dist/commands/inspect.js.map +1 -1
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +61 -8
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/listen.d.ts +13 -0
- package/dist/commands/listen.d.ts.map +1 -1
- package/dist/commands/listen.js +36 -4
- package/dist/commands/listen.js.map +1 -1
- package/dist/commands/register.d.ts +1 -14
- package/dist/commands/register.d.ts.map +1 -1
- package/dist/commands/register.js +52 -56
- package/dist/commands/register.js.map +1 -1
- package/dist/commands/replay.d.ts.map +1 -1
- package/dist/commands/replay.js +4 -2
- package/dist/commands/replay.js.map +1 -1
- package/dist/commands/respond.d.ts.map +1 -1
- package/dist/commands/respond.js +30 -3
- package/dist/commands/respond.js.map +1 -1
- package/dist/commands/resume.d.ts.map +1 -1
- package/dist/commands/resume.js +3 -2
- package/dist/commands/resume.js.map +1 -1
- package/dist/commands/run.d.ts +35 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +246 -12
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/test.d.ts.map +1 -1
- package/dist/commands/test.js +51 -8
- package/dist/commands/test.js.map +1 -1
- package/dist/commands/validate.d.ts +31 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +658 -132
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/watch.d.ts +43 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +366 -42
- package/dist/commands/watch.js.map +1 -1
- package/dist/commands/workflow-list.d.ts +16 -0
- package/dist/commands/workflow-list.d.ts.map +1 -0
- package/dist/commands/workflow-list.js +96 -0
- package/dist/commands/workflow-list.js.map +1 -0
- package/dist/commands-registry.d.ts.map +1 -1
- package/dist/commands-registry.js +2 -0
- package/dist/commands-registry.js.map +1 -1
- package/dist/extensions/load-project-extensions.d.ts.map +1 -1
- package/dist/extensions/load-project-extensions.js +9 -3
- package/dist/extensions/load-project-extensions.js.map +1 -1
- package/dist/index.js +22 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/admission-context.d.ts +58 -0
- package/dist/lib/admission-context.d.ts.map +1 -0
- package/dist/lib/admission-context.js +57 -0
- package/dist/lib/admission-context.js.map +1 -0
- package/dist/lib/load-workflow-for-admission.d.ts +69 -0
- package/dist/lib/load-workflow-for-admission.d.ts.map +1 -0
- package/dist/lib/load-workflow-for-admission.js +130 -0
- package/dist/lib/load-workflow-for-admission.js.map +1 -0
- package/dist/lib/loader-warnings.d.ts +28 -10
- package/dist/lib/loader-warnings.d.ts.map +1 -1
- package/dist/lib/loader-warnings.js +84 -30
- package/dist/lib/loader-warnings.js.map +1 -1
- package/package.json +4 -4
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
// realm validate <path> — validates a workflow YAML file without registering it.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// ONE admission path (issue #553): `validate <file>` calls the same `loadWorkflowForAdmission`
|
|
4
|
+
// register and watch call — file loader (agent-profile resolution), the unconditional
|
|
5
|
+
// project-extensions pass (modules, manifest, config_schema two-pass), real-then-sentinel
|
|
6
|
+
// secret resolution — so what validate blesses register accepts, and what register refuses
|
|
7
|
+
// validate refuses, by construction. The pre-#553 "strictness asymmetry" (extension-free
|
|
8
|
+
// workflows parsed from string, skipping every file-context check) is gone; `--registered`
|
|
9
|
+
// runs the same rules on the stored copy and supplies-or-declares the context-dependent ones.
|
|
8
10
|
import { Command } from 'commander';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { loadWorkflowFromStringWithDiagnostics, resolveAgentProfiles, WorkflowError, shouldEnforceTimeout, DEFAULT_EXECUTION_TIMEOUT_SECONDS, resolveSeverity, renderLoaderWarning, assessStructuredOutputEligibility, renderIneligibleMessage, JsonWorkflowStore, RUNTIME_ONLY_WORKFLOW_KEYS, VERSION, } from '@sensigo/realm';
|
|
14
|
+
import { renderLoadFailure, renderEscalationLine, printLoaderWarnings, rejectOnErrorSeverity, failsStrict, wrapSentinelWarnings, } from '../lib/loader-warnings.js';
|
|
15
|
+
import { loadWorkflowForAdmission, admitProjectExtensions, ExtensionLoadError, admittedDefinitionOf, } from '../lib/load-workflow-for-admission.js';
|
|
16
|
+
import { CONTEXT_DEPENDENT_CHECKS, notRunReason, renderChecksNotRunLine, } from '../lib/admission-context.js';
|
|
15
17
|
/**
|
|
16
18
|
* Advisory (issue A3, never rejects): an auto step declaring `retry:` but no `timeout_seconds`
|
|
17
19
|
* has EVERY attempt bounded by the generous DEFAULT_EXECUTION_TIMEOUT_SECONDS default — a hung
|
|
@@ -27,7 +29,8 @@ import { renderLoadFailure, printLoaderWarnings, rejectOnErrorSeverity, failsStr
|
|
|
27
29
|
* fires). The message is worded to cover both axes without requiring the reader to already know
|
|
28
30
|
* about the cap.
|
|
29
31
|
*/
|
|
30
|
-
|
|
32
|
+
/** @internal Exported for testing only. */
|
|
33
|
+
export function findRetryWithoutExplicitTimeout(definition) {
|
|
31
34
|
const warnings = [];
|
|
32
35
|
for (const [stepName, step] of Object.entries(definition.steps)) {
|
|
33
36
|
if (shouldEnforceTimeout(step) &&
|
|
@@ -38,7 +41,7 @@ function findRetryWithoutExplicitTimeout(definition) {
|
|
|
38
41
|
severity: resolveSeverity('RETRY_NO_TIMEOUT'),
|
|
39
42
|
scope: 'step',
|
|
40
43
|
step: stepName,
|
|
41
|
-
message:
|
|
44
|
+
message: `Step '${stepName}': declares 'retry' but no 'timeout_seconds' — each attempt is ` +
|
|
42
45
|
`bounded by the default execution timeout (${DEFAULT_EXECUTION_TIMEOUT_SECONDS}s), and ` +
|
|
43
46
|
`(absent an explicit 'retry.total_timeout_seconds') the step's overall retry budget ` +
|
|
44
47
|
`defaults to that same per-attempt bound compounded across every attempt plus backoffs. ` +
|
|
@@ -49,34 +52,39 @@ function findRetryWithoutExplicitTimeout(definition) {
|
|
|
49
52
|
}
|
|
50
53
|
return warnings;
|
|
51
54
|
}
|
|
52
|
-
/** Wraps loadProjectExtensions' sentinel-credential warnings as LoaderWarning (issue #169). */
|
|
53
|
-
function wrapSentinelWarnings(sentinelWarnings) {
|
|
54
|
-
return (sentinelWarnings ?? []).map((message) => ({
|
|
55
|
-
code: 'EXTENSION_SENTINEL',
|
|
56
|
-
severity: resolveSeverity('EXTENSION_SENTINEL'),
|
|
57
|
-
scope: 'workflow',
|
|
58
|
-
message: `⚠ ${message}`,
|
|
59
|
-
}));
|
|
60
|
-
}
|
|
61
55
|
/**
|
|
62
56
|
* The single success-path printer BOTH validation branches (extension-free and file-based)
|
|
63
57
|
* share: prints every accumulated warning, then the summary line. Under `--strict`, a non-empty
|
|
64
58
|
* accumulator turns the summary line into a failing one and returns true (the caller exits 1);
|
|
65
59
|
* otherwise the summary line — and, when present, the description line existing tests assert on
|
|
66
60
|
* — print exactly as before and this returns false.
|
|
61
|
+
*
|
|
62
|
+
* `checksNotRun` (issue #553 correction C9, default 0 — the file-mode call site never passes it,
|
|
63
|
+
* since every check runs there) adds the FIRST tail clause when non-zero: `N check(s) not run`.
|
|
64
|
+
* There is no non-strict `— N warning(s)` tail today and this does not invent one — a
|
|
65
|
+
* warnings-bearing run without `--strict` and without a not-run count still prints the bare line,
|
|
66
|
+
* warnings above it. The failing-`--strict` clause, when both fire, comes SECOND, `; `-joined
|
|
67
|
+
* with the not-run clause — `— N check(s) not run; M warning(s); failing due to --strict` — and
|
|
68
|
+
* the description-suppression rule is UNCHANGED: only a failing `--strict` suppresses it, not a
|
|
69
|
+
* bare not-run disclosure (a moved tree is not a reason to hide the workflow's own description).
|
|
67
70
|
*/
|
|
68
|
-
function printValidationOutcome(definition, warnings, strict) {
|
|
71
|
+
function printValidationOutcome(definition, warnings, strict, checksNotRun = 0) {
|
|
69
72
|
printLoaderWarnings(warnings);
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
const stepCount = Object.keys(definition.steps).length;
|
|
74
|
+
const base = `Valid: ${definition.id} v${definition.version} (${stepCount} ${stepCount === 1 ? 'step' : 'steps'})`;
|
|
75
|
+
const strictFailing = strict && failsStrict(warnings);
|
|
76
|
+
const clauses = [];
|
|
77
|
+
if (checksNotRun > 0) {
|
|
78
|
+
clauses.push(`${checksNotRun} ${checksNotRun === 1 ? 'check' : 'checks'} not run`);
|
|
74
79
|
}
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
if (strictFailing) {
|
|
81
|
+
clauses.push(`${warnings.length} ${warnings.length === 1 ? 'warning' : 'warnings'}; failing due to --strict`);
|
|
82
|
+
}
|
|
83
|
+
console.log(clauses.length > 0 ? `${base} — ${clauses.join('; ')}` : base);
|
|
84
|
+
if (!strictFailing && definition.description !== undefined) {
|
|
77
85
|
console.log(` ${definition.description}`);
|
|
78
86
|
}
|
|
79
|
-
return
|
|
87
|
+
return strictFailing;
|
|
80
88
|
}
|
|
81
89
|
/** issue #236: the reasoning-position heuristic (design record §7, ratified via fixture C6) —
|
|
82
90
|
* top-level property NAME match only, never a value/content inspection. */
|
|
@@ -90,16 +98,37 @@ function findReasoningLikeTopLevelProperty(schema) {
|
|
|
90
98
|
return Object.keys(properties).find((name) => REASONING_LIKE_PROPERTY.test(name));
|
|
91
99
|
}
|
|
92
100
|
/**
|
|
93
|
-
* issue #236 (Deliverable 7) — the adoption
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
+
* issue #236 (Deliverable 7) — the adoption nudge, on validate's own INFO channel. Structurally
|
|
102
|
+
* NOT a LoaderWarning (plain `console.log`, never routed through `printLoaderWarnings`/the
|
|
103
|
+
* warnings accumulator/`--strict` — that stays a hard zero-diff rail on `loader-warnings.ts`,
|
|
104
|
+
* since ANY new WarningCode there would auto-fail `validate --strict`).
|
|
105
|
+
*
|
|
106
|
+
* issue #422 reshaped the FORM, not the purpose. It used to print one line per caveat per step,
|
|
107
|
+
* which on a green validate of a file that never mentions structured_output meant fourteen lines
|
|
108
|
+
* (examples/06) or nine (examples/02) of advice nobody asked for. The field's converged answer for
|
|
109
|
+
* adoption discovery on a clean run is one aggregate line plus a named detail command plus a
|
|
110
|
+
* durable silencer — npm fund, cargo's future-incompat report, npm audit; no surveyed tool prints
|
|
111
|
+
* per-item adoption advice on a green run, and npm's RFC 0017 explicitly rejected demoting the
|
|
112
|
+
* class behind a flag instead. So: a summary by default, the full per-step detail behind
|
|
113
|
+
* `--explain`, and `REALM_NO_NUDGE=1` to silence.
|
|
114
|
+
*
|
|
115
|
+
* THE POLICY THAT DECIDES WHICH STEPS ARE LOUD (rustc's attach-to-the-diagnostic rule, made
|
|
116
|
+
* written policy here): advice about config the author DECLARED is a diagnostic and always prints;
|
|
117
|
+
* advice about config they COULD adopt is one line. So an opted-in step's caveats print
|
|
118
|
+
* unconditionally — `--explain` does not gate them and `REALM_NO_NUDGE` does not silence them —
|
|
119
|
+
* while a not-opted-in step's detail is exactly what moves behind the flag.
|
|
120
|
+
*
|
|
121
|
+
* Never printed for an ineligible-and-opted-in step: the LOADER already rejected that combination
|
|
122
|
+
* at load time, so this function structurally never observes it.
|
|
123
|
+
*
|
|
124
|
+
* issue #454: this whole channel is suppressed under `--json` — a caller checking
|
|
125
|
+
* `opts.json` never calls this at all; there is no machinery for it here.
|
|
101
126
|
*/
|
|
102
|
-
function printStructuredOutputNudge(definition) {
|
|
127
|
+
function printStructuredOutputNudge(definition, opts) {
|
|
128
|
+
// Not-opted-in steps whose detail either renders (--explain) or feeds the summary counts.
|
|
129
|
+
const ready = []; // eligible | eligible_with_caveats
|
|
130
|
+
const withCaveats = []; // the subset of `ready` carrying >=1 caveat
|
|
131
|
+
const oneAway = []; // ineligible
|
|
103
132
|
for (const [stepName, step] of Object.entries(definition.steps)) {
|
|
104
133
|
if (step.execution !== 'agent')
|
|
105
134
|
continue;
|
|
@@ -113,35 +142,184 @@ function printStructuredOutputNudge(definition) {
|
|
|
113
142
|
...(step.tools !== undefined ? { tools: step.tools } : {}),
|
|
114
143
|
});
|
|
115
144
|
const reasoningProp = findReasoningLikeTopLevelProperty(effectiveSchema);
|
|
145
|
+
// An opted-in step's advice is about DECLARED config: a diagnostic, printed here and now
|
|
146
|
+
// whatever the flags say. It is also EXCLUDED from the summary's census entirely — its
|
|
147
|
+
// surface is this branch, never the aggregate line.
|
|
148
|
+
if (optedIn) {
|
|
149
|
+
if (verdict.verdict !== 'eligible_with_caveats')
|
|
150
|
+
continue;
|
|
151
|
+
for (const caveat of verdict.caveats) {
|
|
152
|
+
console.log(`ℹ Step '${stepName}': structured_output caveat — ${caveat.remediation}`);
|
|
153
|
+
}
|
|
154
|
+
printReasoningAnnotation(stepName, reasoningProp);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
116
157
|
if (verdict.verdict === 'ineligible') {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
158
|
+
oneAway.push(stepName);
|
|
159
|
+
if (opts.explain) {
|
|
160
|
+
console.log(`ℹ Step '${stepName}': structured_output: strict — one line short: ` +
|
|
161
|
+
`${renderIneligibleMessage(verdict.reasons)}`);
|
|
162
|
+
}
|
|
121
163
|
continue;
|
|
122
164
|
}
|
|
165
|
+
ready.push(stepName);
|
|
123
166
|
if (verdict.verdict === 'eligible_with_caveats') {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
`default thinking there is no regression; on non-thinking configurations prefer ` +
|
|
134
|
-
`'required' + first property order — see docs/reference/yaml-schema.md).`);
|
|
167
|
+
// `eligible_with_caveats` with zero caveats is unconstructible — the assessor mints that
|
|
168
|
+
// verdict only when it has at least one — so the verdict IS the caveated subset.
|
|
169
|
+
withCaveats.push(stepName);
|
|
170
|
+
if (opts.explain) {
|
|
171
|
+
for (const caveat of verdict.caveats) {
|
|
172
|
+
console.log(`ℹ Step '${stepName}': eligible for structured_output: strict, with caveat — ` +
|
|
173
|
+
`${caveat.remediation}`);
|
|
174
|
+
}
|
|
175
|
+
printReasoningAnnotation(stepName, reasoningProp);
|
|
135
176
|
}
|
|
136
177
|
continue;
|
|
137
178
|
}
|
|
138
179
|
// eligible, zero caveats — NEVER printed bare (census: this is a rare, fully-required
|
|
139
180
|
// schema). Always paired with the concrete next step.
|
|
140
|
-
if (
|
|
181
|
+
if (opts.explain) {
|
|
141
182
|
console.log(`ℹ Step '${stepName}': eligible for structured_output: strict — add ` +
|
|
142
183
|
`'structured_output: strict' to opt in.`);
|
|
143
184
|
}
|
|
144
185
|
}
|
|
186
|
+
// `--explain` REPLACES the summary with the detail above — an explicit ask for detail should
|
|
187
|
+
// not also get the pointer telling you how to ask for it.
|
|
188
|
+
if (opts.explain)
|
|
189
|
+
return;
|
|
190
|
+
// Read at call time, never captured at module scope (the #285 class). An explicit `--explain`
|
|
191
|
+
// beats a standing preference, which is why this check sits below the return above.
|
|
192
|
+
if (process.env['REALM_NO_NUDGE'] === '1')
|
|
193
|
+
return;
|
|
194
|
+
const line = renderNudgeSummary(ready.length, withCaveats.length, oneAway.length);
|
|
195
|
+
if (line !== undefined)
|
|
196
|
+
console.log(line);
|
|
197
|
+
}
|
|
198
|
+
/** The reasoning-position annotation, printed beside a caveat list wherever one renders. */
|
|
199
|
+
function printReasoningAnnotation(stepName, reasoningProp) {
|
|
200
|
+
if (reasoningProp === undefined)
|
|
201
|
+
return;
|
|
202
|
+
console.log(`ℹ Step '${stepName}': the optional '${reasoningProp}' property looks like a ` +
|
|
203
|
+
`reasoning field — see the optional_emission caveat above (position matters: with ` +
|
|
204
|
+
`default thinking there is no regression; on non-thinking configurations prefer ` +
|
|
205
|
+
`'required' + first property order — see docs/reference/yaml-schema.md).`);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The one graded summary line (issue #422), or `undefined` when there is nothing to say.
|
|
209
|
+
*
|
|
210
|
+
* The tail teaches BOTH escape routes in the one line it gets — the detail command (npm's
|
|
211
|
+
* "Run `npm fund` for details", cargo's named report command) fused with the silencer (git's
|
|
212
|
+
* squelch-teaching advice hints). A reader who wants more and a reader who wants less are both
|
|
213
|
+
* served without a second line.
|
|
214
|
+
*
|
|
215
|
+
* Each clause pluralizes on its OWN count, and a zero-valued clause is omitted rather than
|
|
216
|
+
* rendered as a zero. The caveats parenthetical and the one-change-away clause are independent:
|
|
217
|
+
* gating the second on the first would silently drop it for a file whose ready steps are all
|
|
218
|
+
* caveat-free.
|
|
219
|
+
*/
|
|
220
|
+
function renderNudgeSummary(ready, withCaveats, oneAway) {
|
|
221
|
+
if (ready === 0 && oneAway === 0)
|
|
222
|
+
return undefined;
|
|
223
|
+
const steps = (n) => (n === 1 ? 'step' : 'steps');
|
|
224
|
+
const tail = ` — run 'realm workflow validate --explain' for detail (REALM_NO_NUDGE=1 to silence).`;
|
|
225
|
+
if (ready === 0) {
|
|
226
|
+
return `ℹ ${oneAway} ${steps(oneAway)} one change away from structured_output: strict${tail}`;
|
|
227
|
+
}
|
|
228
|
+
let line = `ℹ ${ready} ${steps(ready)} ready for structured_output: strict`;
|
|
229
|
+
if (withCaveats > 0)
|
|
230
|
+
line += ` (${withCaveats} with caveats)`;
|
|
231
|
+
if (oneAway > 0)
|
|
232
|
+
line += `, ${oneAway} ${steps(oneAway)} one change away`;
|
|
233
|
+
return line + tail;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* issue #454 — the severity `--json` reports for every diagnostic. Every mint site in the tree
|
|
237
|
+
* ALREADY resolves severity at construction (`severity: resolveSeverity(code)`,
|
|
238
|
+
* diagnostics.ts:239 and its siblings, under DEFAULT_POLICY — never the `--strict` all-error
|
|
239
|
+
* policy, which is a run mode reported separately in the `strict` block) — so minted ≡ effective
|
|
240
|
+
* for every constructible diagnostic today, and this re-resolution is a GUARD against a future
|
|
241
|
+
* mint or a policy change landing without updating this file, not a live divergence. No real
|
|
242
|
+
* fixture can distinguish the two; only a hand-constructed lying warning can (validate-json.test.ts's
|
|
243
|
+
* U1 cell).
|
|
244
|
+
* @internal Exported for testing only.
|
|
245
|
+
*/
|
|
246
|
+
export function normalizeDiagnosticSeverity(w) {
|
|
247
|
+
return { ...w, severity: resolveSeverity(w.code) };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The ONE `--json` emission point (issue #454): every contract arm builds a `ValidateJsonEmit`
|
|
251
|
+
* and calls this, so the machine channel cannot drift arm-to-arm the way independently-written
|
|
252
|
+
* `JSON.stringify` call sites could. One `console.log` carrying the whole object is itself the
|
|
253
|
+
* purity guarantee this surface's cells assert on (nothing else may write to stdout on a
|
|
254
|
+
* contract arm) — `JSON.stringify(obj, null, 2)`, the `workflow list --json` sibling's own idiom.
|
|
255
|
+
*/
|
|
256
|
+
function emitValidateJson(result) {
|
|
257
|
+
console.log(JSON.stringify({
|
|
258
|
+
valid: result.valid,
|
|
259
|
+
mode: result.mode,
|
|
260
|
+
path: result.path,
|
|
261
|
+
workflow_id: result.workflowId,
|
|
262
|
+
loader_version: VERSION,
|
|
263
|
+
schema_version: result.schemaVersion,
|
|
264
|
+
error_count: result.errors.length,
|
|
265
|
+
warning_count: result.diagnostics.length,
|
|
266
|
+
strict: { requested: result.strictRequested, failed: result.strictFailed },
|
|
267
|
+
diagnostics: result.diagnostics.map(normalizeDiagnosticSeverity),
|
|
268
|
+
errors: result.errors,
|
|
269
|
+
checks_not_run: result.checksNotRun,
|
|
270
|
+
}, null, 2));
|
|
271
|
+
}
|
|
272
|
+
/** issue #454 — the shared `err.warnings ?? []` shape four `--json` load-failure sites need
|
|
273
|
+
* (:527, :580, :621, :423 in the pre-#454 line numbering) — everywhere EXCEPT the orphan-guard
|
|
274
|
+
* site (:548), whose diagnostics are the human loop's own accumulated set, not `err.warnings`. */
|
|
275
|
+
function warningsOf(err) {
|
|
276
|
+
return err instanceof WorkflowError ? (err.warnings ?? []) : [];
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The ONE place a load failure is rendered on this command (issue #445).
|
|
280
|
+
*
|
|
281
|
+
* WorkflowError means the workflow is invalid: print the warnings it carried (#424), render the
|
|
282
|
+
* message, exit 1. Anything else is an internal bug and is RETHROWN — the #123 doctrine, pinned
|
|
283
|
+
* by validate-internal-error.test.ts's "genuine-bug-still-loud" cell: a real crash must never be
|
|
284
|
+
* relabelled `Invalid:`, because that tells an author their file is wrong when realm is.
|
|
285
|
+
*
|
|
286
|
+
* The extensions arm used to render ANY error as `Invalid:`, so it violated that doctrine in two
|
|
287
|
+
* directions at once — an internal bug was swallowed, and a user's broken extension module was
|
|
288
|
+
* blamed on their workflow. Both arms route here now, and extension loading has its own catch
|
|
289
|
+
* with its own sentence, so the two populations stay separate.
|
|
290
|
+
*
|
|
291
|
+
* SCOPE, deliberate: watch/register/test/agent keep their own catches (#425's recorded
|
|
292
|
+
* exclusion). If a second surface ever adopts this, move it to lib/loader-warnings.ts — one
|
|
293
|
+
* caller does not earn a shared home.
|
|
294
|
+
*
|
|
295
|
+
* issue #454 — `jsonCtx`, when present, means `--json` was requested: the `errors[]` convention
|
|
296
|
+
* there is the RAW `err.errors ?? [err.message]` (channel prefixes like `Error: `/`Invalid: `
|
|
297
|
+
* are print-time decoration this never applied in the first place — nothing to strip), never the
|
|
298
|
+
* human-rendered `renderLoadFailure(err)` string.
|
|
299
|
+
*/
|
|
300
|
+
function exitOnLoadFailure(err, jsonCtx) {
|
|
301
|
+
if (err instanceof WorkflowError) {
|
|
302
|
+
if (jsonCtx !== undefined) {
|
|
303
|
+
emitValidateJson({
|
|
304
|
+
valid: false,
|
|
305
|
+
mode: jsonCtx.mode,
|
|
306
|
+
path: jsonCtx.path,
|
|
307
|
+
workflowId: jsonCtx.workflowId,
|
|
308
|
+
schemaVersion: jsonCtx.schemaVersion,
|
|
309
|
+
strictRequested: jsonCtx.strictRequested,
|
|
310
|
+
strictFailed: false,
|
|
311
|
+
diagnostics: jsonCtx.diagnostics,
|
|
312
|
+
errors: err.errors ?? [err.message],
|
|
313
|
+
checksNotRun: jsonCtx.checksNotRun,
|
|
314
|
+
});
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
if (err.warnings !== undefined)
|
|
318
|
+
printLoaderWarnings(err.warnings);
|
|
319
|
+
console.error(renderLoadFailure(err));
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
throw err;
|
|
145
323
|
}
|
|
146
324
|
/**
|
|
147
325
|
* The issue #170 boundary-reject, LIVE since the flip: a workflow carrying an unrecognised
|
|
@@ -153,111 +331,459 @@ function rejectIfPolicyEscalates(warnings) {
|
|
|
153
331
|
if (!rejectOnErrorSeverity(warnings))
|
|
154
332
|
return false;
|
|
155
333
|
printLoaderWarnings(warnings);
|
|
156
|
-
|
|
334
|
+
// issue #425: name WHICH warnings escalated. "at least one is escalated" left an author with
|
|
335
|
+
// three warnings above and no way to tell which of them was the refusal — the counts are the
|
|
336
|
+
// aggregate this line adds, so it is the line that has to say.
|
|
337
|
+
//
|
|
338
|
+
// register and watch print the same line since issue #451 (watch adds its timestamp and a
|
|
339
|
+
// `— refusing to register.` tail, because it does not exit); it lives in lib/loader-warnings.ts
|
|
340
|
+
// for that reason. On all three surfaces the warnings printed one line above still read
|
|
341
|
+
// `— ignored` (issue #540 deleted the CLI's print-time rewrite to `— REFUSED below`) — this
|
|
342
|
+
// line is where the refusal, and WHICH warning triggered it, actually gets said.
|
|
343
|
+
console.error(renderEscalationLine(warnings));
|
|
157
344
|
return true;
|
|
158
345
|
}
|
|
159
|
-
/**
|
|
160
|
-
*
|
|
161
|
-
|
|
346
|
+
/**
|
|
347
|
+
* `validate --registered <id>` — audit the STORED copy of a workflow (issue #427).
|
|
348
|
+
*
|
|
349
|
+
* The mechanism is kubectl's server-side dry-run shape: strip the keys the loader stamps, feed
|
|
350
|
+
* the rest back through the REAL loader, report what it says. Zero rules are duplicated here, so
|
|
351
|
+
* this surface cannot drift from what `register` would accept tomorrow.
|
|
352
|
+
*
|
|
353
|
+
* What it audits is the INSTALLED loader — the same limitation kubectl documents. The
|
|
354
|
+
* pre-upgrade journey is therefore: upgrade the CLI first, THEN audit. That is safe precisely
|
|
355
|
+
* because grandfathering holds for the copies it applies to: a CURRENT-SCHEMA registered copy
|
|
356
|
+
* keeps running under the rules it was registered with, so upgrading the CLI to look does not
|
|
357
|
+
* change what your runs do. A legacy (schema_version-less or older) copy is a different case
|
|
358
|
+
* entirely — see the legacy arm below: it is not grandfathered, it is already unreachable.
|
|
359
|
+
*/
|
|
360
|
+
async function validateRegistered(id, strict, json, overrideModule) {
|
|
361
|
+
const store = new JsonWorkflowStore();
|
|
362
|
+
let stored;
|
|
162
363
|
try {
|
|
163
|
-
|
|
164
|
-
return (typeof raw === 'object' &&
|
|
165
|
-
raw !== null &&
|
|
166
|
-
!Array.isArray(raw) &&
|
|
167
|
-
'extensions' in raw);
|
|
364
|
+
stored = await store.get(id);
|
|
168
365
|
}
|
|
169
|
-
catch {
|
|
170
|
-
|
|
366
|
+
catch (err) {
|
|
367
|
+
if (err instanceof WorkflowError && err.code === 'STATE_WORKFLOW_NOT_FOUND') {
|
|
368
|
+
if (json) {
|
|
369
|
+
emitValidateJson({
|
|
370
|
+
valid: false,
|
|
371
|
+
mode: 'registered',
|
|
372
|
+
path: null,
|
|
373
|
+
workflowId: id,
|
|
374
|
+
schemaVersion: null,
|
|
375
|
+
strictRequested: strict,
|
|
376
|
+
strictFailed: false,
|
|
377
|
+
diagnostics: [],
|
|
378
|
+
errors: [err.message],
|
|
379
|
+
checksNotRun: [],
|
|
380
|
+
});
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
console.error(`Error: ${err.message}`);
|
|
384
|
+
console.error('Registered workflows: realm workflow list');
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
387
|
+
if (err instanceof WorkflowError && err.code === 'STATE_LEGACY_FORMAT') {
|
|
388
|
+
// ONE clause only. There is no schema_version to name — nothing parsed far enough to read
|
|
389
|
+
// one — and the grandfathering sentence would be FALSE for this cohort: every runtime
|
|
390
|
+
// consumer resolves through this same get() gate (start_run, execute_step, append_trace,
|
|
391
|
+
// get_workflow_protocol, submit_human_response, replay), so a legacy entry cannot run at
|
|
392
|
+
// all. It is not grandfathered; it is unreachable.
|
|
393
|
+
if (json) {
|
|
394
|
+
emitValidateJson({
|
|
395
|
+
valid: false,
|
|
396
|
+
mode: 'registered',
|
|
397
|
+
path: null,
|
|
398
|
+
workflowId: id,
|
|
399
|
+
schemaVersion: null,
|
|
400
|
+
strictRequested: strict,
|
|
401
|
+
strictFailed: false,
|
|
402
|
+
diagnostics: [],
|
|
403
|
+
errors: [err.message],
|
|
404
|
+
checksNotRun: [],
|
|
405
|
+
});
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
console.log(`Auditing the registered copy of '${id}' with realm ${VERSION}'s loader.`);
|
|
409
|
+
// The store's own message carries the remedy; the loader would say `Missing required
|
|
410
|
+
// field: 'steps'` here, which is true of the shape and useless about the cause.
|
|
411
|
+
console.error(renderLoadFailure(err));
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
if (!(err instanceof WorkflowError)) {
|
|
415
|
+
// get()'s try wraps ONLY the read — JSON.parse sits outside it, so a corrupt stored file
|
|
416
|
+
// arrives here as a bare SyntaxError with no code (executed).
|
|
417
|
+
const notParseableMsg = `the registered copy of '${id}' is not parseable JSON: ${err instanceof Error ? err.message : String(err)}`;
|
|
418
|
+
if (json) {
|
|
419
|
+
emitValidateJson({
|
|
420
|
+
valid: false,
|
|
421
|
+
mode: 'registered',
|
|
422
|
+
path: null,
|
|
423
|
+
workflowId: id,
|
|
424
|
+
schemaVersion: null,
|
|
425
|
+
strictRequested: strict,
|
|
426
|
+
strictFailed: false,
|
|
427
|
+
diagnostics: [],
|
|
428
|
+
errors: [notParseableMsg],
|
|
429
|
+
checksNotRun: [],
|
|
430
|
+
});
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
console.error(`Error: ${notParseableMsg}`);
|
|
434
|
+
console.error('Registered workflows: realm workflow list');
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
throw err; // #123: an unexpected WorkflowError is a bug, and bugs stay loud.
|
|
438
|
+
}
|
|
439
|
+
if (!json) {
|
|
440
|
+
console.log(`Auditing the registered copy of '${id}' (schema_version ${String(stored.schema_version)}) ` +
|
|
441
|
+
`with realm ${VERSION}'s loader.`);
|
|
442
|
+
console.log('Registered copies stay grandfathered at runtime against LOADER changes — this reports ' +
|
|
443
|
+
'what re-registration today would say. A NEW engine-side dispatch check (issue #508, ' +
|
|
444
|
+
'realm 0.42.0) is NOT grandfathered: it applies immediately, whatever schema_version ' +
|
|
445
|
+
'is on file.');
|
|
446
|
+
}
|
|
447
|
+
// issue #553 — read the recorded context BEFORE the strip: these are the paths the
|
|
448
|
+
// context-dependent checks are supplied with (the file loader stamps both since v0.14;
|
|
449
|
+
// older copies carry neither, and say so below).
|
|
450
|
+
const recorded = {
|
|
451
|
+
source_dir: typeof stored.source_dir === 'string' ? stored.source_dir : undefined,
|
|
452
|
+
trust_root: typeof stored.trust_root === 'string' ? stored.trust_root : undefined,
|
|
453
|
+
};
|
|
454
|
+
const storedExtensions = stored.extensions;
|
|
455
|
+
const clone = { ...stored };
|
|
456
|
+
for (const key of RUNTIME_ONLY_WORKFLOW_KEYS)
|
|
457
|
+
delete clone[key];
|
|
458
|
+
// MUST delete: the from-string loader hard-throws on an `extensions` key (allowExtensions:
|
|
459
|
+
// false) with "Register this workflow from its YAML file" — maximally misleading here, where
|
|
460
|
+
// the workflow IS registered and the operator asked about the stored copy. The declared
|
|
461
|
+
// modules are re-attached to the parsed definition below, for the extensions pass that
|
|
462
|
+
// actually consumes them.
|
|
463
|
+
delete clone['extensions'];
|
|
464
|
+
const stripped = JSON.stringify(clone);
|
|
465
|
+
// Supply or declare (issue #553): per member of CONTEXT_DEPENDENT_CHECKS, either the recorded
|
|
466
|
+
// path still exists and the check RUNS with it, or the check is declared not run — here, on
|
|
467
|
+
// the human line, and in `checks_not_run` on every `--json` arm from this point.
|
|
468
|
+
const notRun = [];
|
|
469
|
+
const registeredCtx = (workflowId, diagnostics) => ({
|
|
470
|
+
mode: 'registered',
|
|
471
|
+
path: null,
|
|
472
|
+
workflowId,
|
|
473
|
+
schemaVersion: stored.schema_version ?? null,
|
|
474
|
+
diagnostics,
|
|
475
|
+
strictRequested: strict,
|
|
476
|
+
checksNotRun: notRun,
|
|
477
|
+
});
|
|
478
|
+
let definition;
|
|
479
|
+
let loaderWarnings;
|
|
480
|
+
try {
|
|
481
|
+
// After #553 the four context-free rules (context_wrapper, the workflow_context names,
|
|
482
|
+
// source.path) run here too — the string loader carries them. No `(line N)`: the body is
|
|
483
|
+
// JSON, not the author's file.
|
|
484
|
+
({ definition, warnings: loaderWarnings } = loadWorkflowFromStringWithDiagnostics(stripped));
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
exitOnLoadFailure(err, json ? registeredCtx(id, warningsOf(err)) : undefined);
|
|
488
|
+
}
|
|
489
|
+
const applicable = CONTEXT_DEPENDENT_CHECKS.filter((check) => check.applies(definition));
|
|
490
|
+
for (const check of applicable) {
|
|
491
|
+
const path = recorded[check.needs];
|
|
492
|
+
// `existsSync` is load-bearing for BOTH members: `resolveAgentProfiles` against a dead
|
|
493
|
+
// tree would name a path under the missing tree (the WRONG error), and
|
|
494
|
+
// `admitProjectExtensions` against a nonexistent trust root returns defaults SILENTLY
|
|
495
|
+
// (executed) — a copy whose tree moved would audit as if it had no manifest at all.
|
|
496
|
+
if (path === undefined || !existsSync(path)) {
|
|
497
|
+
const reason = notRunReason(check.needs, path, stored.origin);
|
|
498
|
+
// issue #553 correction C5 — the extensions member cannot apply an override it never
|
|
499
|
+
// reaches: say so beside the reason, never silently, on the human line AND
|
|
500
|
+
// `checks_not_run[].reason` alike, so 7c's label+reason parity holds by construction.
|
|
501
|
+
notRun.push({
|
|
502
|
+
id: check.id,
|
|
503
|
+
reason: check.id === 'project_extensions' && overrideModule !== undefined
|
|
504
|
+
? `${reason}; --extensions-module not applied`
|
|
505
|
+
: reason,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// The disclosure line — always on, never verbose-gated, the old honesty line's slot: after
|
|
510
|
+
// the header, before any verdict. Derived from the constant; nothing hand-typed. A non-empty
|
|
511
|
+
// set does NOT flip `--strict`: a disclosure, not a warning — a moved tree must not fail CI
|
|
512
|
+
// for a reason the operator cannot act on.
|
|
513
|
+
if (!json && notRun.length > 0)
|
|
514
|
+
console.log(renderChecksNotRunLine(notRun));
|
|
515
|
+
let sentinelWarnings;
|
|
516
|
+
for (const check of applicable) {
|
|
517
|
+
if (notRun.some((n) => n.id === check.id))
|
|
518
|
+
continue;
|
|
519
|
+
if (check.id === 'agent_profile_resolution') {
|
|
520
|
+
try {
|
|
521
|
+
resolveAgentProfiles(definition, recorded.source_dir);
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
exitOnLoadFailure(err, json ? registeredCtx(definition.id, loaderWarnings) : undefined);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
// The extensions pass exactly as loadWorkflowForAdmission runs it — ONE shared call
|
|
529
|
+
// (issue #553 correction C2: `admitProjectExtensions` is now the single mint site for the
|
|
530
|
+
// sentinel-credentials advisory pair, replacing this arm's own hand-typed copy) — against
|
|
531
|
+
// the RECORDED paths, re-stamped on the parsed copy (the strip removed them).
|
|
532
|
+
if (recorded.source_dir !== undefined)
|
|
533
|
+
definition.source_dir = recorded.source_dir;
|
|
534
|
+
definition.trust_root = recorded.trust_root; // the member ran ⇒ recorded and present
|
|
535
|
+
if (storedExtensions !== undefined)
|
|
536
|
+
definition.extensions = storedExtensions;
|
|
537
|
+
let loaded;
|
|
538
|
+
try {
|
|
539
|
+
loaded = await admitProjectExtensions(definition, {
|
|
540
|
+
surface: 'validate',
|
|
541
|
+
...(overrideModule !== undefined ? { overrideModule } : {}),
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
catch (err) {
|
|
545
|
+
// issue #445's sentence, issue #454's whole-message convention, issue #463's
|
|
546
|
+
// warnings-first — the file arm's shape, on the stored copy.
|
|
547
|
+
const accumulated = [...loaderWarnings, ...findRetryWithoutExplicitTimeout(definition)];
|
|
548
|
+
const msg = `Error loading extensions: ${err instanceof Error ? err.message : String(err)}`;
|
|
549
|
+
if (json) {
|
|
550
|
+
emitValidateJson({
|
|
551
|
+
valid: false,
|
|
552
|
+
mode: 'registered',
|
|
553
|
+
path: null,
|
|
554
|
+
workflowId: definition.id,
|
|
555
|
+
schemaVersion: stored.schema_version ?? null,
|
|
556
|
+
strictRequested: strict,
|
|
557
|
+
strictFailed: false,
|
|
558
|
+
diagnostics: accumulated,
|
|
559
|
+
errors: [msg],
|
|
560
|
+
checksNotRun: notRun,
|
|
561
|
+
});
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
for (const w of accumulated)
|
|
565
|
+
console.warn(renderLoaderWarning(w));
|
|
566
|
+
console.error(msg);
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
sentinelWarnings = loaded.sentinelWarnings;
|
|
570
|
+
try {
|
|
571
|
+
loadWorkflowFromStringWithDiagnostics(stripped, loaded.registry);
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
exitOnLoadFailure(err, json ? registeredCtx(definition.id, warningsOf(err)) : undefined);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// The file arm's tail, minus the adoption nudge (a stored copy is not where you edit;
|
|
579
|
+
// `--explain` is therefore inert in this mode, deliberately — no machinery for it).
|
|
580
|
+
const accumulated = [
|
|
581
|
+
...loaderWarnings,
|
|
582
|
+
...findRetryWithoutExplicitTimeout(definition),
|
|
583
|
+
...wrapSentinelWarnings(sentinelWarnings),
|
|
584
|
+
];
|
|
585
|
+
if (json) {
|
|
586
|
+
if (rejectOnErrorSeverity(accumulated)) {
|
|
587
|
+
emitValidateJson({
|
|
588
|
+
valid: false,
|
|
589
|
+
mode: 'registered',
|
|
590
|
+
path: null,
|
|
591
|
+
workflowId: definition.id,
|
|
592
|
+
schemaVersion: stored.schema_version ?? null,
|
|
593
|
+
strictRequested: strict,
|
|
594
|
+
strictFailed: false,
|
|
595
|
+
diagnostics: accumulated,
|
|
596
|
+
errors: [renderEscalationLine(accumulated)],
|
|
597
|
+
checksNotRun: notRun,
|
|
598
|
+
});
|
|
599
|
+
process.exit(1);
|
|
600
|
+
}
|
|
601
|
+
const strictFailed = strict && failsStrict(accumulated);
|
|
602
|
+
emitValidateJson({
|
|
603
|
+
valid: true,
|
|
604
|
+
mode: 'registered',
|
|
605
|
+
path: null,
|
|
606
|
+
workflowId: definition.id,
|
|
607
|
+
schemaVersion: stored.schema_version ?? null,
|
|
608
|
+
strictRequested: strict,
|
|
609
|
+
strictFailed,
|
|
610
|
+
diagnostics: accumulated,
|
|
611
|
+
errors: [],
|
|
612
|
+
checksNotRun: notRun,
|
|
613
|
+
});
|
|
614
|
+
if (strictFailed) {
|
|
615
|
+
process.exit(1);
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (rejectIfPolicyEscalates(accumulated)) {
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
622
|
+
const strictFailed = printValidationOutcome(definition, accumulated, strict, notRun.length);
|
|
623
|
+
if (strictFailed) {
|
|
624
|
+
process.exit(1);
|
|
171
625
|
}
|
|
172
626
|
}
|
|
173
627
|
export const validateCommand = new Command('validate')
|
|
174
|
-
.argument('
|
|
628
|
+
.argument('[path]', 'Path to workflow directory or workflow.yaml file')
|
|
629
|
+
.option('--registered <id>', 'Audit the STORED copy of a registered workflow instead of a file (issue #427)')
|
|
175
630
|
.option('--extensions-module <path>', "Extensions module that REPLACES the workflow's declared 'extensions' modules (repair/override)")
|
|
176
631
|
.option('--strict', 'Exit non-zero if any loader warning is present (unknown keys, retry-without-timeout, sentinel credentials — issue #169)')
|
|
632
|
+
.option('--explain', 'Print the full per-step structured_output adoption detail instead of the one-line summary the default run prints (issue #422)')
|
|
633
|
+
.option('--json', 'Emit the result as JSON on stdout, and nothing else')
|
|
177
634
|
.description('Validate a workflow YAML file')
|
|
178
635
|
.action(async (inputPath, opts) => {
|
|
636
|
+
const strict = opts.strict === true;
|
|
637
|
+
const explain = opts.explain === true;
|
|
638
|
+
const json = opts.json === true;
|
|
639
|
+
// Exactly-one, checked FIRST and load-bearing: commander parses a `[path]` positional and
|
|
640
|
+
// a `--registered <id>` option happily together and enforces nothing between them
|
|
641
|
+
// (executed — both arrive). The flag is `--registered <id>` rather than an auto-detecting
|
|
642
|
+
// positional deliberately: nothing can reliably tell an id from a path, and guessing wrong
|
|
643
|
+
// means auditing something the operator did not name.
|
|
644
|
+
//
|
|
645
|
+
// issue #454: NOT under the contract — these two usage errors precede validation entirely
|
|
646
|
+
// (terraform-consistent) and stay human + exit 1 regardless of `--json`.
|
|
647
|
+
if (inputPath === undefined && opts.registered === undefined) {
|
|
648
|
+
console.error('Error: provide a workflow path, or --registered <id> to audit a stored definition.');
|
|
649
|
+
process.exit(1);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (inputPath !== undefined && opts.registered !== undefined) {
|
|
653
|
+
console.error('Error: --registered audits the stored copy — it cannot be combined with a path.');
|
|
654
|
+
process.exit(1);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (opts.registered !== undefined) {
|
|
658
|
+
await validateRegistered(opts.registered, strict, json, opts.extensionsModule);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
179
661
|
const filePath = inputPath.endsWith('.yaml') || inputPath.endsWith('.yml')
|
|
180
662
|
? inputPath
|
|
181
663
|
: join(inputPath, 'workflow.yaml');
|
|
182
|
-
|
|
183
|
-
|
|
664
|
+
// ONE call (issue #553) — the path register and watch take, byte for byte: the file
|
|
665
|
+
// loader (read failure → the loader's `Failed to read workflow file:` sentence; profile
|
|
666
|
+
// resolution; the four context rules), the unconditional extensions pass with real-then-
|
|
667
|
+
// sentinel secret resolution, the config_schema pass 2. Two failure populations leave it,
|
|
668
|
+
// and each keeps its own sentence (issue #445): an ExtensionLoadError is extension or
|
|
669
|
+
// deployment territory (an unresolvable module, a malformed `realm.yaml`, the #123
|
|
670
|
+
// orphaned-manifest refusal) and says `Error loading extensions:`; everything else is the
|
|
671
|
+
// workflow's own invalidity, rendered by exitOnLoadFailure (a non-WorkflowError rethrows
|
|
672
|
+
// loud — the #123 doctrine).
|
|
673
|
+
let definition;
|
|
674
|
+
let warnings;
|
|
675
|
+
let manifest;
|
|
184
676
|
try {
|
|
185
|
-
|
|
677
|
+
({ definition, warnings, manifest } = await loadWorkflowForAdmission(filePath, {
|
|
678
|
+
...(opts.extensionsModule !== undefined ? { overrideModule: opts.extensionsModule } : {}),
|
|
679
|
+
surface: 'validate',
|
|
680
|
+
}));
|
|
186
681
|
}
|
|
187
682
|
catch (err) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
683
|
+
if (err instanceof ExtensionLoadError) {
|
|
684
|
+
// issue #463 — the workflow's own warnings first: pass-1's plus the retry advisory,
|
|
685
|
+
// the same set the success path counts minus the sentinel wraps, which come from the
|
|
686
|
+
// load that just failed — there is nothing to wrap. Plain render, not
|
|
687
|
+
// printLoaderWarnings — the escalation gate never ran on this arm (#540/#542).
|
|
688
|
+
//
|
|
689
|
+
// issue #454 — the errors[] convention's exception: this sentence ships WHOLE, with
|
|
690
|
+
// its `Error loading extensions: ` head — the #445 classification IS the composed
|
|
691
|
+
// message, not a channel prefix a caller prepends at print. Under `--json` the
|
|
692
|
+
// helper's two sentinel ⚠ lines may already have reached stderr (`console.warn`;
|
|
693
|
+
// register has no `--json`): stdout purity holds — #454's contract is stdout — and
|
|
694
|
+
// stderr may carry advisories on a contract arm (J7b pins it).
|
|
695
|
+
const failed = err.definition;
|
|
696
|
+
const accumulated = [
|
|
697
|
+
...(err.warnings ?? []),
|
|
698
|
+
...(failed !== undefined ? findRetryWithoutExplicitTimeout(failed) : []),
|
|
699
|
+
];
|
|
700
|
+
const msg = `Error loading extensions: ${err.message}`;
|
|
701
|
+
if (json) {
|
|
702
|
+
emitValidateJson({
|
|
703
|
+
valid: false,
|
|
704
|
+
mode: 'file',
|
|
705
|
+
path: inputPath,
|
|
706
|
+
workflowId: failed?.id ?? null,
|
|
707
|
+
schemaVersion: null,
|
|
708
|
+
strictRequested: strict,
|
|
709
|
+
strictFailed: false,
|
|
710
|
+
diagnostics: accumulated,
|
|
711
|
+
errors: [msg],
|
|
712
|
+
checksNotRun: [],
|
|
713
|
+
});
|
|
213
714
|
process.exit(1);
|
|
214
715
|
}
|
|
716
|
+
for (const w of accumulated)
|
|
717
|
+
console.warn(renderLoaderWarning(w));
|
|
718
|
+
console.error(msg);
|
|
719
|
+
process.exit(1);
|
|
215
720
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
721
|
+
// A pass-2 (config_schema) refusal carries the pass-1 definition beside it, so
|
|
722
|
+
// `workflow_id` is named exactly as before the collapse (round-2 Q9); a pass-1 refusal
|
|
723
|
+
// has no definition to name.
|
|
724
|
+
exitOnLoadFailure(err, json
|
|
725
|
+
? {
|
|
726
|
+
mode: 'file',
|
|
727
|
+
path: inputPath,
|
|
728
|
+
workflowId: admittedDefinitionOf(err)?.id ?? null,
|
|
729
|
+
schemaVersion: null,
|
|
730
|
+
diagnostics: warningsOf(err),
|
|
731
|
+
strictRequested: strict,
|
|
732
|
+
checksNotRun: [],
|
|
220
733
|
}
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
return;
|
|
734
|
+
: undefined);
|
|
224
735
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
...pass1Warnings,
|
|
241
|
-
...findRetryWithoutExplicitTimeout(definition),
|
|
242
|
-
...wrapSentinelWarnings(sentinelWarnings),
|
|
243
|
-
];
|
|
244
|
-
if (rejectIfPolicyEscalates(accumulated)) {
|
|
736
|
+
const accumulated = [...warnings, ...findRetryWithoutExplicitTimeout(definition)];
|
|
737
|
+
if (json) {
|
|
738
|
+
if (rejectOnErrorSeverity(accumulated)) {
|
|
739
|
+
emitValidateJson({
|
|
740
|
+
valid: false,
|
|
741
|
+
mode: 'file',
|
|
742
|
+
path: inputPath,
|
|
743
|
+
workflowId: definition.id,
|
|
744
|
+
schemaVersion: null,
|
|
745
|
+
strictRequested: strict,
|
|
746
|
+
strictFailed: false,
|
|
747
|
+
diagnostics: accumulated,
|
|
748
|
+
errors: [renderEscalationLine(accumulated)],
|
|
749
|
+
checksNotRun: [],
|
|
750
|
+
});
|
|
245
751
|
process.exit(1);
|
|
246
752
|
}
|
|
247
|
-
const strictFailed =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
753
|
+
const strictFailed = strict && failsStrict(accumulated);
|
|
754
|
+
emitValidateJson({
|
|
755
|
+
valid: true,
|
|
756
|
+
mode: 'file',
|
|
757
|
+
path: inputPath,
|
|
758
|
+
workflowId: definition.id,
|
|
759
|
+
schemaVersion: null,
|
|
760
|
+
strictRequested: strict,
|
|
761
|
+
strictFailed,
|
|
762
|
+
diagnostics: accumulated,
|
|
763
|
+
errors: [],
|
|
764
|
+
checksNotRun: [],
|
|
765
|
+
});
|
|
766
|
+
// issue #422/#236: the Extensions manifest line and the nudge are both suppressed under
|
|
767
|
+
// --json — human-informational, not represented (additive later if ever wanted).
|
|
255
768
|
if (strictFailed) {
|
|
256
769
|
process.exit(1);
|
|
257
770
|
}
|
|
771
|
+
return;
|
|
258
772
|
}
|
|
259
|
-
|
|
260
|
-
|
|
773
|
+
if (rejectIfPolicyEscalates(accumulated)) {
|
|
774
|
+
process.exit(1);
|
|
775
|
+
}
|
|
776
|
+
const strictFailed = printValidationOutcome(definition, accumulated, strict);
|
|
777
|
+
if (manifest.modules.length > 0) {
|
|
778
|
+
console.log(`Extensions: ${manifest.modules.map((m) => m.declared).join(', ')} ` +
|
|
779
|
+
`(adapters: ${manifest.adapters.length}, handlers: ${manifest.handlers.length}, ` +
|
|
780
|
+
`processors: ${manifest.processors.length})`);
|
|
781
|
+
}
|
|
782
|
+
// issue #236: the nudge's own INFO channel — never affects the exit code below.
|
|
783
|
+
// issue #422: genuinely end-of-report, BELOW the Extensions block — the summary is a
|
|
784
|
+
// pointer at what you could do next, not part of what was just validated.
|
|
785
|
+
printStructuredOutputNudge(definition, { explain });
|
|
786
|
+
if (strictFailed) {
|
|
261
787
|
process.exit(1);
|
|
262
788
|
}
|
|
263
789
|
});
|