From f81f63a4859b848c921cd7b5eab07d653a3ef9b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:28:38 +0000 Subject: [PATCH] Sync generated provider output --- .../skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ .../skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ .../skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ .../skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ .../skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ .../skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ .../skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ .pi/skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ .../skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ .../skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ .../skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ .../skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ .../skills/impeccable/scripts/live-server.mjs | 361 +----------------- .../scripts/live/manual-edit-routes.mjs | 357 +++++++++++++++++ 26 files changed, 4810 insertions(+), 4524 deletions(-) create mode 100644 .agents/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 .claude/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 .github/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 .pi/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 .trae/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 plugin/skills/impeccable/scripts/live/manual-edit-routes.mjs diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/.agents/skills/impeccable/scripts/live/manual-edit-routes.mjs b/.agents/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/.agents/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +} diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/.claude/skills/impeccable/scripts/live/manual-edit-routes.mjs b/.claude/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/.claude/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +} diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/.cursor/skills/impeccable/scripts/live/manual-edit-routes.mjs b/.cursor/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +} diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/.gemini/skills/impeccable/scripts/live/manual-edit-routes.mjs b/.gemini/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +} diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/.github/skills/impeccable/scripts/live/manual-edit-routes.mjs b/.github/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/.github/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +} diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/.kiro/skills/impeccable/scripts/live/manual-edit-routes.mjs b/.kiro/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +} diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/.opencode/skills/impeccable/scripts/live/manual-edit-routes.mjs b/.opencode/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +} diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/.pi/skills/impeccable/scripts/live/manual-edit-routes.mjs b/.pi/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/.pi/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +} diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/.qoder/skills/impeccable/scripts/live/manual-edit-routes.mjs b/.qoder/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +} diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/.rovodev/skills/impeccable/scripts/live/manual-edit-routes.mjs b/.rovodev/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +} diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/.trae-cn/skills/impeccable/scripts/live/manual-edit-routes.mjs b/.trae-cn/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +} diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/.trae/skills/impeccable/scripts/live/manual-edit-routes.mjs b/.trae/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/.trae/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +} diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index ec325b7f5..68d547026 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -24,6 +24,7 @@ import { parseDesignMd } from './lib/design-parser.mjs'; import { resolveContextDir } from './context.mjs'; import { createLiveSessionStore } from './live/session-store.mjs'; import { validateEvent } from './live/event-validation.mjs'; +import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; import { LIVE_COMMANDS } from './live/vocabulary.mjs'; import { getDesignSidecarPath, @@ -34,21 +35,11 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; -import { - countByPage as countPendingByPage, - readBuffer as readManualEditsBuffer, - removeEntries as removeManualEditEntries, - stageEntry as stageManualEditEntry, - truncateBuffer as truncateManualEditsBuffer, -} from './live/manual-edits-buffer.mjs'; +import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; import { createManualApplyController, summarizeManualApplyFailures, - summarizeManualDiagnostics, - summarizeManualLogFile, } from './live/manual-apply.mjs'; -import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; -import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { applyDeferredSvelteComponentAccepts, removeAllSvelteComponentSessions, @@ -121,6 +112,16 @@ const manualApply = createManualApplyController({ cwd: () => process.cwd(), }); +const manualEditRoutes = createManualEditRoutes({ + getToken: () => state.token, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd: () => process.cwd(), + env: () => process.env, +}); + function chatAgentLikelyActive() { if (state.pendingPolls.length > 0) return true; if (!state.lastPollAt) return false; @@ -327,20 +328,6 @@ function getManualEditStatus() { } } -function summarizePendingManualEditBatch(pageUrl = null) { - try { - const buffer = readManualEditsBuffer(process.cwd()); - const entries = (buffer.entries || []) - .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); - return { - pendingEntryCount: entries.length, - pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), - }; - } catch (err) { - return { pendingSummaryError: err.message || String(err) }; - } -} - // --------------------------------------------------------------------------- // Load scripts // --------------------------------------------------------------------------- @@ -662,329 +649,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } - // --- Manual copy edits: Save stages entries, Apply commits the staged - // page batch through the local AI copy-edit runner. - if (p === '/manual-edit-stash' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - let msg; - try { msg = JSON.parse(body); } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - if (msg.token !== state.token) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - const error = validateEvent({ ...msg, type: 'manual_edits' }); - if (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error })); - return; - } - try { - stageManualEditEntry(process.cwd(), { - id: msg.id, - pageUrl: msg.pageUrl, - element: msg.element, - ops: msg.ops, - }); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const pendingCount = perPage[msg.pageUrl] || 0; - recordManualEditActivity('manual_edit_stashed', { - id: msg.id, - pageUrl: msg.pageUrl, - opCount: msg.ops.length, - pendingCount, - totalCount, - hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); - }); - return; - } - - // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } - if (p === '/manual-edit-stash' && req.method === 'GET') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl') || ''; - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const buffer = readManualEditsBuffer(process.cwd()); - const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - entries: entriesForPage, - })); - return; - } - - // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch. - if (p === '/manual-edit-commit' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); - const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); - const existingTransaction = manualApply.readTransaction(); - if (repairOnly && !existingTransaction) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); - return; - } - const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_recovered_abandoned_transaction', - }); - const before = getManualEditStatus(); - const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; - recordManualEditActivity('manual_edit_commit_started', { - pageUrl, - repairOnly, - pendingCount, - totalCount: before.totalCount, - recoveredTransaction: recoveredTransaction ? { - id: recoveredTransaction.id, - reason: recoveredTransaction.reason, - skipped: recoveredTransaction.skipped, - rolledBackFiles: recoveredTransaction.rolledBackFiles, - rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), - } : null, - ...summarizePendingManualEditBatch(pageUrl), - }); - if (asyncMode) { - res.writeHead(202, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'started', - pendingCount, - totalCount: before.totalCount, - perPage: before.perPage, - })); - } - (async () => { - let result; - let routedProvider = 'subprocess'; - let transaction = null; - let commitBatch = null; - try { - if (pendingCount > 0) { - const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); - commitBatch = transactionBatch; - if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { - transaction = manualApply.writeTransaction({ - pageUrl, - batch: transactionBatch, - }); - } else if (repairOnly && existingTransaction) { - transaction = existingTransaction; - } - } - const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); - const useChatRoute = requestedMode === 'chat' - || (requestedMode === 'auto' && chatAgentLikelyActive()); - if (useChatRoute) { - routedProvider = 'chat'; - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider: 'chat', - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } else { - const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); - const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; - result = await commitManualEdits({ - cwd: process.cwd(), - pageUrl, - provider, - env: process.env, - timeoutMs, - chatAvailable: chatAgentLikelyActive, - repairOnly, - transactionId: transaction?.id || existingTransaction?.id || null, - batch: commitBatch, - }); - } - } catch (err) { - if (transaction) { - manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_commit_exception', - }); - } - const message = err.stderr?.toString?.() || err.message; - recordManualEditActivity('manual_edit_commit_failed', { - pageUrl, - provider: routedProvider, - error: 'manual_edit_commit_failed', - message, - transactionId: transaction?.id || null, - }); - if (!asyncMode) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'manual_edit_commit_failed', - message, - })); - } - return; - } finally { - if (transaction) { - const shouldKeepTransaction = result?.needsManualDecision === true; - if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); - } - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - if (result?.needsManualDecision) { - recordManualEditActivity('manual_edit_repair_needs_decision', { - pageUrl, - provider: routedProvider, - transactionId: transaction?.id || existingTransaction?.id || null, - repair: result.repair || null, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } else { - recordManualEditActivity('manual_edit_commit_done', { - pageUrl, - provider: routedProvider, - reason: result.reason || null, - repair: result.repair || null, - appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, - failedCount: Array.isArray(result.failed) ? result.failed.length : 0, - failed: summarizeManualApplyFailures(result.failed), - files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - warnings: summarizeManualDiagnostics(result.warnings), - rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], - rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), - unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, - noteCount: Array.isArray(result.notes) ? result.notes.length : 0, - cleared: result.cleared || 0, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - }); - } - if (!asyncMode) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...result, totalCount, perPage })); - } - })(); - return; - } - - // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. - if (p === '/manual-edit-repair-decision' && req.method === 'POST') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let payload = {}; - try { payload = body ? JSON.parse(body) : {}; } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - return; - } - const token = payload.token || url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; - const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); - if (action !== 'rollback') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); - return; - } - const rollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_user_requested_rollback', - }); - const { totalCount, perPage } = countPendingByPage(process.cwd()); - const response = { - action, - pageUrl, - rollback, - remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, - totalCount, - perPage, - }; - recordManualEditActivity('manual_edit_repair_rollback_done', response); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - }); - return; - } - - // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) - if (p === '/manual-edit-discard' && req.method === 'POST') { - const token = url.searchParams.get('token'); - if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const pageUrl = url.searchParams.get('pageUrl'); - let discarded; - let discardedEntries = []; - let canceledApplyEvents = []; - let transactionRollback = null; - try { - const buffer = readManualEditsBuffer(process.cwd()); - transactionRollback = manualApply.rollbackTransaction({ - pageUrl, - reason: 'manual_edit_discarded', - }); - if (pageUrl) { - discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); - discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); - } else { - discardedEntries = buffer.entries; - discarded = truncateManualEditsBuffer(process.cwd()); - } - canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); - return; - } - const { totalCount, perPage } = countPendingByPage(process.cwd()); - recordManualEditActivity('manual_edit_discarded', { - pageUrl, - discarded, - canceledApplyIds: canceledApplyEvents.map((event) => event.id), - transactionRollback: transactionRollback ? { - id: transactionRollback.id, - rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], - rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), - skipped: transactionRollback.skipped, - } : undefined, - totalCount, - }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); - return; - } - - // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. - if (p === '/manual-edit' && req.method === 'POST') { - res.writeHead(410, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); - return; - } + if (manualEditRoutes(req, res, url)) return; // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { diff --git a/plugin/skills/impeccable/scripts/live/manual-edit-routes.mjs b/plugin/skills/impeccable/scripts/live/manual-edit-routes.mjs new file mode 100644 index 000000000..acd250ec4 --- /dev/null +++ b/plugin/skills/impeccable/scripts/live/manual-edit-routes.mjs @@ -0,0 +1,357 @@ +import { validateEvent } from './event-validation.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './manual-edits-buffer.mjs'; +import { + summarizeManualApplyFailures, + summarizeManualDiagnostics, + summarizeManualLogFile, +} from './manual-apply.mjs'; +import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs'; +import { commitManualEdits } from '../live-commit-manual-edits.mjs'; + +export function createManualEditRoutes({ + getToken, + manualApply, + recordManualEditActivity, + getManualEditStatus, + chatAgentLikelyActive, + cwd = () => process.cwd(), + env = () => process.env, +} = {}) { + const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd(); + const currentEnv = () => typeof env === 'function' ? env() : env || process.env; + + return function handleManualEditRoute(req, res, url) { + const p = url.pathname; + + // Save stages entries; Apply commits the staged page batch through the + // local AI copy-edit runner. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + if (msg.token !== getToken()) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + sendJson(res, 400, { error }); + return; + } + try { + stageManualEditEntry(projectCwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + sendJson(res, 500, { error: 'stash_write_failed', message: err.message }); + return; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + recordManualEditActivity('manual_edit_stashed', { + id: msg.id, + pageUrl: msg.pageUrl, + opCount: msg.ops.length, + pendingCount, + totalCount, + hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size, + }); + sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage }); + }); + return true; + } + + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const buffer = readManualEditsBuffer(projectCwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + sendJson(res, 200, { + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + }); + return true; + } + + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); + const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); + const existingTransaction = manualApply.readTransaction(); + if (repairOnly && !existingTransaction) { + sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' }); + return true; + } + const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_recovered_abandoned_transaction', + }); + const before = getManualEditStatus(); + const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; + recordManualEditActivity('manual_edit_commit_started', { + pageUrl, + repairOnly, + pendingCount, + totalCount: before.totalCount, + recoveredTransaction: recoveredTransaction ? { + id: recoveredTransaction.id, + reason: recoveredTransaction.reason, + skipped: recoveredTransaction.skipped, + rolledBackFiles: recoveredTransaction.rolledBackFiles, + rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()), + } : null, + ...summarizePendingManualEditBatch(projectCwd(), pageUrl), + }); + if (asyncMode) { + sendJson(res, 202, { + status: 'started', + pendingCount, + totalCount: before.totalCount, + perPage: before.perPage, + }); + } + (async () => { + let result; + let routedProvider = 'subprocess'; + let transaction = null; + let commitBatch = null; + try { + if (pendingCount > 0) { + const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl }); + commitBatch = transactionBatch; + if (!repairOnly && manualApply.countOps(transactionBatch) > 0) { + transaction = manualApply.writeTransaction({ + pageUrl, + batch: transactionBatch, + }); + } else if (repairOnly && existingTransaction) { + transaction = existingTransaction; + } + } + const envValue = currentEnv(); + const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); + const useChatRoute = requestedMode === 'chat' + || (requestedMode === 'auto' && chatAgentLikelyActive()); + if (useChatRoute) { + routedProvider = 'chat'; + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider: 'chat', + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context), + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } else { + const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); + const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; + result = await commitManualEdits({ + cwd: projectCwd(), + pageUrl, + provider, + env: envValue, + timeoutMs, + chatAvailable: chatAgentLikelyActive, + repairOnly, + transactionId: transaction?.id || existingTransaction?.id || null, + batch: commitBatch, + }); + } + } catch (err) { + if (transaction) { + manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_commit_exception', + }); + } + const message = err.stderr?.toString?.() || err.message; + recordManualEditActivity('manual_edit_commit_failed', { + pageUrl, + provider: routedProvider, + error: 'manual_edit_commit_failed', + message, + transactionId: transaction?.id || null, + }); + if (!asyncMode) { + sendJson(res, 500, { + error: 'manual_edit_commit_failed', + message, + }); + } + return; + } finally { + if (transaction) { + const shouldKeepTransaction = result?.needsManualDecision === true; + if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id); + } + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + if (result?.needsManualDecision) { + recordManualEditActivity('manual_edit_repair_needs_decision', { + pageUrl, + provider: routedProvider, + transactionId: transaction?.id || existingTransaction?.id || null, + repair: result.repair || null, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } else { + recordManualEditActivity('manual_edit_commit_done', { + pageUrl, + provider: routedProvider, + reason: result.reason || null, + repair: result.repair || null, + appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, + failedCount: Array.isArray(result.failed) ? result.failed.length : 0, + failed: summarizeManualApplyFailures(result.failed, projectCwd()), + files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + warnings: summarizeManualDiagnostics(result.warnings, projectCwd()), + rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [], + rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()), + unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined, + noteCount: Array.isArray(result.notes) ? result.notes.length : 0, + cleared: result.cleared || 0, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + }); + } + if (!asyncMode) { + sendJson(res, 200, { ...result, totalCount, perPage }); + } + })(); + return true; + } + + if (p === '/manual-edit-repair-decision' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let payload = {}; + try { payload = body ? JSON.parse(body) : {}; } catch { + sendJson(res, 400, { error: 'Invalid JSON' }); + return; + } + const token = payload.token || url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; + const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); + if (action !== 'rollback') { + sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action }); + return; + } + const rollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_user_requested_rollback', + }); + const { totalCount, perPage } = countPendingByPage(projectCwd()); + const response = { + action, + pageUrl, + rollback, + remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + }; + recordManualEditActivity('manual_edit_repair_rollback_done', response); + sendJson(res, 200, response); + }); + return true; + } + + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + let discardedEntries = []; + let canceledApplyEvents = []; + let transactionRollback = null; + try { + const buffer = readManualEditsBuffer(projectCwd()); + transactionRollback = manualApply.rollbackTransaction({ + pageUrl, + reason: 'manual_edit_discarded', + }); + if (pageUrl) { + discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); + discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discardedEntries = buffer.entries; + discarded = truncateManualEditsBuffer(projectCwd()); + } + canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl); + } catch (err) { + sendJson(res, 500, { error: 'discard_failed', message: err.message }); + return true; + } + const { totalCount, perPage } = countPendingByPage(projectCwd()); + recordManualEditActivity('manual_edit_discarded', { + pageUrl, + discarded, + canceledApplyIds: canceledApplyEvents.map((event) => event.id), + transactionRollback: transactionRollback ? { + id: transactionRollback.id, + rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [], + rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()), + skipped: transactionRollback.skipped, + } : undefined, + totalCount, + }); + sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }); + return true; + } + + if (p === '/manual-edit' && req.method === 'POST') { + sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }); + return true; + } + + return false; + }; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function summarizePendingManualEditBatch(cwd, pageUrl = null) { + try { + const buffer = readManualEditsBuffer(cwd); + const entries = (buffer.entries || []) + .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); + return { + pendingEntryCount: entries.length, + pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), + }; + } catch (err) { + return { pendingSummaryError: err.message || String(err) }; + } +}