From 7133329377d59c8126e9954e5ceff108af7fbc84 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:52:36 +0530 Subject: [PATCH 01/22] Preserve dry-run row evidence, keep blocked outcomes recoverable, and emit bounded UI snapshots --- src/core/command-kernel.js | 79 ++++++++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/src/core/command-kernel.js b/src/core/command-kernel.js index bf6c5129..2fb6381d 100644 --- a/src/core/command-kernel.js +++ b/src/core/command-kernel.js @@ -1,6 +1,6 @@ import { parseCsv, toCsv } from './csv.js'; -import { inferSchema, fingerprintText, validateTargetSchema } from './schema.js'; -import { compileMapping } from './transforms.js'; +import { inferSchema, fingerprintText, validateOutputRow, validateTargetSchema } from './schema.js'; +import { compileMapping, evaluateExpr } from './transforms.js'; import { MigrationEngine } from './migration.js'; import { createJob, transition, assertJobInvariants, PHASES } from './state-machine.js'; import { SpoolError, fail } from './errors.js'; @@ -43,6 +43,37 @@ function representativeRows(rows, limit = AUTOPILOT_DRY_RUN_SIZE) { return sampled; } +function summarizeDryRunViolations(violations, plan) { + const targetFields = new Map(plan.targetSchema.map(field => [field.name, field])); + return violations.map(group => { + const sourceFields = new Set(); + const examples = (group.samples ?? []).slice(0, 3).map(sample => { + const fields = []; + for (const entry of plan.mapping) { + try { + const value = evaluateExpr(entry.expr, sample.row); + validateOutputRow({ [entry.target]: value }, [targetFields.get(entry.target)]); + } catch (error) { + if (error?.code !== group.code) continue; + const name = entry.expr?.value?.name ?? entry.expr?.name ?? entry.target; + sourceFields.add(name); + if (fields.length < 5) { + fields.push({ field: name, value: String(sample.row?.[name] ?? '').slice(0, 120) }); + } + } + } + return { record: sample.rowIndex + 1, fields }; + }); + return { + code: group.code, + count: group.count, + message: String(group.message ?? '').slice(0, 180), + sourceFields: [...sourceFields].slice(0, 12), + examples + }; + }); +} + function mergeViolations(existing, incoming, sampleLimit = 10) { const map = new Map(existing.map(group => [group.code, clone(group)])); for (const group of incoming ?? []) { @@ -64,10 +95,11 @@ function ensureTargetsMatch(targetSchema, mapping) { } export class CommandKernel { - constructor({ store, runtime, onStateChange = () => {} }) { + constructor({ store, runtime, onStateChange = () => {}, snapshotMode = 'full' }) { this.store = store; this.runtime = runtime; this.onStateChange = onStateChange; + this.snapshotMode = snapshotMode; this.workspace = freshWorkspace(); this.engine = new MigrationEngine({ sampleLimit: 10 }); this.runEpoch = 0; @@ -100,7 +132,31 @@ export class CommandKernel { snapshot() { return clone(this.workspace); } - emit() { this.onStateChange(this.snapshot()); } + // Rendering never needs the whole source or completed output. Keep the + // canonical full snapshot for tests/clients and emit a bounded UI view. + viewSnapshot() { + const workspace = this.workspace; + const source = workspace.source; + const sourceView = source ? { + fileName: source.fileName, + fingerprint: source.fingerprint, + headers: [...source.headers], + schema: source.schema, + bytes: source.bytes, + rows: { length: source.rows.length } + } : null; + return clone({ + ...workspace, + source: sourceView, + output: workspace.output.slice(0, 12), + violations: workspace.violations.map(group => ({ + ...group, + samples: (group.samples ?? []).slice(0, 2).map(sample => ({ rowIndex: sample.rowIndex })) + })) + }); + } + + emit() { this.onStateChange(this.snapshotMode === 'ui' ? this.viewSnapshot() : this.snapshot()); } async persist() { this.workspace.updatedAt = new Date().toISOString(); @@ -235,16 +291,23 @@ export class CommandKernel { }); } - validateTargetSchema(plan.targetSchema); + // Preserve-contract is browser CSV-to-CSV: source header spellings may + // contain spaces/punctuation. They were already vetted by parseCsv. + // SQL/typed target authoring continues to require validateTargetSchema. + if (outcome !== AUTOPILOT_OUTCOMES.PRESERVE_CONTRACT) validateTargetSchema(plan.targetSchema); const compiled = compileMapping(plan.mapping); ensureTargetsMatch(plan.targetSchema, compiled.entries); const previewRevision = this.workspace.mappingRevision + 1; const previewRows = representativeRows(this.workspace.source.rows); + const previewIndexes = previewRows.map((_, index) => previewRows.length === this.workspace.source.rows.length + ? index + : Math.floor((index * (this.workspace.source.rows.length - 1)) / (previewRows.length - 1))); const preview = this.engine.run( previewRows, plan.mapping, previewRevision, - plan.targetSchema + plan.targetSchema, + previewIndexes ); const acceptanceRate = preview.processedRows > 0 ? preview.validRows / preview.processedRows : 1; const assessment = preview.validRows === 0 || acceptanceRate < AUTOPILOT_MIN_ACCEPTANCE @@ -260,7 +323,7 @@ export class CommandKernel { minimumAcceptance: AUTOPILOT_MIN_ACCEPTANCE, assessment, sampling: previewRows.length === this.workspace.source.rows.length ? 'full_source' : 'evenly_spaced_full_source', - violationGroups: preview.violations.map(group => ({ code: group.code, count: group.count })) + violationGroups: summarizeDryRunViolations(preview.violations, plan) }; if (assessment === 'BLOCK') { const zeroAcceptance = preview.validRows === 0; @@ -269,7 +332,7 @@ export class CommandKernel { message: zeroAcceptance ? 'Autopilot dry run accepted zero rows. Execution was not started; revise the outcome or target interpretation.' : `Autopilot dry run accepted ${preview.validRows}/${preview.processedRows} representative rows, below the ${(AUTOPILOT_MIN_ACCEPTANCE * 100).toFixed(0)}% safety floor. Execution was not started.`, - sourceFields: [] + sourceFields: this.workspace.mission.dryRun.violationGroups.flatMap(group => group.sourceFields).slice(0, 12) }; this.workspace.mission.status = 'NEEDS_ATTENTION'; this.workspace.mission.ambiguities = [...(this.workspace.mission.ambiguities ?? []), decision]; From 61412bbbf9d1ff1cc63c0a53d9245ac734deadf8 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:52:44 +0530 Subject: [PATCH 02/22] Preserve original source record indices in representative dry-run diagnostics --- src/core/migration.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/migration.js b/src/core/migration.js index a6ab63b4..33b9e373 100644 --- a/src/core/migration.js +++ b/src/core/migration.js @@ -57,12 +57,12 @@ export class MigrationEngine { }); } - run(rows, mappingEntries, revision = 1, targetSchema = null) { + run(rows, mappingEntries, revision = 1, targetSchema = null, sourceIndexes = null) { const accumulator = this.createAccumulator(mappingEntries, revision, targetSchema); const output = []; const rowRevisions = []; for (let i = 0; i < rows.length; i++) { - const result = accumulator.process(rows[i], i); + const result = accumulator.process(rows[i], sourceIndexes?.[i] ?? i); if (result.ok) { output.push(result.row); rowRevisions.push(revision); From 9562e73f4c792251af6eef666c61f5179cbc066c Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:52:53 +0530 Subject: [PATCH 03/22] Avoid full source and output cloning on every IndexedDB checkpoint --- src/storage/indexeddb.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/storage/indexeddb.js b/src/storage/indexeddb.js index b1e819cf..53d39eab 100644 --- a/src/storage/indexeddb.js +++ b/src/storage/indexeddb.js @@ -153,9 +153,12 @@ export class IndexedDbWorkspaceStore { } } - const compact = structuredClone(workspace); - if (compact.source) delete compact.source.rows; - delete compact.output; + // Do not clone source/output merely to delete them afterward: for a + // 50 MiB input that causes repeated, avoidable full-dataset copies. + const { source, output: _output, ...metadata } = workspace; + const sourceMetadata = source ? { ...source } : null; + if (sourceMetadata) delete sourceMetadata.rows; + const compact = structuredClone({ ...metadata, source: sourceMetadata }); metaStore.put({ key: 'active', workspace: compact }); await txDone(tx); From a68d4634d7a3686ecb06cfc0d85f205a253adf17 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:54:02 +0530 Subject: [PATCH 04/22] Restore actionable blocked-migration UX, quality samples, and stable progress feedback --- src/app.js | 104 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 88 insertions(+), 16 deletions(-) diff --git a/src/app.js b/src/app.js index b3b1fa6a..0ab3aab1 100644 --- a/src/app.js +++ b/src/app.js @@ -50,10 +50,12 @@ let webMcpStatus = 'Checking browser support'; let nativeRegistry = null; let registryChain = Promise.resolve(); let renderQueued = false; +let isBusy = false; const kernel = new CommandKernel({ store, runtime, + snapshotMode: 'ui', onStateChange(state) { latestState = state; scheduleRender(); @@ -122,7 +124,7 @@ function navigate(path) { function scheduleRender() { if (renderQueued) return; renderQueued = true; - queueMicrotask(() => { renderQueued = false; render(); }); + window.requestAnimationFrame(() => { renderQueued = false; render(); }); } function topbar(path, state) { @@ -150,7 +152,7 @@ function shell(path, body, state, { studio = false } = {}) {
SPOOLLocal-first deterministic migration infrastructure.
Dataset plane: browser-localNetwork: disabled by CSPTransform IR: constrained
-
`; + `; } function studioRail(path, state) { @@ -366,6 +368,26 @@ function newMigrationPage(state) { `, state, { studio: true }); } +function recoveryPanel(mission, outcome) { + const decisions = mission?.ambiguities ?? []; + const dryRun = mission?.dryRun; + const groups = dryRun?.violationGroups ?? []; + const colliding = decisions.some(item => item.code === 'TARGET_NAME_COLLISION'); + const diagnostics = groups.length + ? `
Why sample records failed · ${number(groups.reduce((total, item) => total + item.count, 0))} rejected
${groups.map(group => `
${esc(group.code)}

${esc(group.message || 'A transformation or target validation failed.')}

${number(group.count)} rows
${group.sourceFields?.length ? `

Fields to review: ${group.sourceFields.map(esc).join(', ')}

` : ''}${group.examples?.length ? `
${group.examples.map(example => `
Source record ${number(example.record)}${example.fields?.length ? `

${example.fields.map(field => `${esc(field.field)} = ${esc(field.value)}`).join(' · ')}

` : '

Inspect this record in the source CSV.

'}
`).join('')}
` : ''}
`).join('')}
` + : ''; + return `
+
REVIEW REQUIRED

${decisions.length} decision${decisions.length === 1 ? '' : 's'} need review

Autopilot has not started execution. Your original CSV is still loaded; nothing has been written to the result set.

${dryRun ? `
${number(dryRun.validRows)} / ${number(dryRun.processedRows)}Sample accepted
${number(dryRun.invalidRows)}Sample rejected
${Math.round(dryRun.minimumAcceptance * 100)}%Required acceptance

This is a representative sample, not a complete assessment of all source rows.

` : ''}
+
${decisions.map(item => `
${esc(item.code)}

${esc(item.message)}

${item.sourceFields?.length ? `

Fields to review: ${item.sourceFields.map(esc).join(', ')}

` : ''}
`).join('')}${diagnostics}
+
${colliding ? 'Some headers collide after normalization. Correct the headers in your CSV, or explicitly choose Preserve contract to retain distinct original names.' : 'The rejected sample exceeds the acceptance limit. Review the failing values or explicitly choose a less transformative outcome; the safety limit is never silently lowered.'}
+
RECOVERY · NO OVERRIDE

Choose how to proceed

Changing an outcome changes the exported data contract. Inspect the differences before starting a fresh dry run.

+
${OUTCOMES.map(item => ``).join('')}
+ +

SPOOL will profile and validate again before executing. A result below the 95% floor will remain blocked.

+
+
`; +} + function missionPage(state) { if (!state.source) return shell('/studio/mission', `
MISSION

No source loaded.

Create a migration first.

New migration
`, state, { studio: true }); const mission = state.mission; @@ -379,7 +401,8 @@ function missionPage(state) { return shell('/studio/mission', `
MISSION · ${esc(mission?.outcome ?? 'not planned')}

${esc(missionHeadline(status))}

${status === 'RUNNING' ? 'No action required. You can leave this page; durable checkpoints protect progress and Autopilot resumes a valid interrupted mission when you return.' : status === 'NEEDS_ATTENTION' ? 'SPOOL refused to guess. Resolve the bounded ambiguity before execution.' : status === 'COMPLETE_VERIFIED' ? 'Every processed row satisfied the target contract and the final revision is ready.' : status === 'COMPLETE_WITH_REJECTIONS' ? 'Valid output is ready, and rejected rows remain explicit in the quality report.' : 'Mission state is stored locally in this browser.'}

${esc(status)}
- ${status === 'NEEDS_ATTENTION' ? `
REVIEW REQUIRED

${ambiguities.length} decision${ambiguities.length === 1 ? '' : 's'} block automatic execution

These choices can change the target contract destructively, so SPOOL fails closed instead of choosing silently.

${ambiguities.map(item => `
${esc(item.code)}

${esc(item.message)}

Source fields: ${(item.sourceFields ?? []).map(esc).join(', ')}

`).join('')}
For this release, destructive name collisions require correcting the source headers before rerunning Autopilot. SPOOL will not invent a rename.
` : ''} + ${status === 'NEEDS_ATTENTION' ? recoveryPanel(mission, selectedOutcome) : ''} + ${state.job.phase === PHASES.FAILED || state.job.phase === PHASES.ABORTED ? `
${esc(state.lastError?.code ?? 'MISSION_STOPPED')}

${esc(state.lastError?.message ?? 'This run is no longer active.')}

Review source and start again
` : ''} ${mission?.dryRun?.assessment === 'WARNING' ? `
Dry-run warning: ${number(mission.dryRun.validRows)} / ${number(mission.dryRun.processedRows)} representative rows passed (${Math.round((mission.dryRun.acceptanceRate ?? 0) * 100)}%). Execution continued because the result met the ${Math.round((mission.dryRun.minimumAcceptance ?? 0.95) * 100)}% safety floor; rejected rows remain explicit.
` : ''} @@ -443,6 +466,11 @@ function render() { '/studio/results': resultsPage }; appRoot.innerHTML = (pages[path] ?? (() => notFoundPage(path, latestState)))(latestState); + if (isBusy) { + const buttons = appRoot.querySelectorAll('[data-action], [data-outcome], #source-file'); + buttons.forEach(button => { button.disabled = true; }); + appRoot.setAttribute('aria-busy', 'true'); + } else appRoot.removeAttribute('aria-busy'); document.title = `${pageTitle(path)} — SPOOL`; } @@ -454,8 +482,14 @@ function pageTitle(path) { } function toast(message, tone = 'neutral') { - const el = document.getElementById('toast'); - if (!el) return; + let el = document.getElementById('toast'); + if (!el) { + el = document.createElement('div'); + el.id = 'toast'; + el.setAttribute('role', 'status'); + el.setAttribute('aria-live', 'polite'); + document.body.appendChild(el); + } el.textContent = message; el.className = `toast show ${tone}`; window.clearTimeout(toast.timer); @@ -463,28 +497,59 @@ function toast(message, tone = 'neutral') { } async function loadText(text, fileName) { + if (isBusy) return; + isBusy = true; + render(); try { + await new Promise(resolve => window.requestAnimationFrame(() => setTimeout(resolve, 0))); await kernel.loadSourceText(text, fileName); selectedOutcome = 'database_ready'; toast(`${fileName} is profiled locally.`, 'success'); } catch (error) { toast(error?.message || String(error), 'danger'); + } finally { + isBusy = false; + render(); } } async function loadFile(file) { - if (!file) return; + if (!file || isBusy) return; if (file.size > 50 * 1024 * 1024) return toast('This UI currently limits file selection to 50 MB. Use a smaller CSV for this release.', 'danger'); if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') return toast('SPOOL currently accepts CSV source files.', 'danger'); - await loadText(await file.text(), file.name); + isBusy = true; + render(); + try { + await new Promise(resolve => window.requestAnimationFrame(() => setTimeout(resolve, 0))); + const text = await file.text(); + await kernel.loadSourceText(text, file.name); + selectedOutcome = 'database_ready'; + toast(`${file.name} is profiled locally.`, 'success'); + } catch (error) { + toast(error?.message || String(error), 'danger'); + } finally { + isBusy = false; + render(); + } } async function runAutopilot() { - const response = await kernel.invoke('run_autopilot', { outcome: selectedOutcome }); - if (!response.ok) return toast(`${response.error.code}: ${response.error.message}`, 'danger'); - navigate('/studio/mission'); - if (response.result?.status === 'NEEDS_ATTENTION') toast('Autopilot stopped before execution because a destructive ambiguity needs attention.', 'warning'); - else toast('Autopilot planned, dry-ran and started the migration.', 'success'); + if (isBusy) return; + isBusy = true; + render(); + try { + await new Promise(resolve => window.requestAnimationFrame(() => setTimeout(resolve, 0))); + const response = await kernel.invoke('run_autopilot', { outcome: selectedOutcome }); + if (!response.ok) return toast(`${response.error.code}: ${response.error.message}`, 'danger'); + navigate('/studio/mission'); + if (response.result?.status === 'NEEDS_ATTENTION') toast('Dry run blocked execution. Review the failed sample records and choose a recovery action.', 'warning'); + else toast('Autopilot passed its safety checks and started processing.', 'success'); + } catch (error) { + toast(error?.message || String(error), 'danger'); + } finally { + isBusy = false; + render(); + } } function download(result) { @@ -497,7 +562,7 @@ function download(result) { async function handleAction(action) { if (action === 'load-demo') return loadText(createDemoCsv(25000), 'customer-export-25k.csv'); - if (action === 'run-autopilot') return runAutopilot(); + if (action === 'run-autopilot' || action === 'retry-autopilot') return runAutopilot(); if (action === 'reset-workspace') { const response = await kernel.invoke('start_new_migration', {}); if (response.ok) { selectedOutcome = 'database_ready'; toast('Workspace cleared.', 'success'); } @@ -526,13 +591,19 @@ function bindGlobalEvents() { const routeLink = event.target.closest('[data-route]'); if (routeLink) { event.preventDefault(); navigate(routeLink.dataset.route); return; } const outcome = event.target.closest('[data-outcome]'); - if (outcome) { selectedOutcome = outcome.dataset.outcome; render(); return; } + if (outcome && !isBusy) { selectedOutcome = outcome.dataset.outcome; render(); return; } const action = event.target.closest('[data-action]'); - if (action && !action.disabled) void handleAction(action.dataset.action); + if (action && !action.disabled && !isBusy) void handleAction(action.dataset.action); }); appRoot.addEventListener('change', event => { if (event.target.id === 'source-file') void loadFile(event.target.files?.[0]); }); + appRoot.addEventListener('keydown', event => { + if (event.target.id === 'source-dropzone' && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + document.getElementById('source-file')?.click(); + } + }); appRoot.addEventListener('dragover', event => { const dropzone = event.target.closest('#source-dropzone'); if (!dropzone) return; @@ -565,7 +636,8 @@ async function boot() { if (!appRoot) throw new Error('Missing #app-root'); bindGlobalEvents(); await kernel.initialize(); - latestState = kernel.snapshot(); + latestState = kernel.viewSnapshot(); + selectedOutcome = latestState.mission?.outcome ?? 'database_ready'; await initWebMcp(); render(); window.__spoolTest = Object.freeze({ From 313ef47996f521167d26010d2fc8482bdf12884c Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:54:20 +0530 Subject: [PATCH 05/22] Style actionable recovery, bounded diagnostics, and responsive mission states --- styles.css | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/styles.css b/styles.css index c432fe33..b1a48587 100644 --- a/styles.css +++ b/styles.css @@ -33,3 +33,36 @@ @media (max-width:900px){.topbar{padding-left:16px;padding-right:16px}.top-actions .status-pill{display:none}.hero,.page-hero,.section,.footer{width:min(100% - 28px,var(--max))}.hero{padding-top:58px}.hero-grid{gap:32px}.hero h1,.page-hero h1{font-size:clamp(44px,12vw,70px)}.comparison-grid,.proof-panel,.architecture-card,.callout,.split-section,.decision-grid,.method-grid,.docs-layout{grid-template-columns:1fr}.step-flow{grid-template-columns:1fr 1fr}.topology-demo{grid-template-columns:1fr}.topology-arrow{transform:rotate(90deg);text-align:center}.docs-nav{position:static;display:flex;flex-direction:row;overflow:auto;border-bottom:1px solid var(--line);padding-bottom:10px}.studio-rail{position:sticky;top:113px;left:auto;bottom:auto;width:auto;height:auto;display:flex;align-items:center;gap:4px;overflow:auto;padding:9px 14px;border-right:0;border-bottom:1px solid var(--line);z-index:35}.rail-label{padding:0 8px}.studio-rail a,.rail-disabled{white-space:nowrap}.studio-main{width:min(100% - 28px,var(--max));margin:0 auto;padding-top:26px}.setup-layout,.studio-grid,.mission-grid,.attention-panel,.lineage-card{grid-template-columns:1fr}.launch-card{grid-column:auto}.attention-note{grid-column:auto}.result-metrics{grid-template-columns:1fr 1fr}.advanced-grid{grid-template-columns:1fr}.advanced-grid article.wide,.advanced-actions{grid-column:auto}.quality-grid{grid-template-columns:1fr 1fr}.compact-hero{padding-top:70px}.footer{flex-direction:column}.benchmark-grid{grid-template-columns:1fr 1fr}.benchmark-grid .benchmark-hero{grid-column:1/-1;grid-row:auto;min-height:300px}.metric-stack{max-width:650px}} @media (max-width:620px){.brand-word small{display:none}.primary-nav{gap:1px}.primary-nav a{padding:8px;font-size:10px}.top-actions .button{min-height:34px;padding:0 10px;font-size:10px}.hero,.page-hero,.section{width:min(100% - 22px,var(--max))}.hero{padding-top:42px}.hero h1,.page-hero h1{font-size:clamp(40px,13vw,58px)}.lede,.page-hero>p,.page-hero>div>p{font-size:14px}.transform-preview{grid-template-columns:1fr}.transform-column{flex-direction:row;flex-wrap:wrap}.transform-column small{width:100%}.visual-progress>div:first-child{flex-direction:column}.step-flow,.comparison-grid,.decision-grid,.tool-grid,.benchmark-grid,.method-grid,.quality-grid{grid-template-columns:1fr}.comparison-card{min-height:auto}.proof-panel,.architecture-card,.callout{padding:22px}.journey-step{grid-template-columns:38px 1fr;gap:15px}.journey-dot{width:38px;height:38px}.journey-line{left:18px}.studio-heading,.mission-header,.result-hero,.launch-card{align-items:flex-start;flex-direction:column}.studio-heading h1,.mission-header h1,.result-hero h1{font-size:38px}.setup-card,.studio-card,.mission-progress-card,.studio-section{padding:18px}.dropzone{min-height:190px}.mini-stats,.result-metrics{grid-template-columns:1fr 1fr}.source-summary-card{align-items:flex-start;flex-direction:column}.evidence-head,.evidence-row{min-width:680px}.result-hero>div:first-child{gap:11px}.export-actions{width:100%}.export-actions .button{flex:1}.footer{width:min(100% - 22px,var(--max))}} + +/* Blocked migrations are a decision workspace, not a terminal error card. */ +.attention-panel .ambiguity-list{min-width:0} +.attention-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:22px} +.attention-metrics>div{padding:13px;border:1px solid rgba(244,201,107,.2);border-radius:11px;background:rgba(0,0,0,.12)} +.attention-metrics strong{display:block;color:var(--text);font-size:18px;font-variant-numeric:tabular-nums} +.attention-metrics span{display:block;margin-top:4px;color:#c0b48f;font-size:10px} +.sample-disclaimer{color:var(--muted);font-size:11px;line-height:1.6} +.quality-diagnostics{border:1px solid rgba(244,201,107,.22);border-radius:13px;overflow:hidden} +.quality-diagnostics>summary{padding:15px;color:var(--text);font-size:12px;font-weight:800;cursor:pointer} +.quality-diagnostic-list{display:grid;gap:8px;padding:0 10px 10px} +.quality-diagnostic-list article{min-width:0;padding:13px;border:1px solid var(--line-2);border-radius:10px;background:rgba(0,0,0,.13)} +.quality-diagnostic-list h3{overflow-wrap:anywhere;font-size:12px;line-height:1.6} +.quality-diagnostic-list article strong{font-size:11px;white-space:nowrap} +.quality-examples{display:grid;gap:7px;margin-top:10px} +.quality-examples>div{padding:8px 10px;border-left:2px solid var(--warn);background:rgba(0,0,0,.13)} +.quality-examples p{overflow-wrap:anywhere} +.quality-examples code{color:var(--text);font-size:11px} +.recovery-workflow{grid-column:1/-1;min-width:0;padding-top:19px;border-top:1px solid rgba(244,201,107,.2)} +.recovery-workflow h3{margin:8px 0 0;font-size:20px} +.recovery-workflow>p{max-width:790px;font-size:12px;line-height:1.65} +.recovery-outcomes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px;margin-top:15px} +.recovery-option{display:block;width:100%;min-width:0;padding:15px;text-align:left;border:1px solid var(--line-2);border-radius:12px;background:var(--panel);color:var(--text);cursor:pointer} +.recovery-option:hover,.recovery-option.selected{border-color:var(--accent);background:var(--accent-soft)} +.recovery-option strong{display:block;font-size:13px} +.recovery-option span{display:block;margin-top:6px;color:var(--muted);font-size:11px;line-height:1.5} +.recovery-option:focus-visible,.quality-diagnostics>summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px} +.recovery-actions{display:flex;flex-wrap:wrap;gap:9px;margin-top:18px} +.mission-error{padding:20px;margin-bottom:15px;border:1px solid rgba(255,143,143,.3);border-radius:14px;background:var(--danger-soft)} +.mission-error strong{color:#ffc5c5} +.mission-error p{color:var(--muted);overflow-wrap:anywhere} +@media(max-width:900px){.recovery-outcomes{grid-template-columns:1fr}.attention-metrics{grid-template-columns:repeat(3,minmax(0,1fr))}} +@media(max-width:620px){.attention-metrics{grid-template-columns:1fr}.recovery-actions{flex-direction:column}.recovery-actions .button{width:100%}.quality-diagnostic-list .card-head{flex-wrap:wrap}} From 84dddc6d67f27f2d11f799862008dbc4b349c58a Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:54:35 +0530 Subject: [PATCH 06/22] Bound UI source metadata and preview cell lengths without truncating exports --- src/core/command-kernel.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/core/command-kernel.js b/src/core/command-kernel.js index 2fb6381d..b355f344 100644 --- a/src/core/command-kernel.js +++ b/src/core/command-kernel.js @@ -140,15 +140,16 @@ export class CommandKernel { const sourceView = source ? { fileName: source.fileName, fingerprint: source.fingerprint, - headers: [...source.headers], - schema: source.schema, + headers: { length: source.headers.length }, bytes: source.bytes, rows: { length: source.rows.length } } : null; return clone({ ...workspace, source: sourceView, - output: workspace.output.slice(0, 12), + output: workspace.output.slice(0, 12).map(row => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, + typeof value === 'string' && value.length > 200 ? `${value.slice(0, 200)}… [truncated preview]` : value + ]))), violations: workspace.violations.map(group => ({ ...group, samples: (group.samples ?? []).slice(0, 2).map(sample => ({ rowIndex: sample.rowIndex })) From 1242df4260360a90d5685957c158cd8b30c5197e Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:55:21 +0530 Subject: [PATCH 07/22] Regression: 91/100 source rejection, explicit recovery, exact sample indices and bounded state --- tests/autopilot-recovery.test.js | 86 ++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/autopilot-recovery.test.js diff --git a/tests/autopilot-recovery.test.js b/tests/autopilot-recovery.test.js new file mode 100644 index 00000000..b950a29b --- /dev/null +++ b/tests/autopilot-recovery.test.js @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CommandKernel } from '../src/core/command-kernel.js'; +import { MemoryWorkspaceStore } from '../src/storage/memory.js'; +import { PHASES } from '../src/core/state-machine.js'; +import { readFile } from 'node:fs/promises'; + +class ControlledRuntime { + constructor() { this.startCalls = []; } + async start(payload) { this.startCalls.push(payload); } + async abort() {} + async pause() {} +} + +async function setup(text) { + const runtime = new ControlledRuntime(); + const kernel = new CommandKernel({ store: new MemoryWorkspaceStore(), runtime, snapshotMode: 'ui' }); + await kernel.initialize(); + await kernel.loadSourceText(text, 'recovery.csv'); + return { kernel, runtime }; +} + +test('91/100 dry-run failure retains the source, shows bounded offending values and never starts a worker', async () => { + const sourceIndexes = Array.from({ length: 100 }, (_, i) => Math.floor((i * 1999) / 99)); + const badRecords = sourceIndexes.filter(i => i >= 1000).slice(0, 9); + const bad = new Set(badRecords); + const rows = Array.from({ length: 2000 }, (_, i) => + [i + 1, bad.has(i) ? 'not-a-number' : `${i + 100}.25`, `Customer ${i + 1}`].join(',') + ); + const { kernel, runtime } = await setup(`Customer ID,amount,Name\n${rows.join('\n')}`); + const blocked = await kernel.invoke('run_autopilot', { outcome: 'database_ready' }); + const state = kernel.snapshot(); + assert.equal(blocked.ok, true); + assert.equal(blocked.result.status, 'NEEDS_ATTENTION'); + assert.equal(state.job.phase, PHASES.SOURCE_READY); + assert.equal(state.mission.dryRun.validRows, 91); + assert.equal(state.mission.dryRun.invalidRows, 9); + assert.equal(state.mission.dryRun.assessment, 'BLOCK'); + assert.equal(state.output.length, 0); + assert.equal(state.source.rows.length, 2000); + assert.equal(runtime.startCalls.length, 0); + const numericViolation = state.mission.dryRun.violationGroups.find(item => item.sourceFields.includes('amount')); + assert.ok(numericViolation); + assert.equal(numericViolation.count, 9); + assert.equal(numericViolation.examples[0].record, badRecords[0] + 1); + assert.deepEqual(numericViolation.examples[0].fields, [{ field: 'amount', value: 'not-a-number' }]); + const inspected = await kernel.invoke('inspect_mission', {}); + assert.equal(inspected.result.dryRun.invalidRows, 9); + assert.ok(!Object.hasOwn(inspected.result, 'sourceRows')); + const compact = kernel.viewSnapshot(); + assert.equal(compact.source.rows.length, 2000); + assert.equal(Array.isArray(compact.source.rows), false); + assert.equal(compact.source.schema, undefined); + assert.deepEqual(compact.output, []); + const safe = await kernel.invoke('run_autopilot', { outcome: 'preserve_contract' }); + assert.equal(safe.ok, true); + assert.equal(safe.state.phase, PHASES.RUNNING); + assert.equal(runtime.startCalls.length, 1); + assert.equal(kernel.snapshot().mission.dryRun.invalidRows, 0); + assert.deepEqual(kernel.snapshot().targetSchema.map(item => item.name), ['Customer ID', 'amount', 'Name']); +}); + +test('normalized header collision is reversible by an explicit preserve-contract retry', async () => { + const { kernel, runtime } = await setup('Customer ID,customer-id\na,b\nc,d'); + const first = await kernel.invoke('run_autopilot', { outcome: 'database_ready' }); + assert.equal(first.ok, true); + assert.equal(first.result.ambiguities[0].code, 'TARGET_NAME_COLLISION'); + assert.equal(runtime.startCalls.length, 0); + const second = await kernel.invoke('run_autopilot', { outcome: 'preserve_contract' }); + assert.equal(second.ok, true); + assert.equal(second.state.phase, PHASES.RUNNING); + assert.equal(runtime.startCalls.length, 1); + assert.deepEqual(kernel.snapshot().targetSchema.map(f => f.name), ['Customer ID', 'customer-id']); +}); + +test('blocked mission interface has recovery controls and preserved diagnostics, not an inert terminal card', async () => { + const app = await readFile(new URL('../src/app.js', import.meta.url), 'utf8'); + const styles = await readFile(new URL('../styles.css', import.meta.url), 'utf8'); + const storage = await readFile(new URL('../src/storage/indexeddb.js', import.meta.url), 'utf8'); + for (const needle of ['recoveryPanel(mission, selectedOutcome)', 'data-action="retry-autopilot"', 'quality-diagnostics', 'Correct or replace CSV', 'data-route="/studio"', 'snapshotMode: \'ui\'', 'isBusy']) { + assert.ok(app.includes(needle), `missing recovery contract: ${needle}`); + } + assert.ok(styles.includes('.recovery-outcomes')); + assert.ok(styles.includes('.quality-examples')); + assert.ok(!storage.includes('structuredClone(workspace);\n if (compact.source) delete compact.source.rows')); +}); From c363242c3659c358d812a2ffc6ad488f89ec9c80 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:57:37 +0530 Subject: [PATCH 08/22] Expose deterministic CSV fixture loader to browser smoke harness --- src/app.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app.js b/src/app.js index 0ab3aab1..8842dbc9 100644 --- a/src/app.js +++ b/src/app.js @@ -646,6 +646,7 @@ async function boot() { invoke: (name, args = {}) => kernel.invoke(name, args), navigate, loadDemo: () => loadText(createDemoCsv(25000), 'customer-export-25k.csv'), + loadCsv: (value, filename = 'browser-recovery.csv') => loadText(value, filename), runAutopilot: outcome => kernel.invoke('run_autopilot', { outcome: outcome ?? selectedOutcome }) }); } From 6d2adfa9f82947b76a4bf879c712e17dffc283da Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:57:52 +0530 Subject: [PATCH 09/22] Exercise real 91/100 blocked migration, actionable DOM recovery, and sample timing in Chromium smoke --- scripts/browser-smoke.py | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/scripts/browser-smoke.py b/scripts/browser-smoke.py index 3872e1bb..4a1afd1d 100644 --- a/scripts/browser-smoke.py +++ b/scripts/browser-smoke.py @@ -202,6 +202,48 @@ def main(): cdp.eval('window.__spoolTest.invoke("start_new_migration", {})') wait_for(lambda: cdp.eval('window.__spoolTest.state().job.phase === "EMPTY"'), label='workspace reset') + bad_indexes = set([i * 1999 // 99 for i in range(100) if i * 1999 // 99 >= 1000][:9]) + # Regression for the exact 91/100 blocked Autopilot mission. + # Source indices are spread across the CSV, so this also verifies + # diagnostic record numbers refer to the original file, not a + # synthetic 0..99 preview numbering. + blocked_rows = [ + f'{index + 1},{("not-a-number" if index in bad_indexes else str(index + 100) + ".25")},Customer {index + 1}' + for index in range(2000) + ] + blocked_csv = 'Customer ID,amount,Name\\n' + '\\n'.join(blocked_rows) + before_blocked = cdp.eval('performance.now()') + cdp.eval('window.__spoolTest.loadCsv(' + json.dumps(blocked_csv) + ')') + wait_for(lambda: cdp.eval('window.__spoolTest.state().job.phase === "SOURCE_READY"'), label='blocked fixture load') + blocked_load_ms = round(cdp.eval('performance.now()') - before_blocked) + blocked_response = cdp.eval('window.__spoolTest.runAutopilot("database_ready")') + assert blocked_response['ok'] is True + assert blocked_response['result']['status'] == 'NEEDS_ATTENTION' + blocked_state = cdp.eval('window.__spoolTest.state()') + assert blocked_state['mission']['dryRun']['validRows'] == 91 + assert blocked_state['mission']['dryRun']['invalidRows'] == 9 + assert blocked_state['job']['phase'] == 'SOURCE_READY' + assert blocked_state['output'] == [] + cdp.eval('window.__spoolTest.navigate("/studio/mission")') + wait_for(lambda: cdp.eval('document.querySelector("[data-action=\\\"retry-autopilot\\\"]") !== null'), label='blocked mission recovery UI') + assert cdp.eval('document.querySelectorAll(".quality-examples > div").length') > 0 + assert cdp.eval('document.body.innerText.includes("Source record")') + assert cdp.eval('document.querySelectorAll(".recovery-option").length') == 3 + cdp.eval('document.querySelector("[data-outcome=\\\"preserve_contract\\\"]").click()') + assert cdp.eval('document.querySelector("[data-outcome=\\\"preserve_contract\\\"]').getAttribute("aria-pressed")') == 'true' + cdp.eval('document.querySelector("[data-action=\\\"retry-autopilot\\\"]").click()') + wait_for(lambda: cdp.eval('window.__spoolTest.state().job.phase === "COMPLETE"'), timeout=20, label='blocked workflow recovery completion') + recovered = cdp.eval('window.__spoolTest.state()') + assert recovered['job']['validRows'] == 2000 + assert recovered['job']['invalidRows'] == 0 + assert recovered['mission']['outcome'] == 'preserve_contract' + assert cdp.eval('document.querySelector(".result-hero")') is None # Still on mission; no fake result screen. + cdp.eval('window.__spoolTest.invoke("start_new_migration", {})') + wait_for(lambda: cdp.eval('window.__spoolTest.state().job.phase === "EMPTY"'), label='recovery fixture cleanup') + print(json.dumps({'blockedDryRun': 'PASS', 'accepted': 91, 'sampleSize': 100, + 'originalRecordDiagnostics': 'PASS', 'recoverableViaButtons': 'PASS', + 'sampleLoadMs': blocked_load_ms}), flush=True) + cdp.eval('window.__spoolTest.loadDemo()') wait_for(lambda: cdp.eval('window.__spoolTest.state().job.phase === "SOURCE_READY"'), timeout=15, label='demo source load') assert cdp.eval('window.__spoolTest.state().source.rows.length') == 25000 From 39938f8500c4a4b44cca37671864834ddd00fe99 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:58:11 +0530 Subject: [PATCH 10/22] Correct browser fixture newlines and DOM query escaping --- scripts/browser-smoke.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/browser-smoke.py b/scripts/browser-smoke.py index 4a1afd1d..3941cab1 100644 --- a/scripts/browser-smoke.py +++ b/scripts/browser-smoke.py @@ -211,7 +211,7 @@ def main(): f'{index + 1},{("not-a-number" if index in bad_indexes else str(index + 100) + ".25")},Customer {index + 1}' for index in range(2000) ] - blocked_csv = 'Customer ID,amount,Name\\n' + '\\n'.join(blocked_rows) + blocked_csv = 'Customer ID,amount,Name\n' + '\n'.join(blocked_rows) before_blocked = cdp.eval('performance.now()') cdp.eval('window.__spoolTest.loadCsv(' + json.dumps(blocked_csv) + ')') wait_for(lambda: cdp.eval('window.__spoolTest.state().job.phase === "SOURCE_READY"'), label='blocked fixture load') @@ -225,13 +225,13 @@ def main(): assert blocked_state['job']['phase'] == 'SOURCE_READY' assert blocked_state['output'] == [] cdp.eval('window.__spoolTest.navigate("/studio/mission")') - wait_for(lambda: cdp.eval('document.querySelector("[data-action=\\\"retry-autopilot\\\"]") !== null'), label='blocked mission recovery UI') + wait_for(lambda: cdp.eval('document.querySelector("[data-action=retry-autopilot]") !== null'), label='blocked mission recovery UI') assert cdp.eval('document.querySelectorAll(".quality-examples > div").length') > 0 assert cdp.eval('document.body.innerText.includes("Source record")') assert cdp.eval('document.querySelectorAll(".recovery-option").length') == 3 - cdp.eval('document.querySelector("[data-outcome=\\\"preserve_contract\\\"]").click()') + cdp.eval('document.querySelector("[data-outcome=preserve_contract]").click()') assert cdp.eval('document.querySelector("[data-outcome=\\\"preserve_contract\\\"]').getAttribute("aria-pressed")') == 'true' - cdp.eval('document.querySelector("[data-action=\\\"retry-autopilot\\\"]").click()') + cdp.eval('document.querySelector("[data-action=retry-autopilot]").click()') wait_for(lambda: cdp.eval('window.__spoolTest.state().job.phase === "COMPLETE"'), timeout=20, label='blocked workflow recovery completion') recovered = cdp.eval('window.__spoolTest.state()') assert recovered['job']['validRows'] == 2000 From 8e99848638fa1e19fb77465765c43f2c234f403e Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:58:35 +0530 Subject: [PATCH 11/22] Gate every Vercel build on JS syntax and blocked-Autopilot regressions without GitHub Actions --- scripts/build-dist.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/build-dist.js b/scripts/build-dist.js index 2905cc20..4e1a94aa 100644 --- a/scripts/build-dist.js +++ b/scripts/build-dist.js @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; import { cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; const RELEASE_FILES = new Set(['release.json', 'release-manifest.json']); @@ -63,6 +64,17 @@ function artifactRootSha256(artifacts) { return createHash('sha256').update(canonical).digest('hex'); } +// Vercel can deploy without GitHub Actions quota. Enforce the browser +// recovery regression and syntax gate inside the build itself. +for (const path of ['src/app.js', 'src/core/command-kernel.js', 'src/core/migration.js', 'src/storage/indexeddb.js']) { + execFileSync(process.execPath, ['--check', path], { stdio: 'inherit' }); +} +execFileSync(process.execPath, ['--test', + 'tests/autopilot-recovery.test.js', + 'tests/autopilot-kernel.test.js', + 'tests/ui-contract.test.js' +], { stdio: 'inherit' }); + const distDir = process.env.SPOOL_DIST_DIR?.trim() || 'dist'; await rm(distDir, { recursive: true, force: true }); await mkdir(distDir, { recursive: true }); From ca6634a5d27a7b9f2e5afc67146097bbb17d1a03 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:58:55 +0530 Subject: [PATCH 12/22] Document blocked-migration recovery and bounded browser state copies --- docs/ARCHITECTURE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6c92867a..7e571db7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,6 +14,8 @@ SPOOL is split into explanatory product routes and a focused Studio. The normal Database-ready inference can promote a source string to boolean, integer, number or date only when sampled evidence crosses the planner confidence threshold. Destructive naming collisions fail closed into `NEEDS_ATTENTION`; SPOOL does not invent a resolution. +A representative dry run below 95% acceptance also enters `NEEDS_ATTENTION` before a Worker is started. The mission retains bounded violation examples with original source-record indices and implicated fields; no invalid-record payload is sent to a backend. The operator can correct/replace the CSV or explicitly choose another supported output contract and rerun the same validation. `preserve_contract` retains parsed CSV header spellings and raw string cell values, including distinct headers that would collide after normalization. It does not override the acceptance gate or implicitly edit the source. + A safe mission executes: `PROFILE → INFER → PLAN → DRY RUN → ASSESS → EXECUTE → VERIFY` @@ -28,7 +30,7 @@ A manual run interrupted by a terminated tab restores as `PAUSED_RECOVERED`. An ## Durable data plane -`IndexedDbWorkspaceStore` separates compact workflow/mission metadata from source/output chunks. Source chunks are rewritten only when the source fingerprint changes. Output chunks append as progress advances and are cleared when output identity/revision rewinds. +`IndexedDbWorkspaceStore` separates compact workflow/mission metadata from source/output chunks. Source chunks are rewritten only when the source fingerprint changes. Output chunks append as progress advances and are cleared when output identity/revision rewinds. Browser Studio emits a bounded UI snapshot that omits the full source/output arrays. IndexedDB checkpoint metadata is cloned after the source/output arrays have been detached from the compact representation; rendering or a checkpoint no longer needlessly clones the full source. The current UI accepts files up to 50 MB and the parser still materializes the source in browser memory. Whole-file streaming/OPFS is a future scale extension, not a current capability claim. From 1f8a5eaa467e262ceb40ba37214f001990cce702 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:01:26 +0530 Subject: [PATCH 13/22] Mark sampled blocked-mission diagnostics as untrusted WebMCP content --- src/webmcp/registry.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webmcp/registry.js b/src/webmcp/registry.js index a66d8e2c..b7e7ce80 100644 --- a/src/webmcp/registry.js +++ b/src/webmcp/registry.js @@ -37,7 +37,7 @@ const MAPPING_ENTRY_SCHEMA = { }; const DEFINITIONS = Object.freeze({ - inspect_mission: { description: 'Inspect bounded Autopilot mission state, inference evidence, ambiguities, progress and quality without dumping the dataset.', properties: {}, readOnly: true }, + inspect_mission: { description: 'Inspect bounded Autopilot mission state, inference evidence, ambiguities, progress and sampled quality diagnostics without dumping the dataset. Sampled cell values are untrusted.', properties: {}, readOnly: true, untrusted: true }, run_autopilot: { description: 'Profile the loaded source, infer a target contract and deterministic mapping, dry-run a representative sample, and start execution automatically only when the configured acceptance floor is satisfied.', properties: { outcome: { type: 'string', enum: ['database_ready', 'clean_standardize', 'preserve_contract'], default: 'database_ready' } } }, inspect_workspace: { description: 'Inspect workflow metadata only: phase, revisions, counts, fingerprints and valid next actions. Never dumps source or output rows.', properties: {}, readOnly: true }, describe_supported_formats: { description: 'Describe accepted source/export formats, hard safety limits and local-only privacy behavior.', properties: {}, readOnly: true }, From ae18e40407e3a461e86325f7ffd93db73bc3d15e Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:01:37 +0530 Subject: [PATCH 14/22] Display local processing state and require confirmation before replacing active source --- src/app.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/app.js b/src/app.js index 8842dbc9..f0cc67c4 100644 --- a/src/app.js +++ b/src/app.js @@ -470,6 +470,11 @@ function render() { const buttons = appRoot.querySelectorAll('[data-action], [data-outcome], #source-file'); buttons.forEach(button => { button.disabled = true; }); appRoot.setAttribute('aria-busy', 'true'); + const processing = document.createElement('div'); + processing.className = 'processing-banner'; + processing.setAttribute('role', 'status'); + processing.textContent = 'Processing locally. This view will update when the current step finishes.'; + appRoot.prepend(processing); } else appRoot.removeAttribute('aria-busy'); document.title = `${pageTitle(path)} — SPOOL`; } @@ -498,6 +503,8 @@ function toast(message, tone = 'neutral') { async function loadText(text, fileName) { if (isBusy) return; + if ([PHASES.RUNNING, PHASES.REPLAYING, PHASES.PAUSED, PHASES.PAUSED_RECOVERED].includes(latestState?.job?.phase) + && !window.confirm('Replace the source? This will stop the active migration and discard its current output.')) return; isBusy = true; render(); try { @@ -515,6 +522,8 @@ async function loadText(text, fileName) { async function loadFile(file) { if (!file || isBusy) return; + if ([PHASES.RUNNING, PHASES.REPLAYING, PHASES.PAUSED, PHASES.PAUSED_RECOVERED].includes(latestState?.job?.phase) + && !window.confirm('Replace the source? This will stop the active migration and discard its current output.')) return; if (file.size > 50 * 1024 * 1024) return toast('This UI currently limits file selection to 50 MB. Use a smaller CSV for this release.', 'danger'); if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') return toast('SPOOL currently accepts CSV source files.', 'danger'); isBusy = true; From 0e6d19cad4615d2ce37894b2e797f2b90993cd42 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:01:48 +0530 Subject: [PATCH 15/22] Increase diagnostic readability and show local processing state on desktop and mobile --- styles.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/styles.css b/styles.css index b1a48587..732cf78a 100644 --- a/styles.css +++ b/styles.css @@ -66,3 +66,19 @@ .mission-error p{color:var(--muted);overflow-wrap:anywhere} @media(max-width:900px){.recovery-outcomes{grid-template-columns:1fr}.attention-metrics{grid-template-columns:repeat(3,minmax(0,1fr))}} @media(max-width:620px){.attention-metrics{grid-template-columns:1fr}.recovery-actions{flex-direction:column}.recovery-actions .button{width:100%}.quality-diagnostic-list .card-head{flex-wrap:wrap}} + +/* Visible, legible state while a local main-thread phase is in progress. */ +.processing-banner{position:fixed;right:20px;top:86px;z-index:80;max-width:min(420px,calc(100vw - 40px));padding:14px 18px;border:1px solid var(--accent);border-radius:13px;background:var(--panel);color:var(--text);font-size:13px;line-height:1.5;box-shadow:var(--shadow)} +.studio-main .kicker{font-size:11px;line-height:1.45} +.studio-main .attention-panel p,.studio-main .ambiguity-list p{font-size:13px;line-height:1.6} +.studio-main .ambiguity-list h3{font-size:14px;line-height:1.5} +.studio-main .attention-note{font-size:12px;line-height:1.6} +.studio-main .mini-stats span{font-size:10px} +.studio-main .mission-progress-card>p{font-size:12px} +.studio-main .evidence-row{font-size:12px} +.studio-main .evidence-head{font-size:10px} +.studio-main .outcome-option p,.studio-main .launch-card p{font-size:12px} +.studio-main .quality-grid p,.studio-main .quality-grid small{font-size:12px;line-height:1.5} +.studio-rail a,.studio-rail .rail-disabled{font-size:12px} +@media(max-width:900px){.processing-banner{top:145px}} +@media(max-width:620px){.processing-banner{right:12px;top:120px}} From e51bce73c402f9a11665a8faf7625ea2f7f5f8a3 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:03:28 +0530 Subject: [PATCH 16/22] Fail closed and recover visibly from Worker startup or checkpoint persistence errors --- src/core/command-kernel.js | 69 +++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/src/core/command-kernel.js b/src/core/command-kernel.js index b355f344..7a684d99 100644 --- a/src/core/command-kernel.js +++ b/src/core/command-kernel.js @@ -124,7 +124,8 @@ export class CommandKernel { this.workspace.job = transition(this.workspace.job, PHASES.RUNNING); this.workspace.mission = { ...this.workspace.mission, status: 'RUNNING', updatedAt: new Date().toISOString() }; await this.persist(); - await this.startRuntime({ startIndex: this.workspace.job.checkpoint, replaying: false }); + try { await this.startRuntime({ startIndex: this.workspace.job.checkpoint, replaying: false }); } + catch (error) { await this.failCurrentRun(error); } } } else this.emit(); return this.snapshot(); @@ -447,11 +448,30 @@ export class CommandKernel { this.workspace.violations = []; this.workspace.needsReplay = false; this.workspace.job = transition(this.workspace.job, PHASES.RUNNING, { processedRows: 0, validRows: 0, invalidRows: 0, checkpoint: 0, mappingRevision: this.workspace.mappingRevision }); - await this.persist(); - await this.startRuntime({ startIndex: 0, replaying: false }); + try { + await this.persist(); + await this.startRuntime({ startIndex: 0, replaying: false }); + } catch (error) { + await this.failCurrentRun(error); + throw error; + } return this.envelope({ started: true }); } + async failCurrentRun(error) { + try { await this.runtime.abort(); } catch { /* Surface original failure. */ } + this.runEpoch += 1; // Stale Worker callbacks must never revive a stopped run. + const code = error?.code || (error?.name === 'QuotaExceededError' ? 'STORAGE_WRITE_FAILED' : 'WORKER_OR_STORAGE_FAILED'); + this.workspace.lastError = { code, message: String(error?.message || error || 'Local execution failed').slice(0, 300) }; + if ([PHASES.RUNNING, PHASES.REPLAYING, PHASES.PAUSED, PHASES.PAUSED_RECOVERED].includes(this.workspace.job.phase)) { + this.workspace.job = transition(this.workspace.job, PHASES.FAILED); + } + if (this.workspace.mission?.mode === 'autopilot') { + this.workspace.mission = { ...this.workspace.mission, status: 'FAILED', updatedAt: new Date().toISOString() }; + } + try { await this.persist(); } catch { this.emit(); } // Never claim durable completion after a failed checkpoint. + } + async startRuntime({ startIndex, replaying }) { const epoch = ++this.runEpoch; const payload = { @@ -463,10 +483,16 @@ export class CommandKernel { startIndex, chunkSize: 500 }; + const enqueue = action => { + this.runtimeEventChain = this.runtimeEventChain.then(action).catch(async error => { + if (epoch !== this.runEpoch) return; + await this.failCurrentRun(error); + }); + }; const handlers = { - onProgress: progress => { this.runtimeEventChain = this.runtimeEventChain.then(() => this.applyProgress(progress, epoch)); }, - onComplete: summary => { this.runtimeEventChain = this.runtimeEventChain.then(() => this.applyComplete(summary, epoch)); }, - onError: error => { this.runtimeEventChain = this.runtimeEventChain.then(() => this.applyRuntimeError(error, epoch)); } + onProgress: progress => enqueue(() => this.applyProgress(progress, epoch)), + onComplete: summary => enqueue(() => this.applyComplete(summary, epoch)), + onError: error => enqueue(() => this.applyRuntimeError(error, epoch)) }; await this.runtime.start(payload, handlers); if (replaying) this.workspace.needsReplay = false; @@ -532,19 +558,24 @@ export class CommandKernel { async cmd_resume_run() { const replay = this.workspace.needsReplay; - if (replay) { - await this.runtime.abort(); - this.runEpoch += 1; - this.workspace.output = []; - this.workspace.violations = []; - this.workspace.outputRevision = this.workspace.mappingRevision; - this.workspace.job = transition(this.workspace.job, PHASES.REPLAYING, { processedRows: 0, validRows: 0, invalidRows: 0, checkpoint: 0, mappingRevision: this.workspace.mappingRevision }); - await this.persist(); - await this.startRuntime({ startIndex: 0, replaying: true }); - } else { - this.workspace.job = transition(this.workspace.job, PHASES.RUNNING); - await this.persist(); - await this.startRuntime({ startIndex: this.workspace.job.checkpoint, replaying: false }); + try { + if (replay) { + await this.runtime.abort(); + this.runEpoch += 1; + this.workspace.output = []; + this.workspace.violations = []; + this.workspace.outputRevision = this.workspace.mappingRevision; + this.workspace.job = transition(this.workspace.job, PHASES.REPLAYING, { processedRows: 0, validRows: 0, invalidRows: 0, checkpoint: 0, mappingRevision: this.workspace.mappingRevision }); + await this.persist(); + await this.startRuntime({ startIndex: 0, replaying: true }); + } else { + this.workspace.job = transition(this.workspace.job, PHASES.RUNNING); + await this.persist(); + await this.startRuntime({ startIndex: this.workspace.job.checkpoint, replaying: false }); + } + } catch (error) { + await this.failCurrentRun(error); + throw error; } return this.envelope({ replaying: replay, checkpoint: this.workspace.job.checkpoint }); } From e00f52c855c02ffb552f1b4835c6f4782d3bf087 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:03:50 +0530 Subject: [PATCH 17/22] Permit recovered run to enter terminal failure when startup cannot resume --- src/core/state-machine.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/state-machine.js b/src/core/state-machine.js index 01d10188..71c8c05f 100644 --- a/src/core/state-machine.js +++ b/src/core/state-machine.js @@ -24,7 +24,7 @@ const ALLOWED = Object.freeze({ RUNNING: new Set(['PAUSED', 'COMPLETE', 'FAILED', 'ABORTED']), PAUSED: new Set(['RUNNING', 'REPLAYING', 'ABORTED', 'FAILED']), REPLAYING: new Set(['RUNNING', 'FAILED', 'ABORTED']), - PAUSED_RECOVERED: new Set(['RUNNING', 'REPLAYING', 'ABORTED']), + PAUSED_RECOVERED: new Set(['RUNNING', 'REPLAYING', 'ABORTED', 'FAILED']), COMPLETE: new Set(['EMPTY']), FAILED: new Set(['EMPTY']), ABORTED: new Set(['EMPTY']) From b625b935b69d81662250c6d04282835880e90533 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:03:54 +0530 Subject: [PATCH 18/22] Navigate to actionable failure details after a Worker start error --- src/app.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app.js b/src/app.js index f0cc67c4..ee861844 100644 --- a/src/app.js +++ b/src/app.js @@ -549,7 +549,10 @@ async function runAutopilot() { try { await new Promise(resolve => window.requestAnimationFrame(() => setTimeout(resolve, 0))); const response = await kernel.invoke('run_autopilot', { outcome: selectedOutcome }); - if (!response.ok) return toast(`${response.error.code}: ${response.error.message}`, 'danger'); + if (!response.ok) { + if (response.state?.phase === PHASES.FAILED) navigate('/studio/mission'); + return toast(`${response.error.code}: ${response.error.message}`, 'danger'); + } navigate('/studio/mission'); if (response.result?.status === 'NEEDS_ATTENTION') toast('Dry run blocked execution. Review the failed sample records and choose a recovery action.', 'warning'); else toast('Autopilot passed its safety checks and started processing.', 'success'); From 5aa34a9a30e2a1a2be3a98574850e6cdeba8674d Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:04:14 +0530 Subject: [PATCH 19/22] Regress Worker-start and storage-checkpoint failures without permanent running state --- tests/autopilot-recovery.test.js | 45 +++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/tests/autopilot-recovery.test.js b/tests/autopilot-recovery.test.js index b950a29b..872c5d25 100644 --- a/tests/autopilot-recovery.test.js +++ b/tests/autopilot-recovery.test.js @@ -7,7 +7,7 @@ import { readFile } from 'node:fs/promises'; class ControlledRuntime { constructor() { this.startCalls = []; } - async start(payload) { this.startCalls.push(payload); } + async start(payload, handlers) { this.startCalls.push({ payload, handlers }); } async abort() {} async pause() {} } @@ -84,3 +84,46 @@ test('blocked mission interface has recovery controls and preserved diagnostics, assert.ok(styles.includes('.quality-examples')); assert.ok(!storage.includes('structuredClone(workspace);\n if (compact.source) delete compact.source.rows')); }); + +test('Worker startup failures terminate the mission instead of leaving an inert RUNNING screen', async () => { + const { kernel, runtime } = await setup('id,amount\\n1,10.25\\n2,20.50'); + runtime.start = async () => { throw new Error('Worker unavailable'); }; + const result = await kernel.invoke('run_autopilot', { outcome: 'database_ready' }); + assert.equal(result.ok, false); + assert.equal(result.state.phase, PHASES.FAILED); + assert.equal(kernel.snapshot().job.phase, PHASES.FAILED); + assert.equal(kernel.snapshot().mission.status, 'FAILED'); + assert.match(kernel.snapshot().lastError.message, /Worker unavailable/); + const reset = await kernel.invoke('start_new_migration', {}); + assert.equal(reset.ok, true); + assert.equal(reset.state.phase, PHASES.EMPTY); +}); + +test('checkpoint write failure fails the run visibly instead of poisoning all subsequent Worker events', async () => { + let saved = null; + const store = { + async load() { return saved ? structuredClone(saved) : null; }, + async save(workspace) { + if (workspace.job.phase === PHASES.RUNNING && workspace.job.processedRows > 0) { + throw new Error('simulated checkpoint failure'); + } + saved = structuredClone(workspace); + } + }; + const runtime = new ControlledRuntime(); + const kernel = new CommandKernel({ store, runtime }); + await kernel.initialize(); + await kernel.loadSourceText('id,amount\\n1,10.25\\n2,20.50', 'small.csv'); + const started = await kernel.invoke('run_autopilot', { outcome: 'database_ready' }); + assert.equal(started.ok, true); + runtime.startCalls[0].handlers.onProgress({ + processedRows: 1, validRows: 1, invalidRows: 0, + outputChunk: [{ id: 1, amount: 10.25 }], violations: [] + }); + await kernel.whenRuntimeIdle(); + assert.equal(kernel.snapshot().job.phase, PHASES.FAILED); + assert.match(kernel.snapshot().lastError.message, /checkpoint failure/); + assert.equal(kernel.snapshot().mission.status, 'FAILED'); + assert.equal(saved.job.phase, PHASES.FAILED); + assert.equal((await kernel.invoke('start_new_migration', {})).ok, true); +}); From a05146d3e64ac46e109192b1dbbbf326b4b26a1f Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:04:23 +0530 Subject: [PATCH 20/22] Correct fixture newlines for Worker and checkpoint error regressions --- tests/autopilot-recovery.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/autopilot-recovery.test.js b/tests/autopilot-recovery.test.js index 872c5d25..3789fa5b 100644 --- a/tests/autopilot-recovery.test.js +++ b/tests/autopilot-recovery.test.js @@ -86,7 +86,7 @@ test('blocked mission interface has recovery controls and preserved diagnostics, }); test('Worker startup failures terminate the mission instead of leaving an inert RUNNING screen', async () => { - const { kernel, runtime } = await setup('id,amount\\n1,10.25\\n2,20.50'); + const { kernel, runtime } = await setup('id,amount\n1,10.25\n2,20.50'); runtime.start = async () => { throw new Error('Worker unavailable'); }; const result = await kernel.invoke('run_autopilot', { outcome: 'database_ready' }); assert.equal(result.ok, false); @@ -113,7 +113,7 @@ test('checkpoint write failure fails the run visibly instead of poisoning all su const runtime = new ControlledRuntime(); const kernel = new CommandKernel({ store, runtime }); await kernel.initialize(); - await kernel.loadSourceText('id,amount\\n1,10.25\\n2,20.50', 'small.csv'); + await kernel.loadSourceText('id,amount\n1,10.25\n2,20.50', 'small.csv'); const started = await kernel.invoke('run_autopilot', { outcome: 'database_ready' }); assert.equal(started.ok, true); runtime.startCalls[0].handlers.onProgress({ From 2c820d73fb5d44ac1d537917cfa5fc3fe591df66 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:05:34 +0530 Subject: [PATCH 21/22] Document recoverable blocked missions and bounded untrusted diagnostics --- docs/WEBMCP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/WEBMCP.md b/docs/WEBMCP.md index b13d7695..40daf5ef 100644 --- a/docs/WEBMCP.md +++ b/docs/WEBMCP.md @@ -28,7 +28,7 @@ Every command response identifies the current phase, job identity, mapping revis ## Context discipline -`inspect_workspace` returns metadata only. `inspect_mission` exposes bounded inference evidence, ambiguity information, progress and quality groups. Source/result rows require explicit bounded sampling tools. Violation inspection returns grouped failure classes with capped examples. User-provided source/result content is marked with `untrustedContentHint` in tool annotations. +`inspect_workspace` returns metadata only. `inspect_mission` exposes bounded inference evidence, ambiguity information, progress and quality groups. Explicit source/result sampling tools remain bounded. A blocked dry-run mission inspector can also return at most three bounded diagnostic examples per violation class (including source-record indices and implicated cell values truncated to 120 characters). Treat these as untrusted content; `inspect_mission` carries `untrustedContentHint`. Violation inspection returns grouped failure classes with capped examples. User-provided source/result content is marked with `untrustedContentHint` in tool annotations. ## Principal phases From 292fbe5a172402ce20ddcc85a87e9feedb60e050 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:05:37 +0530 Subject: [PATCH 22/22] Document recoverable blocked missions and bounded untrusted diagnostics --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f583d39d..f63db401 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ messy CSV → typed output + violations + lineage ``` -The browser path is local-first and retains the production CSP boundary `connect-src 'none'`. The current browser input limit is **50 MiB**. SPOOL fails closed on ambiguous dates/numbers, invalid target values, storage-capacity failure and unsafe export cells. +The browser path is local-first and retains the production CSP boundary `connect-src 'none'`. The current browser input limit is **50 MiB**. SPOOL fails closed on ambiguous dates/numbers, invalid target values, storage-capacity failure and unsafe export cells. A blocked Autopilot mission remains recoverable in the Studio: bounded failed-sample diagnostics identify implicated fields and original record numbers, and the operator can correct/replace the CSV or explicitly choose another output contract and rerun the dry run. The 95% automatic-acceptance floor is never silently reduced; preserving original CSV field names is a different output contract, not a data-cleaning shortcut. ### Gate B Local Runner