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 } = {}) {
SPOOL Local-first deterministic migration infrastructure.
Dataset plane: browser-local Network: disabled by CSP Transform 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 => `${esc(item.title)} ${esc(item.copy)} `).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', ``, state, { studio: true });
const mission = state.mission;
@@ -379,7 +401,8 @@ function missionPage(state) {
return shell('/studio/mission', `
- ${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