@sanity/workflow-cli 0.32.0 → 0.34.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.
@@ -4,6 +4,7 @@ import { formatDateTime } from '@sanity/cli-core/dates';
4
4
  import { diagnoseInputFromEvaluation, workflow, unsatisfiedTransitionSummaries, } from '@sanity/workflow-engine';
5
5
  import logSymbols from 'log-symbols';
6
6
  import { WorkflowCommand } from "../../lib/base-command.js";
7
+ import { stuckDiagnosisLines, stuckHeadline } from "../../lib/cause-detail.js";
7
8
  import { resolveInstanceContext } from "../../lib/context.js";
8
9
  import { fail, failureDetail } from "../../lib/fail.js";
9
10
  import { instanceFlags, jsonFlags } from "../../lib/flags.js";
@@ -40,39 +41,6 @@ function currentStageLines(input, explanations) {
40
41
  }
41
42
  return lines;
42
43
  }
43
- function failedEffectDetail(effect) {
44
- const ran = effect.durationMs !== undefined ? ` (after ${effect.durationMs}ms)` : '';
45
- return {
46
- headline: `a failed effect from action '${effect.origin.name}' is blocking its activity`,
47
- why: [
48
- styleText('red', `${logSymbols.error} failed effect: ${effect.name}`),
49
- ` queued by action '${effect.origin.name}', failed ${formatDateTime(effect.ranAt)}${ran}`,
50
- ...(effect.error !== undefined ? [` error: ${effect.error.message}`] : []),
51
- '',
52
- `The activity that fired '${effect.origin.name}' is waiting on this effect. It failed`,
53
- `against an external system, so the activity never resolves and the stage can't advance.`,
54
- ],
55
- };
56
- }
57
- function hungEffectDetail(effect) {
58
- return {
59
- headline: `effect '${effect.name}' was claimed but never completed`,
60
- why: [
61
- styleText('yellow', `${logSymbols.warning} hung effect: ${effect.name}`),
62
- ` claimed ${formatDateTime(effect.claim?.claimedAt ?? '?')} but never reported back — the`,
63
- ` drainer likely died mid-dispatch, so it won't drain on its own.`,
64
- ],
65
- };
66
- }
67
- function failedActivityDetail(activity) {
68
- return {
69
- headline: `activity '${activity}' failed`,
70
- why: [
71
- styleText('red', `${logSymbols.error} activity '${activity}' is in a terminal failed state.`),
72
- `Any exit transition gated on '${activity}' being done can never fire.`,
73
- ],
74
- };
75
- }
76
44
  function waitingHeadline(w) {
77
45
  const who = w.assignees.length > 0 ? ` on ${w.assignees.map(formatAssignee).join(', ')}` : '';
78
46
  if (w.waitingFor === 'automation') {
@@ -120,44 +88,6 @@ function blockedLines(b) {
120
88
  `activity completing, required content landing). It will not advance on its own.`,
121
89
  ];
122
90
  }
123
- function noTransitionDetail() {
124
- return {
125
- headline: `no exit transition's trigger is satisfied`,
126
- why: [
127
- `${logSymbols.info} every activity is resolved, but no exit transition's \`when\` is true.`,
128
- `Likely a routing state value a trigger reads never got written.`,
129
- ],
130
- };
131
- }
132
- function transitionUnevaluableDetail(transitions) {
133
- return {
134
- headline: `an exit transition's trigger could not be evaluated`,
135
- why: [
136
- `${logSymbols.info} every activity is resolved, but ${transitions.join(', ')} reads an operand`,
137
- `that is missing or unreadable (GROQ null), so routing is held rather than`,
138
- `falling through. Make the data the trigger reads readable — publish the`,
139
- `subject (or fill the field) — and the instance advances on its own; no`,
140
- `set-stage needed.`,
141
- ],
142
- };
143
- }
144
- function remediationLines(remediations) {
145
- return remediations.map((r) => ` • ${r.verb} — ${r.rationale}`);
146
- }
147
- function causeDetail(cause) {
148
- switch (cause.kind) {
149
- case 'failed-effect':
150
- return failedEffectDetail(cause.effect);
151
- case 'hung-effect':
152
- return hungEffectDetail(cause.effect);
153
- case 'failed-activity':
154
- return failedActivityDetail(cause.activity);
155
- case 'no-transition-fires':
156
- return noTransitionDetail();
157
- case 'transition-unevaluable':
158
- return transitionUnevaluableDetail(cause.transitions);
159
- }
160
- }
161
91
  function liveChildrenTail(diagnosis) {
162
92
  if (diagnosis.liveChildren === undefined)
163
93
  return '';
@@ -178,7 +108,7 @@ function statusLine(diagnosis) {
178
108
  return `${logSymbols.info} ${styleText('dim', 'aborted')} at ${formatDateTime(diagnosis.at)}${tail}.${liveChildrenTail(diagnosis)}`;
179
109
  }
180
110
  case 'stuck':
181
- return `${logSymbols.warning} ${styleText('yellow', 'STUCK')} — ${causeDetail(diagnosis.cause).headline}`;
111
+ return stuckHeadline(diagnosis.cause);
182
112
  }
183
113
  }
