Setting the file. One moment. Orchestration Router · Wix Replatform · wix/skills · Skills Docslib/orchestration-router.js
lib/orchestration-router.js
JavaScript·356 lines·18 KB
);
7const { validateWebsiteHandoff } = require('./website-handoff.js');
8const { statEnvKeys, readEnvFile } = require('./config-env.js');
9const { hashArtifact } = require('./artifact-freshness.js');
10const { validateSetupVerification } = require('./setup-verification.js');
11const {
12 loadMigrationCompletionInputs,
13 validateFrontendCompletion,
14 validateMigrationCompletion,
15} = require('./migration-completion.js');
16
17async function exists(filePath) {
18 try {
19 await fs.access(filePath);
20 return true;
21 } catch {
22 return false;
23 }
24}
25
26async function readJson(filePath) {
27 try {
28 return JSON.parse(await fs.readFile(filePath, 'utf8'));
29 } catch {
30 return null;
31 }
32}
33
34// A setup-verification.json that exists but doesn't validate (wrong schema, incomplete
35// plan coverage, or a stale planDigest) is treated identically to one that doesn't exist —
36// no separate router state, no fail-open branch. See
37// specs/0081-setup-verification-fail-closed-receipt.md.
38async function isSetupVerified(projectDir) {
39 const verificationPath = path.join(projectDir, 'setup', 'setup-verification.json');
40 if (!(await exists(verificationPath))) return false;
41 const verification = await readJson(verificationPath);
42 const plan = await readJson(path.join(projectDir, 'setup', 'setup-plan.json'));
43 const planDigest = hashArtifact(projectDir, path.join('setup', 'setup-plan.json'));
44 return validateSetupVerification({ verification, plan, planDigest }).ok;
45}
46
47async function determineNextStep(projectDir, artifacts) {
48 const validation = validateArtifacts(artifacts);
49 if (!validation.ok) {
50 return {
51 ok: false,
52 nextState: 'failed',
53 nextResource: null,
54 reason: 'invalid_orchestration_artifacts',
55 errors: validation.errors,
56 };
57 }
58
59 const decisions = artifacts.decisions || {};
60 const approvals = artifacts.approvals || {};
61 const sourceUrl = getDecisionValue(decisions, 'sourceUrl');
62 const sourceMode = getDecisionValue(decisions, 'sourceMode');
63 const fileInputPaths = getDecisionValue(decisions, 'fileInputPaths');
64 const hasFileInputs = Array.isArray(fileInputPaths) && fileInputPaths.length > 0;
65 const deliveryMode = getDecisionValue(decisions, 'deliveryMode');
66 const isOneClick = isExplicitUserOneClick(decisions);
67 const websiteScope = getDecisionValue(decisions, 'websiteScope');
68 const targetSiteStrategy = getDecisionValue(decisions, 'targetSiteStrategy');
69 const managementImportMode = getDecisionValue(decisions, 'managementImportMode') || 'standard';
70 const sourcePlatform = getDecisionValue(decisions, 'sourcePlatform');
71 const preflight = artifacts.preflight || null;
72
73 if (deliveryMode && !['management', 'website', 'management_and_website'].includes(deliveryMode)) {
74 return { ok: false, nextState: 'failed', nextResource: 'resources/rp-project-intake/', reason: 'invalid_delivery_mode' };
75 }
76
77 if (!deliveryMode) {
78 return { ok: true, nextState: 'awaiting_destination_strategy', nextResource: 'resources/rp-project-intake/', reason: 'missing_delivery_mode' };
79 }
80
81 if (managementImportMode && !['standard', 'quick'].includes(managementImportMode)) {
82 return { ok: false, nextState: 'failed', nextResource: 'resources/rp-project-intake/', reason: 'invalid_management_import_mode' };
83 }
84
85 // Frontend-only work deliberately avoids the backend migration pipeline. The headless
86 // skill owns its destination/project and terminal website artifacts in this mode.
87 if (deliveryMode === 'website') {
88 if (!sourceUrl) {
89 return { ok: true, nextState: 'awaiting_source', nextResource: 'resources/rp-source-inputs/', reason: 'missing_source_url' };
90 }
91 const completionInputs = await loadMigrationCompletionInputs(projectDir);
92 const frontend = validateFrontendCompletion(completionInputs.frontendCompletion, null, { requireHandoff: false });
93 if (frontend.ok) {
94 return { ok: true, nextState: 'completed', nextResource: null, reason: 'website_completion_present' };
95 }
96 return { ok: true, nextState: 'storefront_running', nextResource: 'resources/rp-website-continuation/', reason: 'website_only_frontend' };
97 }
98
99 if (!targetSiteStrategy) {
100 return { ok: true, nextState: 'awaiting_destination_strategy', nextResource: 'resources/rp-destination/', reason: 'missing_target_site_strategy' };
101 }
102
103 // The decision above only records which strategy was CHOSEN — it says nothing about
104 // whether the destination actually got provisioned. rp-destination persists the
105 // metasite id into config/wix.env on success; if that never happened (a scaffold
106 // failure, a 409, anything short of completion), targetSiteStrategy can still be set
107 // while no destination exists. Without this check the router falls through toward
108 // preflight, which reports the generic "wix.env is missing" — indistinguishable from a
109 // run that never started — and there is no route back into destination creation for
110 // the rest of the run (spec 0080). Applies to both strategies uniformly: rp-destination
111 // is the one module responsible for resolving either, and "persist the metasite id" is
112 // its stated postcondition for both, so this only re-verifies that postcondition rather
113 // than re-deriving which strategy path was taken.
114 //
115 // nextState is 'destination_running', NOT 'awaiting_destination_strategy': the
116 // decision is already made, so this is the agent actively retrying creation, not a
117 // pause for a new user decision. orchestration-state.js requires needsUserInput=true
118 // for every 'awaiting_*' state (isAwaitingState) — reusing that prefix here would
119 // force a stop for input that was never needed and could wrongly halt 1-click
120 // automation on a plain retry. 'destination_running' follows the same naming and
121 // needsUserInput=false convention as the other in-progress states here
122 // (preflight_running, discovery_running, etc.).
123 // spec 0085: WIX_SCAFFOLD_STATUS must be read and classified *before* deciding whether
124 // the missing-WIX_SITE_ID case is a clean first attempt or a durable, already-failed
125 // one — PR #184 review correction (round 7). The wrapper's own round-6 fix can now leave
126 // a durable in_progress/ambiguous marker behind with NO WIX_SITE_ID at all (an
127 // interrupted or unconfirmed attempt); checking WIX_SITE_ID's presence first and
128 // returning immediately, as a prior version of this router did, never looks at that
129 // marker — every restart re-classifies it as a fresh, automatically-retryable
130 // destination_running, rp-destination invokes the wrapper, the wrapper correctly
131 // refuses again, and the router repeats the same non-decision forever. This is exactly
132 // the no-progress loop the WIX_SITE_ID-present branch below already exists to prevent
133 // for `incomplete` — it just wasn't extended to the no-site-id side.
134 const wixEnvPath = path.join(projectDir, 'config', 'wix.env');
135 const wixEnv = await statEnvKeys(wixEnvPath, ['WIX_SITE_ID']);
136 const wixEnvValues = await readEnvFile(wixEnvPath).catch((error) => {
137 if (error && error.code === 'ENOENT') return {};
138 throw error;
139 });
140 const scaffoldStatus = wixEnvValues.WIX_SCAFFOLD_STATUS;
141
142 if (wixEnv.keys.WIX_SITE_ID !== 'present') {
143 // No confirmed destination yet. Only a genuinely untouched wix.env (no status marker
144 // at all) is a clean first attempt — anything the wrapper itself already recorded
145 // here is a durable outcome of a real, failed attempt and must not be treated as one.
146 if (scaffoldStatus === undefined) {
147 return { ok: true, nextState: 'destination_running', nextResource: 'resources/rp-destination/', reason: 'destination_not_provisioned' };
148 }
149 if (scaffoldStatus === 'in_progress') {
150 // An attempt was interrupted before it could record any outcome, and no local
151 // receipt exists either — the wrapper itself now refuses to retry through this
152 // (round 6); the router must not retry around it. 'blocked', not
153 // 'destination_running': no repair path exists, so an auto-retryable state here
154 // is a no-progress loop, exactly like the incomplete case below.
155 return { ok: true, nextState: 'blocked', nextResource: 'resources/rp-destination/', reason: 'destination_scaffold_interrupted' };
156 }
157 // Any other present value with no WIX_SITE_ID (ambiguous, or corrupted/unexpected
158 // text) — the wrapper cannot confirm whether a destination exists in this state
159 // either, and this router has no more information than the wrapper does.
160 return { ok: true, nextState: 'blocked', nextResource: 'resources/rp-destination/', reason: 'destination_scaffold_ambiguous' };
161 }
162
163 // spec 0085: WIX_SCAFFOLD_STATUS must also be consumed once WIX_SITE_ID is present, not
164 // just written by the scaffold wrapper — otherwise a restarted run sees WIX_SITE_ID
165 // present, advances past rp-destination, and never re-invokes the wrapper branch that
166 // rejects an incomplete scaffold. Routes to 'blocked' (not 'destination_running'): this
167 // spec designs no repair path, so an automatically-retryable state here would be a
168 // no-progress loop — 'blocked' is the one state orchestration-state.js's own
169 // validateRun invariant structurally requires needsUserInput=true for.
170 if (scaffoldStatus === 'incomplete') {
171 return { ok: true, nextState: 'blocked', nextResource: 'resources/rp-destination/', reason: 'destination_scaffold_incomplete' };
172 }
173 if (scaffoldStatus !== undefined && scaffoldStatus !== 'complete') {
174 return { ok: true, nextState: 'blocked', nextResource: 'resources/rp-destination/', reason: 'destination_scaffold_status_invalid' };
175 }
176
177 // A file-provided run (e.g. platform=csv) has no source URL to probe; its
178 // input files stand in for one. Destination resolution has already happened,
179 // so source readiness can now branch between file and URL inputs.
180 if (sourceMode === 'files_only') {
181 if (!hasFileInputs) {
182 return { ok: true, nextState: 'awaiting_files', nextResource: 'resources/rp-source-inputs/', reason: 'missing_file_inputs' };
183 }
184 } else if (!sourceUrl && !hasFileInputs) {
185 return { ok: true, nextState: 'awaiting_source', nextResource: 'resources/rp-source-inputs/', reason: 'missing_source_url' };
186 }
187 if (!sourceMode) {
188 return { ok: true, nextState: 'awaiting_import_scope', nextResource: 'resources/rp-source-inputs/', reason: 'missing_source_mode' };
189 }
190
191 if (!preflight || !preflight.status || preflight.status === 'not_started') {
192 return { ok: true, nextState: 'preflight_running', nextResource: 'resources/rp-preflight/', reason: 'preflight_missing' };
193 }
194 if (preflight.status === 'blocked') {
195 return { ok: true, nextState: 'preflight_blocked', nextResource: 'resources/rp-preflight/', reason: 'preflight_blocked' };
196 }
197 if (preflight.status === 'failed') {
198 return { ok: false, nextState: 'failed', nextResource: null, reason: 'preflight_failed', errors: [] };
199 }
200
201 if (managementImportMode === 'quick') {
202 if (!sourcePlatform) {
203 return { ok: true, nextState: 'quick_mode_adapter_resolution_required', nextResource: 'resources/rp-source-inputs/', reason: 'quick_mode_platform_not_detected' };
204 }
205 const quickResources = {
206 shopify: 'resources/rp-quick-shopify/',
207 woocommerce: 'resources/rp-quick-woocommerce/',
208 };
209 const quickResource = quickResources[sourcePlatform];
210 if (!quickResource) {
211 return { ok: true, nextState: 'blocked', nextResource: 'resources/rp-source-inputs/', reason: 'quick_mode_unsupported_platform', errors: [{ platform: sourcePlatform }] };
212 }
213 const quickPreflight = await readJson(path.join(projectDir, 'quick-mode', 'preflight.json'));
214 if (!quickPreflight) {
215 return { ok: true, nextState: 'quick_mode_preflight_required', nextResource: quickResource, reason: 'quick_mode_preflight_missing' };
216 }
217 if (quickPreflight.status !== 'passed') {
218 return {
219 ok: true,
220 nextState: 'blocked',
221 nextResource: quickResource,
222 reason: 'quick_mode_preflight_blocked',
223 errors: [{ status: quickPreflight.status || 'invalid' }],
224 };
225 }
226 if (!(await exists(path.join(projectDir, 'quick-mode', 'plan.json')))) {
227 return { ok: true, nextState: 'quick_mode_plan_required', nextResource: quickResource, reason: 'quick_mode_plan_missing' };
228 }
229
230 if (!(await exists(path.join(projectDir, 'setup', 'setup-plan.json'))) || !(await exists(path.join(projectDir, 'setup', 'setup-requirements.json')))) {
231 return { ok: true, nextState: 'setup_discovery_running', nextResource: 'resources/rp-setup-discovery/', reason: 'quick_mode_setup_plan_missing' };
232 }
233
234 if (!(await exists(path.join(projectDir, 'execution', 'execution-manifest.json')))) {
235 return { ok: true, nextState: 'quick_mode_plan_required', nextResource: quickResource, reason: 'quick_mode_execution_manifest_missing' };
236 }
237
238 if (!isOneClick && approvals.execution && approvals.execution.status === 'pending') {
239 return { ok: true, nextState: 'awaiting_execution_approval', nextResource: 'resources/rp-execution-policy/', reason: 'execution_approval_pending' };
240 }
241 if (!(await isSetupVerified(projectDir))) {
242 return { ok: true, nextState: 'executing_setup', nextResource: 'resources/rp-execute-setup/', reason: 'missing_setup_verification' };
243 }
244 if (!(await exists(path.join(projectDir, 'execution', 'completion-report.json')))) {
245 return { ok: true, nextState: 'executing_import', nextResource: quickResource, reason: 'quick_mode_import_required' };
246 }
247 }
248
249 if (managementImportMode !== 'quick' && !(await exists(path.join(projectDir, 'source-schema.json')))) {
250 return { ok: true, nextState: 'discovery_running', nextResource: 'resources/rp-discovery/', reason: 'missing_source_schema' };
251 }
252
253 if (managementImportMode !== 'quick' && !(await exists(path.join(projectDir, 'mapping', 'mapping-plan.json')))) {
254 return { ok: true, nextState: 'mapping_running', nextResource: 'resources/rp-mapper/', reason: 'missing_mapping_plan' };
255 }
256
257 if (managementImportMode !== 'quick' && !isOneClick && approvals.mapping && approvals.mapping.status === 'pending') {
258 return { ok: true, nextState: 'awaiting_mapping_approval', nextResource: 'resources/rp-mapper/', reason: 'mapping_approval_pending' };
259 }
260
261 if (!(await exists(path.join(projectDir, 'setup', 'setup-plan.json')))) {
262 return { ok: true, nextState: 'setup_discovery_running', nextResource: 'resources/rp-setup-discovery/', reason: 'missing_setup_plan' };
263 }
264
265 if (!(await exists(path.join(projectDir, 'setup', 'setup-requirements.json')))) {
266 return { ok: true, nextState: 'setup_discovery_running', nextResource: 'resources/rp-setup-discovery/', reason: 'missing_setup_requirements' };
267 }
268
269 if (deliveryMode === 'management_and_website' && !websiteScope && !isOneClick) {
270 return { ok: true, nextState: 'awaiting_website_scope', nextResource: 'resources/rp-project-intake/', reason: 'missing_website_scope' };
271 }
272
273 const handoffStatus = await validateWebsiteHandoff(projectDir);
274 if (!handoffStatus.present) {
275 return {
276 ok: true,
277 nextState: 'website_handoff_running',
278 nextResource: 'resources/rp-website-continuation/',
279 reason: 'missing_website_handoff',
280 };
281 }
282 if (handoffStatus.stale) {
283 return {
284 ok: true,
285 nextState: 'website_handoff_running',
286 nextResource: 'resources/rp-website-continuation/',
287 reason: 'stale_website_handoff',
288 changes: handoffStatus.changes,
289 };
290 }
291
292 if (managementImportMode !== 'quick' && !(await exists(path.join(projectDir, 'execution', 'execution-manifest.json')))) {
293 return { ok: true, nextState: 'codegen_running', nextResource: 'resources/rp-import-codegen/', reason: 'missing_execution_manifest' };
294 }
295
296 // Sample-preview sub-gate: codegen emits the extractor, runs it in sample
297 // mode, and records the user's validation in preview/preview-result.json.
298 // Routing back into codegen while that is missing or pending is what enforces
299 // the gate — the artifact is stage-local, so no orchestration phase or
300 // schema change is involved. Projects that do not use the gate simply never
301 // create the file and are unaffected.
302 const previewResult = await readJson(path.join(projectDir, 'preview', 'preview-result.json'));
303 if (managementImportMode !== 'quick' && previewResult && previewResult.status === 'pending') {
304 return { ok: true, nextState: 'codegen_running', nextResource: 'resources/rp-import-codegen/', reason: 'sample_preview_pending' };
305 }
306
307 if (!isOneClick && approvals.execution && approvals.execution.status === 'pending') {
308 return { ok: true, nextState: 'awaiting_execution_approval', nextResource: 'resources/rp-execution-policy/', reason: 'execution_approval_pending' };
309 }
310
311 if (!(await isSetupVerified(projectDir))) {
312 return { ok: true, nextState: 'executing_setup', nextResource: 'resources/rp-execute-setup/', reason: 'missing_setup_verification' };
313 }
314
315 if (!(await exists(path.join(projectDir, 'execution', 'completion-report.json')))) {
316 return { ok: true, nextState: 'executing_import', nextResource: 'resources/rp-execute-import/', reason: 'missing_completion_report' };
317 }
318
319 const completionInputs = await loadMigrationCompletionInputs(projectDir);
320 if (deliveryMode === 'management_and_website') {
321 const frontend = validateFrontendCompletion(completionInputs.frontendCompletion, completionInputs.handoff);
322 if (!frontend.ok) {
323 return {
324 ok: frontend.reason !== 'frontend_blocked',
325 nextState: frontend.reason === 'frontend_blocked' ? 'blocked' : 'storefront_running',
326 nextResource: 'resources/rp-website-continuation/',
327 reason: frontend.reason,
328 requiredArtifacts: ['website/handoff.json', 'website/completion.json'],
329 blocking: frontend.reason === 'frontend_blocked',
330 };
331 }
332 }
333
334 const aggregate = validateMigrationCompletion(completionInputs.completion, {
335 deliveryMode,
336 backendCompletion: completionInputs.backendCompletion,
337 frontendCompletion: completionInputs.frontendCompletion,
338 handoff: completionInputs.handoff,
339 });
340 if (!aggregate.ok) {
341 return {
342 ok: true,
343 nextState: 'finalizing',
344 nextResource: 'resources/rp-execution-policy/',
345 reason: aggregate.reason,
346 requiredArtifacts: ['completion/migration-completion.json'],
347 blocking: false,
348 };
349 }
350
351 return { ok: true, nextState: 'completed', nextResource: null, reason: 'migration_completion_present' };
352}
353
354module.exports = {
355 determineNextStep,
356};