@sanity/workflow-cli 0.9.0 → 0.10.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +485 -0
  2. package/README.md +62 -18
  3. package/bin/run.js +0 -4
  4. package/dist/commands/abort.d.ts +4 -6
  5. package/dist/commands/abort.js +13 -24
  6. package/dist/commands/definition/delete.d.ts +2 -2
  7. package/dist/commands/definition/delete.js +14 -27
  8. package/dist/commands/definition/diff.d.ts +2 -2
  9. package/dist/commands/definition/diff.js +3 -12
  10. package/dist/commands/definition/list.d.ts +3 -4
  11. package/dist/commands/definition/list.js +40 -46
  12. package/dist/commands/definition/show.d.ts +22 -3
  13. package/dist/commands/definition/show.js +23 -21
  14. package/dist/commands/deploy.d.ts +11 -5
  15. package/dist/commands/deploy.js +54 -57
  16. package/dist/commands/diagnose.d.ts +22 -4
  17. package/dist/commands/diagnose.js +39 -45
  18. package/dist/commands/fire-action.d.ts +2 -4
  19. package/dist/commands/fire-action.js +27 -61
  20. package/dist/commands/list.d.ts +7 -8
  21. package/dist/commands/list.js +43 -56
  22. package/dist/commands/reset-activity.js +0 -1
  23. package/dist/commands/set-stage.d.ts +2 -2
  24. package/dist/commands/set-stage.js +14 -30
  25. package/dist/commands/show.d.ts +2 -2
  26. package/dist/commands/show.js +12 -24
  27. package/dist/commands/start.d.ts +4 -7
  28. package/dist/commands/start.js +18 -54
  29. package/dist/commands/tail.d.ts +2 -2
  30. package/dist/commands/tail.js +20 -25
  31. package/dist/hooks/finally/telemetry.d.ts +8 -0
  32. package/dist/hooks/finally/telemetry.js +13 -0
  33. package/dist/hooks/prerun/telemetry.d.ts +5 -0
  34. package/dist/hooks/prerun/telemetry.js +5 -0
  35. package/dist/index.js +0 -3
  36. package/dist/lib/base-command.d.ts +14 -0
  37. package/dist/lib/base-command.js +10 -0
  38. package/dist/lib/client.js +13 -30
  39. package/dist/lib/context.d.ts +49 -13
  40. package/dist/lib/context.js +49 -48
  41. package/dist/lib/definitions.js +5 -24
  42. package/dist/lib/diff.d.ts +5 -2
  43. package/dist/lib/diff.js +61 -27
  44. package/dist/lib/env.d.ts +4 -0
  45. package/dist/lib/env.js +4 -3
  46. package/dist/lib/fail.d.ts +12 -6
  47. package/dist/lib/fail.js +24 -25
  48. package/dist/lib/flags.d.ts +0 -7
  49. package/dist/lib/flags.js +0 -17
  50. package/dist/lib/load-config.d.ts +11 -7
  51. package/dist/lib/load-config.js +40 -20
  52. package/dist/lib/operation-args.d.ts +14 -23
  53. package/dist/lib/operation-args.js +6 -38
  54. package/dist/lib/ops-report.d.ts +18 -10
  55. package/dist/lib/ops-report.js +20 -16
  56. package/dist/lib/params.js +0 -8
  57. package/dist/lib/read-fanout.d.ts +22 -0
  58. package/dist/lib/read-fanout.js +28 -0
  59. package/dist/lib/select-deployment.js +0 -21
  60. package/dist/lib/share-definitions.d.ts +86 -0
  61. package/dist/lib/share-definitions.js +106 -0
  62. package/dist/lib/stub.js +0 -7
  63. package/dist/lib/telemetry-setup.d.ts +66 -0
  64. package/dist/lib/telemetry-setup.js +92 -0
  65. package/dist/lib/telemetry.d.ts +100 -0
  66. package/dist/lib/telemetry.js +89 -0
  67. package/dist/lib/ui.d.ts +33 -1
  68. package/dist/lib/ui.js +32 -21
  69. package/oclif.manifest.json +8 -47
  70. package/package.json +14 -8
