workflow-v2.mjs
280 lines · archify/migrations/workflow-v2.mjs at a406594
1import { compileWorkflow } from '../renderers/workflow/workflow-compiler.mjs';
2import {
3 createMappedWorkflowCandidate,
4 intrinsicWorkflow,
5 planningWorkflow,
6} from '../renderers/workflow/workflow-migration-geometry.mjs';
7import { validateSchema } from '../renderers/shared/validator.mjs';
9export { createHorizontalRankMapper } from '../renderers/workflow/workflow-migration-geometry.mjs';
11const TARGET_SCHEMA_VERSION = 2;
13function clone(value) {
14 return JSON.parse(JSON.stringify(value));
15}
17function diagnostic({ code, message, subject = {}, evidence = {}, supportedFixes = [] }) {
18 return {
19 code,
20 severity: 'error',
21 message,
22 subject,
23 evidence,
24 supportedFixes,
25 };
26}
28function schemaDiagnostics(workflow) {
29 try {
30 validateSchema('workflow', workflow);
31 return [];
32 } catch (error) {
33 if (Array.isArray(error?.archifyDiagnostics)) {
34 return error.archifyDiagnostics.map((entry) => ({ ...entry }));
35 }
36 throw error;
37 }
38}
40function legacyLayoutProbe(workflow, qualityProfile) {
41 // The probe discovers fixed-v1 rank centers, not authored canvas capacity.
42 // Omitting viewBox lets a capacity-only legacy failure reach the v2 compiler,
43 // which can measure and monotonically expand the real migrated document.
44 const probe = {
45 schema_version: 1,
46 diagram_type: 'workflow',
47 meta: {
48 title: workflow.meta.title,
49 output: workflow.meta.output,
50 ...(workflow.meta.locale ? { locale: workflow.meta.locale } : {}),
51 legend: { mode: 'hidden' },
52 },
53 lanes: clone(workflow.lanes),
54 nodes: [{
55 id: 'migration_probe',
56 lane: workflow.lanes[0].id,
57 col: 0,
58 type: 'backend',
59 label: 'Probe',
60 }],
61 edges: [],
62 };
63 return compileWorkflow({ workflow: probe, qualityProfile });
64}
66function legacyRequirementProbe(workflow, qualityProfile) {
67 // Measure the complete fixed-v1 document without treating an authored
68 // viewBox as its intrinsic requirement. The authored viewBox remains a
69 // migration capacity and is preserved separately on the migrated document.
70 const probe = clone(workflow);
71 delete probe.meta.viewBox;
72 return compileWorkflow({ workflow: probe, qualityProfile });
73}
75function requiredViewBoxFrom(result) {
76 if (Array.isArray(result?.receipt?.requiredViewBox)) {
77 return [...result.receipt.requiredViewBox];
78 }
79 const required = result?.diagnostics
80 ?.map((entry) => entry?.evidence?.requiredViewBox)
81 .find((candidate) => Array.isArray(candidate) && candidate.length === 2);
82 return required ? [...required] : null;
83}
85function expandableViewBox(result) {
86 if (result.ok || !result.diagnostics?.length) return null;
87 if (!result.diagnostics.every((entry) => entry.code === 'workflow/viewbox-capacity')) return null;
88 return requiredViewBoxFrom(result);
89}
91function result({
92 ok,
93 document,
94 fromSchemaVersion = 1,
95 preExistingDiagnostics = [],
96 migrationDiagnostics = [],
97 newSchemaDiagnostics = [],
98 changedCoordinates = [],
99 oldRequiredViewBox = null,
100 newRequiredViewBox = null,
101}) {
102 return {
103 ok,
104 ...(document ? { document } : {}),
105 fromSchemaVersion,
106 toSchemaVersion: TARGET_SCHEMA_VERSION,
107 preExistingDiagnostics,
108 migrationDiagnostics,
109 newSchemaDiagnostics,
110 changedCoordinates,
111 oldRequiredViewBox,
112 newRequiredViewBox,
113 };
114}
116export function migrateWorkflowDocument(inputWorkflow) {
117 if (!inputWorkflow || typeof inputWorkflow !== 'object' || Array.isArray(inputWorkflow)) {
118 return result({
119 ok: false,
120 migrationDiagnostics: [diagnostic({
121 code: 'migration/source-document',
122 message: 'Workflow migration requires one parsed JSON object.',
123 supportedFixes: ['provide one workflow schema v1 JSON document'],
124 })],
125 });
126 }
127 // Migration has no quality override: the authored policy (or effective
128 // standard default) must validate the document after it leaves this process.
129 const qualityProfile = inputWorkflow.meta?.quality_profile || 'standard';
131 const workflow = clone(inputWorkflow);
132 const preExistingDiagnostics = schemaDiagnostics(workflow);
133 if (preExistingDiagnostics.length) {
134 return result({
135 ok: false,
136 fromSchemaVersion: workflow.schema_version,
137 preExistingDiagnostics,
138 });
139 }
140 if (workflow.schema_version === TARGET_SCHEMA_VERSION) {
141 const compiled = compileWorkflow({ workflow: clone(workflow), qualityProfile });
142 const requiredViewBox = requiredViewBoxFrom(compiled);
143 if (!compiled.ok) {
144 return result({
145 ok: false,
146 fromSchemaVersion: TARGET_SCHEMA_VERSION,
147 preExistingDiagnostics: compiled.diagnostics,
148 oldRequiredViewBox: requiredViewBox,
149 newRequiredViewBox: requiredViewBox,
150 });
151 }
152 return result({
153 ok: true,
154 document: workflow,
155 fromSchemaVersion: TARGET_SCHEMA_VERSION,
156 oldRequiredViewBox: requiredViewBox,
157 newRequiredViewBox: requiredViewBox,
158 });
159 }
160 if (workflow.schema_version !== 1) {
161 return result({
162 ok: false,
163 fromSchemaVersion: workflow.schema_version,
164 migrationDiagnostics: [diagnostic({
165 code: 'migration/source-schema-version',
166 message: 'Workflow migration to schema v2 requires a schema v1 or v2 source.',
167 subject: { path: '/schema_version' },
168 evidence: { actual: workflow.schema_version, expected: [1, 2] },
169 supportedFixes: ['use an unchanged schema v1 workflow or an already migrated schema v2 workflow as the source'],
170 })],
171 });
172 }
174 const legacy = compileWorkflow({ workflow: clone(workflow), qualityProfile });
175 const legacyProbe = legacyLayoutProbe(workflow, qualityProfile);
176 if (!legacyProbe.ok) {
177 return result({
178 ok: false,
179 preExistingDiagnostics: legacy.ok ? [] : legacy.diagnostics,
180 migrationDiagnostics: legacyProbe.diagnostics,
181 });
182 }
183 const legacyRequirement = legacyRequirementProbe(workflow, qualityProfile);
184 const oldRequiredViewBox = requiredViewBoxFrom(legacyRequirement)
185 || requiredViewBoxFrom(legacyProbe)
186 || requiredViewBoxFrom(legacy);
187 const preExistingLayoutDiagnostics = legacy.ok ? [] : legacy.diagnostics;
189 let planned = compileWorkflow({ workflow: intrinsicWorkflow(workflow), qualityProfile });
190 if (!planned.ok) {
191 // Old absolute pins can be invalid at the new rank centers before their X
192 // coordinates are mapped. Obtain the same rank plan from an automatic-route
193 // projection, then validate every authored pin again after mapping.
194 planned = compileWorkflow({ workflow: planningWorkflow(workflow), qualityProfile });
195 }
196 if (!planned.ok) {
197 return result({
198 ok: false,
199 preExistingDiagnostics: preExistingLayoutDiagnostics,
200 newSchemaDiagnostics: planned.diagnostics,
201 oldRequiredViewBox,
202 newRequiredViewBox: requiredViewBoxFrom(planned),
203 });
204 }
206 let mappedCandidate;
207 try {
208 mappedCandidate = createMappedWorkflowCandidate(
209 workflow,
210 legacyProbe.receipt.columns,
211 planned.receipt.columns,
212 );
213 } catch (error) {
214 return result({
215 ok: false,
216 preExistingDiagnostics: preExistingLayoutDiagnostics,
217 migrationDiagnostics: [diagnostic({
218 code: 'migration/rank-mapping',
219 message: 'Could not construct a stable horizontal rank mapping.',
220 evidence: { reason: error.message },
221 supportedFixes: ['report the workflow and compiler receipts to the Archify maintainers'],
222 })],
223 oldRequiredViewBox,
224 newRequiredViewBox: requiredViewBoxFrom(planned),
225 });
226 }
228 const { document: migrated, changedCoordinates } = mappedCandidate;
230 let compiled = compileWorkflow({ workflow: migrated, qualityProfile });
231 const requiredExpansion = migrated.meta.viewBox ? expandableViewBox(compiled) : null;
232 if (requiredExpansion) {
233 const current = migrated.meta.viewBox;
234 const expanded = [
235 Math.max(current[0], requiredExpansion[0]),
236 Math.max(current[1], requiredExpansion[1]),
237 ];
238 if (expanded[0] > current[0] || expanded[1] > current[1]) {
239 migrated.meta.viewBox = expanded;
240 compiled = compileWorkflow({ workflow: migrated, qualityProfile });
241 }
242 }
244 const newRequiredViewBox = requiredViewBoxFrom(compiled) || requiredViewBoxFrom(planned);
245 if (!compiled.ok) {
246 return result({
247 ok: false,
248 preExistingDiagnostics: preExistingLayoutDiagnostics,
249 newSchemaDiagnostics: compiled.diagnostics,
250 changedCoordinates,
251 oldRequiredViewBox,
252 newRequiredViewBox,
253 });
254 }
256 const migratedSchemaDiagnostics = schemaDiagnostics(migrated);
257 if (migratedSchemaDiagnostics.length) {
258 return result({
259 ok: false,
260 preExistingDiagnostics: preExistingLayoutDiagnostics,
261 newSchemaDiagnostics: migratedSchemaDiagnostics,
262 changedCoordinates,
263 oldRequiredViewBox,
264 newRequiredViewBox,
265 });
266 }
268 return result({
269 ok: true,
270 document: migrated,
271 preExistingDiagnostics: preExistingLayoutDiagnostics,
272 changedCoordinates,
273 oldRequiredViewBox,
274 newRequiredViewBox,
275 });
276}
278export function serializeMigratedWorkflow(workflow) {
279 return `${JSON.stringify(workflow, null, 2)}\n`;
280}