@sanity/workflow-cli 0.31.0 → 0.33.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/CHANGELOG.md +183 -0
- package/README.md +244 -42
- package/dist/commands/workflows/blueprint/generate.d.ts +9 -0
- package/dist/commands/workflows/blueprint/generate.js +28 -0
- package/dist/commands/workflows/diagnose.d.ts +4 -2
- package/dist/commands/workflows/diagnose.js +26 -88
- package/dist/commands/workflows/list.d.ts +10 -2
- package/dist/commands/workflows/list.js +102 -35
- package/dist/commands/workflows/show.d.ts +3 -5
- package/dist/commands/workflows/show.js +35 -7
- package/dist/commands/workflows/tail.js +2 -2
- package/dist/hooks/prerun/telemetry.d.ts +6 -0
- package/dist/hooks/prerun/telemetry.js +14 -7
- package/dist/lib/blueprint-emit.d.ts +30 -0
- package/dist/lib/blueprint-emit.js +147 -0
- package/dist/lib/blueprint-needs.d.ts +2 -0
- package/dist/lib/blueprint-needs.js +109 -0
- package/dist/lib/cause-detail.d.ts +9 -0
- package/dist/lib/cause-detail.js +111 -0
- package/dist/lib/load-config.d.ts +20 -0
- package/dist/lib/load-config.js +16 -13
- package/dist/lib/share-definitions.d.ts +2 -2
- package/dist/lib/share-definitions.js +2 -2
- package/dist/lib/telemetry-setup.d.ts +2 -0
- package/dist/lib/telemetry-setup.js +2 -2
- package/dist/lib/telemetry.d.ts +5 -2
- package/dist/lib/telemetry.js +13 -4
- package/dist/lib/ui.d.ts +0 -6
- package/dist/lib/ui.js +0 -17
- package/dist/standalone-argv.d.ts +2 -2
- package/dist/standalone-argv.js +1 -0
- package/oclif.manifest.json +60 -1
- package/package.json +18 -8
|
@@ -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,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[];
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
2
|
+
import { formatDateTime } from '@sanity/cli-core/dates';
|
|
3
|
+
import { _additionalMissingDocuments, _fieldTargetLabel, _missingDocumentsSummary, } from '@sanity/workflow-engine';
|
|
4
|
+
import logSymbols from 'log-symbols';
|
|
5
|
+
import { sectionHeader } from "./ui.js";
|
|
6
|
+
function missingDocumentsDetail(documents) {
|
|
7
|
+
return {
|
|
8
|
+
headline: 'referenced document missing',
|
|
9
|
+
why: [
|
|
10
|
+
...documents.map(({ target, reference }) => `${_fieldTargetLabel(target)}: ${reference.id}`),
|
|
11
|
+
_missingDocumentsSummary(documents),
|
|
12
|
+
],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function additionalDocumentLines(diagnosis, allMissingDocuments) {
|
|
16
|
+
const documents = _additionalMissingDocuments(diagnosis, allMissingDocuments);
|
|
17
|
+
if (documents.length === 0)
|
|
18
|
+
return [];
|
|
19
|
+
return ['', sectionHeader('Missing references'), ...missingDocumentsDetail(documents).why];
|
|
20
|
+
}
|
|
21
|
+
function failedEffectDetail(effect) {
|
|
22
|
+
const ran = effect.durationMs !== undefined ? ` (after ${effect.durationMs}ms)` : '';
|
|
23
|
+
return {
|
|
24
|
+
headline: `a failed effect from action '${effect.origin.name}' is blocking its activity`,
|
|
25
|
+
why: [
|
|
26
|
+
styleText('red', `${logSymbols.error} failed effect: ${effect.name}`),
|
|
27
|
+
` queued by action '${effect.origin.name}', failed ${formatDateTime(effect.ranAt)}${ran}`,
|
|
28
|
+
...(effect.error !== undefined ? [` error: ${effect.error.message}`] : []),
|
|
29
|
+
'',
|
|
30
|
+
`The activity that fired '${effect.origin.name}' is waiting on this effect. It failed`,
|
|
31
|
+
`against an external system, so the activity never resolves and the stage can't advance.`,
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function hungEffectDetail(effect) {
|
|
36
|
+
return {
|
|
37
|
+
headline: `effect '${effect.name}' was claimed but never completed`,
|
|
38
|
+
why: [
|
|
39
|
+
styleText('yellow', `${logSymbols.warning} hung effect: ${effect.name}`),
|
|
40
|
+
` claimed ${formatDateTime(effect.claim?.claimedAt ?? '?')} but never reported back — the`,
|
|
41
|
+
` drainer likely died mid-dispatch, so it won't drain on its own.`,
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function failedActivityDetail(activity) {
|
|
46
|
+
return {
|
|
47
|
+
headline: `activity '${activity}' failed`,
|
|
48
|
+
why: [
|
|
49
|
+
styleText('red', `${logSymbols.error} activity '${activity}' is in a terminal failed state.`),
|
|
50
|
+
`Any exit transition gated on '${activity}' being done can never fire.`,
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function noTransitionDetail() {
|
|
55
|
+
return {
|
|
56
|
+
headline: `no exit transition's trigger is satisfied`,
|
|
57
|
+
why: [
|
|
58
|
+
`${logSymbols.info} every activity is resolved, but no exit transition's \`when\` is true.`,
|
|
59
|
+
`Likely a routing state value a trigger reads never got written.`,
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function transitionUnevaluableDetail(transitions) {
|
|
64
|
+
return {
|
|
65
|
+
headline: `an exit transition's trigger could not be evaluated`,
|
|
66
|
+
why: [
|
|
67
|
+
`${logSymbols.info} every activity is resolved, but ${transitions.join(', ')} reads an operand`,
|
|
68
|
+
`that is missing or unreadable (GROQ null), so routing is held rather than`,
|
|
69
|
+
`falling through. Make the data the trigger reads readable — publish the`,
|
|
70
|
+
`subject (or fill the field) — and the instance advances on its own; no`,
|
|
71
|
+
`set-stage needed.`,
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function remediationLines(remediations) {
|
|
76
|
+
return remediations.map((r) => ` • ${r.verb} — ${r.rationale}`);
|
|
77
|
+
}
|
|
78
|
+
function causeDetail(cause) {
|
|
79
|
+
switch (cause.kind) {
|
|
80
|
+
case 'document-missing':
|
|
81
|
+
return missingDocumentsDetail(cause.documents);
|
|
82
|
+
case 'failed-effect':
|
|
83
|
+
return failedEffectDetail(cause.effect);
|
|
84
|
+
case 'hung-effect':
|
|
85
|
+
return hungEffectDetail(cause.effect);
|
|
86
|
+
case 'failed-activity':
|
|
87
|
+
return failedActivityDetail(cause.activity);
|
|
88
|
+
case 'no-transition-fires':
|
|
89
|
+
return noTransitionDetail();
|
|
90
|
+
case 'transition-unevaluable':
|
|
91
|
+
return transitionUnevaluableDetail(cause.transitions);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function stuckHeadline(cause) {
|
|
95
|
+
return `${logSymbols.warning} ${styleText('yellow', 'STUCK')} — ${causeDetail(cause).headline}`;
|
|
96
|
+
}
|
|
97
|
+
export function stuckDiagnosisLines({ diagnosis, allMissingDocuments, remediations, }) {
|
|
98
|
+
if (diagnosis.state !== 'stuck')
|
|
99
|
+
return [];
|
|
100
|
+
const detail = causeDetail(diagnosis.cause);
|
|
101
|
+
const runnable = remediations.filter((remediation) => remediation.available);
|
|
102
|
+
return [
|
|
103
|
+
'',
|
|
104
|
+
sectionHeader("Why it's stuck"),
|
|
105
|
+
...detail.why,
|
|
106
|
+
...additionalDocumentLines(diagnosis, allMissingDocuments),
|
|
107
|
+
...(runnable.length > 0
|
|
108
|
+
? ['', sectionHeader('Suggested fix'), ...remediationLines(runnable)]
|
|
109
|
+
: []),
|
|
110
|
+
];
|
|
111
|
+
}
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { type WorkflowConfig } from '@sanity/workflow-engine';
|
|
2
|
+
/**
|
|
3
|
+
* The validated config a `sanity.workflow` module exports, and the names that
|
|
4
|
+
* module exports it alongside.
|
|
5
|
+
*/
|
|
6
|
+
export interface LoadedWorkflowConfig {
|
|
7
|
+
config: WorkflowConfig;
|
|
8
|
+
/** The discovered file's name, so a diagnostic names the file the user
|
|
9
|
+
* actually has out of {@link CONFIG_FILE_NAMES}. */
|
|
10
|
+
configFile: string;
|
|
11
|
+
/** Every name the module exports, `default` included. `blueprint generate`
|
|
12
|
+
* reads it because each generated module imports its deployment as a named
|
|
13
|
+
* export from this file. */
|
|
14
|
+
exportedNames: readonly string[];
|
|
15
|
+
}
|
|
2
16
|
/**
|
|
3
17
|
* Discover the `sanity.workflow.{ts,js,mjs}` in `cwd`, import its default
|
|
4
18
|
* export, and validate it through {@link defineWorkflowConfig}. Any problem —
|
|
@@ -6,6 +20,12 @@ import { type WorkflowConfig } from '@sanity/workflow-engine';
|
|
|
6
20
|
* path.
|
|
7
21
|
*/
|
|
8
22
|
export declare function loadWorkflowConfig(cwd?: string): Promise<WorkflowConfig>;
|
|
23
|
+
/**
|
|
24
|
+
* {@link loadWorkflowConfig} plus the names the config module exports, for a
|
|
25
|
+
* caller that has to check one. Takes the same clean `fail` path on every
|
|
26
|
+
* problem.
|
|
27
|
+
*/
|
|
28
|
+
export declare function loadWorkflowConfigModule(cwd?: string): Promise<LoadedWorkflowConfig>;
|
|
9
29
|
/**
|
|
10
30
|
* {@link loadWorkflowConfig} for telemetry setup, which must never break (or
|
|
11
31
|
* exit) a command: `undefined` when the file is absent or unusable. A broken
|
package/dist/lib/load-config.js
CHANGED
|
@@ -9,15 +9,11 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
10
|
import { basename, dirname, join } from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { CONFIG_FILE_NAMES } from '@sanity/workflow-blueprint/generate';
|
|
12
13
|
import { errorMessage } from '@sanity/workflow-engine';
|
|
13
14
|
import { defineWorkflowConfig } from '@sanity/workflow-engine/define';
|
|
14
15
|
import { createJiti } from 'jiti';
|
|
15
16
|
import { fail } from "./fail.js";
|
|
16
|
-
const CONFIG_FILE_NAMES = [
|
|
17
|
-
'sanity.workflow.ts',
|
|
18
|
-
'sanity.workflow.js',
|
|
19
|
-
'sanity.workflow.mjs',
|
|
20
|
-
];
|
|
21
17
|
function findConfigFile(cwd) {
|
|
22
18
|
for (const name of CONFIG_FILE_NAMES) {
|
|
23
19
|
const candidate = join(cwd, name);
|
|
@@ -36,13 +32,13 @@ class ConfigLoadError extends Error {
|
|
|
36
32
|
this.detail = detail;
|
|
37
33
|
}
|
|
38
34
|
}
|
|
39
|
-
async function
|
|
35
|
+
async function importModule(filePath) {
|
|
40
36
|
const url = pathToFileURL(filePath).href;
|
|
41
37
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return
|
|
38
|
+
const loaded = filePath.endsWith('.ts')
|
|
39
|
+
? await createJiti(dirname(filePath)).import(url)
|
|
40
|
+
: await import(__rewriteRelativeImportExtension(url, true));
|
|
41
|
+
return { default: loaded.default, names: Object.keys(loaded) };
|
|
46
42
|
}
|
|
47
43
|
catch (err) {
|
|
48
44
|
throw new ConfigLoadError(`Failed to load ${basename(filePath)}:`, errorMessage(err));
|
|
@@ -54,9 +50,13 @@ function parseConfigFile(filePath) {
|
|
|
54
50
|
if (cached !== undefined) {
|
|
55
51
|
return cached;
|
|
56
52
|
}
|
|
57
|
-
const parsed =
|
|
53
|
+
const parsed = importModule(filePath).then((loaded) => {
|
|
58
54
|
try {
|
|
59
|
-
return
|
|
55
|
+
return {
|
|
56
|
+
config: defineWorkflowConfig(loaded.default),
|
|
57
|
+
configFile: basename(filePath),
|
|
58
|
+
exportedNames: loaded.names,
|
|
59
|
+
};
|
|
60
60
|
}
|
|
61
61
|
catch (err) {
|
|
62
62
|
throw new ConfigLoadError(`Invalid config in ${basename(filePath)}:`, errorMessage(err));
|
|
@@ -66,6 +66,9 @@ function parseConfigFile(filePath) {
|
|
|
66
66
|
return parsed;
|
|
67
67
|
}
|
|
68
68
|
export async function loadWorkflowConfig(cwd = process.cwd()) {
|
|
69
|
+
return (await loadWorkflowConfigModule(cwd)).config;
|
|
70
|
+
}
|
|
71
|
+
export async function loadWorkflowConfigModule(cwd = process.cwd()) {
|
|
69
72
|
const filePath = findConfigFile(cwd);
|
|
70
73
|
if (filePath === undefined) {
|
|
71
74
|
fail(`No ${CONFIG_FILE_NAMES[0]} found in ${cwd}.`, 'Create one that `export default defineWorkflowConfig({deployments: [...]})`.');
|
|
@@ -85,5 +88,5 @@ export async function loadWorkflowConfigIfPresent(cwd = process.cwd()) {
|
|
|
85
88
|
if (filePath === undefined) {
|
|
86
89
|
return undefined;
|
|
87
90
|
}
|
|
88
|
-
return parseConfigFile(filePath).
|
|
91
|
+
return parseConfigFile(filePath).then((loaded) => loaded.config, () => undefined);
|
|
89
92
|
}
|
|
@@ -11,7 +11,7 @@ export interface ShareClient {
|
|
|
11
11
|
tag: string;
|
|
12
12
|
}): Promise<T>;
|
|
13
13
|
request<T>(opts: {
|
|
14
|
-
|
|
14
|
+
url: string;
|
|
15
15
|
method: string;
|
|
16
16
|
body: unknown;
|
|
17
17
|
tag: string;
|
|
@@ -31,7 +31,7 @@ export interface ShareCandidate {
|
|
|
31
31
|
}
|
|
32
32
|
/** Sanity's first-party definition-feedback endpoint (editorial-ai-backend,
|
|
33
33
|
* routed project-agnostically through the API gateway). */
|
|
34
|
-
export declare const
|
|
34
|
+
export declare const SHARE_ENDPOINT_URL = "/workflow/definition-feedback";
|
|
35
35
|
/**
|
|
36
36
|
* The first-run disclosure — a product contract, pinned by test. It must
|
|
37
37
|
* name the recipient (Sanity), state that the document ships VERBATIM with its
|
|
@@ -5,7 +5,7 @@ import { WORKFLOWS_DEPLOY_COMMAND_ID } from "../command-ids.js";
|
|
|
5
5
|
import { buildDefinitionShowQuery } from "./definitions.js";
|
|
6
6
|
import { canPromptOnStderr } from "./prompt.js";
|
|
7
7
|
import { cliTelemetry, WorkflowDefinitionShared, WorkflowDefinitionSharingDecided, } from "./telemetry.js";
|
|
8
|
-
export const
|
|
8
|
+
export const SHARE_ENDPOINT_URL = '/workflow/definition-feedback';
|
|
9
9
|
const SHARE_TAG = 'definition.share';
|
|
10
10
|
const SHARE_DECISION_KEY = 'workflowCliDefinitionSharing';
|
|
11
11
|
export const SHARE_FIRST_RUN_NOTICE = `${styleText('bold', 'Sharing new workflow definitions with Sanity')} to improve Workflows.\n` +
|
|
@@ -127,7 +127,7 @@ async function donate(args) {
|
|
|
127
127
|
return false;
|
|
128
128
|
}
|
|
129
129
|
await shared[0].candidate.client.request({
|
|
130
|
-
|
|
130
|
+
url: SHARE_ENDPOINT_URL,
|
|
131
131
|
method: 'POST',
|
|
132
132
|
body: { definitions: shared.map(({ entry }) => entry) },
|
|
133
133
|
tag: SHARE_TAG,
|
|
@@ -41,6 +41,8 @@ export declare function setupCliTelemetry(args?: {
|
|
|
41
41
|
/** Intake client override — tests inject a fake here, like the other
|
|
42
42
|
* hook dependencies above; omitted, the real project client applies. */
|
|
43
43
|
client?: TelemetryIntakeClient;
|
|
44
|
+
/** oclif command id — becomes the command trace's `groupOrCommand` context. */
|
|
45
|
+
commandId?: string;
|
|
44
46
|
}): Promise<void>;
|
|
45
47
|
/**
|
|
46
48
|
* The invocation's environment user properties — the sanity CLI's precedent
|
|
@@ -51,7 +51,7 @@ export async function setupCliTelemetry(args) {
|
|
|
51
51
|
env: args?.env ?? process.env,
|
|
52
52
|
forceSend: args?.forceSend ?? false,
|
|
53
53
|
});
|
|
54
|
-
setCliTelemetry(telemetry);
|
|
54
|
+
setCliTelemetry(telemetry, args?.commandId !== undefined ? { commandId: args.commandId } : undefined);
|
|
55
55
|
attachBuiltinContext({ telemetry, project, client, deps: args });
|
|
56
56
|
}
|
|
57
57
|
catch {
|
|
@@ -87,7 +87,7 @@ export function cliUserProperties(args) {
|
|
|
87
87
|
export async function resolveOrgId(client, projectId) {
|
|
88
88
|
try {
|
|
89
89
|
const project = await raceWithDeadline(client.request({
|
|
90
|
-
|
|
90
|
+
url: `/projects/${encodeURIComponent(projectId)}`,
|
|
91
91
|
tag: CONTEXT_TAG,
|
|
92
92
|
timeout: ORG_LOOKUP_DEADLINE_MS,
|
|
93
93
|
}), ORG_LOOKUP_DEADLINE_MS);
|
package/dist/lib/telemetry.d.ts
CHANGED
|
@@ -58,8 +58,11 @@ export type CliTelemetry = {
|
|
|
58
58
|
};
|
|
59
59
|
export declare function cliTelemetry(): CliTelemetry;
|
|
60
60
|
/** Install the invocation's shell and, for the built-in store, start the
|
|
61
|
-
* command trace (completed by {@link finishCliTelemetry}).
|
|
62
|
-
|
|
61
|
+
* command trace (completed by {@link finishCliTelemetry}). `commandId`
|
|
62
|
+
* becomes that trace's `groupOrCommand` context. */
|
|
63
|
+
export declare function setCliTelemetry(telemetry: CliTelemetry, options?: {
|
|
64
|
+
commandId?: string;
|
|
65
|
+
}): void;
|
|
63
66
|
export declare function clearCliTelemetry(): void;
|
|
64
67
|
/** Build the built-in Sanity-intake shell over an authenticated,
|
|
65
68
|
* project-bound client. `forceSend` overrides the environment denial
|
package/dist/lib/telemetry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createBatchedStore, createSessionId, defineEvent, defineTrace, } from '@sanity/telemetry';
|
|
2
|
-
import { createTelemetryIntake, isTelemetryEnvDenied, noopTelemetry, } from '@sanity/workflow-engine';
|
|
2
|
+
import { createTelemetryIntake, _resolveTelemetryEnvironment, isTelemetryEnvDenied, noopTelemetry, } from '@sanity/workflow-engine';
|
|
3
3
|
export const WorkflowCliCommandExecuted = defineTrace({
|
|
4
4
|
name: 'Workflows CLI Command Executed',
|
|
5
5
|
version: 1,
|
|
@@ -21,10 +21,11 @@ let activeTrace;
|
|
|
21
21
|
export function cliTelemetry() {
|
|
22
22
|
return current;
|
|
23
23
|
}
|
|
24
|
-
export function setCliTelemetry(telemetry) {
|
|
24
|
+
export function setCliTelemetry(telemetry, options) {
|
|
25
25
|
current = telemetry;
|
|
26
26
|
if (telemetry.kind === 'builtin') {
|
|
27
|
-
|
|
27
|
+
const context = options?.commandId !== undefined ? { groupOrCommand: options.commandId } : undefined;
|
|
28
|
+
activeTrace = telemetry.store.logger.trace(WorkflowCliCommandExecuted, context);
|
|
28
29
|
activeTrace.start();
|
|
29
30
|
}
|
|
30
31
|
}
|
|
@@ -35,7 +36,15 @@ export function clearCliTelemetry() {
|
|
|
35
36
|
export function createBuiltinTelemetry(args) {
|
|
36
37
|
const { client, projectId, env, forceSend = false } = args;
|
|
37
38
|
const envDenied = isTelemetryEnvDenied(env);
|
|
38
|
-
const store = createBatchedStore(createSessionId(), createTelemetryIntake({
|
|
39
|
+
const store = createBatchedStore(createSessionId(), createTelemetryIntake({
|
|
40
|
+
client,
|
|
41
|
+
projectId,
|
|
42
|
+
denied: forceSend ? false : envDenied,
|
|
43
|
+
context: {
|
|
44
|
+
surface: 'cli',
|
|
45
|
+
environment: _resolveTelemetryEnvironment(env.NODE_ENV, 'production'),
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
39
48
|
return { kind: 'builtin', logger: store.logger, store, envDenied, forceSend };
|
|
40
49
|
}
|
|
41
50
|
function traceAsLogEvent(trace) {
|
package/dist/lib/ui.d.ts
CHANGED
|
@@ -69,9 +69,3 @@ export declare function resourceLabel(resource: WorkflowResource): string;
|
|
|
69
69
|
/** Status glyph per activity state. log-symbols ship pre-colored (green ✔, red ✖),
|
|
70
70
|
* so those need no extra wrap; the rest carry their own state color. */
|
|
71
71
|
export declare const activityIcon: Record<ActivityStatus, string>;
|
|
72
|
-
/** An engine ISO-8601 timestamp as an absolute `yyyy-MM-dd HH:mm:ss` — the
|
|
73
|
-
* Sanity CLI's audit-log format (see `backups/list`). For detail views. */
|
|
74
|
-
export declare function formatTimestamp(iso: string): string;
|
|
75
|
-
/** An engine ISO-8601 timestamp as a relative `… ago` — the Sanity CLI's
|
|
76
|
-
* job-list format (see `datasets/copy`). For scannable overview tables. */
|
|
77
|
-
export declare function formatAge(iso: string): string;
|