184
114
  export function rawTransitionProjection(evaluation) {
@@ -195,7 +125,7 @@ export function rawTransitionProjection(evaluation) {
195
125
  })),
196
126
  }));
197
127
  }
198
- export function renderDiagnosis({ diagnosis, input, remediations, explanations, }) {
128
+ export function renderDiagnosis({ diagnosis, input, remediations, explanations, allMissingDocuments, }) {
199
129
  const lines = [statusLine(diagnosis)];
200
130
  const inFlight = diagnosis.state === 'progressing' ||
201
131
  diagnosis.state === 'waiting' ||
@@ -210,14 +140,7 @@ export function renderDiagnosis({ diagnosis, input, remediations, explanations,
210
140
  if (diagnosis.state === 'blocked') {
211
141
  lines.push('', ...blockedLines(diagnosis));
212
142
  }
213
- if (diagnosis.state === 'stuck') {
214
- const detail = causeDetail(diagnosis.cause);
215
- const runnable = remediations.filter((r) => r.available);
216
- lines.push('', sectionHeader("Why it's stuck"), ...detail.why);
217
- if (runnable.length > 0) {
218
- lines.push('', sectionHeader('Suggested fix'), ...remediationLines(runnable));
219
- }
220
- }
143
+ lines.push(...stuckDiagnosisLines({ diagnosis, allMissingDocuments, remediations }));
221
144
  return lines;
222
145
  }
223
146
  export default class Diagnose extends WorkflowCommand {
@@ -258,7 +181,13 @@ export default class Diagnose extends WorkflowCommand {
258
181
  this.log(instanceHeader(evaluation.instance));
259
182
  this.log('');
260
183
  const explanations = new Map(unsatisfiedTransitionSummaries(evaluation).map((entry) => [entry.transition, entry.summary]));
261
- for (const line of renderDiagnosis({ diagnosis, input, remediations, explanations })) {
184
+ for (const line of renderDiagnosis({
185
+ diagnosis,
186
+ input,
187
+ remediations,
188
+ explanations,
189
+ allMissingDocuments: evaluation.missingDocuments,
190
+ })) {
262
191
  this.log(line);
263
192
  }
264
193
  }
@@ -1,5 +1,6 @@
1
1
  import { type WorkflowInstance } from '@sanity/workflow-engine';
2
2
  import { WorkflowCommand } from '../../lib/base-command.ts';
3
+ import { type InstanceDiagnostic } from '../../lib/cause-detail.ts';
3
4
  export default class Show extends WorkflowCommand {
4
5
  static description: string;
5
6
  static examples: string[];
@@ -12,15 +13,12 @@ export default class Show extends WorkflowCommand {
12
13
  tag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
14
  };
14
15
  run(): Promise<void>;
16
+ private diagnostic;
15
17
  }
16
18
  /** The summary block at the top of `show` (and reused by `diagnose` /
17
19
  * `fire-action`): a bold workflow + id title, then aligned detail rows. */
18
20
  export declare function instanceHeader(instance: WorkflowInstance): string;
19
- /**
20
- * The per-instance body lines: every stage with its activities, any pending
21
- * effects, and (optionally) the history log. Pure so it can be asserted
22
- * without driving the oclif command.
23
- */
24
21
  export declare function describeInstance(instance: WorkflowInstance, options: {
25
22
  includeHistory: boolean;
23
+ diagnostic?: InstanceDiagnostic | undefined;
26
24
  }): string[];
@@ -1,11 +1,13 @@
1
1
  import { styleText } from 'node:util';
2
2
  import { Args, Flags } from '@oclif/core';
3
3
  import { formatDateTime } from '@sanity/cli-core/dates';
4
- import { abortReason, displayTitle, terminalState, } from '@sanity/workflow-engine';
4
+ import { abortReason, workflow, errorMessage, displayTitle, terminalState, } from '@sanity/workflow-engine';
5
5
  import logSymbols from 'log-symbols';
6
6
  import { WorkflowCommand } from "../../lib/base-command.js";
7
+ import { stuckDiagnosisLines, stuckHeadline, } from "../../lib/cause-detail.js";
7
8
  import { findInstance, resolveReadTargets } from "../../lib/context.js";
8
9
  import { jsonFlags, tagFlags } from "../../lib/flags.js";
10
+ import { baseEngineArgs } from "../../lib/operation-args.js";
9
11
  import { formatKeyValue, sectionHeader, activityIcon } from "../../lib/ui.js";
10
12
  export default class Show extends WorkflowCommand {
11
13
  static description = 'Show the state, activities, and effects of a workflow instance.';
@@ -42,14 +44,33 @@ export default class Show extends WorkflowCommand {
42
44
  this.log(JSON.stringify(instance, null, 2));
43
45
  return;
44
46
  }
47
+ const diagnostic = await this.diagnostic(hit);
45
48
  this.log(instanceHeader(instance));
46
49
  this.log('');
47
50
  for (const line of describeInstance(instance, {
48
51
  includeHistory: flags.include.includes('history'),
52
+ diagnostic,
49
53
  })) {
50
54
  this.log(line);
51
55
  }
52
56
  }
57
+ async diagnostic(hit) {
58
+ const { instance } = hit;
59
+ if (terminalState(instance) !== 'in-flight')
60
+ return undefined;
61
+ try {
62
+ const { diagnosis, evaluation, remediations } = await workflow.diagnose({
63
+ client: hit.client,
64
+ ...baseEngineArgs({ tag: instance.tag, workflowResource: instance.workflowResource }),
65
+ instanceId: instance._id,
66
+ });
67
+ return { diagnosis, remediations, allMissingDocuments: evaluation.missingDocuments };
68
+ }
69
+ catch (error) {
70
+ this.warn(`Could not evaluate the instance: ${errorMessage(error)}. Showing stored state; reference availability is unknown.`);
71
+ return undefined;
72
+ }
73
+ }
53
74
  }
54
75
  const HEADER_PAD = 9;
55
76
  export function instanceHeader(instance) {
@@ -77,11 +98,17 @@ export function instanceHeader(instance) {
77
98
  }
78
99
  export function describeInstance(instance, options) {
79
100
  return [
101
+ ...diagnosticLines(options.diagnostic),
80
102
  ...stageLines(instance),
81
103
  ...pendingEffectLines(instance),
82
104
  ...(options.includeHistory ? historyLines(instance) : []),
83
105
  ];
84
106
  }
107
+ function diagnosticLines(diagnostic) {
108
+ if (diagnostic?.diagnosis.state !== 'stuck')
109
+ return [];
110
+ return [stuckHeadline(diagnostic.diagnosis.cause), ...stuckDiagnosisLines(diagnostic), ''];
111
+ }
85
112
  function stageLines(instance) {
86
113
  return [
87
114
  sectionHeader('Stages'),
@@ -6,5 +6,11 @@ import type { Hook } from '@oclif/core';
6
6
  * resolved from raw argv because the store is built before oclif parses
7
7
  * flags. Skips non-workflows commands when this package is mounted as a
8
8
  * host plugin so `sanity dataset list` does not inherit the shell. */
9
+ export declare function installPrerunTelemetry(args: {
10
+ bin: string;
11
+ version: string;
12
+ commandId: string | undefined;
13
+ argv: string[];
14
+ }): Promise<void>;
9
15
  declare const hook: Hook<'prerun'>;
10
16
  export default hook;
@@ -1,16 +1,23 @@
1
1
  import { shouldRunWorkflowCliTelemetry } from "../../command-ids.js";
2
2
  import { shouldForceShareTelemetry } from "../../lib/share-definitions.js";
3
3
  import { setupCliTelemetry } from "../../lib/telemetry-setup.js";
4
- const hook = async function ({ config, Command, argv }) {
5
- if (!shouldRunWorkflowCliTelemetry({
6
- bin: config.bin,
7
- commandId: Command?.id,
8
- })) {
4
+ export async function installPrerunTelemetry(args) {
5
+ const { bin, version, commandId, argv } = args;
6
+ if (!shouldRunWorkflowCliTelemetry({ bin, commandId })) {
9
7
  return;
10
8
  }
11
9
  await setupCliTelemetry({
12
- cliVersion: config.version,
13
- forceSend: shouldForceShareTelemetry({ commandId: Command?.id, argv }),
10
+ cliVersion: version,
11
+ forceSend: shouldForceShareTelemetry({ commandId, argv }),
12
+ ...(commandId !== undefined ? { commandId } : {}),
13
+ });
14
+ }
15
+ const hook = async function ({ config, Command, argv }) {
16
+ await installPrerunTelemetry({
17
+ bin: config.bin,
18
+ version: config.version,
19
+ commandId: Command?.id,
20
+ argv,
14
21
  });
15
22
  };
16
23
  export default hook;
@@ -0,0 +1,30 @@
1
+ import type { WorkflowConfig } from '@sanity/workflow-engine';
2
+ export interface BlueprintGenerationArgs {
3
+ /** The directory the config was loaded from. Every planned path is relative
4
+ * to it, and the tree is written under it. */
5
+ root: string;
6
+ config: WorkflowConfig;
7
+ /** The config file's name as discovered in `root`. Decides the specifier the
8
+ * generated modules import their deployment from, and names the file in a
9
+ * diagnostic. */
10
+ configFile: string;
11
+ exportedNames: readonly string[];
12
+ log: (line: string) => void;
13
+ }
14
+ /**
15
+ * Writes the runtime tree `config` requires under `root`. A handler stub is
16
+ * written only where none exists, and nothing is ever deleted.
17
+ *
18
+ * Exits through the clean `fail` path, writing nothing, when a definition fails
19
+ * deploy-time validation, when the definitions cannot produce a plan, when the
20
+ * config module does not export a deployment by name, or when the project
21
+ * declares no version for a dependency the generated functions import.
22
+ */
23
+ export declare function generateBlueprint(args: BlueprintGenerationArgs): void;
24
+ /**
25
+ * Compares the runtime tree `config` requires with the files under `root`,
26
+ * writing nothing. Logs one success line when they match, and otherwise exits
27
+ * non-zero through the clean `fail` path with one entry per problem. Takes the
28
+ * same exits as {@link generateBlueprint} before it can compare anything.
29
+ */
30
+ export declare function checkBlueprint(args: BlueprintGenerationArgs): void;
@@ -0,0 +1,147 @@
1
+ import { styleText } from 'node:util';
2
+ import { applyEmissionWritePlan, deploymentExportIdentifier, emissionDivergences, emissionWritePlan, GENERATED_BY, readGenerationRoot, runtimeEmissionPlan, } from '@sanity/workflow-blueprint/generate';
3
+ import logSymbols from 'log-symbols';
4
+ import { needsReportLines } from "./blueprint-needs.js";
5
+ import { validateOrFail } from "./definitions.js";
6
+ import { fail, failOnThrow } from "./fail.js";
7
+ import { deploymentLabel } from "./select-deployment.js";
8
+ import { sectionHeader } from "./ui.js";
9
+ export function generateBlueprint(args) {
10
+ const planned = planGeneration(args);
11
+ for (const line of needsReportLines(planned.needs))
12
+ args.log(line);
13
+ const applied = failOnThrow('Failed to write the generated tree:', () => applyEmissionWritePlan({ root: args.root, plan: planned.writePlan }));
14
+ args.log('');
15
+ for (const line of emitReportLines({ applied, planned }))
16
+ args.log(line);
17
+ }
18
+ export function checkBlueprint(args) {
19
+ const planned = planGeneration(args);
20
+ const problems = [
21
+ ...emissionDivergences({ root: args.root, plan: planned.writePlan }).map((divergence) => [
22
+ divergenceRow(divergence),
23
+ ]),
24
+ ...misalignmentReports(planned.writePlan),
25
+ ];
26
+ if (problems.length === 0) {
27
+ args.log(`${logSymbols.success} the generated tree matches the definitions`);
28
+ return;
29
+ }
30
+ fail(`The generated tree does not match the definitions (${problems.length}):`, [...problems.flat(), `Run \`${GENERATED_BY}\` to bring it back in line.`].join('\n'));
31
+ }
32
+ function planGeneration(args) {
33
+ validateEveryDeployment(args.config);
34
+ const needs = failOnThrow('The definitions cannot produce a runtime:', () => runtimeEmissionPlan(args.config));
35
+ const onDisk = readGenerationRoot(args.root);
36
+ const writePlan = failOnThrow('Cannot plan the generated tree:', () => emissionWritePlan({ plan: needs, configFile: args.configFile, ...onDisk }));
37
+ assertDeploymentsAreExported(args);
38
+ return { needs, writePlan, existingPaths: onDisk.existingPaths };
39
+ }
40
+ function validateEveryDeployment(config) {
41
+ const attributed = config.deployments.length > 1;
42
+ for (const deployment of config.deployments) {
43
+ validateOrFail(deployment.definitions, attributed ? `${deploymentLabel(deployment)} — ` : '');
44
+ }
45
+ }
46
+ function assertDeploymentsAreExported(args) {
47
+ const exported = new Set(args.exportedNames);
48
+ const missing = args.config.deployments
49
+ .map((deployment) => deploymentExportIdentifier(deployment.name))
50
+ .filter((identifier) => !exported.has(identifier));
51
+ if (missing.length === 0)
52
+ return;
53
+ const [first = ''] = missing;
54
+ fail(`${args.configFile} does not export ${missing.map((name) => `"${name}"`).join(', ')}:`, [
55
+ `Every generated module imports its deployment by name from ${args.configFile}, so each`,
56
+ `deployment needs its own named export, and that export must be the deployment object`,
57
+ `itself. Declare it once and reuse it:`,
58
+ '',
59
+ ` export const ${first} = {name: '${first}', /* … */}`,
60
+ ` export default defineWorkflowConfig({deployments: [${missing.join(', ')}]})`,
61
+ '',
62
+ `Only the name is checked here, so make sure each export is the deployment this config`,
63
+ `lists. A typecheck of the generated modules catches a wrong export only where a`,
64
+ `generated function uses it.`,
65
+ ].join('\n'));
66
+ }
67
+ const STATUS_WIDTH = 10;
68
+ const WRITE_NOTES = {
69
+ generated: '',
70
+ scaffold: 'yours now — implement the effect',
71
+ wired: 'the workflow resources spread',
72
+ };
73
+ const DIVERGENCE_NOTES = {
74
+ generated: '',
75
+ scaffold: 'no handler for a declared effect',
76
+ wired: 'the workflow resources spread is absent',
77
+ };
78
+ function statusRow(args) {
79
+ const tail = args.note === '' ? '' : ` ${styleText('dim', args.note)}`;
80
+ return ` ${args.status.padEnd(STATUS_WIDTH)} ${args.path}${tail}`;
81
+ }
82
+ function warningRow(path, note) {
83
+ return ` ${logSymbols.warning} ${path} — ${note}`;
84
+ }
85
+ function emitReportLines(args) {
86
+ const { applied, planned } = args;
87
+ const kept = ownedStubPaths(planned);
88
+ const misalignments = misalignmentReports(planned.writePlan);
89
+ const attention = misalignments.length === 0 ? '' : `, ${misalignments.length} need your attention`;
90
+ return [
91
+ sectionHeader('Generated tree'),
92
+ ...applied.map((write) => statusRow({ status: write.status, path: write.path, note: WRITE_NOTES[write.ownership] })),
93
+ ...kept.map((path) => statusRow({ status: 'kept', path, note: 'yours, left untouched' })),
94
+ ...misalignments.flat(),
95
+ '',
96
+ `${logSymbols.success} ${applied.length} file(s) written, ${kept.length} left untouched${attention}`,
97
+ ];
98
+ }
99
+ function ownedStubPaths(planned) {
100
+ const written = new Set(planned.writePlan.writes.map((write) => write.path));
101
+ const reported = new Set(reportedPaths(planned.writePlan));
102
+ return planned.existingPaths
103
+ .filter((path) => path.endsWith('.ts') && !written.has(path) && !reported.has(path))
104
+ .toSorted();
105
+ }
106
+ function reportedPaths(plan) {
107
+ return [
108
+ ...plan.orphanedStubs.map((stub) => stub.path),
109
+ ...plan.caseCollisions.map((collision) => collision.existingPath),
110
+ ...(plan.orphanedRegistry === undefined ? [] : [plan.orphanedRegistry]),
111
+ ];
112
+ }
113
+ function divergenceRow(divergence) {
114
+ return statusRow({
115
+ status: divergence.reason,
116
+ path: divergence.path,
117
+ note: DIVERGENCE_NOTES[divergence.ownership],
118
+ });
119
+ }
120
+ function misalignmentReports(plan) {
121
+ const wiring = plan.manualBlueprintWiring;
122
+ return [
123
+ ...plan.orphanedStubs.map((stub) => [
124
+ warningRow(stub.path, `no definition declares the effect "${stub.effectName}" — delete it`),
125
+ ]),
126
+ ...(plan.orphanedRegistry === undefined
127
+ ? []
128
+ : [
129
+ [
130
+ warningRow(plan.orphanedRegistry, 'no definition declares an effect, so nothing imports the registry — delete it'),
131
+ ],
132
+ ]),
133
+ ...plan.caseCollisions.map((collision) => [
134
+ warningRow(collision.plannedPath, `not written: ${collision.existingPath} differs from it only in case — rename that file ` +
135
+ `to match the effect "${collision.effectName}"`),
136
+ ]),
137
+ ...(wiring === undefined
138
+ ? []
139
+ : [
140
+ [
141
+ warningRow(wiring.path, 'could not be wired automatically — add these two lines by hand'),
142
+ ` ${wiring.importLine}`,
143
+ ` ${wiring.spreadLine}`,
144
+ ],
145
+ ]),
146
+ ];
147
+ }
@@ -0,0 +1,2 @@
1
+ import type { RuntimeEmissionPlan } from '@sanity/workflow-blueprint/generate';
2
+ export declare function needsReportLines(plan: RuntimeEmissionPlan): string[];
@@ -0,0 +1,109 @@
1
+ import { styleText } from 'node:util';
2
+ import logSymbols from 'log-symbols';
3
+ import { deploymentLabel } from "./select-deployment.js";
4
+ import { sectionHeader } from "./ui.js";
5
+ export function needsReportLines(plan) {
6
+ return [
7
+ sectionHeader('Runtime needs'),
8
+ ...plan.deployments.flatMap((deployment) => ['', ...deploymentNeedsLines(deployment)]),
9
+ ];
10
+ }
11
+ const DETAIL = ' → ';
12
+ const REASON = ' ';
13
+ function deploymentNeedsLines(plan) {
14
+ const { emission } = plan;
15
+ return [
16
+ styleText('bold', `▸ ${deploymentLabel({ name: plan.deploymentName, tag: plan.tag })}`),
17
+ ...hostingLines(plan),
18
+ ...(emission === undefined
19
+ ? [' no functions needed — the reactive session is already this deployment runtime']
20
+ : [...drainLines(plan, emission), ...clockLines(plan, emission), ...watcherLines(emission)]),
21
+ ...selfHostedLines(plan),
22
+ ...warningLines(plan),
23
+ ];
24
+ }
25
+ const HOSTING_NOTE = {
26
+ function: '',
27
+ durableFunction: 'no functions are emitted for these yet',
28
+ selfHosted: 'nothing is emitted — your own process runs these',
29
+ };
30
+ const HOSTING_ORDER = ['function', 'durableFunction', 'selfHosted'];
31
+ function hostingLines(plan) {
32
+ return [
33
+ ` hosting: this deployment declares ${plan.runtimeKind}, and each level below inherits it`,
34
+ ...kindLines('workflow', plan.hosting.workflows),
35
+ ...kindLines('effect', plan.hosting.effects),
36
+ ];
37
+ }
38
+ function kindLines(what, byKind) {
39
+ return HOSTING_ORDER.flatMap((kind) => {
40
+ const names = byKind[kind];
41
+ if (names.length === 0)
42
+ return [];
43
+ const note = HOSTING_NOTE[kind];
44
+ const suffix = note === '' ? '' : ` — ${note}`;
45
+ return [`${DETAIL}${kind} ${what}(s): ${names.join(', ')}${suffix}`];
46
+ });
47
+ }
48
+ const SELF_HOSTED_DUTIES = [
49
+ { key: 'startInstances', duty: 'start instances of' },
50
+ { key: 'tickDeadlines', duty: 'tick the $now deadlines of' },
51
+ { key: 'drainEffects', duty: 'drain the effects' },
52
+ { key: 'reevaluateParents', duty: 're-evaluate, when a child settles,' },
53
+ ];
54
+ function selfHostedLines(plan) {
55
+ const duties = SELF_HOSTED_DUTIES.flatMap(({ key, duty }) => {
56
+ const names = plan.selfHosted[key];
57
+ return names.length === 0 ? [] : [`${REASON}${duty} ${names.join(', ')}`];
58
+ });
59
+ if (duties.length === 0)
60
+ return [];
61
+ return [' self-hosted: nothing below is emitted, so your own process must', ...duties];
62
+ }
63
+ function drainLines(plan, emission) {
64
+ const declared = plan.needs.drain.effectNames;
65
+ if (declared.length === 0)
66
+ return [];
67
+ return [
68
+ ` effects: ${declared.length} declared — ${declared.join(', ')}`,
69
+ ...emission.drains.map((drain) => `${DETAIL}drain function ${drain.name} runs ${drain.effectNames.join(', ')}`),
70
+ `${REASON}triggered by instance writes matching ${emission.drainFilter}`,
71
+ ];
72
+ }
73
+ function clockLines(plan, emission) {
74
+ const { heartbeat } = emission;
75
+ if (heartbeat === undefined)
76
+ return [];
77
+ const ticked = new Set(plan.hosting.workflows.function);
78
+ return [
79
+ ` clock: ${heartbeat.reasons.join('; ')}`,
80
+ ...timeSiteLines(plan.needs.heartbeat.timeSites.filter((site) => ticked.has(site.definition))),
81
+ `${DETAIL}scheduled function ${heartbeat.name} on ${heartbeat.schedule}`,
82
+ `${REASON}a scheduled function runs at most as often as your organization's plan allows` +
83
+ ` — every minute on Enterprise, hourly on Growth, daily on Free` +
84
+ ` (https://www.sanity.io/docs/functions/functions-introduction) — and a schedule below` +
85
+ ` that threshold is deployed and never invoked`,
86
+ ];
87
+ }
88
+ function timeSiteLines(sites) {
89
+ return sites.map((site) => `${REASON}${site.definition} · ${site.stage ?? 'definition'} · ${site.address.kind} · ` +
90
+ site.condition);
91
+ }
92
+ function watcherLines(emission) {
93
+ const [first] = emission.startWatchers;
94
+ if (first === undefined)
95
+ return [];
96
+ return [
97
+ ` autonomous starts: ${first.definitions.length} definition(s) — ${first.definitions.join(', ')}`,
98
+ ...emission.startWatchers.map((watcher) => `${DETAIL}start watcher ${watcher.name} on ${watcher.projectId}.${watcher.dataset}` +
99
+ ` matching ${watcher.filter}`),
100
+ ];
101
+ }
102
+ function warningLines(plan) {
103
+ return [
104
+ ...plan.anyTypeSubjects.map((name) => ` ${logSymbols.warning} ${name} declares a subject with no types, so its watcher fires` +
105
+ ` on every create in the dataset — declare types unless that is intended`),
106
+ ...plan.subjectlessDefinitions.map((name) => ` ${logSymbols.warning} ${name} starts autonomously but declares no subject, so no` +
107
+ ` document write can start it and it gets no watcher`),
108
+ ];
109
+ }
@@ -0,0 +1,9 @@
1
+ import { type Diagnosis, type MissingDocument, type StuckCause, type SuggestedRemediation } from '@sanity/workflow-engine';
2
+ export declare function stuckHeadline(cause: StuckCause): string;
3
+ export interface InstanceDiagnostic {
4
+ diagnosis: Diagnosis;
5
+ /** Full evaluation evidence, including references outside the blocking subset. */
6
+ allMissingDocuments?: MissingDocument[] | undefined;
7
+ remediations: SuggestedRemediation[];
8
+ }
9
+ export declare function stuckDiagnosisLines({ diagnosis, allMissingDocuments, remediations, }: InstanceDiagnostic): string[];