@@ -1,5 +1,5 @@
1
- import { Command } from '@oclif/core';
2
1
  import { type DeployDefinitionResult, type WorkflowDefinition, type WorkflowDeployment } from '@sanity/workflow-engine';
2
+ import { WorkflowCommand } from '../lib/base-command.ts';
3
3
  /** A deployment paired with the definitions selected + validated for it. */
4
4
  interface DeployBatch {
5
5
  deployment: WorkflowDeployment;
@@ -10,7 +10,7 @@ interface DeployFailure {
10
10
  deployment: Pick<WorkflowDeployment, 'name' | 'tag'>;
11
11
  message: string;
12
12
  }
13
- export default class Deploy extends Command {
13
+ export default class Deploy extends WorkflowCommand {
14
14
  static description: string;
15
15
  static examples: string[];
16
16
  static flags: {
@@ -18,9 +18,13 @@ export default class Deploy extends Command {
18
18
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
19
  check: import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
20
  only: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
+ 'share-definitions-with-sanity': import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
22
  tag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
22
23
  };
23
24
  run(): Promise<void>;
25
+ /** Deploy (or diff, for `--dry-run`) one deployment's definitions.
26
+ * Returns the per-definition results for a real deploy; `undefined`
27
+ * for a dry run, which writes nothing. */
24
28
  private processDeployment;
25
29
  private executeDeploy;
26
30
  private runDryRun;
@@ -42,6 +46,8 @@ export declare function buildBatches(deployments: WorkflowDeployment[], only: st
42
46
  * to their message for {@link deploySummary}. A single-batch run rethrows
43
47
  * instead: it has no summary, so a throw from before the deploy spinner starts
44
48
  * would otherwise surface nowhere — and propagating keeps the stack visible.
49
+ * An auth rejection also rethrows regardless of batch count: a dead token is
50
+ * never batch-local, so continuing would just repeat the same failure.
45
51
  */
46
52
  export declare function reconcileBatches({ batches, log, processBatch, }: {
47
53
  batches: DeployBatch[];
@@ -62,14 +68,14 @@ export declare function deploySummary({ total, failures, }: {
62
68
  /**
63
69
  * The banner printed above a deployment's output so a multi-deployment run is
64
70
  * attributable. `undefined` for a single-target run — it keeps its original
65
- * unprefixed output. A leading blank line separates every deployment after the
66
- * first.
71
+ * unprefixed output.
67
72
  */
68
73
  export declare function deploymentBanner({ deployment, index, total, }: {
69
74
  deployment: Pick<WorkflowDeployment, 'name' | 'tag'>;
70
75
  index: number;
71
76
  total: number;
72
77
  }): string | undefined;
73
- /** One line per deployed definition, symbol-tagged by its result status. */
78
+ /** One line per deployed definition, symbol-tagged by its result status,
79
+ * followed by any advisory effect-output lint warnings indented beneath it. */
74
80
  export declare function deployResultLines(results: DeployDefinitionResult[]): string[];
75
81
  export {};
@@ -1,16 +1,20 @@
1
1
  import { styleText } from 'node:util';
2
- import { Command, Flags } from '@oclif/core';
3
- import { computeDiffEntries, workflow, } from '@sanity/workflow-engine';
2
+ import { Flags } from '@oclif/core';
3
+ import { computeDiffEntries, errorMessage, workflow, } from '@sanity/workflow-engine';
4
4
  import logSymbols from 'log-symbols';
5
5
  import ora from 'ora';
6
+ import { WorkflowCommand } from "../lib/base-command.js";
6
7
  import { clientFor, resolveTokenOrFail } from "../lib/client.js";
7
8
  import { selectDefinitions, validateOrFail } from "../lib/definitions.js";
8
9
  import { diffReport } from "../lib/diff.js";
9
- import { errorMessage, fail } from "../lib/fail.js";
10
+ import { fail, isAuthRejection } from "../lib/fail.js";
10
11
  import { tagFlags } from "../lib/flags.js";
11
12
  import { loadWorkflowConfig } from "../lib/load-config.js";
12
13
  import { deploymentToTarget, selectDeployments } from "../lib/select-deployment.js";
13
- export default class Deploy extends Command {
14
+ import { shareDefinitionsAfterDeploy } from "../lib/share-definitions.js";
15
+ import { cliTelemetry } from "../lib/telemetry.js";
16
+ import { groupBanner } from "../lib/ui.js";
17
+ export default class Deploy extends WorkflowCommand {
14
18
  static description = 'Validate, diff, and deploy workflow definitions to the resource bound by the selected deployment.';
15
19
  static examples = [
16
20
  '<%= config.bin %> deploy --tag prod',
@@ -37,17 +41,17 @@ export default class Deploy extends Command {
37
41
  only: Flags.string({
38
42
  description: 'Limit deploy/check/diff to a single workflow definition by name. Every targeted deployment must contain it.',
39
43
  }),
44
+ 'share-definitions-with-sanity': Flags.boolean({
45
+ description: 'Share the definition documents newly created by this deploy with Sanity — the full document, verbatim (structure, names, filters, effect configuration, seeded values), plus its deployment coordinates (project and dataset, or resource id); never content documents, instances, or your Sanity auth token. Opt-in for this invocation only; the CLI never prompts. Independent of telemetry consent.',
46
+ default: false,
47
+ }),
40
48
  };
41
49
  async run() {
42
50
  const { flags } = await this.parse(Deploy);
43
51
  validateModeFlags(flags.check, flags['dry-run']);
44
52
  const config = await loadWorkflowConfig();
45
- // Validate every deployment up front (below), so a bad definition fails
46
- // before any write — never a partial reconcile from a validation error.
47
53
  const batches = buildBatches(selectDeployments(config, { tag: flags.tag, allTags: flags['all-tags'] }), flags.only);
48
54
  const log = (line) => this.log(line);
49
- // buildBatches already validated everything offline — --check only has to
50
- // report that pass, so it never resolves a token or builds a client.
51
55
  if (flags.check) {
52
56
  await reconcileBatches({
53
57
  batches,
@@ -58,36 +62,63 @@ export default class Deploy extends Command {
58
62
  });
59
63
  return;
60
64
  }
61
- // One token up front, so an --all-tags run authenticates once, not per deployment.
62
65
  const token = await resolveTokenOrFail();
66
+ const candidates = [];
63
67
  const failures = await reconcileBatches({
64
68
  batches,
65
69
  log,
66
- processBatch: ({ deployment, defs }) => this.processDeployment({ deployment, defs, dryRun: flags['dry-run'], token }),
70
+ processBatch: async ({ deployment, defs }) => {
71
+ const client = clientFor(deployment.workflowResource, token);
72
+ const deployed = await this.processDeployment({
73
+ deployment,
74
+ defs,
75
+ dryRun: flags['dry-run'],
76
+ client,
77
+ });
78
+ if (deployed !== undefined) {
79
+ candidates.push({
80
+ client,
81
+ tag: deployment.tag,
82
+ resource: deployment.workflowResource,
83
+ results: deployed.results,
84
+ deployId: deployed.deployId,
85
+ });
86
+ }
87
+ },
67
88
  });
68
89
  for (const line of deploySummary({ total: batches.length, failures })) {
69
90
  log(line);
70
91
  }
92
+ await shareDefinitionsAfterDeploy({
93
+ flag: flags['share-definitions-with-sanity'],
94
+ candidates,
95
+ warn: (message) => this.warn(message),
96
+ });
71
97
  if (failures.length > 0) {
72
98
  this.exit(1);
73
99
  }
74
100
  }
75
- async processDeployment({ deployment, defs, dryRun, token, }) {
101
+ async processDeployment({ deployment, defs, dryRun, client, }) {
76
102
  const target = deploymentToTarget(deployment);
77
- const client = clientFor(deployment.workflowResource, token);
78
103
  if (dryRun) {
79
104
  await this.runDryRun({ client, defs, target });
80
- return;
105
+ return undefined;
81
106
  }
82
- await this.executeDeploy({ client, defs, target });
107
+ return this.executeDeploy({ client, defs, target });
83
108
  }
84
109
  async executeDeploy({ client, defs, target, }) {
85
110
  const spinner = ora(`Deploying ${defs.length} definition(s)…`).start();
86
111
  try {
87
- const { results } = await workflow.deployDefinitions({ ...target, client, definitions: defs });
88
- spinner.succeed(`Processed ${results.length} definition(s)`);
89
- for (const line of deployResultLines(results))
112
+ const deployed = await workflow.deployDefinitions({
113
+ ...target,
114
+ client,
115
+ definitions: defs,
116
+ telemetry: cliTelemetry().logger,
117
+ });
118
+ spinner.succeed(`Processed ${deployed.results.length} definition(s)`);
119
+ for (const line of deployResultLines(deployed.results))
90
120
  this.log(line);
121
+ return deployed;
91
122
  }
92
123
  catch (error) {
93
124
  spinner.fail(`Deploy failed — ${errorMessage(error)}`);
@@ -108,24 +139,14 @@ export default class Deploy extends Command {
108
139
  }
109
140
  }
110
141
  }
111
- /** Reject the contradictory `--check --dry-run` combination. */
112
142
  export function validateModeFlags(check, dryRun) {
113
143
  if (check && dryRun) {
114
144
  fail('Pass either --check or --dry-run, not both.');
115
145
  }
116
146
  }
117
- /** The one identity format for a deployment in output — banner, failure
118
- * summary, and validation attribution all render it the same way. */
119
147
  function deploymentLabel({ name, tag }) {
120
148
  return `${name} (${tag})`;
121
149
  }
122
- /**
123
- * Select and validate every deployment's definitions up front. Validation runs
124
- * before any network write ({@link validateOrFail} exits on a bad definition),
125
- * so a whole-config deploy can never leave a partial reconcile behind a broken
126
- * batch. In a multi-deployment run the failure names its deployment — it exits
127
- * before any banner could attribute it.
128
- */
129
150
  export function buildBatches(deployments, only) {
130
151
  return deployments.map((deployment) => {
131
152
  const context = deployments.length > 1 ? `${deploymentLabel(deployment)} — ` : '';
@@ -134,14 +155,6 @@ export function buildBatches(deployments, only) {
134
155
  return { deployment, defs };
135
156
  });
136
157
  }
137
- /**
138
- * Run `processBatch` over each batch, continuing past a failure so one bad
139
- * deployment can't strand the rest — deployments are independent, and deploys
140
- * are idempotent, so a partial run is safe to re-run. Failures are flattened
141
- * to their message for {@link deploySummary}. A single-batch run rethrows
142
- * instead: it has no summary, so a throw from before the deploy spinner starts
143
- * would otherwise surface nowhere — and propagating keeps the stack visible.
144
- */
145
158
  export async function reconcileBatches({ batches, log, processBatch, }) {
146
159
  const failures = [];
147
160
  for (const [index, batch] of batches.entries()) {
@@ -153,7 +166,7 @@ export async function reconcileBatches({ batches, log, processBatch, }) {
153
166
  await processBatch(batch);
154
167
  }
155
168
  catch (error) {
156
- if (batches.length === 1) {
169
+ if (batches.length === 1 || isAuthRejection(error)) {
157
170
  throw error;
158
171
  }
159
172
  failures.push({ deployment: batch.deployment, message: errorMessage(error) });
@@ -161,13 +174,6 @@ export async function reconcileBatches({ batches, log, processBatch, }) {
161
174
  }
162
175
  return failures;
163
176
  }
164
- /**
165
- * The closing summary for a multi-deployment run that had failures: the
166
- * succeeded/failed tally and each failed deployment. Empty for a clean run.
167
- * The wording is mode-neutral because a `--dry-run` failure lands here too,
168
- * where nothing was deployed. Single-batch runs never reach it — their lone
169
- * failure rethrows out of {@link reconcileBatches}.
170
- */
171
177
  export function deploySummary({ total, failures, }) {
172
178
  if (failures.length === 0) {
173
179
  return [];
@@ -178,26 +184,17 @@ export function deploySummary({ total, failures, }) {
178
184
  ...failures.map((f) => styleText('red', ` ${logSymbols.error} ${deploymentLabel(f.deployment)} — ${f.message}`)),
179
185
  ];
180
186
  }
181
- /**
182
- * The banner printed above a deployment's output so a multi-deployment run is
183
- * attributable. `undefined` for a single-target run — it keeps its original
184
- * unprefixed output. A leading blank line separates every deployment after the
185
- * first.
186
- */
187
187
  export function deploymentBanner({ deployment, index, total, }) {
188
- if (total <= 1) {
189
- return undefined;
190
- }
191
- const gap = index > 0 ? '\n' : '';
192
- return `${gap}${styleText('bold', `▸ ${deploymentLabel(deployment)}`)}`;
188
+ return groupBanner({ label: deploymentLabel(deployment), index, total });
193
189
  }
194
- /** One line per deployed definition, symbol-tagged by its result status. */
195
190
  export function deployResultLines(results) {
196
191
  const symbols = {
197
192
  created: logSymbols.success,
198
193
  };
199
- return results.map((r) => {
194
+ return results.flatMap((r) => {
200
195
  const symbol = symbols[r.status] ?? logSymbols.warning;
201
- return ` ${symbol} ${r.status.padEnd(9)} ${r.name} v${r.version}`;
196
+ const head = ` ${symbol} ${r.status.padEnd(9)} ${r.name} v${r.version}`;
197
+ const warnings = (r.warnings ?? []).map((w) => ` ${logSymbols.warning} ${w}`);
198
+ return [head, ...warnings];
202
199
  });
203
200
  }
@@ -1,5 +1,20 @@
1
- import { Command } from '@oclif/core';
2
- import { type Diagnosis, type DiagnoseInput, type SuggestedRemediation } from '@sanity/workflow-engine';
1
+ import { type Diagnosis, type DiagnoseInput, type SuggestedRemediation, type WorkflowEvaluation } from '@sanity/workflow-engine';
2
+ import { WorkflowCommand } from '../lib/base-command.ts';
3
+ /** The `--json` transitions payload: raw derived state (atom GROQ, negation,
4
+ * pivotality, solvable requirement) — structure for scripts, never baked
5
+ * English. Pure so the shape is a testable contract. */
6
+ export declare function rawTransitionProjection(evaluation: WorkflowEvaluation): {
7
+ name: string;
8
+ to: string;
9
+ filterSatisfied: boolean;
10
+ unevaluable: boolean;
11
+ blockedBy: {
12
+ requirement?: import("@sanity/groq-condition-describe").AtomRequirement;
13
+ groq: string;
14
+ negated: boolean;
15
+ pivotal: boolean;
16
+ }[];
17
+ }[];
3
18
  /**
4
19
  * The diagnosis body: the verdict line, the current stage's activities +
5
20
  * transitions as evidence (for any in-flight state), and the cause block with
@@ -8,12 +23,15 @@ import { type Diagnosis, type DiagnoseInput, type SuggestedRemediation } from '@
8
23
  * {@link SuggestedRemediation}s rather than re-deriving them, so the rendered
9
24
  * fix block and the `--json` output stay one computation.
10
25
  */
11
- export declare function renderDiagnosis({ diagnosis, input, remediations, }: {
26
+ export declare function renderDiagnosis({ diagnosis, input, remediations, explanations, }: {
12
27
  diagnosis: Diagnosis;
13
28
  input: DiagnoseInput;
14
29
  remediations: SuggestedRemediation[];
30
+ /** Insight summaries per unsatisfied transition name — rendered under the
31
+ * transition line so "stuck" reads as "stuck until X". */
32
+ explanations?: ReadonlyMap<string, string> | undefined;
15
33
  }): string[];
16
- export default class Diagnose extends Command {
34
+ export default class Diagnose extends WorkflowCommand {
17
35
  static description: string;
18
36
  static examples: string[];
19
37
  static args: {
@@ -1,10 +1,12 @@
1
1
  import { styleText } from 'node:util';
2
- import { Args, Command } from '@oclif/core';
3
- import { diagnoseInputFromEvaluation, workflow, } from '@sanity/workflow-engine';
2
+ import { Args } from '@oclif/core';
3
+ import { diagnoseInputFromEvaluation, workflow, unsatisfiedTransitionSummaries, } from '@sanity/workflow-engine';
4
4
  import logSymbols from 'log-symbols';
5
+ import { WorkflowCommand } from "../lib/base-command.js";
5
6
  import { resolveInstanceContext } from "../lib/context.js";
6
- import { errorMessage, fail } from "../lib/fail.js";
7
+ import { fail, failureDetail } from "../lib/fail.js";
7
8
  import { jsonFlags, tagFlags } from "../lib/flags.js";
9
+ import { baseEngineArgs } from "../lib/operation-args.js";
8
10
  import { formatTimestamp, sectionHeader, activityIcon } from "../lib/ui.js";
9
11
  import { instanceHeader } from "./show.js";
10
12
  function formatAssignee(a) {
@@ -16,11 +18,12 @@ function activityLine(t, assignees) {
16
18
  : '';
17
19
  return ` ${activityIcon[t.status]} ${t.activity.name} ${styleText('dim', `[${t.status}]`)}${who}`;
18
20
  }
19
- function transitionLine(tr) {
21
+ function transitionLine(tr, explanation) {
20
22
  const mark = tr.filterSatisfied ? styleText('green', 'ready') : styleText('red', 'false');
21
- return ` → ${tr.transition.to} ${styleText('dim', tr.transition.filter ?? 'true')} ${mark}`;
23
+ const line = ` → ${tr.transition.to} ${styleText('dim', tr.transition.filter ?? 'true')} ${mark}`;
24
+ return explanation !== undefined ? `${line}\n ${styleText('dim', `↳ ${explanation}`)}` : line;
22
25
  }
23
- function currentStageLines(input) {
26
+ function currentStageLines(input, explanations) {
24
27
  const lines = [
25
28
  `${sectionHeader('Stage')} ${input.instance.currentStage}`,
26
29
  sectionHeader('Activities'),
@@ -31,7 +34,7 @@ function currentStageLines(input) {
31
34
  if (input.transitions.length > 0) {
32
35
  lines.push('', sectionHeader('Exit transitions'));
33
36
  for (const tr of input.transitions) {
34
- lines.push(transitionLine(tr));
37
+ lines.push(transitionLine(tr, explanations?.get(tr.transition.name)));
35
38
  }
36
39
  }
37
40
  return lines;
@@ -107,11 +110,6 @@ function noTransitionDetail() {
107
110
  ],
108
111
  };
109
112
  }
110
- /** An unevaluable exit filter is a *recoverable* hold, not a dead end: it
111
- * clears itself once the operand becomes readable, and no engine verb
112
- * unsticks it (so the engine emits no remediation). The "what to do" —
113
- * make the operand readable — lives here in the why, since there is no
114
- * runnable fix to render. */
115
113
  function transitionUnevaluableDetail(transitions) {
116
114
  return {
117
115
  headline: `an exit transition's filter could not be evaluated`,
@@ -124,12 +122,6 @@ function transitionUnevaluableDetail(transitions) {
124
122
  ],
125
123
  };
126
124
  }
127
- /** Render the engine's structured remediations as the "suggested fix" block.
128
- * Verbs render under their engine names — the same vocabulary `--json`
129
- * emits, so scripts and humans read one language. Only runnable ones reach
130
- * here: {@link renderDiagnosis} filters on the engine's `available` flag,
131
- * so verbs that aren't callable engine operations yet never render as a
132
- * fix (the full set, planned verbs included, still rides `--json`). */
133
125
  function remediationLines(remediations) {
134
126
  return remediations.map((r) => ` • ${r.verb} — ${r.rationale}`);
135
127
  }
@@ -147,6 +139,11 @@ function causeDetail(cause) {
147
139
  return transitionUnevaluableDetail(cause.transitions);
148
140
  }
149
141
  }
142
+ function liveChildrenTail(diagnosis) {
143
+ if (diagnosis.liveChildren === undefined)
144
+ return '';
145
+ return ` ${styleText('yellow', `${diagnosis.liveChildren} spawned child workflow(s) still running.`)}`;
146
+ }
150
147
  function statusLine(diagnosis) {
151
148
  switch (diagnosis.state) {
152
149
  case 'progressing':
@@ -156,31 +153,37 @@ function statusLine(diagnosis) {
156
153
  case 'blocked':
157
154
  return `${logSymbols.info} ${styleText('cyan', 'blocked')} — ${blockedHeadline(diagnosis)}`;
158
155
  case 'completed':
159
- return `${logSymbols.success} ${styleText('green', 'completed')} at ${formatTimestamp(diagnosis.at)}.`;
156
+ return `${logSymbols.success} ${styleText('green', 'completed')} at ${formatTimestamp(diagnosis.at)}.${liveChildrenTail(diagnosis)}`;
160
157
  case 'aborted': {
161
158
  const tail = diagnosis.reason !== undefined ? ` — ${diagnosis.reason}` : '';
162
- return `${logSymbols.info} ${styleText('dim', 'aborted')} at ${formatTimestamp(diagnosis.at)}${tail}.`;
159
+ return `${logSymbols.info} ${styleText('dim', 'aborted')} at ${formatTimestamp(diagnosis.at)}${tail}.${liveChildrenTail(diagnosis)}`;
163
160
  }
164
161
  case 'stuck':
165
162
  return `${logSymbols.warning} ${styleText('yellow', 'STUCK')} — ${causeDetail(diagnosis.cause).headline}`;
166
163
  }
167
164
  }
168
- /**
169
- * The diagnosis body: the verdict line, the current stage's activities +
170
- * transitions as evidence (for any in-flight state), and the cause block with
171
- * the engine's suggested fix when stuck. Pure so every verdict permutation is
172
- * assertable without driving a live evaluation. Takes the engine's
173
- * {@link SuggestedRemediation}s rather than re-deriving them, so the rendered
174
- * fix block and the `--json` output stay one computation.
175
- */
176
- export function renderDiagnosis({ diagnosis, input, remediations, }) {
165
+ export function rawTransitionProjection(evaluation) {
166
+ return evaluation.currentStage.transitions.map((t) => ({
167
+ name: t.transition.name,
168
+ to: t.transition.to,
169
+ filterSatisfied: t.filterSatisfied,
170
+ unevaluable: t.unevaluable,
171
+ blockedBy: t.insight.blockedBy.map((atom) => ({
172
+ groq: atom.atom.groq,
173
+ negated: atom.atom.negated,
174
+ pivotal: atom.pivotal,
175
+ ...(atom.requirement !== undefined ? { requirement: atom.requirement } : {}),
176
+ })),
177
+ }));
178
+ }
179
+ export function renderDiagnosis({ diagnosis, input, remediations, explanations, }) {
177
180
  const lines = [statusLine(diagnosis)];
178
181
  const inFlight = diagnosis.state === 'progressing' ||
179
182
  diagnosis.state === 'waiting' ||
180
183
  diagnosis.state === 'blocked' ||
181
184
  diagnosis.state === 'stuck';
182
185
  if (inFlight) {
183
- lines.push('', ...currentStageLines(input));
186
+ lines.push('', ...currentStageLines(input, explanations));
184
187
  }
185
188
  if (diagnosis.state === 'waiting') {
186
189
  lines.push('', ...waitingLines(diagnosis));
@@ -192,16 +195,13 @@ export function renderDiagnosis({ diagnosis, input, remediations, }) {
192
195
  const detail = causeDetail(diagnosis.cause);
193
196
  const runnable = remediations.filter((r) => r.available);
194
197
  lines.push('', sectionHeader("Why it's stuck"), ...detail.why);
195
- // A recoverable hold (e.g. an unevaluable transition filter) has no
196
- // runnable remediation — its "what to do" is in the why above, so skip
197
- // the empty fix header rather than dangle it.
198
198
  if (runnable.length > 0) {
199
199
  lines.push('', sectionHeader('Suggested fix'), ...remediationLines(runnable));
200
200
  }
201
201
  }
202
202
  return lines;
203
203
  }
204
- export default class Diagnose extends Command {
204
+ export default class Diagnose extends WorkflowCommand {
205
205
  static description = "Explain why a workflow instance is or isn't progressing, and what would unstick it.";
206
206
  static examples = [
207
207
  '<%= config.bin %> diagnose wf-instance.abc123',
@@ -221,18 +221,10 @@ export default class Diagnose extends Command {
221
221
  const { evaluation, diagnosis, remediations } = await workflow
222
222
  .diagnose({
223
223
  client,
224
- tag: scope.tag,
225
- workflowResource: scope.workflowResource,
224
+ ...baseEngineArgs(scope),
226
225
  instanceId: args.instanceId,
227
- // Token-auth CLI can't hit /users/me; pin the same system actor the
228
- // write verbs use. Transition filters and activity status are actor-
229
- // independent. A requirement that reads $actor/$assigned/$can is the
230
- // exception — it's evaluated against this system actor, so a `blocked`
231
- // verdict on such a requirement reflects the engine's view, not a
232
- // specific assignee's.
233
- access: { actor: { kind: 'system', id: 'workflow-cli' } },
234
226
  })
235
- .catch((error) => fail('diagnose failed:', errorMessage(error)));
227
+ .catch((error) => fail('diagnose failed:', failureDetail(error)));
236
228
  const input = diagnoseInputFromEvaluation(evaluation);
237
229
  if (flags.json) {
238
230
  this.log(JSON.stringify({
@@ -240,12 +232,14 @@ export default class Diagnose extends Command {
240
232
  stage: evaluation.instance.currentStage,
241
233
  diagnosis,
242
234
  remediations,
235
+ transitions: rawTransitionProjection(evaluation),
243
236
  }, null, 2));
244
237
  return;
245
238
  }
246
239
  this.log(instanceHeader(evaluation.instance));
247
240
  this.log('');
248
- for (const line of renderDiagnosis({ diagnosis, input, remediations })) {
241
+ const explanations = new Map(unsatisfiedTransitionSummaries(evaluation).map((entry) => [entry.transition, entry.summary]));
242
+ for (const line of renderDiagnosis({ diagnosis, input, remediations, explanations })) {
249
243
  this.log(line);
250
244
  }
251
245
  }
@@ -1,5 +1,5 @@
1
- import { Command } from '@oclif/core';
2
1
  import { type AvailableAction } from '@sanity/workflow-engine';
2
+ import { WorkflowCommand } from '../lib/base-command.ts';
3
3
  import { type WriteOutcome, type WriteReport } from '../lib/ops-report.ts';
4
4
  /**
5
5
  * The list-mode body: every action on the current stage's activities, marked
@@ -29,7 +29,7 @@ export declare function fireActionReport({ result, activity, action, }: {
29
29
  activity: string;
30
30
  action: string;
31
31
  }): WriteReport;
32
- export default class FireAction extends Command {
32
+ export default class FireAction extends WorkflowCommand {
33
33
  static description: string;
34
34
  static examples: string[];
35
35
  static args: {
@@ -37,8 +37,6 @@ export default class FireAction extends Command {
37
37
  };
38
38
  static flags: {
39
39
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
40
- as: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
41
- 'as-role': import("@oclif/core/interfaces").OptionFlag<string[], import("@oclif/core/interfaces").CustomOptions>;
42
40
  activity: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
43
41
  action: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
44
42
  param: import("@oclif/core/interfaces").OptionFlag<string[], import("@oclif/core/interfaces").CustomOptions>;