@seamapp/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,3 +29,36 @@ create table if not exists seam_changes (
29
29
  );
30
30
 
31
31
  create index if not exists seam_changes_project_idx on seam_changes (project_id, created_at desc);
32
+
33
+ -- Row-level security. The local engine reads/writes these tables with the anon
34
+ -- (publishable) key. If RLS is enabled with no policy, every snapshot insert
35
+ -- fails with 42501. Enable RLS and grant the anon role the access the engine
36
+ -- needs: INSERT + SELECT + UPDATE on snapshots, INSERT + SELECT + UPDATE on changes.
37
+ alter table seam_snapshots enable row level security;
38
+
39
+ drop policy if exists "anon insert snapshots" on seam_snapshots;
40
+ create policy "anon insert snapshots"
41
+ on seam_snapshots for insert to anon with check (true);
42
+
43
+ drop policy if exists "anon select snapshots" on seam_snapshots;
44
+ create policy "anon select snapshots"
45
+ on seam_snapshots for select to anon using (true);
46
+
47
+ drop policy if exists "anon update snapshots" on seam_snapshots;
48
+ create policy "anon update snapshots"
49
+ on seam_snapshots for update to anon using (true) with check (true);
50
+
51
+ alter table seam_changes enable row level security;
52
+
53
+ drop policy if exists "anon insert changes" on seam_changes;
54
+ create policy "anon insert changes"
55
+ on seam_changes for insert to anon with check (true);
56
+
57
+ drop policy if exists "anon select changes" on seam_changes;
58
+ create policy "anon select changes"
59
+ on seam_changes for select to anon using (true);
60
+
61
+ -- The reverse/restore toggle flips a change's status (completed <-> rolled_back).
62
+ drop policy if exists "anon update changes" on seam_changes;
63
+ create policy "anon update changes"
64
+ on seam_changes for update to anon using (true) with check (true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamapp/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./src/server.js",
6
6
  "files": [
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@anthropic-ai/sdk": "^0.39.0",
22
- "@seamapp/graphify-bridge": "*",
22
+ "@seamapp/graphify-bridge": "^0.2.0",
23
23
  "@supabase/supabase-js": "^2.43.0",
24
24
  "cors": "^2.8.5",
25
25
  "diff": "^5.2.0",
@@ -0,0 +1,51 @@
1
+ import { execa } from 'execa';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Publish the working tree to the live site after a successful apply or rollback.
6
+ *
7
+ * This is the missing half of the loop: Seam edits local files, but the whole
8
+ * point is for the change to GO LIVE, not sit on disk. Configure in
9
+ * seam.config.json:
10
+ *
11
+ * "deploy": { "enabled": true, "command": "vercel", "args": ["--prod","--yes"], "liveUrl": "https://bidwithsmith.com" }
12
+ *
13
+ * Fire-and-forget: never throws into the apply path. Broadcasts its own
14
+ * lifecycle events so the widget can show "Publishing… / Live ✓".
15
+ * DEPLOYING — started
16
+ * DEPLOYED — { url } success
17
+ * DEPLOY_FAILED — { message }
18
+ * DEPLOY_SKIPPED — deploy not configured (silent, informational)
19
+ */
20
+ export async function runDeploy(config, broadcast, { reason } = {}) {
21
+ const d = config.deploy;
22
+ if (!d || d.enabled === false || !d.command) {
23
+ broadcast?.({ type: 'DEPLOY_SKIPPED', message: 'Auto-deploy is not configured for this project.' });
24
+ return { skipped: true };
25
+ }
26
+
27
+ const cwd = path.resolve(process.cwd(), config.projectRoot || '.');
28
+ broadcast?.({ type: 'DEPLOYING', message: 'Publishing to your live site…', reason });
29
+
30
+ try {
31
+ const res = await execa(d.command, d.args || [], {
32
+ cwd,
33
+ timeout: d.timeoutMs || 180_000,
34
+ all: true
35
+ });
36
+ // Pull the production URL out of the CLI output (Vercel prints it).
37
+ const text = res.all || res.stdout || '';
38
+ const urls = text.match(/https?:\/\/[^\s"']+/g) || [];
39
+ const deployUrl = urls[urls.length - 1] || null;
40
+ const url = d.liveUrl || deployUrl;
41
+ broadcast?.({ type: 'DEPLOYED', url, deployUrl, message: url ? `Live at ${url}` : 'Published to your live site.' });
42
+ return { ok: true, url, deployUrl };
43
+ } catch (err) {
44
+ const msg = (err.shortMessage || err.message || 'deploy failed').split('\n')[0];
45
+ broadcast?.({
46
+ type: 'DEPLOY_FAILED',
47
+ message: `Saved locally, but publishing to the live site failed: ${msg}. Your change is on disk and reversible — you can retry the publish.`
48
+ });
49
+ return { ok: false, error: msg };
50
+ }
51
+ }
@@ -0,0 +1,102 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { queryGraph } from '../../graphify-bridge/src/index.js';
5
+
6
+ // Lazy client: the Anthropic key is loaded by dotenv in server.js's body, which
7
+ // runs AFTER this module is imported. Instantiating at module-load time would
8
+ // capture an undefined key. Build it on first use instead, when env is populated.
9
+ let _client;
10
+ function client() {
11
+ if (!_client) _client = new Anthropic({ apiKey: process.env.SEAM_ANTHROPIC_KEY });
12
+ return _client;
13
+ }
14
+
15
+ // Files Seam can actually edit. Images/video/binaries are excluded.
16
+ const EDITABLE = /\.(html?|jsx?|tsx?|mjs|cjs|css|scss|sass|less|json|jsonc|md|mdx|sql|vue|svelte|astro|py|rb|go|rs|java|php|txt|ya?ml|toml|xml|svg)$/i;
17
+ const SKIP = /(^|[\\/])(node_modules|\.git|graphify-out|dist|build|\.next|\.tmp|coverage)[\\/]/;
18
+
19
+ function readManifest(graphifyOut) {
20
+ const p = path.join(process.cwd(), graphifyOut, 'manifest.json');
21
+ let m;
22
+ try { m = fs.readJsonSync(p); } catch { return []; }
23
+ const files = Array.isArray(m) ? m : Object.keys(m);
24
+ return files.filter(f => EDITABLE.test(f) && !SKIP.test(f));
25
+ }
26
+
27
+ /**
28
+ * Natural-language file resolver. Replaces brittle keyword gating: an LLM reads
29
+ * the user's plain-English request plus the project's file list and decides
30
+ * which files to edit — or asks for clarification, or says it can't tell.
31
+ *
32
+ * Graphify is used only as a *ranking hint*, never as a gate. Returns one of:
33
+ * { status: 'resolved', files: [...], reason }
34
+ * { status: 'clarify', question, options: [...] } // options are full, ready-to-send requests
35
+ * { status: 'unclear', message }
36
+ * plus { usage } for token logging.
37
+ */
38
+ export async function resolveFiles({ request, config }) {
39
+ const allFiles = readManifest(config.graphifyOut);
40
+ if (!allFiles.length) {
41
+ return { status: 'unclear', message: "I can't see any editable files in this project yet. Try rebuilding the graph, then ask again.", usage: null };
42
+ }
43
+
44
+ // Best-effort graph hint — never throws, never gates.
45
+ let hints = [];
46
+ try {
47
+ const nodes = await queryGraph(request, config.graphifyOut);
48
+ hints = [...new Set(nodes.map(n => n.source_file || n.file).filter(Boolean))];
49
+ } catch { /* graph optional */ }
50
+
51
+ const system = `You map a user's plain-English website change request to the file(s) that must be edited.
52
+
53
+ You will get the request and the full list of editable files in the project. A "graph hint" may list files that keyword-matched the request — treat it as a weak suggestion that is often wrong or noisy; trust your own reasoning about what the request means.
54
+
55
+ Reply with ONLY a JSON object, no markdown, exactly one of these shapes:
56
+ { "status": "resolved", "files": ["relative/path", ...], "reason": "one short sentence" }
57
+ { "status": "clarify", "question": "a short, friendly question", "options": ["a full restated request", "another full restated request"] }
58
+ { "status": "unclear", "message": "a friendly sentence telling the user what you'd need to proceed" }
59
+
60
+ Rules:
61
+ - Choose the FEWEST files that satisfy the request. A typical small site change is ONE file.
62
+ - Use ONLY paths that appear verbatim in the file list. Never invent or guess a path.
63
+ - For a static site, visible content (buttons, text, hero, sections, forms, colors) almost always lives in the .html files — prefer those over docs/specs/markdown unless the request is clearly about docs.
64
+ - If the request is ambiguous (could mean two different places/things), return "clarify". Each option MUST be a complete, self-contained request the user could send as-is (e.g. "Change the Book Smith button on the homepage to green").
65
+ - If nothing in the project plausibly matches, or the request is too vague to act on, return "unclear" and suggest how to phrase it (name the page or the visible element).
66
+ - Be forgiving of typos and loose wording — infer intent the way a helpful teammate would.`;
67
+
68
+ const user = [
69
+ `Request: ${request}`,
70
+ hints.length ? `\nGraph hint (may be wrong/noisy): ${hints.join(', ')}` : '',
71
+ `\nEditable files in this project:\n${allFiles.join('\n')}`
72
+ ].filter(Boolean).join('\n');
73
+
74
+ let resp;
75
+ try {
76
+ resp = await client().messages.create({
77
+ model: 'claude-haiku-4-5-20251001',
78
+ max_tokens: 1024,
79
+ system,
80
+ messages: [{ role: 'user', content: user }]
81
+ });
82
+ } catch (err) {
83
+ return { status: 'unclear', message: "I couldn't reach the model to interpret your request. Check the Anthropic key and try again.", usage: null };
84
+ }
85
+
86
+ const raw = (resp.content[0]?.text || '').replace(/```json|```/g, '').trim();
87
+ let out;
88
+ try { out = JSON.parse(raw); }
89
+ catch { return { status: 'unclear', message: "I had trouble understanding that — can you rephrase, naming the page or element you mean?", usage: resp.usage }; }
90
+
91
+ if (out.status === 'resolved') {
92
+ const valid = (out.files || []).filter(f => allFiles.includes(f));
93
+ if (!valid.length) {
94
+ return { status: 'unclear', message: "I couldn't pinpoint which file to change — can you name the page or the visible element (e.g. 'the Book Smith button on the homepage')?", usage: resp.usage };
95
+ }
96
+ return { status: 'resolved', files: valid, reason: out.reason || '', usage: resp.usage };
97
+ }
98
+ if (out.status === 'clarify' && out.question) {
99
+ return { status: 'clarify', question: out.question, options: Array.isArray(out.options) ? out.options.slice(0, 4) : [], usage: resp.usage };
100
+ }
101
+ return { status: 'unclear', message: out.message || "I couldn't tell what you want to change — try naming the page or element.", usage: resp.usage };
102
+ }
package/src/fileWriter.js CHANGED
@@ -105,16 +105,17 @@ export async function buildDryRunPreview(changePlan, config) {
105
105
  }
106
106
 
107
107
  /**
108
- * Read only the files referenced by graph nodes — returns { filepath: content }.
108
+ * Read the given files (array of relative path strings) — returns { filepath: content }.
109
109
  * Hard-capped at 10 files per COST_AND_SAFETY.md rules.
110
110
  */
111
- export async function readRelevantFiles(graphNodes, projectRoot) {
111
+ export async function readRelevantFiles(filePaths, projectRoot) {
112
112
  const files = {};
113
- const nodes = graphNodes.slice(0, 10);
114
- for (const node of nodes) {
115
- const absPath = path.resolve(projectRoot || process.cwd(), node.file);
113
+ const paths = (filePaths || []).slice(0, 10);
114
+ for (const rel of paths) {
115
+ if (!rel) continue;
116
+ const absPath = path.resolve(projectRoot || process.cwd(), rel);
116
117
  if (await fs.pathExists(absPath)) {
117
- files[node.file] = await fs.readFile(absPath, 'utf8');
118
+ files[rel] = await fs.readFile(absPath, 'utf8');
118
119
  }
119
120
  }
120
121
  return files;
@@ -1,12 +1,20 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
- import { queryGraph, getBlastRadius } from '../../graphify-bridge/src/index.js';
2
+ import { getNodesForFiles, getBlastRadius } from '../../graphify-bridge/src/index.js';
3
+ import { resolveFiles } from './fileResolver.js';
3
4
  import { classifyRisk } from './riskClassifier.js';
4
5
  import { createSnapshot, logChange, rollbackToToken } from './snapshotManager.js';
5
6
  import { applyChangePlan, buildDryRunPreview, readRelevantFiles } from './fileWriter.js';
6
7
  import { processAttachments } from './attachmentProcessor.js';
7
8
  import { logTokenUsage } from './tokenLogger.js';
8
-
9
- const client = new Anthropic({ apiKey: process.env.SEAM_ANTHROPIC_KEY });
9
+ import { runDeploy } from './deployer.js';
10
+
11
+ // Lazy client: dotenv loads the key in server.js's body, which runs AFTER this
12
+ // module is imported. Build the client on first use so it captures the real key.
13
+ let _client;
14
+ function client() {
15
+ if (!_client) _client = new Anthropic({ apiKey: process.env.SEAM_ANTHROPIC_KEY });
16
+ return _client;
17
+ }
10
18
 
11
19
  const pendingPlans = new Map();
12
20
 
@@ -38,43 +46,68 @@ async function _handleChangeRequest({ requestId, request, attachments, config, b
38
46
  return;
39
47
  }
40
48
 
41
- broadcast({ type: 'STATUS', status: 'analyzing', requestId, message: 'Reading the graph...' });
49
+ broadcast({ type: 'STATUS', status: 'analyzing', requestId, message: 'Understanding your request...' });
42
50
 
43
51
  const processedAttachments = await processAttachments(attachments, config);
44
52
 
45
- // Graph query 5s timeout per COST_AND_SAFETY.md
46
- const graphNodes = await Promise.race([
47
- queryGraph(request, config.graphifyOut),
48
- timeout(5_000, 'Graph query')
53
+ // Natural-language file resolution (LLM). Graphify is a hint, not a gate.
54
+ // 15s timeout a single cheap Haiku call.
55
+ const resolution = await Promise.race([
56
+ resolveFiles({ request, config }),
57
+ timeout(15_000, 'File resolution')
49
58
  ]);
50
59
 
51
- if (!graphNodes.length) {
60
+ if (resolution.usage) {
61
+ await logTokenUsage({
62
+ requestId, request, tier: 'resolve',
63
+ inputTokens: resolution.usage.input_tokens,
64
+ outputTokens: resolution.usage.output_tokens,
65
+ status: 'resolved'
66
+ }).catch(() => {});
67
+ }
68
+
69
+ // Ambiguous request → ask the user instead of guessing or failing.
70
+ if (resolution.status === 'clarify') {
71
+ broadcast({
72
+ type: 'CLARIFY', requestId,
73
+ question: resolution.question,
74
+ options: resolution.options || []
75
+ });
76
+ return;
77
+ }
78
+
79
+ // Couldn't tell what to change → guide the user, don't dead-end.
80
+ if (resolution.status !== 'resolved' || !resolution.files?.length) {
52
81
  broadcast({
53
82
  type: 'FAILED', requestId,
54
- message: "Seam couldn't find the relevant files in your codebase graph. Try rebuilding the graph (Settings Rebuild Graph) or be more specific about what you want to change."
83
+ message: resolution.message || "I couldn't tell which file to change try naming the page or the visible element (e.g. 'the Book Smith button on the homepage')."
55
84
  });
56
85
  return;
57
86
  }
58
87
 
88
+ const filePaths = resolution.files;
89
+
90
+ // Pull graph nodes for the resolved files so risk/blast-radius still work.
91
+ const graphNodes = await getNodesForFiles(filePaths, config.graphifyOut).catch(() => []);
59
92
  const blastRadius = getBlastRadius(graphNodes);
60
93
  const tier = classifyRisk(request, graphNodes, blastRadius);
61
94
 
62
95
  broadcast({
63
96
  type: 'RISK_ASSESSMENT', requestId, tier, blastRadius,
64
- affectedFiles: graphNodes.map(n => n.file),
97
+ affectedFiles: filePaths,
65
98
  message: getRiskMessage(tier, blastRadius)
66
99
  });
67
100
 
68
101
  if (tier === 'B' || tier === 'C') {
69
102
  pendingPlans.set(requestId, {
70
- request, graphNodes, blastRadius, tier,
103
+ request, filePaths, blastRadius, tier,
71
104
  processedAttachments, config,
72
105
  state: 'awaiting_confirmation'
73
106
  });
74
107
  return;
75
108
  }
76
109
 
77
- await executePlan({ requestId, request, graphNodes, processedAttachments, tier, config, broadcast });
110
+ await executePlan({ requestId, request, filePaths, processedAttachments, tier, config, broadcast });
78
111
  }
79
112
 
80
113
  export async function executeConfirmedPlan(planId, broadcast) {
@@ -97,7 +130,7 @@ async function _executeConfirmedPlan(planId, broadcast) {
97
130
  await executePlan({
98
131
  requestId: planId,
99
132
  request: pending.request,
100
- graphNodes: pending.graphNodes,
133
+ filePaths: pending.filePaths,
101
134
  processedAttachments: pending.processedAttachments,
102
135
  tier: pending.tier,
103
136
  config: pending.config,
@@ -105,10 +138,10 @@ async function _executeConfirmedPlan(planId, broadcast) {
105
138
  });
106
139
  }
107
140
 
108
- async function executePlan({ requestId, request, graphNodes, processedAttachments, tier, config, broadcast }) {
141
+ async function executePlan({ requestId, request, filePaths, processedAttachments, tier, config, broadcast }) {
109
142
  broadcast({ type: 'STATUS', status: 'planning', requestId, message: 'Planning changes...' });
110
143
 
111
- const fileContents = await readRelevantFiles(graphNodes, config.projectRoot);
144
+ const fileContents = await readRelevantFiles(filePaths, config.projectRoot);
112
145
 
113
146
  // One Claude call — 30s timeout per COST_AND_SAFETY.md
114
147
  let claudeResponse;
@@ -196,9 +229,18 @@ async function _applyApprovedPlan({ requestId, changePlan, fileContents, tier, r
196
229
  return;
197
230
  }
198
231
 
232
+ // Capture the POST-change contents so the change has a redo target. Without
233
+ // this the after-snapshot is empty and Reverse can't be un-reversed. Reading
234
+ // the just-written files is best-effort — a failure here must not undo a
235
+ // successful change, so we fall back to an empty snapshot (undo still works).
236
+ let afterContents = {};
237
+ try {
238
+ afterContents = await readRelevantFiles(writtenFiles, config.projectRoot);
239
+ } catch { /* redo target unavailable; undo via before-snapshot still works */ }
240
+
199
241
  const afterToken = await createSnapshot({
200
242
  changePlan, status: 'after', projectId: config.projectId,
201
- request, tier, fileContents: {}, parentToken: beforeToken
243
+ request, tier, fileContents: afterContents, parentToken: beforeToken
202
244
  });
203
245
 
204
246
  await logChange({
@@ -207,6 +249,11 @@ async function _applyApprovedPlan({ requestId, changePlan, fileContents, tier, r
207
249
  });
208
250
 
209
251
  broadcast({ type: 'COMPLETE', requestId, beforeToken, afterToken, filesChanged: writtenFiles, message: 'Done' });
252
+
253
+ // The whole point of the loop: the change must GO LIVE, not sit on disk.
254
+ // Fire-and-forget so a slow publish never blocks/fails the apply — the deployer
255
+ // broadcasts its own DEPLOYING/DEPLOYED/DEPLOY_FAILED events to the widget.
256
+ runDeploy(config, broadcast, { reason: 'apply' }).catch(() => {});
210
257
  }
211
258
 
212
259
  async function callClaude({ request, fileContents, processedAttachments }) {
@@ -221,7 +268,7 @@ Your job is to return a JSON change plan ONLY — no explanation, no markdown, n
221
268
  The JSON must be this exact shape:
222
269
  {
223
270
  "originalRequest": "the user's request verbatim",
224
- "summary": "one sentence: what this change does",
271
+ "summary": "a plain-English confirmation written for a non-technical site owner. Restate what they asked for in their own terms, name the visible on-page element by what it LOOKS LIKE and WHERE it sits (e.g. 'the As Seen On Shark Tank badge in the top-right of the hero photo'), and state its current value vs the new value in plain words (e.g. 'it's green right now — changing it to brown'). NO file names, CSS classes, variable names, hex codes, or code.",
225
272
  "changes": [
226
273
  {
227
274
  "filepath": "relative/path/to/file.tsx",
@@ -237,7 +284,8 @@ Rules:
237
284
  - Make the MINIMUM change that satisfies the request
238
285
  - Do not refactor unrelated code
239
286
  - Do not add features not asked for
240
- - originalSnippet must be unique enough to find exactly once in the file`;
287
+ - originalSnippet must be unique enough to find exactly once in the file
288
+ - The "summary" is shown to the user as a comprehension check BEFORE they approve — it must prove you understood them. Describe the element the way they SEE it on the page, not the way it appears in code.`;
241
289
 
242
290
  const messageContent = [];
243
291
 
@@ -258,7 +306,7 @@ Rules:
258
306
 
259
307
  messageContent.push({ type: 'text', text: userMessage });
260
308
 
261
- const response = await client.messages.create({
309
+ const response = await client().messages.create({
262
310
  model: 'claude-sonnet-4-20250514',
263
311
  max_tokens: 4096,
264
312
  system: systemPrompt,
@@ -286,7 +334,7 @@ Rules:
286
334
  }
287
335
 
288
336
  function getRiskMessage(tier, blastRadius) {
289
- if (tier === 'A') return 'Safe changeready to apply.';
290
- if (tier === 'B') return `Logic change affects ${blastRadius.affectedNodes} file(s) across ${blastRadius.communities} section(s). Review before applying.`;
291
- return `High risk touches ${blastRadius.godNodes.length || 'core'} critical module(s) across ${blastRadius.communities} section(s). Read carefully.`;
337
+ if (tier === 'A') return 'Small, self-contained edit safe to apply.';
338
+ if (tier === 'B') return 'This touches a few connected parts of your site, so it could affect more than what you asked. Worth a quick review before applying.';
339
+ return 'This edits a central part of your site, so a small change here can ripple further than expected. Read the preview, then apply — your current version is saved, so you can reverse it in one click.';
292
340
  }
package/src/server.js CHANGED
@@ -10,7 +10,7 @@ import fs from 'fs-extra';
10
10
  import { fileURLToPath } from 'url';
11
11
 
12
12
  import { handleChangeRequest, executeConfirmedPlan, applyApprovedPlan } from './requestHandler.js';
13
- import { rollbackToToken, getChangeHistory } from './snapshotManager.js';
13
+ import { rollbackToToken, getChangeHistory, toggleChange } from './snapshotManager.js';
14
14
  import { rebuildGraph } from '../../graphify-bridge/src/index.js';
15
15
 
16
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -129,6 +129,23 @@ app.post('/api/rollback', async (req, res) => {
129
129
  }
130
130
  });
131
131
 
132
+ // Toggle a change on/off. direction 'undo' restores the before-state,
133
+ // 'redo' restores the after-state — both re-publish and flip the change status.
134
+ app.post('/api/toggle', async (req, res) => {
135
+ const { beforeToken, afterToken, direction } = req.body;
136
+ if (!beforeToken || !afterToken) return res.status(400).json({ error: 'beforeToken and afterToken required' });
137
+ if (activeRequest) return res.status(409).json({ error: 'A change is already in progress.' });
138
+ activeRequest = true;
139
+ res.json({ status: 'toggling' });
140
+ try {
141
+ await toggleChange({ beforeToken, afterToken, direction: direction === 'redo' ? 'redo' : 'undo', config, broadcast });
142
+ } catch (err) {
143
+ broadcast({ type: 'FAILED', message: err.message });
144
+ } finally {
145
+ activeRequest = false;
146
+ }
147
+ });
148
+
132
149
  app.get('/api/history', async (req, res) => {
133
150
  const history = await getChangeHistory(config.projectId);
134
151
  res.json({ history });
@@ -91,17 +91,18 @@ export async function logChange({ projectId, request, tier, filesChanged, before
91
91
  }
92
92
 
93
93
  /**
94
- * Full rollback: restore files from a before_token snapshot.
95
- * Returns the list of restored file paths.
94
+ * Write the files captured in a snapshot back to disk. Pure file restore —
95
+ * no status bookkeeping, no deploy. Returns the restored file paths.
96
+ * Shared by the write-failure rollback and the user-facing toggle.
96
97
  */
97
- export async function rollbackToToken(beforeToken, config, broadcast) {
98
- const snapshot = await loadSnapshot(beforeToken);
98
+ async function restoreFilesFromSnapshot(token, config) {
99
+ const snapshot = await loadSnapshot(token);
99
100
 
100
101
  const { applyChangePlan } = await import('./fileWriter.js');
101
- const restoredFiles = Object.keys(snapshot.files);
102
+ const restoredFiles = Object.keys(snapshot.files || {});
102
103
 
103
- const rollbackPlan = {
104
- originalRequest: `Rollback to ${beforeToken}`,
104
+ const restorePlan = {
105
+ originalRequest: `Restore ${token}`,
105
106
  tier: snapshot.risk_tier,
106
107
  changes: restoredFiles.map(filepath => ({
107
108
  filepath,
@@ -110,9 +111,71 @@ export async function rollbackToToken(beforeToken, config, broadcast) {
110
111
  }))
111
112
  };
112
113
 
113
- await applyChangePlan(rollbackPlan, config);
114
+ await applyChangePlan(restorePlan, config);
115
+ return restoredFiles;
116
+ }
117
+
118
+ /**
119
+ * Flip a logged change's status (completed <-> rolled_back) so the History
120
+ * toggle survives reloads. Matched on the change's before/after token pair.
121
+ */
122
+ async function setChangeStatus({ beforeToken, afterToken, status }) {
123
+ const supabase = getClient();
124
+ const { error } = await supabase
125
+ .from('seam_changes')
126
+ .update({ status })
127
+ .eq('before_token', beforeToken)
128
+ .eq('after_token', afterToken);
129
+ if (error) throw new Error(`setChangeStatus failed: ${error.message}`);
130
+ }
131
+
132
+ /**
133
+ * Full rollback: restore files from a before_token snapshot.
134
+ * Used on a failed write to undo partial changes. Returns restored paths.
135
+ */
136
+ export async function rollbackToToken(beforeToken, config, broadcast) {
137
+ const restoredFiles = await restoreFilesFromSnapshot(beforeToken, config);
114
138
  await markRolledBack(beforeToken);
115
139
 
116
140
  if (broadcast) broadcast({ type: 'STATUS', status: 'rolled_back', message: `Rolled back to ${beforeToken.slice(0, 8)}...` });
141
+
142
+ // A rescind only matters if the live site reverts too. Publish the restored
143
+ // files (fire-and-forget — the deployer broadcasts its own publish events).
144
+ const { runDeploy } = await import('./deployer.js');
145
+ runDeploy(config, broadcast, { reason: 'rollback' }).catch(() => {});
146
+
117
147
  return { restoredFiles, beforeToken };
118
148
  }
149
+
150
+ /**
151
+ * Toggle a single change on or off — the reversible on/off switch.
152
+ * direction 'undo' → restore the BEFORE snapshot (the change's pre-state)
153
+ * direction 'redo' → restore the AFTER snapshot (the change's post-state)
154
+ * Both directions re-publish the restored files to the live site and flip the
155
+ * logged change's status so the widget knows which way the toggle points. This
156
+ * is what makes Reverse reversible: every change stores both old and new file
157
+ * state, so flipping it off and back on are symmetric operations.
158
+ */
159
+ export async function toggleChange({ beforeToken, afterToken, direction, config, broadcast }) {
160
+ const undo = direction !== 'redo';
161
+ const token = undo ? beforeToken : afterToken;
162
+ if (!token) throw new Error(`Cannot ${undo ? 'reverse' : 'restore'} — this change has no ${undo ? 'before' : 'after'} snapshot.`);
163
+
164
+ const restoredFiles = await restoreFilesFromSnapshot(token, config);
165
+
166
+ // Best-effort: flip the logged status so the History button toggles. The
167
+ // files are already restored, so a status-write failure (e.g. a missing
168
+ // "anon update changes" RLS policy) must not fail the whole reverse.
169
+ await setChangeStatus({ beforeToken, afterToken, status: undo ? 'rolled_back' : 'completed' }).catch(() => {});
170
+
171
+ if (broadcast) broadcast({
172
+ type: 'STATUS',
173
+ status: undo ? 'rolled_back' : 'restored',
174
+ message: undo ? `Reversed ${beforeToken.slice(0, 8)}…` : `Restored ${afterToken.slice(0, 8)}…`
175
+ });
176
+
177
+ const { runDeploy } = await import('./deployer.js');
178
+ runDeploy(config, broadcast, { reason: direction }).catch(() => {});
179
+
180
+ return { restoredFiles, direction };
181
+ }