@bastani/atomic 0.8.20 → 0.8.21
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.
- package/CHANGELOG.md +12 -0
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/agents/code-simplifier.md +78 -22
- package/dist/builtin/subagents/agents/debugger.md +4 -3
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/workflows/CHANGELOG.md +25 -0
- package/dist/builtin/workflows/package.json +1 -1
- package/dist/builtin/workflows/skills/create-spec/SKILL.md +169 -125
- package/dist/builtin/workflows/skills/impeccable/SKILL.md +89 -80
- package/dist/builtin/workflows/skills/impeccable/agents/impeccable_asset_producer.toml +92 -0
- package/dist/builtin/workflows/skills/impeccable/agents/impeccable_manual_edit_applier.toml +95 -0
- package/dist/builtin/workflows/skills/impeccable/agents/openai.yaml +4 -0
- package/dist/builtin/workflows/skills/impeccable/reference/adapt.md +122 -1
- package/dist/builtin/workflows/skills/impeccable/reference/animate.md +38 -12
- package/dist/builtin/workflows/skills/impeccable/reference/audit.md +5 -5
- package/dist/builtin/workflows/skills/impeccable/reference/bolder.md +7 -7
- package/dist/builtin/workflows/skills/impeccable/reference/brand.md +4 -14
- package/dist/builtin/workflows/skills/impeccable/reference/clarify.md +115 -1
- package/dist/builtin/workflows/skills/impeccable/reference/codex.md +3 -3
- package/dist/builtin/workflows/skills/impeccable/reference/colorize.md +109 -6
- package/dist/builtin/workflows/skills/impeccable/reference/craft.md +7 -7
- package/dist/builtin/workflows/skills/impeccable/reference/critique.md +623 -94
- package/dist/builtin/workflows/skills/impeccable/reference/delight.md +2 -2
- package/dist/builtin/workflows/skills/impeccable/reference/distill.md +2 -2
- package/dist/builtin/workflows/skills/impeccable/reference/document.md +16 -14
- package/dist/builtin/workflows/skills/impeccable/reference/extract.md +1 -1
- package/dist/builtin/workflows/skills/impeccable/reference/harden.md +1 -1
- package/dist/builtin/workflows/skills/impeccable/reference/init.md +172 -0
- package/dist/builtin/workflows/skills/impeccable/reference/interaction-design.md +0 -6
- package/dist/builtin/workflows/skills/impeccable/reference/layout.md +33 -13
- package/dist/builtin/workflows/skills/impeccable/reference/live.md +96 -19
- package/dist/builtin/workflows/skills/impeccable/reference/onboard.md +1 -1
- package/dist/builtin/workflows/skills/impeccable/reference/optimize.md +1 -1
- package/dist/builtin/workflows/skills/impeccable/reference/overdrive.md +1 -1
- package/dist/builtin/workflows/skills/impeccable/reference/polish.md +3 -4
- package/dist/builtin/workflows/skills/impeccable/reference/product.md +1 -3
- package/dist/builtin/workflows/skills/impeccable/reference/quieter.md +2 -2
- package/dist/builtin/workflows/skills/impeccable/reference/shape.md +5 -5
- package/dist/builtin/workflows/skills/impeccable/reference/typeset.md +158 -3
- package/dist/builtin/workflows/skills/impeccable/scripts/cleanup-deprecated.mjs +1 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/command-metadata.json +2 -2
- package/dist/builtin/workflows/skills/impeccable/scripts/context-signals.mjs +225 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/context.mjs +266 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/critique-storage.mjs +17 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/design-parser.mjs +16 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/detect.mjs +21 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/browser/injected/index.mjs +1725 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/cli/main.mjs +244 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +4543 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns.mjs +43 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +252 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +535 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +986 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +208 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +189 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/findings.mjs +12 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/node/file-system.mjs +198 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/profile/profiler.mjs +166 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/registry/antipatterns.mjs +419 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/rules/checks.mjs +2316 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/color.mjs +124 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/constants.mjs +101 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/page.mjs +7 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/impeccable-paths.mjs +17 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/is-generated.mjs +2 -2
- package/dist/builtin/workflows/skills/impeccable/scripts/live-accept.mjs +139 -96
- package/dist/builtin/workflows/skills/impeccable/scripts/live-browser.js +4491 -526
- package/dist/builtin/workflows/skills/impeccable/scripts/live-commit-manual-edits.mjs +1241 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-copy-edit-agent.mjs +683 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-discard-manual-edits.mjs +51 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-event-validation.mjs +136 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-inject.mjs +22 -9
- package/dist/builtin/workflows/skills/impeccable/scripts/live-insert-ui.mjs +458 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-insert.mjs +232 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edit-evidence.mjs +363 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edits-buffer.mjs +152 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-poll.mjs +288 -110
- package/dist/builtin/workflows/skills/impeccable/scripts/live-resume.mjs +47 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/live-server.mjs +1443 -100
- package/dist/builtin/workflows/skills/impeccable/scripts/live-session-store.mjs +17 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-status.mjs +17 -3
- package/dist/builtin/workflows/skills/impeccable/scripts/live-wrap.mjs +216 -6
- package/dist/builtin/workflows/skills/impeccable/scripts/live.mjs +2 -3
- package/dist/builtin/workflows/skills/impeccable/scripts/palette.mjs +633 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/pin.mjs +1 -1
- package/dist/builtin/workflows/src/extension/index.ts +67 -3
- package/dist/builtin/workflows/src/extension/render-result.ts +26 -1
- package/dist/builtin/workflows/src/runs/foreground/executor.ts +227 -3
- package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +94 -7
- package/dist/builtin/workflows/src/shared/stage-prompt.ts +326 -0
- package/dist/builtin/workflows/src/shared/stage-ui-broker.ts +62 -7
- package/dist/builtin/workflows/src/shared/store-types.ts +43 -0
- package/dist/builtin/workflows/src/shared/store.ts +37 -0
- package/dist/builtin/workflows/src/tui/chat-surface-message.ts +22 -4
- package/dist/builtin/workflows/src/tui/graph-view.ts +47 -0
- package/dist/builtin/workflows/src/tui/overlay-adapter.ts +43 -1
- package/dist/builtin/workflows/src/tui/run-detail.ts +10 -4
- package/dist/builtin/workflows/src/tui/stage-chat-view.ts +117 -15
- package/dist/builtin/workflows/src/tui/workflow-attach-pane.ts +9 -0
- package/dist/core/skills.d.ts.map +1 -1
- package/dist/core/skills.js +2 -5
- package/dist/core/skills.js.map +1 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +11 -29
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/docs/quickstart.md +1 -2
- package/package.json +4 -4
- package/dist/builtin/workflows/skills/impeccable/reference/cognitive-load.md +0 -106
- package/dist/builtin/workflows/skills/impeccable/reference/color-and-contrast.md +0 -105
- package/dist/builtin/workflows/skills/impeccable/reference/heuristics-scoring.md +0 -234
- package/dist/builtin/workflows/skills/impeccable/reference/motion-design.md +0 -109
- package/dist/builtin/workflows/skills/impeccable/reference/personas.md +0 -179
- package/dist/builtin/workflows/skills/impeccable/reference/responsive-design.md +0 -114
- package/dist/builtin/workflows/skills/impeccable/reference/spatial-design.md +0 -100
- package/dist/builtin/workflows/skills/impeccable/reference/teach.md +0 -156
- package/dist/builtin/workflows/skills/impeccable/reference/typography.md +0 -159
- package/dist/builtin/workflows/skills/impeccable/reference/ux-writing.md +0 -107
- package/dist/builtin/workflows/skills/impeccable/scripts/load-context.mjs +0 -141
|
@@ -21,36 +21,36 @@ import path from 'node:path';
|
|
|
21
21
|
import net from 'node:net';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
import { parseDesignMd } from './design-parser.mjs';
|
|
24
|
-
import { resolveContextDir } from './
|
|
24
|
+
import { resolveContextDir } from './context.mjs';
|
|
25
25
|
import { createLiveSessionStore } from './live-session-store.mjs';
|
|
26
|
+
import { validateEvent } from './live-event-validation.mjs';
|
|
26
27
|
import {
|
|
27
28
|
getDesignSidecarPath,
|
|
29
|
+
getLiveDir,
|
|
28
30
|
getLiveAnnotationsDir,
|
|
29
31
|
readLiveServerInfo,
|
|
30
32
|
removeLiveServerInfo,
|
|
31
33
|
resolveDesignSidecarPath,
|
|
32
34
|
writeLiveServerInfo,
|
|
33
35
|
} from './impeccable-paths.mjs';
|
|
36
|
+
import {
|
|
37
|
+
countByPage as countPendingByPage,
|
|
38
|
+
readBuffer as readManualEditsBuffer,
|
|
39
|
+
removeEntries as removeManualEditEntries,
|
|
40
|
+
stageEntry as stageManualEditEntry,
|
|
41
|
+
truncateBuffer as truncateManualEditsBuffer,
|
|
42
|
+
} from './live-manual-edits-buffer.mjs';
|
|
43
|
+
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
|
|
44
|
+
import { commitManualEdits } from './live-commit-manual-edits.mjs';
|
|
34
45
|
|
|
35
46
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
-
// PRODUCT.md / DESIGN.md live wherever
|
|
47
|
+
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
|
37
48
|
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
|
38
49
|
// DESIGN.json fallback for existing projects.
|
|
39
50
|
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
|
40
51
|
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
|
41
|
-
const MIN_POLL_TIMEOUT = 1_000;
|
|
42
|
-
const MAX_POLL_TIMEOUT = 600_000;
|
|
43
|
-
const DEFAULT_LEASE_MS = 30_000;
|
|
44
|
-
const MIN_LEASE_MS = 1_000;
|
|
45
|
-
const MAX_LEASE_MS = 300_000;
|
|
46
52
|
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
|
47
53
|
|
|
48
|
-
function readBoundedInteger(value, fallback, min, max) {
|
|
49
|
-
const parsed = Number.parseInt(String(value ?? ''), 10);
|
|
50
|
-
if (!Number.isSafeInteger(parsed)) return fallback;
|
|
51
|
-
return Math.min(max, Math.max(min, parsed));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
54
|
// ---------------------------------------------------------------------------
|
|
55
55
|
// Port detection
|
|
56
56
|
// ---------------------------------------------------------------------------
|
|
@@ -76,19 +76,802 @@ const state = {
|
|
|
76
76
|
sseClients: new Set(), // SSE response objects (server→browser push)
|
|
77
77
|
pendingEvents: [], // browser events waiting for agent ack ({ event, leaseUntil })
|
|
78
78
|
pendingPolls: [], // agent poll callbacks waiting for browser events
|
|
79
|
+
nextEventSeq: 1,
|
|
80
|
+
lastAgentPollingBroadcast: null,
|
|
79
81
|
exitTimer: null,
|
|
80
82
|
sessionDir: null, // per-session tmp dir for annotation screenshots
|
|
81
83
|
sessionStore: null,
|
|
82
84
|
leaseTimer: null,
|
|
85
|
+
manualEditActivity: null,
|
|
86
|
+
nextManualEditSeq: 1,
|
|
87
|
+
// Deferreds for in-flight chat-routed Apply events. Keyed by event id; each
|
|
88
|
+
// entry is resolved when the chat agent POSTs an ack carrying the batch
|
|
89
|
+
// result, or rejected when the hard timeout fires.
|
|
90
|
+
pendingApplyDeferreds: new Map(),
|
|
91
|
+
// Updated whenever a /poll long-poll request arrives or is resolved with an
|
|
92
|
+
// event. Used to detect "a chat agent is likely attached" without requiring
|
|
93
|
+
// a poll to be parked at the exact moment we dispatch.
|
|
94
|
+
lastPollAt: 0,
|
|
95
|
+
timedOutApplyIds: new Map(),
|
|
83
96
|
};
|
|
84
97
|
|
|
98
|
+
const CHAT_POLL_FRESHNESS_MS = 60_000;
|
|
99
|
+
const APPLY_EVENT_HARD_TIMEOUT_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_HARD_TIMEOUT_MS || 150_000);
|
|
100
|
+
const APPLY_EVENT_SOFT_DEADLINE_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_SOFT_DEADLINE_MS || 120_000);
|
|
101
|
+
const DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE = 3;
|
|
102
|
+
const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
|
|
103
|
+
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
|
|
104
|
+
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
|
|
105
|
+
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
|
|
106
|
+
const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
|
|
107
|
+
|
|
108
|
+
function tombstoneTimedOutApplyId(eventId, details = {}) {
|
|
109
|
+
if (!eventId) return;
|
|
110
|
+
state.timedOutApplyIds.set(eventId, details);
|
|
111
|
+
if (state.timedOutApplyIds.size <= 200) return;
|
|
112
|
+
const oldest = state.timedOutApplyIds.keys().next().value;
|
|
113
|
+
state.timedOutApplyIds.delete(oldest);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function chatAgentLikelyActive() {
|
|
117
|
+
if (state.pendingPolls.length > 0) return true;
|
|
118
|
+
if (!state.lastPollAt) return false;
|
|
119
|
+
return Date.now() - state.lastPollAt < CHAT_POLL_FRESHNESS_MS;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function manualEditApplyChunkSize(env = process.env) {
|
|
123
|
+
const raw = Number(env.IMPECCABLE_LIVE_MANUAL_EDIT_CHUNK_SIZE);
|
|
124
|
+
if (!Number.isFinite(raw)) return DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE;
|
|
125
|
+
const size = Math.trunc(raw);
|
|
126
|
+
return Math.max(MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE, Math.min(MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE, size));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function countManualApplyOps(entriesOrBatch) {
|
|
130
|
+
const entries = Array.isArray(entriesOrBatch)
|
|
131
|
+
? entriesOrBatch
|
|
132
|
+
: Array.isArray(entriesOrBatch?.entries) ? entriesOrBatch.entries : [];
|
|
133
|
+
let count = 0;
|
|
134
|
+
for (const entry of entries) count += Array.isArray(entry.ops) ? entry.ops.length : 0;
|
|
135
|
+
return count;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function pushApplyEventAndWait(batch, pageUrl, chunk = null, repair = null) {
|
|
139
|
+
const eventId = randomUUID().replace(/-/g, '').slice(0, 8);
|
|
140
|
+
const evidencePath = writeManualApplyEvidence(eventId, batch);
|
|
141
|
+
const event = {
|
|
142
|
+
type: 'manual_edit_apply',
|
|
143
|
+
id: eventId,
|
|
144
|
+
pageUrl,
|
|
145
|
+
batch: compactManualApplyBatch(batch),
|
|
146
|
+
evidencePath,
|
|
147
|
+
agentAction: buildManualApplyAgentAction(eventId),
|
|
148
|
+
schemaVersion: 1,
|
|
149
|
+
deadlineMs: APPLY_EVENT_SOFT_DEADLINE_MS,
|
|
150
|
+
};
|
|
151
|
+
if (chunk) event.chunk = chunk;
|
|
152
|
+
if (repair) event.repair = repair;
|
|
153
|
+
const rollbackSnapshot = snapshotApplyEventFiles(batch);
|
|
154
|
+
recordManualEditActivity('manual_edit_apply_dispatched', {
|
|
155
|
+
id: eventId,
|
|
156
|
+
pageUrl,
|
|
157
|
+
chunk,
|
|
158
|
+
repair,
|
|
159
|
+
entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
|
|
160
|
+
opCount: countManualApplyOps(batch),
|
|
161
|
+
fileCount: collectManualApplyFiles(batch).length,
|
|
162
|
+
});
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const timer = setTimeout(() => {
|
|
165
|
+
state.pendingApplyDeferreds.delete(eventId);
|
|
166
|
+
tombstoneTimedOutApplyId(eventId, { batch, rollbackSnapshot });
|
|
167
|
+
acknowledgePendingEvent(eventId);
|
|
168
|
+
removeManualApplyEvidence(evidencePath);
|
|
169
|
+
recordManualEditActivity('manual_edit_apply_timeout', {
|
|
170
|
+
id: eventId,
|
|
171
|
+
pageUrl,
|
|
172
|
+
chunk,
|
|
173
|
+
entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
|
|
174
|
+
opCount: countManualApplyOps(batch),
|
|
175
|
+
});
|
|
176
|
+
reject(new Error('chat_agent_timeout'));
|
|
177
|
+
}, APPLY_EVENT_HARD_TIMEOUT_MS);
|
|
178
|
+
state.pendingApplyDeferreds.set(eventId, { resolve, reject, timer, event, batch, pageUrl, rollbackSnapshot });
|
|
179
|
+
enqueueEvent(event);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function writeManualApplyEvidence(eventId, batch) {
|
|
184
|
+
const dir = manualApplyEvidenceDir(process.cwd());
|
|
185
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
186
|
+
const evidencePath = path.join(dir, `${eventId}.json`);
|
|
187
|
+
fs.writeFileSync(evidencePath, JSON.stringify(batch, null, 2) + '\n', 'utf-8');
|
|
188
|
+
return evidencePath;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function manualApplyEvidenceDir(cwd = process.cwd()) {
|
|
192
|
+
return path.join(getLiveDir(cwd), 'manual-edit-evidence');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function normalizeManualApplyEvidencePath(evidencePath, cwd = process.cwd()) {
|
|
196
|
+
if (!evidencePath || typeof evidencePath !== 'string') return null;
|
|
197
|
+
const fullPath = path.isAbsolute(evidencePath) ? evidencePath : path.resolve(cwd, evidencePath);
|
|
198
|
+
const evidenceDir = manualApplyEvidenceDir(cwd);
|
|
199
|
+
const relative = path.relative(evidenceDir, fullPath);
|
|
200
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
|
201
|
+
if (path.extname(relative) !== '.json') return null;
|
|
202
|
+
return fullPath;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function removeManualApplyEvidence(evidencePath, cwd = process.cwd()) {
|
|
206
|
+
const fullPath = normalizeManualApplyEvidencePath(evidencePath, cwd);
|
|
207
|
+
if (!fullPath) return false;
|
|
208
|
+
try {
|
|
209
|
+
fs.unlinkSync(fullPath);
|
|
210
|
+
return true;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function referencedManualApplyEvidencePaths(cwd = process.cwd()) {
|
|
217
|
+
const referenced = new Set();
|
|
218
|
+
const add = (event) => {
|
|
219
|
+
const fullPath = normalizeManualApplyEvidencePath(event?.evidencePath, cwd);
|
|
220
|
+
if (fullPath) referenced.add(fullPath);
|
|
221
|
+
};
|
|
222
|
+
for (const entry of state.pendingEvents) add(entry.event);
|
|
223
|
+
for (const deferred of state.pendingApplyDeferreds.values()) add(deferred.event);
|
|
224
|
+
return referenced;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function pruneStaleManualApplyEvidence(cwd = process.cwd()) {
|
|
228
|
+
const dir = manualApplyEvidenceDir(cwd);
|
|
229
|
+
if (!fs.existsSync(dir)) return [];
|
|
230
|
+
const referenced = referencedManualApplyEvidencePaths(cwd);
|
|
231
|
+
const removed = [];
|
|
232
|
+
for (const name of fs.readdirSync(dir)) {
|
|
233
|
+
if (!name.endsWith('.json')) continue;
|
|
234
|
+
const fullPath = path.join(dir, name);
|
|
235
|
+
if (referenced.has(fullPath)) continue;
|
|
236
|
+
try {
|
|
237
|
+
fs.unlinkSync(fullPath);
|
|
238
|
+
removed.push(fullPath);
|
|
239
|
+
} catch {
|
|
240
|
+
// Stale evidence cleanup is best-effort; Apply verification never relies
|
|
241
|
+
// on deleting these files.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return removed;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function compactManualApplyBatch(batch = {}) {
|
|
248
|
+
const entries = (batch.entries || []).map(compactManualApplyEntry);
|
|
249
|
+
const candidates = compactManualApplyCandidates(batch.candidates || []);
|
|
250
|
+
return {
|
|
251
|
+
version: batch.version,
|
|
252
|
+
pageUrl: batch.pageUrl || null,
|
|
253
|
+
count: batch.count,
|
|
254
|
+
entries,
|
|
255
|
+
ops: entries.flatMap((entry) => entry.ops.map((op) => ({ ...op, entryId: entry.id }))),
|
|
256
|
+
candidates: candidates.length > 0 ? candidates : undefined,
|
|
257
|
+
context: batch.context ? {
|
|
258
|
+
bufferPath: batch.context.bufferPath,
|
|
259
|
+
totalEntries: batch.context.totalEntries,
|
|
260
|
+
totalOps: batch.context.totalOps,
|
|
261
|
+
chunkIndex: batch.context.chunkIndex,
|
|
262
|
+
chunkTotal: batch.context.chunkTotal,
|
|
263
|
+
totalApplyOps: batch.context.totalApplyOps,
|
|
264
|
+
} : undefined,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function compactManualApplyCandidates(candidates) {
|
|
269
|
+
return (Array.isArray(candidates) ? candidates : [])
|
|
270
|
+
.slice(0, 24)
|
|
271
|
+
.map((candidate) => ({
|
|
272
|
+
entryId: candidate.entryId,
|
|
273
|
+
ref: candidate.ref,
|
|
274
|
+
sourceHint: compactManualApplySourceMatch(candidate.sourceHint),
|
|
275
|
+
textMatches: compactManualApplySourceMatches(candidate.textMatches, 8),
|
|
276
|
+
objectKeyMatches: compactManualApplySourceMatches(candidate.objectKeyMatches, 8),
|
|
277
|
+
contextTextMatches: compactManualApplySourceMatches(candidate.contextTextMatches, 8),
|
|
278
|
+
locatorMatches: compactManualApplySourceMatches(candidate.locatorMatches, 6),
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function compactManualApplySourceMatches(matches, limit) {
|
|
283
|
+
return (Array.isArray(matches) ? matches : [])
|
|
284
|
+
.slice(0, limit)
|
|
285
|
+
.map(compactManualApplySourceMatch)
|
|
286
|
+
.filter(Boolean);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function compactManualApplySourceMatch(match) {
|
|
290
|
+
if (!match || typeof match !== 'object') return null;
|
|
291
|
+
const file = match.relativeFile || match.file;
|
|
292
|
+
if (!file && !match.line) return null;
|
|
293
|
+
return {
|
|
294
|
+
file: summarizeManualLogFile(file),
|
|
295
|
+
line: match.line || null,
|
|
296
|
+
column: match.column || null,
|
|
297
|
+
reason: match.reason || match.kind || undefined,
|
|
298
|
+
status: match.status || undefined,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function compactManualApplyEntry(entry = {}) {
|
|
303
|
+
return {
|
|
304
|
+
id: entry.id,
|
|
305
|
+
pageUrl: entry.pageUrl,
|
|
306
|
+
stagedAt: entry.stagedAt || null,
|
|
307
|
+
element: compactManualApplyContext(entry.element),
|
|
308
|
+
ops: (entry.ops || []).map(compactManualApplyOp),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function compactManualApplyOp(op = {}) {
|
|
313
|
+
return {
|
|
314
|
+
entryId: op.entryId,
|
|
315
|
+
ref: op.ref,
|
|
316
|
+
contextRef: op.contextRef,
|
|
317
|
+
tag: op.tag,
|
|
318
|
+
elementId: op.elementId,
|
|
319
|
+
classes: Array.isArray(op.classes) ? op.classes : [],
|
|
320
|
+
originalText: op.originalText,
|
|
321
|
+
newText: op.newText,
|
|
322
|
+
deleted: op.deleted === true || undefined,
|
|
323
|
+
sourceHint: op.sourceHint || null,
|
|
324
|
+
leaf: compactManualApplyContext(op.leaf),
|
|
325
|
+
nearbyEditableTexts: compactNearbyManualEditTexts(op.nearbyEditableTexts),
|
|
326
|
+
container: compactManualApplyContext(op.container),
|
|
327
|
+
contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 8) : undefined,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function compactManualApplyContext(value) {
|
|
332
|
+
if (!value || typeof value !== 'object') return null;
|
|
333
|
+
return {
|
|
334
|
+
ref: value.ref,
|
|
335
|
+
tagName: value.tagName || value.tag || null,
|
|
336
|
+
id: value.id || null,
|
|
337
|
+
classes: Array.isArray(value.classes) ? value.classes : [],
|
|
338
|
+
textContent: truncateManualApplyText(value.textContent, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function compactNearbyManualEditTexts(items) {
|
|
343
|
+
return (Array.isArray(items) ? items : [])
|
|
344
|
+
.slice(0, MANUAL_APPLY_COMPACT_NEARBY_LIMIT)
|
|
345
|
+
.map((item) => typeof item === 'string' ? { text: truncateManualApplyText(item, MANUAL_APPLY_COMPACT_TEXT_LIMIT) } : {
|
|
346
|
+
ref: item?.ref,
|
|
347
|
+
tag: item?.tag,
|
|
348
|
+
classes: Array.isArray(item?.classes) ? item.classes : [],
|
|
349
|
+
text: truncateManualApplyText(item?.text, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function truncateManualApplyText(value, max) {
|
|
354
|
+
if (typeof value !== 'string') return value || null;
|
|
355
|
+
return value.length > max ? value.slice(0, max) : value;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function pushApplyBatchInChunksAndWait(batch, pageUrl, context = {}) {
|
|
359
|
+
const repair = context?.repair || batch?.repair || null;
|
|
360
|
+
if (repair) return pushApplyEventAndWait(batch, pageUrl, null, repair);
|
|
361
|
+
const chunks = splitManualApplyBatch(batch, manualEditApplyChunkSize());
|
|
362
|
+
if (chunks.length <= 1) return pushApplyEventAndWait(batch, pageUrl);
|
|
363
|
+
|
|
364
|
+
const expectedOpsByEntry = new Map();
|
|
365
|
+
for (const entry of batch?.entries || []) {
|
|
366
|
+
expectedOpsByEntry.set(entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const appliedOpsByEntry = new Map();
|
|
370
|
+
const failedByEntry = new Map();
|
|
371
|
+
const files = new Set();
|
|
372
|
+
const notes = [];
|
|
373
|
+
let aborted = false;
|
|
374
|
+
|
|
375
|
+
for (const chunk of chunks) {
|
|
376
|
+
if (aborted) {
|
|
377
|
+
markChunkEntriesFailed(failedByEntry, chunk, 'manual_edit_chunk_aborted');
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let result;
|
|
382
|
+
try {
|
|
383
|
+
result = normalizeApplyChunkResult(await pushApplyEventAndWait(chunk.batch, pageUrl, chunk.meta));
|
|
384
|
+
} catch (err) {
|
|
385
|
+
markChunkEntriesFailed(failedByEntry, chunk, err.message || 'chat_agent_error');
|
|
386
|
+
aborted = true;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
for (const file of result.files) files.add(file);
|
|
391
|
+
notes.push(...result.notes);
|
|
392
|
+
|
|
393
|
+
const chunkFailedIds = new Set();
|
|
394
|
+
for (const item of result.failed) {
|
|
395
|
+
const entryId = item.entryId || item.id;
|
|
396
|
+
if (!entryId) continue;
|
|
397
|
+
chunkFailedIds.add(entryId);
|
|
398
|
+
if (!failedByEntry.has(entryId)) {
|
|
399
|
+
failedByEntry.set(entryId, {
|
|
400
|
+
entryId,
|
|
401
|
+
reason: item.reason || item.message || 'failed',
|
|
402
|
+
candidates: Array.isArray(item.candidates) ? item.candidates : [],
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (result.status === 'error') {
|
|
408
|
+
markChunkEntriesFailed(failedByEntry, chunk, result.message || firstFailureReason(result) || 'chat_agent_error');
|
|
409
|
+
aborted = true;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const reportedAppliedIds = new Set(result.appliedEntryIds);
|
|
414
|
+
for (const entryId of reportedAppliedIds) {
|
|
415
|
+
if (!chunk.entryIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
|
|
416
|
+
appliedOpsByEntry.set(entryId, (appliedOpsByEntry.get(entryId) || 0) + (chunk.opCountsByEntry.get(entryId) || 0));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
for (const entryId of chunk.entryIds) {
|
|
420
|
+
if (reportedAppliedIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
|
|
421
|
+
if (!failedByEntry.has(entryId)) {
|
|
422
|
+
failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const appliedEntryIds = [];
|
|
428
|
+
for (const [entryId, expectedOps] of expectedOpsByEntry.entries()) {
|
|
429
|
+
if (failedByEntry.has(entryId)) continue;
|
|
430
|
+
if ((appliedOpsByEntry.get(entryId) || 0) === expectedOps && expectedOps > 0) {
|
|
431
|
+
appliedEntryIds.push(entryId);
|
|
432
|
+
} else if (!failedByEntry.has(entryId)) {
|
|
433
|
+
failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const failed = [...failedByEntry.values()];
|
|
438
|
+
return {
|
|
439
|
+
status: failed.length === 0 ? 'done' : appliedEntryIds.length > 0 ? 'partial' : 'error',
|
|
440
|
+
appliedEntryIds,
|
|
441
|
+
failed,
|
|
442
|
+
files: [...files],
|
|
443
|
+
notes,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function normalizeApplyChunkResult(result) {
|
|
448
|
+
const status = result?.status === 'partial' ? 'partial' : result?.status === 'error' ? 'error' : 'done';
|
|
449
|
+
return {
|
|
450
|
+
status,
|
|
451
|
+
message: typeof result?.message === 'string' ? result.message : null,
|
|
452
|
+
appliedEntryIds: Array.isArray(result?.appliedEntryIds) ? result.appliedEntryIds.filter((id) => typeof id === 'string') : [],
|
|
453
|
+
failed: Array.isArray(result?.failed) ? result.failed.filter(Boolean) : [],
|
|
454
|
+
files: Array.isArray(result?.files) ? result.files.filter((file) => typeof file === 'string') : [],
|
|
455
|
+
notes: Array.isArray(result?.notes) ? result.notes.filter((note) => typeof note === 'string') : [],
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function manualApplyResultShapeHint(eventId = 'EVENT_ID') {
|
|
460
|
+
return `Use live-poll.mjs --reply ${eventId} done --data '{"status":"done","appliedEntryIds":["ENTRY_ID"],"failed":[],"files":["src/page.html"],"notes":[]}'`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function invalidManualApplyResult(reason, eventId, extra = {}) {
|
|
464
|
+
return {
|
|
465
|
+
ok: false,
|
|
466
|
+
body: {
|
|
467
|
+
error: 'invalid_manual_apply_result',
|
|
468
|
+
reason,
|
|
469
|
+
hint: manualApplyResultShapeHint(eventId),
|
|
470
|
+
...extra,
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function validateManualApplyResultMessage(msg, deferred) {
|
|
476
|
+
let data = msg?.data;
|
|
477
|
+
const eventId = msg?.id || deferred?.event?.id || 'EVENT_ID';
|
|
478
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
479
|
+
return invalidManualApplyResult('missing_result_data', eventId);
|
|
480
|
+
}
|
|
481
|
+
if ('entries' in data || 'ops' in data) {
|
|
482
|
+
return invalidManualApplyResult('summary_result_not_allowed', eventId);
|
|
483
|
+
}
|
|
484
|
+
if (!['done', 'partial', 'error'].includes(data.status)) {
|
|
485
|
+
return invalidManualApplyResult('invalid_status', eventId, { status: data.status ?? null });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
for (const key of ['appliedEntryIds', 'failed', 'files', 'notes']) {
|
|
489
|
+
if (!Array.isArray(data[key])) {
|
|
490
|
+
return invalidManualApplyResult(`${key}_must_be_array`, eventId);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
for (const [index, value] of data.appliedEntryIds.entries()) {
|
|
495
|
+
if (typeof value !== 'string' || !value) {
|
|
496
|
+
return invalidManualApplyResult('appliedEntryIds_must_contain_strings', eventId, { index });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
for (const [index, value] of data.files.entries()) {
|
|
500
|
+
if (typeof value !== 'string' || !value) {
|
|
501
|
+
return invalidManualApplyResult('files_must_contain_strings', eventId, { index });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
for (const [index, value] of data.notes.entries()) {
|
|
505
|
+
if (typeof value !== 'string') {
|
|
506
|
+
return invalidManualApplyResult('notes_must_contain_strings', eventId, { index });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
for (const [index, item] of data.failed.entries()) {
|
|
510
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
511
|
+
return invalidManualApplyResult('failed_must_contain_objects', eventId, { index });
|
|
512
|
+
}
|
|
513
|
+
if (typeof item.entryId !== 'string' || !item.entryId) {
|
|
514
|
+
return invalidManualApplyResult('failed_entryId_required', eventId, { index });
|
|
515
|
+
}
|
|
516
|
+
if (typeof item.reason !== 'string' || !item.reason) {
|
|
517
|
+
return invalidManualApplyResult('failed_reason_required', eventId, { index });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const eventEntryIds = new Set((deferred?.batch?.entries || []).map((entry) => entry.id).filter(Boolean));
|
|
522
|
+
for (const entryId of data.appliedEntryIds) {
|
|
523
|
+
if (eventEntryIds.size > 0 && !eventEntryIds.has(entryId)) {
|
|
524
|
+
return invalidManualApplyResult('applied_entry_id_not_in_event', eventId, { entryId });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
for (const item of data.failed) {
|
|
528
|
+
if (eventEntryIds.size > 0 && !eventEntryIds.has(item.entryId)) {
|
|
529
|
+
return invalidManualApplyResult('failed_entry_id_not_in_event', eventId, { entryId: item.entryId });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (data.status === 'done') {
|
|
534
|
+
if (data.failed.length > 0) {
|
|
535
|
+
return invalidManualApplyResult('done_result_has_failed_entries', eventId);
|
|
536
|
+
}
|
|
537
|
+
if (countManualApplyOps(deferred?.batch) > 0 && data.appliedEntryIds.length === 0) {
|
|
538
|
+
return invalidManualApplyResult('done_result_missing_applied_entry_ids', eventId);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (data.status === 'partial' && data.appliedEntryIds.length === 0 && data.failed.length === 0) {
|
|
542
|
+
return invalidManualApplyResult('partial_result_has_no_entries', eventId);
|
|
543
|
+
}
|
|
544
|
+
if (data.status === 'error' && data.appliedEntryIds.length > 0) {
|
|
545
|
+
return invalidManualApplyResult('error_result_has_applied_entries', eventId);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
ok: true,
|
|
550
|
+
result: {
|
|
551
|
+
status: data.status,
|
|
552
|
+
message: typeof data.message === 'string' ? data.message : undefined,
|
|
553
|
+
appliedEntryIds: data.appliedEntryIds,
|
|
554
|
+
failed: data.failed,
|
|
555
|
+
files: data.files,
|
|
556
|
+
notes: data.notes,
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function firstFailureReason(result) {
|
|
562
|
+
const first = Array.isArray(result?.failed) ? result.failed.find(Boolean) : null;
|
|
563
|
+
return first?.reason || first?.message || null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function markChunkEntriesFailed(failedByEntry, chunk, reason) {
|
|
567
|
+
for (const entryId of chunk.entryIds) {
|
|
568
|
+
if (failedByEntry.has(entryId)) continue;
|
|
569
|
+
failedByEntry.set(entryId, { entryId, reason, candidates: [] });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function splitManualApplyBatch(batch, maxOps) {
|
|
574
|
+
const totalOpCount = countManualApplyOps(batch);
|
|
575
|
+
if (totalOpCount <= maxOps) {
|
|
576
|
+
return [{
|
|
577
|
+
batch,
|
|
578
|
+
meta: null,
|
|
579
|
+
entryIds: new Set((batch?.entries || []).map((entry) => entry.id).filter(Boolean)),
|
|
580
|
+
opCountsByEntry: new Map((batch?.entries || []).map((entry) => [entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0])),
|
|
581
|
+
}];
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const rawChunks = [];
|
|
585
|
+
let current = createManualApplyChunkBuilder();
|
|
586
|
+
for (const entry of batch?.entries || []) {
|
|
587
|
+
const ops = entry.ops || [];
|
|
588
|
+
if (ops.length <= maxOps) {
|
|
589
|
+
if (current.opCount > 0 && current.opCount + ops.length > maxOps) {
|
|
590
|
+
rawChunks.push(current);
|
|
591
|
+
current = createManualApplyChunkBuilder();
|
|
592
|
+
}
|
|
593
|
+
for (const op of ops) addOpToManualApplyChunk(current, entry, op);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (current.opCount > 0) {
|
|
597
|
+
rawChunks.push(current);
|
|
598
|
+
current = createManualApplyChunkBuilder();
|
|
599
|
+
}
|
|
600
|
+
for (const op of ops) {
|
|
601
|
+
if (current.opCount >= maxOps) {
|
|
602
|
+
rawChunks.push(current);
|
|
603
|
+
current = createManualApplyChunkBuilder();
|
|
604
|
+
}
|
|
605
|
+
addOpToManualApplyChunk(current, entry, op);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (current.opCount > 0) rawChunks.push(current);
|
|
609
|
+
|
|
610
|
+
return rawChunks.map((chunk, index) => ({
|
|
611
|
+
batch: {
|
|
612
|
+
...batch,
|
|
613
|
+
count: chunk.opCount,
|
|
614
|
+
entries: chunk.entries,
|
|
615
|
+
ops: chunk.ops,
|
|
616
|
+
candidates: filterManualApplyChunkCandidates(batch, chunk.refsByEntry),
|
|
617
|
+
context: {
|
|
618
|
+
...(batch?.context || {}),
|
|
619
|
+
totalEntries: chunk.entries.length,
|
|
620
|
+
totalOps: chunk.opCount,
|
|
621
|
+
chunkIndex: index + 1,
|
|
622
|
+
chunkTotal: rawChunks.length,
|
|
623
|
+
totalApplyOps: totalOpCount,
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
meta: {
|
|
627
|
+
index: index + 1,
|
|
628
|
+
total: rawChunks.length,
|
|
629
|
+
opCount: chunk.opCount,
|
|
630
|
+
totalOpCount,
|
|
631
|
+
},
|
|
632
|
+
entryIds: new Set(chunk.entries.map((entry) => entry.id).filter(Boolean)),
|
|
633
|
+
opCountsByEntry: chunk.opCountsByEntry,
|
|
634
|
+
}));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function createManualApplyChunkBuilder() {
|
|
638
|
+
return {
|
|
639
|
+
entries: [],
|
|
640
|
+
entryById: new Map(),
|
|
641
|
+
entryIds: new Set(),
|
|
642
|
+
ops: [],
|
|
643
|
+
refsByEntry: new Map(),
|
|
644
|
+
opCountsByEntry: new Map(),
|
|
645
|
+
opCount: 0,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function addOpToManualApplyChunk(chunk, entry, op) {
|
|
650
|
+
let chunkEntry = chunk.entryById.get(entry.id);
|
|
651
|
+
if (!chunkEntry) {
|
|
652
|
+
chunkEntry = { ...entry, ops: [] };
|
|
653
|
+
chunk.entryById.set(entry.id, chunkEntry);
|
|
654
|
+
chunk.entryIds.add(entry.id);
|
|
655
|
+
chunk.entries.push(chunkEntry);
|
|
656
|
+
}
|
|
657
|
+
chunkEntry.ops.push(op);
|
|
658
|
+
chunk.ops.push({ ...op, entryId: op.entryId || entry.id });
|
|
659
|
+
if (!chunk.refsByEntry.has(entry.id)) chunk.refsByEntry.set(entry.id, new Set());
|
|
660
|
+
if (op.ref) chunk.refsByEntry.get(entry.id).add(op.ref);
|
|
661
|
+
chunk.opCountsByEntry.set(entry.id, (chunk.opCountsByEntry.get(entry.id) || 0) + 1);
|
|
662
|
+
chunk.opCount += 1;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function filterManualApplyChunkCandidates(batch, refsByEntry) {
|
|
666
|
+
return (batch?.candidates || []).filter((candidate) => {
|
|
667
|
+
const refs = refsByEntry.get(candidate.entryId);
|
|
668
|
+
if (!refs) return false;
|
|
669
|
+
if (!candidate.ref) return true;
|
|
670
|
+
return refs.has(candidate.ref);
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function resolveApplyDeferred(eventId, body) {
|
|
675
|
+
const deferred = state.pendingApplyDeferreds.get(eventId);
|
|
676
|
+
if (!deferred) return false;
|
|
677
|
+
state.pendingApplyDeferreds.delete(eventId);
|
|
678
|
+
clearTimeout(deferred.timer);
|
|
679
|
+
removeManualApplyEvidence(deferred.event?.evidencePath);
|
|
680
|
+
deferred.resolve(body);
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function rejectApplyDeferred(eventId, reason) {
|
|
685
|
+
const deferred = state.pendingApplyDeferreds.get(eventId);
|
|
686
|
+
if (!deferred) return false;
|
|
687
|
+
state.pendingApplyDeferreds.delete(eventId);
|
|
688
|
+
clearTimeout(deferred.timer);
|
|
689
|
+
removeManualApplyEvidence(deferred.event?.evidencePath);
|
|
690
|
+
deferred.reject(new Error(reason || 'chat_agent_error'));
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function snapshotApplyEventFiles(batch) {
|
|
695
|
+
const snapshot = new Map();
|
|
696
|
+
for (const relativeFile of collectManualApplyFiles(batch)) {
|
|
697
|
+
const absolute = path.resolve(process.cwd(), relativeFile);
|
|
698
|
+
try {
|
|
699
|
+
snapshot.set(relativeFile, {
|
|
700
|
+
exists: fs.existsSync(absolute),
|
|
701
|
+
content: fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf-8') : '',
|
|
702
|
+
});
|
|
703
|
+
} catch {
|
|
704
|
+
// If a file cannot be read before dispatch, do not attempt late rollback.
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return snapshot;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function manualApplyTransactionPath(cwd = process.cwd()) {
|
|
711
|
+
return path.join(getLiveDir(cwd), 'manual-edit-apply-transaction.json');
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function readManualApplyTransaction(cwd = process.cwd()) {
|
|
715
|
+
const file = manualApplyTransactionPath(cwd);
|
|
716
|
+
if (!fs.existsSync(file)) return null;
|
|
717
|
+
try {
|
|
718
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
719
|
+
} catch {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function writeManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, batch }) {
|
|
725
|
+
const file = manualApplyTransactionPath(cwd);
|
|
726
|
+
const files = collectManualApplyFiles(batch);
|
|
727
|
+
const transaction = {
|
|
728
|
+
version: 1,
|
|
729
|
+
id: randomUUID().replace(/-/g, '').slice(0, 8),
|
|
730
|
+
createdAt: new Date().toISOString(),
|
|
731
|
+
pageUrl,
|
|
732
|
+
entryIds: (batch?.entries || []).map((entry) => entry.id).filter(Boolean),
|
|
733
|
+
files: files.map((relativeFile) => {
|
|
734
|
+
const absolute = path.resolve(cwd, relativeFile);
|
|
735
|
+
const exists = fs.existsSync(absolute);
|
|
736
|
+
return {
|
|
737
|
+
file: relativeFile,
|
|
738
|
+
exists,
|
|
739
|
+
content: exists ? fs.readFileSync(absolute, 'utf-8') : '',
|
|
740
|
+
};
|
|
741
|
+
}),
|
|
742
|
+
};
|
|
743
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
744
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(transaction, null, 2) + '\n', 'utf-8');
|
|
745
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
746
|
+
return transaction;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function clearManualApplyTransaction(cwd = process.cwd(), transactionId = null) {
|
|
750
|
+
const file = manualApplyTransactionPath(cwd);
|
|
751
|
+
if (!fs.existsSync(file)) return false;
|
|
752
|
+
if (transactionId) {
|
|
753
|
+
const existing = readManualApplyTransaction(cwd);
|
|
754
|
+
if (existing?.id && existing.id !== transactionId) return false;
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
fs.unlinkSync(file);
|
|
758
|
+
return true;
|
|
759
|
+
} catch {
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function rollbackManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, reason = 'manual_edit_transaction_rollback' } = {}) {
|
|
765
|
+
const transaction = readManualApplyTransaction(cwd);
|
|
766
|
+
if (!transaction) return null;
|
|
767
|
+
if (pageUrl && transaction.pageUrl && transaction.pageUrl !== pageUrl) return null;
|
|
768
|
+
|
|
769
|
+
let pendingIds = new Set();
|
|
770
|
+
try {
|
|
771
|
+
const buffer = readManualEditsBuffer(cwd);
|
|
772
|
+
pendingIds = new Set((buffer.entries || []).map((entry) => entry.id).filter(Boolean));
|
|
773
|
+
} catch {
|
|
774
|
+
pendingIds = new Set(transaction.entryIds || []);
|
|
775
|
+
}
|
|
776
|
+
const shouldRollback = (transaction.entryIds || []).some((id) => pendingIds.has(id));
|
|
777
|
+
if (!shouldRollback) {
|
|
778
|
+
clearManualApplyTransaction(cwd, transaction.id);
|
|
779
|
+
return { id: transaction.id, reason, rolledBackFiles: [], rollbackFailures: [], skipped: 'entries_not_pending' };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const rolledBackFiles = [];
|
|
783
|
+
const rollbackFailures = [];
|
|
784
|
+
for (const item of transaction.files || []) {
|
|
785
|
+
const relativeFile = normalizeProjectFile(item.file);
|
|
786
|
+
if (!relativeFile) continue;
|
|
787
|
+
const absolute = path.resolve(cwd, relativeFile);
|
|
788
|
+
try {
|
|
789
|
+
if (item.exists) {
|
|
790
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
791
|
+
fs.writeFileSync(absolute, item.content || '', 'utf-8');
|
|
792
|
+
} else if (fs.existsSync(absolute)) {
|
|
793
|
+
fs.rmSync(absolute);
|
|
794
|
+
}
|
|
795
|
+
rolledBackFiles.push(relativeFile);
|
|
796
|
+
} catch (err) {
|
|
797
|
+
rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
clearManualApplyTransaction(cwd, transaction.id);
|
|
801
|
+
recordManualEditActivity('manual_edit_transaction_rolled_back', {
|
|
802
|
+
id: transaction.id,
|
|
803
|
+
pageUrl: transaction.pageUrl || null,
|
|
804
|
+
reason,
|
|
805
|
+
entryIds: transaction.entryIds || [],
|
|
806
|
+
rolledBackFiles: rolledBackFiles.map(summarizeManualLogFile).filter(Boolean),
|
|
807
|
+
rollbackFailures: summarizeManualDiagnostics(rollbackFailures),
|
|
808
|
+
});
|
|
809
|
+
return { id: transaction.id, reason, rolledBackFiles, rollbackFailures };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function collectManualApplyFiles(batch, extraFiles = []) {
|
|
813
|
+
const files = [];
|
|
814
|
+
for (const entry of batch?.entries || []) {
|
|
815
|
+
for (const op of entry.ops || []) files.push(op.sourceHint?.file);
|
|
816
|
+
}
|
|
817
|
+
for (const candidate of batch?.candidates || []) {
|
|
818
|
+
files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file);
|
|
819
|
+
for (const item of candidate.textMatches || []) files.push(item.file);
|
|
820
|
+
for (const item of candidate.objectKeyMatches || []) files.push(item.file);
|
|
821
|
+
for (const item of candidate.locatorMatches || []) files.push(item.file);
|
|
822
|
+
for (const item of candidate.contextTextMatches || []) files.push(item.file);
|
|
823
|
+
}
|
|
824
|
+
files.push(...(extraFiles || []));
|
|
825
|
+
return [...new Set(files)]
|
|
826
|
+
.map((file) => normalizeProjectFile(file))
|
|
827
|
+
.filter(Boolean);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function normalizeProjectFile(file) {
|
|
831
|
+
if (!file || typeof file !== 'string') return null;
|
|
832
|
+
const absolute = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file);
|
|
833
|
+
const relative = path.relative(process.cwd(), absolute);
|
|
834
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
|
835
|
+
return relative;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function rollbackApplySnapshot(batch, rollbackSnapshot, extraFiles = [], reason = 'manual_edit_apply_snapshot_rollback') {
|
|
839
|
+
const scope = collectManualApplyFiles(batch, extraFiles);
|
|
840
|
+
const rolledBackFiles = [];
|
|
841
|
+
const rollbackFailures = [];
|
|
842
|
+
for (const relativeFile of scope) {
|
|
843
|
+
const before = rollbackSnapshot?.get(relativeFile);
|
|
844
|
+
if (!before) continue;
|
|
845
|
+
const absolute = path.resolve(process.cwd(), relativeFile);
|
|
846
|
+
try {
|
|
847
|
+
if (before.exists) {
|
|
848
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
849
|
+
fs.writeFileSync(absolute, before.content, 'utf-8');
|
|
850
|
+
} else if (fs.existsSync(absolute)) {
|
|
851
|
+
fs.rmSync(absolute);
|
|
852
|
+
}
|
|
853
|
+
rolledBackFiles.push(relativeFile);
|
|
854
|
+
} catch (err) {
|
|
855
|
+
rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return { rolledBackFiles, rollbackFailures };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function rollbackTimedOutApplyReply(msg) {
|
|
862
|
+
const details = state.timedOutApplyIds.get(msg.id);
|
|
863
|
+
if (!details) return { rolledBackFiles: [], rollbackFailures: [] };
|
|
864
|
+
state.timedOutApplyIds.delete(msg.id);
|
|
865
|
+
return rollbackApplySnapshot(details.batch, details.rollbackSnapshot, msg.data?.files || [], 'stale_manual_edit_apply_reply');
|
|
866
|
+
}
|
|
867
|
+
|
|
85
868
|
// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
|
|
86
869
|
// cap at 10 MB to guard against runaway writes from a misbehaving client.
|
|
87
870
|
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
|
88
871
|
|
|
89
872
|
function enqueueEvent(event) {
|
|
90
873
|
if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
|
|
91
|
-
state.pendingEvents.push({ event, leaseUntil: 0 });
|
|
874
|
+
state.pendingEvents.push({ event, leaseUntil: 0, seq: state.nextEventSeq++ });
|
|
92
875
|
flushPendingPolls();
|
|
93
876
|
}
|
|
94
877
|
|
|
@@ -100,7 +883,11 @@ function restorePendingEventsFromStore() {
|
|
|
100
883
|
}
|
|
101
884
|
|
|
102
885
|
function findAvailablePendingEvent(now = Date.now()) {
|
|
103
|
-
|
|
886
|
+
for (const entry of state.pendingEvents) {
|
|
887
|
+
if (entry.leaseUntil && entry.leaseUntil > now) continue;
|
|
888
|
+
return entry;
|
|
889
|
+
}
|
|
890
|
+
return null;
|
|
104
891
|
}
|
|
105
892
|
|
|
106
893
|
function leaseEvent(entry, leaseMs) {
|
|
@@ -117,9 +904,96 @@ function acknowledgePendingEvent(id) {
|
|
|
117
904
|
if (!id) return false;
|
|
118
905
|
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
|
|
119
906
|
if (idx === -1) return false;
|
|
907
|
+
const acknowledged = state.pendingEvents[idx].event;
|
|
120
908
|
state.pendingEvents.splice(idx, 1);
|
|
121
909
|
scheduleLeaseFlush();
|
|
122
|
-
return
|
|
910
|
+
return acknowledged;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
|
|
914
|
+
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
|
|
915
|
+
return `live-poll.mjs --reply ${id} done --data '<json>'`;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function buildManualApplyAgentAction(eventOrId = 'EVENT_ID') {
|
|
919
|
+
return {
|
|
920
|
+
kind: 'manual_edit_apply',
|
|
921
|
+
required: 'apply_source_edits_then_reply',
|
|
922
|
+
replyCommand: manualApplyReplyCommand(eventOrId),
|
|
923
|
+
warning: 'Polling only leases this work item; it does not commit source edits.',
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function summarizeManualApplyEvent(event = {}, batch = event.batch) {
|
|
928
|
+
const entries = Array.isArray(batch?.entries) ? batch.entries : [];
|
|
929
|
+
const opCount = entries.reduce((sum, entry) => sum + (Array.isArray(entry.ops) ? entry.ops.length : 0), 0);
|
|
930
|
+
return {
|
|
931
|
+
pageUrl: event.pageUrl || null,
|
|
932
|
+
chunk: event.chunk || null,
|
|
933
|
+
entryCount: entries.length,
|
|
934
|
+
opCount,
|
|
935
|
+
files: collectManualApplyFiles(batch),
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function summarizePendingEventForStatus(entry) {
|
|
940
|
+
const event = entry.event || {};
|
|
941
|
+
const summary = {
|
|
942
|
+
id: event.id,
|
|
943
|
+
type: event.type,
|
|
944
|
+
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
|
|
945
|
+
leaseUntil: entry.leaseUntil || null,
|
|
946
|
+
};
|
|
947
|
+
if (event.type === 'manual_edit_apply') {
|
|
948
|
+
summary.pageUrl = event.pageUrl || null;
|
|
949
|
+
summary.chunk = event.chunk || null;
|
|
950
|
+
summary.repair = event.repair || null;
|
|
951
|
+
summary.evidencePath = event.evidencePath || null;
|
|
952
|
+
summary.agentAction = event.agentAction || buildManualApplyAgentAction(event);
|
|
953
|
+
summary.manualApplySummary = summarizeManualApplyEvent(event, state.pendingApplyDeferreds.get(event.id)?.batch || event.batch);
|
|
954
|
+
}
|
|
955
|
+
return summary;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
|
|
959
|
+
const canceledById = new Map();
|
|
960
|
+
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
|
|
961
|
+
|
|
962
|
+
for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
|
|
963
|
+
const event = state.pendingEvents[i]?.event;
|
|
964
|
+
if (!shouldCancel(event)) continue;
|
|
965
|
+
state.pendingEvents.splice(i, 1);
|
|
966
|
+
removeManualApplyEvidence(event.evidencePath);
|
|
967
|
+
canceledById.set(event.id, {
|
|
968
|
+
id: event.id,
|
|
969
|
+
pageUrl: event.pageUrl,
|
|
970
|
+
entryCount: event.batch?.entries?.length || 0,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
for (const [eventId, deferred] of [...state.pendingApplyDeferreds.entries()]) {
|
|
975
|
+
if (!shouldCancel(deferred.event)) continue;
|
|
976
|
+
state.pendingApplyDeferreds.delete(eventId);
|
|
977
|
+
clearTimeout(deferred.timer);
|
|
978
|
+
const rollback = rollbackApplySnapshot(deferred.batch, deferred.rollbackSnapshot, [], reason);
|
|
979
|
+
tombstoneTimedOutApplyId(eventId, {
|
|
980
|
+
batch: deferred.batch,
|
|
981
|
+
rollbackSnapshot: deferred.rollbackSnapshot,
|
|
982
|
+
reason,
|
|
983
|
+
});
|
|
984
|
+
removeManualApplyEvidence(deferred.event?.evidencePath);
|
|
985
|
+
canceledById.set(eventId, {
|
|
986
|
+
id: eventId,
|
|
987
|
+
pageUrl: deferred.pageUrl,
|
|
988
|
+
entryCount: deferred.batch?.entries?.length || 0,
|
|
989
|
+
rolledBackFiles: rollback.rolledBackFiles,
|
|
990
|
+
rollbackFailures: rollback.rollbackFailures,
|
|
991
|
+
});
|
|
992
|
+
deferred.reject(new Error(reason));
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (canceledById.size > 0) flushPendingPolls();
|
|
996
|
+
return [...canceledById.values()];
|
|
123
997
|
}
|
|
124
998
|
|
|
125
999
|
function scheduleLeaseFlush() {
|
|
@@ -141,16 +1015,31 @@ function scheduleLeaseFlush() {
|
|
|
141
1015
|
}
|
|
142
1016
|
|
|
143
1017
|
function flushPendingPolls() {
|
|
1018
|
+
let changed = false;
|
|
144
1019
|
while (state.pendingPolls.length > 0) {
|
|
145
1020
|
const entry = findAvailablePendingEvent();
|
|
146
1021
|
if (!entry) {
|
|
147
1022
|
scheduleLeaseFlush();
|
|
1023
|
+
broadcastAgentPollingIfChanged();
|
|
148
1024
|
return;
|
|
149
1025
|
}
|
|
150
1026
|
const poll = state.pendingPolls.shift();
|
|
151
1027
|
poll.resolve(leaseEvent(entry, poll.leaseMs));
|
|
1028
|
+
changed = true;
|
|
152
1029
|
}
|
|
153
1030
|
scheduleLeaseFlush();
|
|
1031
|
+
if (changed) broadcastAgentPollingIfChanged();
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function agentPollingConnected() {
|
|
1035
|
+
return state.pendingPolls.length > 0;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function broadcastAgentPollingIfChanged() {
|
|
1039
|
+
const connected = agentPollingConnected();
|
|
1040
|
+
if (state.lastAgentPollingBroadcast === connected) return;
|
|
1041
|
+
state.lastAgentPollingBroadcast = connected;
|
|
1042
|
+
broadcast({ type: 'agent_polling', connected });
|
|
154
1043
|
}
|
|
155
1044
|
|
|
156
1045
|
/** Push a message to all connected SSE clients. */
|
|
@@ -161,15 +1050,107 @@ function broadcast(msg) {
|
|
|
161
1050
|
}
|
|
162
1051
|
}
|
|
163
1052
|
|
|
1053
|
+
function recordManualEditActivity(type, details = {}) {
|
|
1054
|
+
const entry = {
|
|
1055
|
+
seq: state.nextManualEditSeq++,
|
|
1056
|
+
type,
|
|
1057
|
+
ts: new Date().toISOString(),
|
|
1058
|
+
...details,
|
|
1059
|
+
};
|
|
1060
|
+
state.manualEditActivity = entry;
|
|
1061
|
+
if (DEBUG_MANUAL_EDIT_EVENTS) {
|
|
1062
|
+
try {
|
|
1063
|
+
const filePath = path.join(getLiveDir(process.cwd()), 'manual-edit-events.jsonl');
|
|
1064
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1065
|
+
fs.appendFileSync(filePath, JSON.stringify(entry) + '\n');
|
|
1066
|
+
} catch {
|
|
1067
|
+
/* diagnostics are best-effort; never block live mode on observability */
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
broadcast(entry);
|
|
1071
|
+
return entry;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function getManualEditStatus() {
|
|
1075
|
+
try {
|
|
1076
|
+
const { totalCount, perPage } = countPendingByPage(process.cwd());
|
|
1077
|
+
return { totalCount, perPage, lastActivity: state.manualEditActivity };
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
return {
|
|
1080
|
+
totalCount: null,
|
|
1081
|
+
perPage: {},
|
|
1082
|
+
lastActivity: state.manualEditActivity,
|
|
1083
|
+
error: err.message,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function summarizePendingManualEditBatch(pageUrl = null) {
|
|
1089
|
+
try {
|
|
1090
|
+
const buffer = readManualEditsBuffer(process.cwd());
|
|
1091
|
+
const entries = (buffer.entries || [])
|
|
1092
|
+
.filter((entry) => !pageUrl || entry.pageUrl === pageUrl);
|
|
1093
|
+
return {
|
|
1094
|
+
pendingEntryCount: entries.length,
|
|
1095
|
+
pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0),
|
|
1096
|
+
};
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
return { pendingSummaryError: err.message || String(err) };
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function summarizeManualApplyFailures(failed) {
|
|
1103
|
+
if (!Array.isArray(failed)) return [];
|
|
1104
|
+
return failed.slice(0, 20).map((item) => ({
|
|
1105
|
+
id: item.id || item.entryId || null,
|
|
1106
|
+
reason: item.reason || item.message || 'failed',
|
|
1107
|
+
message: compactManualLogText(item.message, 300),
|
|
1108
|
+
files: Array.isArray(item.files) ? item.files.slice(0, 12).map(summarizeManualLogFile).filter(Boolean) : undefined,
|
|
1109
|
+
checks: summarizeManualDiagnostics(item.checks),
|
|
1110
|
+
failures: summarizeManualDiagnostics(item.failures),
|
|
1111
|
+
candidates: summarizeManualDiagnostics(item.candidates),
|
|
1112
|
+
}));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function summarizeManualDiagnostics(items) {
|
|
1116
|
+
if (!Array.isArray(items) || items.length === 0) return undefined;
|
|
1117
|
+
return items.slice(0, 12).map((item) => ({
|
|
1118
|
+
reason: item.reason || item.kind || undefined,
|
|
1119
|
+
detail: compactManualLogText(item.detail, 220),
|
|
1120
|
+
message: compactManualLogText(item.message, 300),
|
|
1121
|
+
file: summarizeManualLogFile(item.file || item.relativeFile),
|
|
1122
|
+
line: item.line || undefined,
|
|
1123
|
+
ref: compactManualLogText(item.ref, 180),
|
|
1124
|
+
marker: compactManualLogText(item.marker, 120),
|
|
1125
|
+
files: Array.isArray(item.files) ? item.files.slice(0, 8).map(summarizeManualLogFile).filter(Boolean) : undefined,
|
|
1126
|
+
}));
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function summarizeManualLogFile(file) {
|
|
1130
|
+
if (!file || typeof file !== 'string') return undefined;
|
|
1131
|
+
if (!path.isAbsolute(file)) return file;
|
|
1132
|
+
const relative = path.relative(process.cwd(), file);
|
|
1133
|
+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function compactManualLogText(value, max = 200) {
|
|
1137
|
+
if (typeof value !== 'string') return undefined;
|
|
1138
|
+
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
1139
|
+
if (normalized.length <= max) return normalized;
|
|
1140
|
+
return normalized.slice(0, max) + `... [truncated ${normalized.length - max} chars]`;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
164
1143
|
// ---------------------------------------------------------------------------
|
|
165
1144
|
// Load scripts
|
|
166
1145
|
// ---------------------------------------------------------------------------
|
|
167
1146
|
|
|
168
1147
|
function loadBrowserScripts() {
|
|
169
|
-
// Detection script:
|
|
170
|
-
//
|
|
1148
|
+
// Detection script: prefer the skill-bundled detector, then fall back to
|
|
1149
|
+
// source/npm package locations for local development and older installs.
|
|
171
1150
|
// This one IS cached — detect.js rarely changes during a session.
|
|
172
1151
|
const detectPaths = [
|
|
1152
|
+
path.join(__dirname, 'detector', 'detect-antipatterns-browser.js'),
|
|
1153
|
+
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
|
|
173
1154
|
path.join(__dirname, '..', '..', '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
|
|
174
1155
|
path.join(process.cwd(), 'node_modules', 'impeccable', 'cli', 'engine', 'detect-antipatterns-browser.js'),
|
|
175
1156
|
];
|
|
@@ -196,8 +1177,7 @@ function loadBrowserScripts() {
|
|
|
196
1177
|
function hasProjectContext() {
|
|
197
1178
|
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
|
198
1179
|
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
|
199
|
-
// concern, surfaced by the design panel's own empty state.
|
|
200
|
-
// .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs.
|
|
1180
|
+
// concern, surfaced by the design panel's own empty state.
|
|
201
1181
|
try {
|
|
202
1182
|
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
|
203
1183
|
return true;
|
|
@@ -208,67 +1188,6 @@ function statOrNull(filePath) {
|
|
|
208
1188
|
try { return fs.statSync(filePath); } catch { return null; }
|
|
209
1189
|
}
|
|
210
1190
|
|
|
211
|
-
// ---------------------------------------------------------------------------
|
|
212
|
-
// Validation (inline — no external import needed for self-contained script)
|
|
213
|
-
// ---------------------------------------------------------------------------
|
|
214
|
-
|
|
215
|
-
const VISUAL_ACTIONS = [
|
|
216
|
-
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
|
|
217
|
-
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
|
|
218
|
-
];
|
|
219
|
-
|
|
220
|
-
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
|
|
221
|
-
// and variantIds via String(small integer). Restrict to those shapes so
|
|
222
|
-
// any value that reaches a downstream child_process or DOM selector is
|
|
223
|
-
// inert by construction.
|
|
224
|
-
const ID_PATTERN = /^[0-9a-f]{8}$/;
|
|
225
|
-
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
|
|
226
|
-
|
|
227
|
-
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
|
|
228
|
-
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
|
|
229
|
-
|
|
230
|
-
function validateEvent(msg) {
|
|
231
|
-
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
|
|
232
|
-
switch (msg.type) {
|
|
233
|
-
case 'generate':
|
|
234
|
-
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
|
|
235
|
-
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
|
|
236
|
-
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
|
|
237
|
-
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
|
|
238
|
-
// Optional annotation fields (all-or-nothing: if any present, all must be well-formed).
|
|
239
|
-
if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string';
|
|
240
|
-
if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array';
|
|
241
|
-
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
|
|
242
|
-
return null;
|
|
243
|
-
case 'accept':
|
|
244
|
-
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
|
|
245
|
-
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
|
|
246
|
-
if (msg.paramValues !== undefined) {
|
|
247
|
-
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
|
|
248
|
-
return 'accept: paramValues must be an object';
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return null;
|
|
252
|
-
case 'discard':
|
|
253
|
-
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
|
|
254
|
-
case 'checkpoint':
|
|
255
|
-
if (!isValidId(msg.id)) return 'checkpoint: missing or malformed id';
|
|
256
|
-
if (!Number.isInteger(msg.revision) || msg.revision < 0) return 'checkpoint: revision must be a non-negative integer';
|
|
257
|
-
if (msg.paramValues !== undefined && (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues))) {
|
|
258
|
-
return 'checkpoint: paramValues must be an object';
|
|
259
|
-
}
|
|
260
|
-
return null;
|
|
261
|
-
case 'exit':
|
|
262
|
-
return null;
|
|
263
|
-
case 'prefetch':
|
|
264
|
-
if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl';
|
|
265
|
-
return null;
|
|
266
|
-
default:
|
|
267
|
-
return 'Unknown event type: ' + msg.type;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// ---------------------------------------------------------------------------
|
|
272
1191
|
// HTTP request handler
|
|
273
1192
|
// ---------------------------------------------------------------------------
|
|
274
1193
|
|
|
@@ -405,13 +1324,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
|
|
405
1324
|
status: 'ok',
|
|
406
1325
|
port: state.port,
|
|
407
1326
|
connectedClients: state.sseClients.size,
|
|
408
|
-
pendingEvents: state.pendingEvents.map((entry) => (
|
|
409
|
-
|
|
410
|
-
type: entry.event?.type,
|
|
411
|
-
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
|
|
412
|
-
leaseUntil: entry.leaseUntil || null,
|
|
413
|
-
})),
|
|
1327
|
+
pendingEvents: state.pendingEvents.map((entry) => summarizePendingEventForStatus(entry)),
|
|
1328
|
+
agentPolling: agentPollingConnected(),
|
|
414
1329
|
activeSessions: sessions,
|
|
1330
|
+
manualEdits: getManualEditStatus(),
|
|
415
1331
|
}));
|
|
416
1332
|
return;
|
|
417
1333
|
}
|
|
@@ -515,6 +1431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
|
|
515
1431
|
res.write('data: ' + JSON.stringify({
|
|
516
1432
|
type: 'connected',
|
|
517
1433
|
hasProjectContext: hasProjectContext(),
|
|
1434
|
+
agentPolling: agentPollingConnected(),
|
|
518
1435
|
}) + '\n\n');
|
|
519
1436
|
|
|
520
1437
|
state.sseClients.add(res);
|
|
@@ -538,6 +1455,335 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
|
|
538
1455
|
return;
|
|
539
1456
|
}
|
|
540
1457
|
|
|
1458
|
+
// --- Manual copy edits: Save stages entries, Apply commits the staged
|
|
1459
|
+
// page batch through the local AI copy-edit runner.
|
|
1460
|
+
if (p === '/manual-edit-stash' && req.method === 'POST') {
|
|
1461
|
+
let body = '';
|
|
1462
|
+
req.on('data', (c) => { body += c; });
|
|
1463
|
+
req.on('end', () => {
|
|
1464
|
+
let msg;
|
|
1465
|
+
try { msg = JSON.parse(body); } catch {
|
|
1466
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1467
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (msg.token !== state.token) {
|
|
1471
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1472
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
const error = validateEvent({ ...msg, type: 'manual_edits' });
|
|
1476
|
+
if (error) {
|
|
1477
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1478
|
+
res.end(JSON.stringify({ error }));
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
try {
|
|
1482
|
+
stageManualEditEntry(process.cwd(), {
|
|
1483
|
+
id: msg.id,
|
|
1484
|
+
pageUrl: msg.pageUrl,
|
|
1485
|
+
element: msg.element,
|
|
1486
|
+
ops: msg.ops,
|
|
1487
|
+
});
|
|
1488
|
+
} catch (err) {
|
|
1489
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1490
|
+
res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message }));
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
const { totalCount, perPage } = countPendingByPage(process.cwd());
|
|
1494
|
+
const pendingCount = perPage[msg.pageUrl] || 0;
|
|
1495
|
+
recordManualEditActivity('manual_edit_stashed', {
|
|
1496
|
+
id: msg.id,
|
|
1497
|
+
pageUrl: msg.pageUrl,
|
|
1498
|
+
opCount: msg.ops.length,
|
|
1499
|
+
pendingCount,
|
|
1500
|
+
totalCount,
|
|
1501
|
+
hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size,
|
|
1502
|
+
});
|
|
1503
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1504
|
+
res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage }));
|
|
1505
|
+
});
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// GET /manual-edit-stash?pageUrl=<url> → { count, totalCount, perPage, entries }
|
|
1510
|
+
if (p === '/manual-edit-stash' && req.method === 'GET') {
|
|
1511
|
+
const token = url.searchParams.get('token');
|
|
1512
|
+
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
|
1513
|
+
const pageUrl = url.searchParams.get('pageUrl') || '';
|
|
1514
|
+
const { totalCount, perPage } = countPendingByPage(process.cwd());
|
|
1515
|
+
const buffer = readManualEditsBuffer(process.cwd());
|
|
1516
|
+
const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries;
|
|
1517
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1518
|
+
res.end(JSON.stringify({
|
|
1519
|
+
count: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
|
|
1520
|
+
totalCount,
|
|
1521
|
+
perPage,
|
|
1522
|
+
entries: entriesForPage,
|
|
1523
|
+
}));
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// POST /manual-edit-commit?pageUrl=<url> → ask the AI to apply the staged page batch.
|
|
1528
|
+
if (p === '/manual-edit-commit' && req.method === 'POST') {
|
|
1529
|
+
const token = url.searchParams.get('token');
|
|
1530
|
+
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
|
1531
|
+
const pageUrl = url.searchParams.get('pageUrl');
|
|
1532
|
+
const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || '');
|
|
1533
|
+
const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || '');
|
|
1534
|
+
const existingTransaction = readManualApplyTransaction(process.cwd());
|
|
1535
|
+
if (repairOnly && !existingTransaction) {
|
|
1536
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1537
|
+
res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' }));
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const recoveredTransaction = repairOnly ? null : rollbackManualApplyTransaction({
|
|
1541
|
+
cwd: process.cwd(),
|
|
1542
|
+
pageUrl,
|
|
1543
|
+
reason: 'manual_edit_commit_recovered_abandoned_transaction',
|
|
1544
|
+
});
|
|
1545
|
+
const before = getManualEditStatus();
|
|
1546
|
+
const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount;
|
|
1547
|
+
recordManualEditActivity('manual_edit_commit_started', {
|
|
1548
|
+
pageUrl,
|
|
1549
|
+
repairOnly,
|
|
1550
|
+
pendingCount,
|
|
1551
|
+
totalCount: before.totalCount,
|
|
1552
|
+
recoveredTransaction: recoveredTransaction ? {
|
|
1553
|
+
id: recoveredTransaction.id,
|
|
1554
|
+
reason: recoveredTransaction.reason,
|
|
1555
|
+
skipped: recoveredTransaction.skipped,
|
|
1556
|
+
rolledBackFiles: recoveredTransaction.rolledBackFiles,
|
|
1557
|
+
rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures),
|
|
1558
|
+
} : null,
|
|
1559
|
+
...summarizePendingManualEditBatch(pageUrl),
|
|
1560
|
+
});
|
|
1561
|
+
if (asyncMode) {
|
|
1562
|
+
res.writeHead(202, { 'Content-Type': 'application/json' });
|
|
1563
|
+
res.end(JSON.stringify({
|
|
1564
|
+
status: 'started',
|
|
1565
|
+
pendingCount,
|
|
1566
|
+
totalCount: before.totalCount,
|
|
1567
|
+
perPage: before.perPage,
|
|
1568
|
+
}));
|
|
1569
|
+
}
|
|
1570
|
+
(async () => {
|
|
1571
|
+
let result;
|
|
1572
|
+
let routedProvider = 'subprocess';
|
|
1573
|
+
let transaction = null;
|
|
1574
|
+
let commitBatch = null;
|
|
1575
|
+
try {
|
|
1576
|
+
if (pendingCount > 0) {
|
|
1577
|
+
const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl });
|
|
1578
|
+
commitBatch = transactionBatch;
|
|
1579
|
+
if (!repairOnly && countManualApplyOps(transactionBatch) > 0) {
|
|
1580
|
+
transaction = writeManualApplyTransaction({
|
|
1581
|
+
cwd: process.cwd(),
|
|
1582
|
+
pageUrl,
|
|
1583
|
+
batch: transactionBatch,
|
|
1584
|
+
});
|
|
1585
|
+
} else if (repairOnly && existingTransaction) {
|
|
1586
|
+
transaction = existingTransaction;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase();
|
|
1590
|
+
const useChatRoute = requestedMode === 'chat'
|
|
1591
|
+
|| (requestedMode === 'auto' && chatAgentLikelyActive());
|
|
1592
|
+
if (useChatRoute) {
|
|
1593
|
+
routedProvider = 'chat';
|
|
1594
|
+
const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
|
|
1595
|
+
result = await commitManualEdits({
|
|
1596
|
+
cwd: process.cwd(),
|
|
1597
|
+
pageUrl,
|
|
1598
|
+
provider: 'chat',
|
|
1599
|
+
env: process.env,
|
|
1600
|
+
timeoutMs,
|
|
1601
|
+
chatAvailable: chatAgentLikelyActive,
|
|
1602
|
+
applyBatchToSource: (batch, context) => pushApplyBatchInChunksAndWait(batch, pageUrl, context),
|
|
1603
|
+
repairOnly,
|
|
1604
|
+
transactionId: transaction?.id || existingTransaction?.id || null,
|
|
1605
|
+
batch: commitBatch,
|
|
1606
|
+
});
|
|
1607
|
+
} else {
|
|
1608
|
+
const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
|
|
1609
|
+
const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined;
|
|
1610
|
+
result = await commitManualEdits({
|
|
1611
|
+
cwd: process.cwd(),
|
|
1612
|
+
pageUrl,
|
|
1613
|
+
provider,
|
|
1614
|
+
env: process.env,
|
|
1615
|
+
timeoutMs,
|
|
1616
|
+
chatAvailable: chatAgentLikelyActive,
|
|
1617
|
+
repairOnly,
|
|
1618
|
+
transactionId: transaction?.id || existingTransaction?.id || null,
|
|
1619
|
+
batch: commitBatch,
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
if (transaction) {
|
|
1624
|
+
rollbackManualApplyTransaction({
|
|
1625
|
+
cwd: process.cwd(),
|
|
1626
|
+
pageUrl,
|
|
1627
|
+
reason: 'manual_edit_commit_exception',
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
const message = err.stderr?.toString?.() || err.message;
|
|
1631
|
+
recordManualEditActivity('manual_edit_commit_failed', {
|
|
1632
|
+
pageUrl,
|
|
1633
|
+
provider: routedProvider,
|
|
1634
|
+
error: 'manual_edit_commit_failed',
|
|
1635
|
+
message,
|
|
1636
|
+
transactionId: transaction?.id || null,
|
|
1637
|
+
});
|
|
1638
|
+
if (!asyncMode) {
|
|
1639
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1640
|
+
res.end(JSON.stringify({
|
|
1641
|
+
error: 'manual_edit_commit_failed',
|
|
1642
|
+
message,
|
|
1643
|
+
}));
|
|
1644
|
+
}
|
|
1645
|
+
return;
|
|
1646
|
+
} finally {
|
|
1647
|
+
if (transaction) {
|
|
1648
|
+
const shouldKeepTransaction = result?.needsManualDecision === true;
|
|
1649
|
+
if (!shouldKeepTransaction) clearManualApplyTransaction(process.cwd(), transaction.id);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
const { totalCount, perPage } = countPendingByPage(process.cwd());
|
|
1653
|
+
if (result?.needsManualDecision) {
|
|
1654
|
+
recordManualEditActivity('manual_edit_repair_needs_decision', {
|
|
1655
|
+
pageUrl,
|
|
1656
|
+
provider: routedProvider,
|
|
1657
|
+
transactionId: transaction?.id || existingTransaction?.id || null,
|
|
1658
|
+
repair: result.repair || null,
|
|
1659
|
+
failed: summarizeManualApplyFailures(result.failed),
|
|
1660
|
+
files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [],
|
|
1661
|
+
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
|
|
1662
|
+
totalCount,
|
|
1663
|
+
});
|
|
1664
|
+
} else {
|
|
1665
|
+
recordManualEditActivity('manual_edit_commit_done', {
|
|
1666
|
+
pageUrl,
|
|
1667
|
+
provider: routedProvider,
|
|
1668
|
+
reason: result.reason || null,
|
|
1669
|
+
repair: result.repair || null,
|
|
1670
|
+
appliedCount: Array.isArray(result.applied) ? result.applied.length : 0,
|
|
1671
|
+
failedCount: Array.isArray(result.failed) ? result.failed.length : 0,
|
|
1672
|
+
failed: summarizeManualApplyFailures(result.failed),
|
|
1673
|
+
files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [],
|
|
1674
|
+
warnings: summarizeManualDiagnostics(result.warnings),
|
|
1675
|
+
rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [],
|
|
1676
|
+
rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures),
|
|
1677
|
+
unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined,
|
|
1678
|
+
noteCount: Array.isArray(result.notes) ? result.notes.length : 0,
|
|
1679
|
+
cleared: result.cleared || 0,
|
|
1680
|
+
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
|
|
1681
|
+
totalCount,
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
if (!asyncMode) {
|
|
1685
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1686
|
+
res.end(JSON.stringify({ ...result, totalCount, perPage }));
|
|
1687
|
+
}
|
|
1688
|
+
})();
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// POST /manual-edit-repair-decision → user resolves an exhausted repair loop.
|
|
1693
|
+
if (p === '/manual-edit-repair-decision' && req.method === 'POST') {
|
|
1694
|
+
let body = '';
|
|
1695
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
1696
|
+
req.on('end', () => {
|
|
1697
|
+
let payload = {};
|
|
1698
|
+
try { payload = body ? JSON.parse(body) : {}; } catch {
|
|
1699
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1700
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
const token = payload.token || url.searchParams.get('token');
|
|
1704
|
+
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
|
1705
|
+
const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null;
|
|
1706
|
+
const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase();
|
|
1707
|
+
if (action !== 'rollback') {
|
|
1708
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1709
|
+
res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action }));
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
const rollback = rollbackManualApplyTransaction({
|
|
1713
|
+
cwd: process.cwd(),
|
|
1714
|
+
pageUrl,
|
|
1715
|
+
reason: 'manual_edit_user_requested_rollback',
|
|
1716
|
+
});
|
|
1717
|
+
const { totalCount, perPage } = countPendingByPage(process.cwd());
|
|
1718
|
+
const response = {
|
|
1719
|
+
action,
|
|
1720
|
+
pageUrl,
|
|
1721
|
+
rollback,
|
|
1722
|
+
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
|
|
1723
|
+
totalCount,
|
|
1724
|
+
perPage,
|
|
1725
|
+
};
|
|
1726
|
+
recordManualEditActivity('manual_edit_repair_rollback_done', response);
|
|
1727
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1728
|
+
res.end(JSON.stringify(response));
|
|
1729
|
+
});
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// POST /manual-edit-discard?pageUrl=<url> → drops entries (all if no pageUrl)
|
|
1734
|
+
if (p === '/manual-edit-discard' && req.method === 'POST') {
|
|
1735
|
+
const token = url.searchParams.get('token');
|
|
1736
|
+
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
|
1737
|
+
const pageUrl = url.searchParams.get('pageUrl');
|
|
1738
|
+
let discarded;
|
|
1739
|
+
let discardedEntries = [];
|
|
1740
|
+
let canceledApplyEvents = [];
|
|
1741
|
+
let transactionRollback = null;
|
|
1742
|
+
try {
|
|
1743
|
+
const buffer = readManualEditsBuffer(process.cwd());
|
|
1744
|
+
transactionRollback = rollbackManualApplyTransaction({
|
|
1745
|
+
cwd: process.cwd(),
|
|
1746
|
+
pageUrl,
|
|
1747
|
+
reason: 'manual_edit_discarded',
|
|
1748
|
+
});
|
|
1749
|
+
if (pageUrl) {
|
|
1750
|
+
discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl);
|
|
1751
|
+
discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl);
|
|
1752
|
+
} else {
|
|
1753
|
+
discardedEntries = buffer.entries;
|
|
1754
|
+
discarded = truncateManualEditsBuffer(process.cwd());
|
|
1755
|
+
}
|
|
1756
|
+
canceledApplyEvents = cancelPendingManualApplyEvents(pageUrl);
|
|
1757
|
+
} catch (err) {
|
|
1758
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1759
|
+
res.end(JSON.stringify({ error: 'discard_failed', message: err.message }));
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
const { totalCount, perPage } = countPendingByPage(process.cwd());
|
|
1763
|
+
recordManualEditActivity('manual_edit_discarded', {
|
|
1764
|
+
pageUrl,
|
|
1765
|
+
discarded,
|
|
1766
|
+
canceledApplyIds: canceledApplyEvents.map((event) => event.id),
|
|
1767
|
+
transactionRollback: transactionRollback ? {
|
|
1768
|
+
id: transactionRollback.id,
|
|
1769
|
+
rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [],
|
|
1770
|
+
rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures),
|
|
1771
|
+
skipped: transactionRollback.skipped,
|
|
1772
|
+
} : undefined,
|
|
1773
|
+
totalCount,
|
|
1774
|
+
});
|
|
1775
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1776
|
+
res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }));
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// Defense in depth: redirect any stragglers from the old /manual-edit endpoint.
|
|
1781
|
+
if (p === '/manual-edit' && req.method === 'POST') {
|
|
1782
|
+
res.writeHead(410, { 'Content-Type': 'application/json' });
|
|
1783
|
+
res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }));
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
541
1787
|
// --- Browser→server events (replaces WebSocket messages) ---
|
|
542
1788
|
if (p === '/events' && req.method === 'POST') {
|
|
543
1789
|
let body = '';
|
|
@@ -554,6 +1800,18 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
|
|
554
1800
|
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
555
1801
|
return;
|
|
556
1802
|
}
|
|
1803
|
+
// Defense in depth: manual copy edits must use the staged stash/apply
|
|
1804
|
+
// endpoints. The direct Save event path is disabled in the browser.
|
|
1805
|
+
if (msg.type === 'manual_edits') {
|
|
1806
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1807
|
+
res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit-stash, not /events' }));
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
if (msg.type === 'manual_edit_apply') {
|
|
1811
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1812
|
+
res.end(JSON.stringify({ error: 'manual_edit_apply is disabled; use /manual-edit-stash then /manual-edit-commit' }));
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
557
1815
|
const error = validateEvent(msg);
|
|
558
1816
|
if (error) {
|
|
559
1817
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -569,7 +1827,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
|
|
569
1827
|
return;
|
|
570
1828
|
}
|
|
571
1829
|
}
|
|
572
|
-
if (msg.type !== 'checkpoint')
|
|
1830
|
+
if (msg.type !== 'checkpoint') {
|
|
1831
|
+
enqueueEvent(msg);
|
|
1832
|
+
}
|
|
573
1833
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
574
1834
|
res.end(JSON.stringify({ ok: true }));
|
|
575
1835
|
});
|
|
@@ -611,8 +1871,9 @@ function handlePollGet(req, res, url) {
|
|
|
611
1871
|
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
612
1872
|
return;
|
|
613
1873
|
}
|
|
614
|
-
|
|
615
|
-
const
|
|
1874
|
+
state.lastPollAt = Date.now();
|
|
1875
|
+
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
|
|
1876
|
+
const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
|
|
616
1877
|
const available = findAvailablePendingEvent();
|
|
617
1878
|
if (available) {
|
|
618
1879
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -623,20 +1884,24 @@ function handlePollGet(req, res, url) {
|
|
|
623
1884
|
const timer = setTimeout(() => {
|
|
624
1885
|
const idx = state.pendingPolls.indexOf(poll);
|
|
625
1886
|
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
|
1887
|
+
broadcastAgentPollingIfChanged();
|
|
626
1888
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
627
1889
|
res.end(JSON.stringify({ type: 'timeout' }));
|
|
628
1890
|
}, timeout);
|
|
629
1891
|
function resolve(event) {
|
|
630
1892
|
clearTimeout(timer);
|
|
1893
|
+
state.lastPollAt = Date.now();
|
|
631
1894
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
632
1895
|
res.end(JSON.stringify(event));
|
|
633
1896
|
}
|
|
634
1897
|
state.pendingPolls.push(poll);
|
|
1898
|
+
broadcastAgentPollingIfChanged();
|
|
635
1899
|
scheduleLeaseFlush();
|
|
636
1900
|
req.on('close', () => {
|
|
637
1901
|
clearTimeout(timer);
|
|
638
1902
|
const idx = state.pendingPolls.indexOf(poll);
|
|
639
1903
|
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
|
1904
|
+
broadcastAgentPollingIfChanged();
|
|
640
1905
|
});
|
|
641
1906
|
}
|
|
642
1907
|
|
|
@@ -655,21 +1920,90 @@ function handlePollPost(req, res) {
|
|
|
655
1920
|
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
656
1921
|
return;
|
|
657
1922
|
}
|
|
658
|
-
|
|
659
|
-
if (
|
|
1923
|
+
const pendingApplyDeferred = state.pendingApplyDeferreds.get(msg.id);
|
|
1924
|
+
if (pendingApplyDeferred) {
|
|
1925
|
+
const validation = validateManualApplyResultMessage(msg, pendingApplyDeferred);
|
|
1926
|
+
if (!validation.ok) {
|
|
1927
|
+
recordManualEditActivity('manual_edit_apply_reply_invalid', {
|
|
1928
|
+
id: msg.id,
|
|
1929
|
+
pageUrl: pendingApplyDeferred.pageUrl,
|
|
1930
|
+
chunk: pendingApplyDeferred.event?.chunk || null,
|
|
1931
|
+
repair: pendingApplyDeferred.event?.repair || null,
|
|
1932
|
+
reason: validation.body?.reason || validation.body?.error || 'invalid_manual_apply_result',
|
|
1933
|
+
status: msg.data?.status || null,
|
|
1934
|
+
});
|
|
1935
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1936
|
+
res.end(JSON.stringify(validation.body));
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
recordManualEditActivity('manual_edit_apply_reply_received', {
|
|
1940
|
+
id: msg.id,
|
|
1941
|
+
pageUrl: pendingApplyDeferred.pageUrl,
|
|
1942
|
+
chunk: pendingApplyDeferred.event?.chunk || null,
|
|
1943
|
+
repair: pendingApplyDeferred.event?.repair || null,
|
|
1944
|
+
status: validation.result.status,
|
|
1945
|
+
appliedCount: validation.result.appliedEntryIds.length,
|
|
1946
|
+
failed: summarizeManualApplyFailures(validation.result.failed),
|
|
1947
|
+
fileCount: validation.result.files.length,
|
|
1948
|
+
noteCount: validation.result.notes.length,
|
|
1949
|
+
});
|
|
1950
|
+
resolveApplyDeferred(msg.id, validation.result);
|
|
1951
|
+
acknowledgePendingEvent(msg.id);
|
|
1952
|
+
flushPendingPolls();
|
|
1953
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1954
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
if (state.timedOutApplyIds.has(msg.id)) {
|
|
1958
|
+
const rollback = rollbackTimedOutApplyReply(msg);
|
|
1959
|
+
recordManualEditActivity('manual_edit_apply_stale_reply_rejected', {
|
|
1960
|
+
id: msg.id,
|
|
1961
|
+
rolledBackFileCount: rollback.rolledBackFiles?.length || 0,
|
|
1962
|
+
rollbackFailureCount: rollback.rollbackFailures?.length || 0,
|
|
1963
|
+
});
|
|
1964
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1965
|
+
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
const acknowledgedEvent = acknowledgePendingEvent(msg.id);
|
|
1969
|
+
let skipJournalReply = false;
|
|
1970
|
+
let existingSession = null;
|
|
1971
|
+
if (!acknowledgedEvent && state.sessionStore && msg.id) {
|
|
1972
|
+
try {
|
|
1973
|
+
existingSession = state.sessionStore.getSnapshot(msg.id, { includeCompleted: true });
|
|
1974
|
+
if (!existingSession?.updatedAt) existingSession = null;
|
|
1975
|
+
skipJournalReply = existingSession?.phase === 'completed' || existingSession?.phase === 'discarded';
|
|
1976
|
+
} catch { /* fall through and record the reply normally */ }
|
|
1977
|
+
}
|
|
1978
|
+
if (!acknowledgedEvent && !existingSession) {
|
|
1979
|
+
recordManualEditActivity('manual_edit_poll_reply_unknown', {
|
|
1980
|
+
id: msg.id || null,
|
|
1981
|
+
type: msg.type || null,
|
|
1982
|
+
});
|
|
1983
|
+
res.writeHead(msg.id ? 404 : 400, { 'Content-Type': 'application/json' });
|
|
1984
|
+
res.end(JSON.stringify({
|
|
1985
|
+
error: msg.id ? 'unknown_poll_reply_id' : 'missing_poll_reply_id',
|
|
1986
|
+
id: msg.id,
|
|
1987
|
+
}));
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
if (state.sessionStore && msg.id && !skipJournalReply) {
|
|
660
1991
|
try {
|
|
661
|
-
const eventType = msg.type === '
|
|
662
|
-
? '
|
|
663
|
-
: msg.type === '
|
|
664
|
-
? '
|
|
665
|
-
: msg.type === '
|
|
666
|
-
? '
|
|
667
|
-
: '
|
|
1992
|
+
const eventType = msg.type === 'steer_done'
|
|
1993
|
+
? 'steer_done'
|
|
1994
|
+
: msg.type === 'discard' || msg.type === 'discarded'
|
|
1995
|
+
? 'discarded'
|
|
1996
|
+
: msg.type === 'complete'
|
|
1997
|
+
? 'complete'
|
|
1998
|
+
: msg.type === 'error'
|
|
1999
|
+
? 'agent_error'
|
|
2000
|
+
: 'agent_done';
|
|
668
2001
|
state.sessionStore.appendEvent({
|
|
669
2002
|
type: eventType,
|
|
670
2003
|
id: msg.id,
|
|
671
2004
|
file: msg.file,
|
|
672
2005
|
message: msg.message,
|
|
2006
|
+
sourceEventType: acknowledgedEvent?.type,
|
|
673
2007
|
carbonize: msg.data?.carbonize === true,
|
|
674
2008
|
});
|
|
675
2009
|
} catch { /* keep reply path best-effort; browser still needs SSE */ }
|
|
@@ -732,6 +2066,9 @@ Endpoints:
|
|
|
732
2066
|
/annotation POST raw image/png to stage a variant screenshot
|
|
733
2067
|
/events SSE stream (server→browser) + POST (browser→server)
|
|
734
2068
|
/poll Long-poll for agent CLI
|
|
2069
|
+
/manual-edit-stash Stage browser copy edits
|
|
2070
|
+
/manual-edit-commit Apply staged browser copy edits
|
|
2071
|
+
/manual-edit-discard Discard staged browser copy edits
|
|
735
2072
|
/source Raw source file reader (no-HMR fallback)
|
|
736
2073
|
/status Durable recovery status (token-protected)
|
|
737
2074
|
/health Health check`);
|
|
@@ -821,7 +2158,12 @@ if (existingRecord?.info) {
|
|
|
821
2158
|
|
|
822
2159
|
state.token = randomUUID();
|
|
823
2160
|
state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
|
|
2161
|
+
rollbackManualApplyTransaction({
|
|
2162
|
+
cwd: process.cwd(),
|
|
2163
|
+
reason: 'manual_edit_server_start_recovered_abandoned_transaction',
|
|
2164
|
+
});
|
|
824
2165
|
restorePendingEventsFromStore();
|
|
2166
|
+
pruneStaleManualApplyEvidence(process.cwd());
|
|
825
2167
|
const portArg = args.find(a => a.startsWith('--port='));
|
|
826
2168
|
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
|
|
827
2169
|
// Annotation screenshots live in the project root so the agent's Read tool
|
|
@@ -839,7 +2181,8 @@ httpServer.listen(state.port, '127.0.0.1', () => {
|
|
|
839
2181
|
const url = `http://localhost:${state.port}`;
|
|
840
2182
|
console.log(`\nImpeccable live server running on ${url}`);
|
|
841
2183
|
console.log(`Token: ${state.token}\n`);
|
|
842
|
-
console.log(`
|
|
2184
|
+
console.log(`Script: ${url}/live.js`);
|
|
2185
|
+
console.log('Inject: managed by live-inject.mjs; Astro source tags use is:inline automatically.');
|
|
843
2186
|
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
|
|
844
2187
|
});
|
|
845
2188
|
|