nstantpage-agent 0.8.4 → 0.8.5
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/dist/agentSync.d.ts +14 -26
- package/dist/agentSync.js +163 -461
- package/package.json +1 -1
package/dist/agentSync.d.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agentSync.ts —
|
|
2
|
+
* agentSync.ts — Simplified file sync for the local agent.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Computes disk checksums, fetches backend checksums via HTTP, compares them,
|
|
6
|
-
* and provides push-to-backend capability.
|
|
4
|
+
* CORE PRINCIPLE: Disk is ALWAYS the source of truth. ONE-WAY only.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* When a file changes on disk → push it to the backend DB. No checksums,
|
|
7
|
+
* no comparisons, no conflict resolution. Whatever is on disk wins.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. File watcher detects changes (debounced 1s)
|
|
11
|
+
* 2. Accumulate changed file paths in pendingChangedFiles
|
|
12
|
+
* 3. Fire onSyncDirty → frontend receives notification
|
|
13
|
+
* 4. Frontend calls pushToBackend → agent reads pending files from disk → pushes to DB
|
|
10
14
|
*/
|
|
11
15
|
export type SyncDirection = 'in-sync' | 'backend-ahead' | 'disk-ahead' | 'diverged';
|
|
12
16
|
export interface SyncStatusResult {
|
|
@@ -42,31 +46,17 @@ export declare class AgentSync {
|
|
|
42
46
|
private projectId;
|
|
43
47
|
private backendUrl;
|
|
44
48
|
private onSyncDirty?;
|
|
45
|
-
private diskDirty;
|
|
46
|
-
private diskChecksumCache;
|
|
47
|
-
private backendChecksumCache;
|
|
48
|
-
private lastSyncTimestamp;
|
|
49
|
-
private syncedBaselineChecksums;
|
|
50
49
|
private fileWatcher;
|
|
51
50
|
private debounceTimer;
|
|
52
51
|
private pendingChangedFiles;
|
|
53
|
-
private
|
|
54
|
-
private
|
|
52
|
+
private pushing;
|
|
53
|
+
private lastVersionId;
|
|
54
|
+
private lastPushAt;
|
|
55
55
|
constructor(options: AgentSyncOptions);
|
|
56
56
|
startFileWatcher(): void;
|
|
57
57
|
stopFileWatcher(): void;
|
|
58
|
-
private markDiskDirty;
|
|
59
|
-
private computeDiskChecksums;
|
|
60
|
-
private getDiskData;
|
|
61
|
-
private fetchBackendChecksums;
|
|
62
|
-
private filterBackendChecksums;
|
|
63
58
|
getSyncStatus(): Promise<SyncStatusResult>;
|
|
64
59
|
getPerFileStatus(): Promise<PerFileStatus[]>;
|
|
65
|
-
getDiskChanges(): Promise<{
|
|
66
|
-
modified: Record<string, string>;
|
|
67
|
-
added: Record<string, string>;
|
|
68
|
-
deleted: string[];
|
|
69
|
-
}>;
|
|
70
60
|
pushToBackend(): Promise<{
|
|
71
61
|
success: boolean;
|
|
72
62
|
filesPushed: number;
|
|
@@ -78,13 +68,11 @@ export declare class AgentSync {
|
|
|
78
68
|
message?: string;
|
|
79
69
|
}>;
|
|
80
70
|
markSynced(versionId: string): void;
|
|
81
|
-
private snapshotBaseline;
|
|
82
71
|
/** Read a single file from disk (for diff view) */
|
|
83
72
|
readDiskFile(filePath: string): Promise<string | null>;
|
|
84
73
|
/**
|
|
85
74
|
* Pull files from the backend DB and write them to disk.
|
|
86
|
-
*
|
|
87
|
-
* Files that exist only on disk (not in DB) are left untouched.
|
|
75
|
+
* After writing, clears pending changes (the writes would trigger the watcher).
|
|
88
76
|
*/
|
|
89
77
|
pullFromBackend(): Promise<{
|
|
90
78
|
success: boolean;
|
package/dist/agentSync.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agentSync.ts —
|
|
2
|
+
* agentSync.ts — Simplified file sync for the local agent.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Computes disk checksums, fetches backend checksums via HTTP, compares them,
|
|
6
|
-
* and provides push-to-backend capability.
|
|
4
|
+
* CORE PRINCIPLE: Disk is ALWAYS the source of truth. ONE-WAY only.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* When a file changes on disk → push it to the backend DB. No checksums,
|
|
7
|
+
* no comparisons, no conflict resolution. Whatever is on disk wins.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. File watcher detects changes (debounced 1s)
|
|
11
|
+
* 2. Accumulate changed file paths in pendingChangedFiles
|
|
12
|
+
* 3. Fire onSyncDirty → frontend receives notification
|
|
13
|
+
* 4. Frontend calls pushToBackend → agent reads pending files from disk → pushes to DB
|
|
10
14
|
*/
|
|
11
|
-
import crypto from 'crypto';
|
|
12
15
|
import path from 'path';
|
|
13
16
|
import fs from 'fs/promises';
|
|
14
17
|
import { watch, existsSync } from 'fs';
|
|
15
|
-
// ─── Skip Patterns
|
|
18
|
+
// ─── Skip Patterns ────────────────────────────────────────────
|
|
16
19
|
const SKIP_DIRS = new Set([
|
|
17
20
|
'node_modules', '.git', 'dist', 'build', '.next', 'out',
|
|
18
21
|
'.vite-cache', '.cache', '.turbo', 'coverage', 'tools',
|
|
@@ -27,20 +30,6 @@ const SKIP_EXTENSIONS = new Set([
|
|
|
27
30
|
'.map', '.log', '.tmp', '.swp', '.swo', '.svg', '.png', '.jpg', '.jpeg',
|
|
28
31
|
'.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot',
|
|
29
32
|
]);
|
|
30
|
-
const TEMPLATE_SCAFFOLD_FILES = new Set([
|
|
31
|
-
'vite.config.ts', 'vite.config.js',
|
|
32
|
-
'postcss.config.ts', 'postcss.config.js',
|
|
33
|
-
'tailwind.config.ts', 'tailwind.config.js',
|
|
34
|
-
'tsconfig.json', 'tsconfig.app.json', 'tsconfig.node.json',
|
|
35
|
-
'eslint.config.js', '.eslintrc.js', '.eslintrc.cjs',
|
|
36
|
-
'drizzle.config.ts',
|
|
37
|
-
'pnpm-workspace.yaml',
|
|
38
|
-
'project-manifest.json',
|
|
39
|
-
'babel-plugin-inject-jsx-meta.ts',
|
|
40
|
-
'.env', '.npmrc', '.gitignore',
|
|
41
|
-
'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock',
|
|
42
|
-
'README.md', 'index.html',
|
|
43
|
-
]);
|
|
44
33
|
function shouldSkip(name, isDir) {
|
|
45
34
|
if (isDir)
|
|
46
35
|
return SKIP_DIRS.has(name) || name.startsWith('.');
|
|
@@ -51,28 +40,14 @@ function shouldSkip(name, isDir) {
|
|
|
51
40
|
const ext = path.extname(name);
|
|
52
41
|
return SKIP_EXTENSIONS.has(ext);
|
|
53
42
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (TEMPLATE_SCAFFOLD_FILES.has(basename))
|
|
61
|
-
return false;
|
|
62
|
-
return true;
|
|
63
|
-
}
|
|
64
|
-
// ─── Checksum Computation ─────────────────────────────────────
|
|
65
|
-
function sha256(content) {
|
|
66
|
-
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
67
|
-
}
|
|
68
|
-
function computeGlobalChecksum(fileContents) {
|
|
69
|
-
const hash = crypto.createHash('sha256');
|
|
70
|
-
const sortedPaths = [...fileContents.keys()].sort();
|
|
71
|
-
for (const filePath of sortedPaths) {
|
|
72
|
-
hash.update(filePath);
|
|
73
|
-
hash.update(fileContents.get(filePath));
|
|
43
|
+
/** Check if a relative path should be skipped (checks every path component) */
|
|
44
|
+
function shouldSkipPath(relPath) {
|
|
45
|
+
const parts = relPath.split(/[\/\\]/);
|
|
46
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
47
|
+
if (shouldSkip(parts[i], true))
|
|
48
|
+
return true;
|
|
74
49
|
}
|
|
75
|
-
return
|
|
50
|
+
return shouldSkip(parts[parts.length - 1], false);
|
|
76
51
|
}
|
|
77
52
|
export class AgentSync {
|
|
78
53
|
projectDir;
|
|
@@ -80,17 +55,12 @@ export class AgentSync {
|
|
|
80
55
|
backendUrl;
|
|
81
56
|
onSyncDirty;
|
|
82
57
|
// State
|
|
83
|
-
diskDirty = false;
|
|
84
|
-
diskChecksumCache = null;
|
|
85
|
-
backendChecksumCache = null;
|
|
86
|
-
lastSyncTimestamp = null;
|
|
87
|
-
syncedBaselineChecksums = null;
|
|
88
58
|
fileWatcher = null;
|
|
89
59
|
debounceTimer = null;
|
|
90
60
|
pendingChangedFiles = new Set();
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
61
|
+
pushing = false;
|
|
62
|
+
lastVersionId = null;
|
|
63
|
+
lastPushAt = null;
|
|
94
64
|
constructor(options) {
|
|
95
65
|
this.projectDir = options.projectDir;
|
|
96
66
|
this.projectId = options.projectId;
|
|
@@ -103,28 +73,24 @@ export class AgentSync {
|
|
|
103
73
|
return;
|
|
104
74
|
if (!existsSync(this.projectDir))
|
|
105
75
|
return;
|
|
106
|
-
/** Track whether we're currently writing files (skip self-triggered events) */
|
|
107
|
-
let selfWriting = false;
|
|
108
76
|
try {
|
|
109
|
-
this.fileWatcher = watch(this.projectDir, { recursive: true }, (
|
|
110
|
-
if (!filename
|
|
77
|
+
this.fileWatcher = watch(this.projectDir, { recursive: true }, (_eventType, filename) => {
|
|
78
|
+
if (!filename)
|
|
111
79
|
return;
|
|
112
|
-
|
|
113
|
-
if (parts.some(p => SKIP_DIRS.has(p) || (p.startsWith('.') && p !== '.')))
|
|
80
|
+
if (shouldSkipPath(filename))
|
|
114
81
|
return;
|
|
115
|
-
const basename = path.basename(filename);
|
|
116
|
-
if (shouldSkip(basename, false))
|
|
117
|
-
return;
|
|
118
|
-
// Accumulate changed filenames
|
|
119
82
|
this.pendingChangedFiles.add(filename);
|
|
120
83
|
if (this.debounceTimer)
|
|
121
84
|
clearTimeout(this.debounceTimer);
|
|
122
85
|
this.debounceTimer = setTimeout(() => {
|
|
123
|
-
const
|
|
124
|
-
this.pendingChangedFiles.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
86
|
+
const count = this.pendingChangedFiles.size;
|
|
87
|
+
const preview = [...this.pendingChangedFiles].slice(0, 5).join(', ');
|
|
88
|
+
console.log(` [AgentSync] Change detected: ${count} file(s) — ${preview}${count > 5 ? '...' : ''}`);
|
|
89
|
+
// Notify frontend so it triggers auto-push
|
|
90
|
+
if (this.onSyncDirty) {
|
|
91
|
+
this.onSyncDirty(this.projectId);
|
|
92
|
+
}
|
|
93
|
+
}, 1000);
|
|
128
94
|
});
|
|
129
95
|
this.fileWatcher.on('error', (err) => {
|
|
130
96
|
console.warn(` [AgentSync] File watcher error:`, err.message);
|
|
@@ -146,410 +112,147 @@ export class AgentSync {
|
|
|
146
112
|
this.debounceTimer = null;
|
|
147
113
|
}
|
|
148
114
|
}
|
|
149
|
-
// ───
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const filePaths = [];
|
|
168
|
-
const collectPaths = async (dir) => {
|
|
169
|
-
let entries;
|
|
170
|
-
try {
|
|
171
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
172
|
-
}
|
|
173
|
-
catch {
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
for (const entry of entries) {
|
|
177
|
-
if (shouldSkip(entry.name, entry.isDirectory()))
|
|
178
|
-
continue;
|
|
179
|
-
const fullPath = path.join(dir, entry.name);
|
|
180
|
-
if (entry.isDirectory()) {
|
|
181
|
-
await collectPaths(fullPath);
|
|
182
|
-
}
|
|
183
|
-
else {
|
|
184
|
-
const relPath = path.relative(this.projectDir, fullPath);
|
|
185
|
-
if (relPath.startsWith('dist/') || relPath.startsWith('build/'))
|
|
186
|
-
continue;
|
|
187
|
-
filePaths.push(fullPath);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
115
|
+
// ─── Sync Status (simplified — just pending file count) ───
|
|
116
|
+
async getSyncStatus() {
|
|
117
|
+
const pending = this.pendingChangedFiles.size;
|
|
118
|
+
const isDirty = pending > 0 || this.pushing;
|
|
119
|
+
return {
|
|
120
|
+
inSync: !isDirty,
|
|
121
|
+
direction: isDirty ? 'disk-ahead' : 'in-sync',
|
|
122
|
+
diskChecksum: null,
|
|
123
|
+
backendChecksum: null,
|
|
124
|
+
diskFileCount: 0,
|
|
125
|
+
backendFileCount: 0,
|
|
126
|
+
modifiedFiles: [...this.pendingChangedFiles].sort(),
|
|
127
|
+
diskOnlyFiles: [],
|
|
128
|
+
backendOnlyFiles: [],
|
|
129
|
+
lastSyncedVersionId: this.lastVersionId,
|
|
130
|
+
lastSyncedAt: this.lastPushAt,
|
|
131
|
+
diskDirty: isDirty,
|
|
132
|
+
computedAt: Date.now(),
|
|
190
133
|
};
|
|
191
|
-
await collectPaths(this.projectDir);
|
|
192
|
-
// Phase 2: Read files in parallel batches of 50
|
|
193
|
-
const BATCH_SIZE = 50;
|
|
194
|
-
for (let i = 0; i < filePaths.length; i += BATCH_SIZE) {
|
|
195
|
-
const batch = filePaths.slice(i, i + BATCH_SIZE);
|
|
196
|
-
await Promise.all(batch.map(async (fullPath) => {
|
|
197
|
-
try {
|
|
198
|
-
const content = await fs.readFile(fullPath, 'utf-8');
|
|
199
|
-
const relPath = path.relative(this.projectDir, fullPath);
|
|
200
|
-
checksums.set(relPath, sha256(content));
|
|
201
|
-
fileContents.set(relPath, content);
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
// Binary file or unreadable — skip
|
|
205
|
-
}
|
|
206
|
-
}));
|
|
207
|
-
}
|
|
208
|
-
const globalChecksum = computeGlobalChecksum(fileContents);
|
|
209
|
-
return { checksums, fileContents, globalChecksum };
|
|
210
134
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
const computed = await this.computeDiskChecksums();
|
|
218
|
-
this.diskChecksumCache = { ...computed, computedAt: Date.now() };
|
|
219
|
-
if (this.diskDirty)
|
|
220
|
-
this.diskDirty = false;
|
|
221
|
-
return computed;
|
|
135
|
+
// ─── Per-File Status ──────────────────────────────────────
|
|
136
|
+
async getPerFileStatus() {
|
|
137
|
+
return [...this.pendingChangedFiles].sort().map(f => ({
|
|
138
|
+
path: f,
|
|
139
|
+
status: 'modified',
|
|
140
|
+
}));
|
|
222
141
|
}
|
|
223
|
-
// ─── Backend
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
142
|
+
// ─── Push to Backend ──────────────────────────────────────
|
|
143
|
+
//
|
|
144
|
+
// Reads the pending changed files from disk and pushes them to the DB.
|
|
145
|
+
// No checksums, no comparison — just read and push.
|
|
146
|
+
// If a file no longer exists on disk, it's marked as deleted.
|
|
147
|
+
async pushToBackend() {
|
|
148
|
+
const filesToProcess = [...this.pendingChangedFiles];
|
|
149
|
+
if (filesToProcess.length === 0) {
|
|
228
150
|
return {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const url = `${this.backendUrl}/api/sandbox/file-checksums?projectId=${this.projectId}`;
|
|
237
|
-
const controller = new AbortController();
|
|
238
|
-
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
239
|
-
const response = await fetch(url, { signal: controller.signal });
|
|
240
|
-
clearTimeout(timeout);
|
|
241
|
-
if (!response.ok) {
|
|
242
|
-
console.warn(` [AgentSync] file-checksums returned ${response.status}`);
|
|
243
|
-
return null;
|
|
244
|
-
}
|
|
245
|
-
const data = await response.json();
|
|
246
|
-
const checksums = new Map(Object.entries(data.files));
|
|
247
|
-
const result = {
|
|
248
|
-
checksums,
|
|
249
|
-
globalChecksum: data.checksum,
|
|
250
|
-
versionId: String(data.versionId),
|
|
251
|
-
fileCount: data.fileCount,
|
|
252
|
-
};
|
|
253
|
-
this.backendChecksumCache = {
|
|
254
|
-
...result,
|
|
255
|
-
fetchedAt: Date.now(),
|
|
151
|
+
success: true,
|
|
152
|
+
filesPushed: 0,
|
|
153
|
+
filesDeleted: 0,
|
|
154
|
+
modifiedFiles: [],
|
|
155
|
+
addedFiles: [],
|
|
156
|
+
deletedFiles: [],
|
|
157
|
+
message: 'Already in sync — no changes to push',
|
|
256
158
|
};
|
|
257
|
-
return result;
|
|
258
159
|
}
|
|
259
|
-
|
|
260
|
-
if (error.name === 'AbortError') {
|
|
261
|
-
console.warn(` [AgentSync] Backend checksum request timed out`);
|
|
262
|
-
}
|
|
263
|
-
else {
|
|
264
|
-
console.warn(` [AgentSync] Error fetching backend checksums:`, error.message);
|
|
265
|
-
}
|
|
266
|
-
return null;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
// ─── Filter backend checksums (same skip rules as disk scan) ──
|
|
270
|
-
filterBackendChecksums(backendChecksums) {
|
|
271
|
-
const filtered = new Map();
|
|
272
|
-
for (const [filePath, sha] of backendChecksums) {
|
|
273
|
-
const basename = path.basename(filePath);
|
|
274
|
-
const ext = path.extname(filePath);
|
|
275
|
-
if (SKIP_FILES.has(basename))
|
|
276
|
-
continue;
|
|
277
|
-
if (basename.startsWith('.'))
|
|
278
|
-
continue;
|
|
279
|
-
if (SKIP_EXTENSIONS.has(ext))
|
|
280
|
-
continue;
|
|
281
|
-
// Match disk scan: skip files inside SKIP_DIRS or dotfile directories
|
|
282
|
-
const parts = filePath.split('/');
|
|
283
|
-
if (parts.some(p => SKIP_DIRS.has(p) || (p.startsWith('.') && p !== '.')))
|
|
284
|
-
continue;
|
|
285
|
-
// Match disk scan: skip dist/ and build/ top-level paths
|
|
286
|
-
if (filePath.startsWith('dist/') || filePath.startsWith('build/'))
|
|
287
|
-
continue;
|
|
288
|
-
filtered.set(filePath, sha);
|
|
289
|
-
}
|
|
290
|
-
return filtered;
|
|
291
|
-
}
|
|
292
|
-
// ─── Sync Status ──────────────────────────────────────────
|
|
293
|
-
async getSyncStatus() {
|
|
294
|
-
const diskResult = await this.getDiskData();
|
|
295
|
-
const backendResult = await this.fetchBackendChecksums();
|
|
296
|
-
if (!backendResult) {
|
|
160
|
+
if (this.pushing) {
|
|
297
161
|
return {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
backendChecksum: null,
|
|
302
|
-
diskFileCount: diskResult.checksums.size,
|
|
303
|
-
backendFileCount: 0,
|
|
162
|
+
success: true,
|
|
163
|
+
filesPushed: 0,
|
|
164
|
+
filesDeleted: 0,
|
|
304
165
|
modifiedFiles: [],
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
lastSyncedAt: this.lastSyncTimestamp?.timestamp || null,
|
|
309
|
-
diskDirty: this.diskDirty,
|
|
310
|
-
computedAt: Date.now(),
|
|
166
|
+
addedFiles: [],
|
|
167
|
+
deletedFiles: [],
|
|
168
|
+
message: 'Push already in progress',
|
|
311
169
|
};
|
|
312
170
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (!currentDiskSha) {
|
|
323
|
-
if (filteredBackend.has(filePath)) {
|
|
324
|
-
modifiedFiles.push(filePath);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
else if (currentDiskSha !== baselineSha) {
|
|
328
|
-
modifiedFiles.push(filePath);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
for (const [filePath] of diskResult.checksums) {
|
|
332
|
-
if (!baseline.has(filePath) && isUserCreatedFile(filePath)) {
|
|
333
|
-
diskOnlyFiles.push(filePath);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
for (const [filePath, backendSha] of filteredBackend) {
|
|
337
|
-
if (!baseline.has(filePath) && !diskResult.checksums.has(filePath)) {
|
|
338
|
-
backendOnlyFiles.push(filePath);
|
|
339
|
-
}
|
|
340
|
-
else if (baseline.has(filePath) && diskResult.checksums.has(filePath)) {
|
|
341
|
-
const baselineSha = baseline.get(filePath);
|
|
342
|
-
const diskSha = diskResult.checksums.get(filePath);
|
|
343
|
-
if (backendSha !== baselineSha && diskSha === baselineSha) {
|
|
344
|
-
if (!modifiedFiles.includes(filePath))
|
|
345
|
-
modifiedFiles.push(filePath);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
else {
|
|
351
|
-
// No baseline: direct disk vs backend comparison
|
|
352
|
-
for (const [filePath, backendSha] of filteredBackend) {
|
|
353
|
-
const diskSha = diskResult.checksums.get(filePath);
|
|
354
|
-
if (!diskSha) {
|
|
355
|
-
backendOnlyFiles.push(filePath);
|
|
356
|
-
}
|
|
357
|
-
else if (diskSha !== backendSha) {
|
|
358
|
-
modifiedFiles.push(filePath);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
for (const [filePath] of diskResult.checksums) {
|
|
362
|
-
if (!filteredBackend.has(filePath) && isUserCreatedFile(filePath)) {
|
|
363
|
-
diskOnlyFiles.push(filePath);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
// Determine direction
|
|
368
|
-
// Disk is always the source of truth. DB only changes via push or AI coding agent
|
|
369
|
-
// (which updates both disk+DB simultaneously). So direction is only ever
|
|
370
|
-
// 'in-sync' or 'disk-ahead'. backendOnlyFiles are stale DB remnants, not "newer" files.
|
|
371
|
-
const inSync = modifiedFiles.length === 0 && diskOnlyFiles.length === 0 && backendOnlyFiles.length === 0;
|
|
372
|
-
const direction = inSync ? 'in-sync' : 'disk-ahead';
|
|
373
|
-
return {
|
|
374
|
-
inSync,
|
|
375
|
-
direction,
|
|
376
|
-
diskChecksum: diskResult.globalChecksum || null,
|
|
377
|
-
backendChecksum: backendResult.globalChecksum || null,
|
|
378
|
-
diskFileCount: diskResult.checksums.size,
|
|
379
|
-
backendFileCount: backendResult.checksums.size,
|
|
380
|
-
modifiedFiles: modifiedFiles.sort(),
|
|
381
|
-
diskOnlyFiles: diskOnlyFiles.sort(),
|
|
382
|
-
backendOnlyFiles: backendOnlyFiles.sort(),
|
|
383
|
-
lastSyncedVersionId: this.lastSyncTimestamp?.versionId || null,
|
|
384
|
-
lastSyncedAt: this.lastSyncTimestamp?.timestamp || null,
|
|
385
|
-
diskDirty: this.diskDirty,
|
|
386
|
-
computedAt: Date.now(),
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
// ─── Per-File Status (Detailed) ───────────────────────────
|
|
390
|
-
async getPerFileStatus() {
|
|
391
|
-
const diskComputed = await this.computeDiskChecksums();
|
|
392
|
-
const backendResult = await this.fetchBackendChecksums();
|
|
393
|
-
if (!backendResult)
|
|
394
|
-
return [];
|
|
395
|
-
// Use filtered backend checksums (same skip rules as disk scan) for consistency with getSyncStatus
|
|
396
|
-
const filteredBackend = this.filterBackendChecksums(backendResult.checksums);
|
|
397
|
-
const baseline = this.syncedBaselineChecksums;
|
|
398
|
-
const result = [];
|
|
399
|
-
if (baseline) {
|
|
400
|
-
for (const [filePath, baselineSha] of baseline) {
|
|
401
|
-
const currentDiskSha = diskComputed.checksums.get(filePath);
|
|
402
|
-
const backendSha = filteredBackend.get(filePath);
|
|
403
|
-
if (!currentDiskSha) {
|
|
404
|
-
if (backendSha) {
|
|
405
|
-
result.push({ path: filePath, status: 'modified', backendSha });
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
else if (currentDiskSha !== baselineSha) {
|
|
409
|
-
result.push({ path: filePath, status: 'modified', diskSha: currentDiskSha, backendSha });
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
for (const [filePath, diskSha] of diskComputed.checksums) {
|
|
413
|
-
if (!baseline.has(filePath) && isUserCreatedFile(filePath)) {
|
|
414
|
-
result.push({ path: filePath, status: 'disk-only', diskSha });
|
|
171
|
+
this.pushing = true;
|
|
172
|
+
try {
|
|
173
|
+
const files = {};
|
|
174
|
+
const deleted = [];
|
|
175
|
+
for (const relPath of filesToProcess) {
|
|
176
|
+
const fullPath = path.join(this.projectDir, relPath);
|
|
177
|
+
try {
|
|
178
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
179
|
+
files[relPath] = content;
|
|
415
180
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
result.push({ path: filePath, status: 'backend-only', backendSha });
|
|
181
|
+
catch {
|
|
182
|
+
// File doesn't exist or is binary/unreadable
|
|
183
|
+
if (!existsSync(fullPath)) {
|
|
184
|
+
deleted.push(relPath);
|
|
421
185
|
}
|
|
422
186
|
}
|
|
423
187
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
result.push({ path: filePath, status: 'backend-only', backendSha });
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
const sortedDisk = [...diskComputed.checksums.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
442
|
-
for (const [filePath, diskSha] of sortedDisk) {
|
|
443
|
-
if (!filteredBackend.has(filePath) && isUserCreatedFile(filePath)) {
|
|
444
|
-
result.push({ path: filePath, status: 'disk-only', diskSha });
|
|
445
|
-
}
|
|
188
|
+
const totalChanges = Object.keys(files).length + deleted.length;
|
|
189
|
+
if (totalChanges === 0) {
|
|
190
|
+
// All pending files were binary/unreadable — clear them
|
|
191
|
+
for (const f of filesToProcess)
|
|
192
|
+
this.pendingChangedFiles.delete(f);
|
|
193
|
+
return {
|
|
194
|
+
success: true,
|
|
195
|
+
filesPushed: 0,
|
|
196
|
+
filesDeleted: 0,
|
|
197
|
+
modifiedFiles: [],
|
|
198
|
+
addedFiles: [],
|
|
199
|
+
deletedFiles: [],
|
|
200
|
+
message: 'No pushable changes',
|
|
201
|
+
};
|
|
446
202
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
// Use filtered backend checksums so dotfile dirs (.angular etc) and skip patterns are excluded
|
|
461
|
-
const filteredBackend = this.filterBackendChecksums(backendResult.checksums);
|
|
462
|
-
for (const [filePath, backendSha] of filteredBackend) {
|
|
463
|
-
const diskSha = diskData.checksums.get(filePath);
|
|
464
|
-
if (!diskSha) {
|
|
465
|
-
deleted.push(filePath);
|
|
466
|
-
}
|
|
467
|
-
else if (diskSha !== backendSha) {
|
|
468
|
-
const content = diskData.fileContents.get(filePath);
|
|
469
|
-
if (content !== undefined)
|
|
470
|
-
modified[filePath] = content;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
for (const [filePath] of diskData.checksums) {
|
|
474
|
-
if (!filteredBackend.has(filePath) && isUserCreatedFile(filePath)) {
|
|
475
|
-
if (this.syncedBaselineChecksums) {
|
|
476
|
-
const baselineSha = this.syncedBaselineChecksums.get(filePath);
|
|
477
|
-
if (baselineSha && baselineSha === diskData.checksums.get(filePath)) {
|
|
478
|
-
continue; // Unchanged since initial sync — template noise
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
const content = diskData.fileContents.get(filePath);
|
|
482
|
-
if (content !== undefined)
|
|
483
|
-
added[filePath] = content;
|
|
203
|
+
const pushUrl = `${this.backendUrl}/api/sandbox/push-files`;
|
|
204
|
+
const response = await fetch(pushUrl, {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: { 'Content-Type': 'application/json' },
|
|
207
|
+
body: JSON.stringify({
|
|
208
|
+
projectId: parseInt(this.projectId, 10),
|
|
209
|
+
files,
|
|
210
|
+
deletedFiles: deleted,
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
const errorText = await response.text();
|
|
215
|
+
throw new Error(`Backend push-files failed: ${response.status} ${errorText}`);
|
|
484
216
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
Object.keys(
|
|
493
|
-
|
|
494
|
-
if (totalChanges === 0) {
|
|
217
|
+
const pushResult = await response.json();
|
|
218
|
+
// Success — remove pushed files from pending
|
|
219
|
+
for (const f of filesToProcess)
|
|
220
|
+
this.pendingChangedFiles.delete(f);
|
|
221
|
+
if (pushResult.versionId)
|
|
222
|
+
this.lastVersionId = String(pushResult.versionId);
|
|
223
|
+
this.lastPushAt = Date.now();
|
|
224
|
+
const fileNames = Object.keys(files);
|
|
225
|
+
console.log(` [AgentSync] Pushed ${fileNames.length} files, deleted ${deleted.length}`);
|
|
495
226
|
return {
|
|
496
227
|
success: true,
|
|
497
|
-
filesPushed:
|
|
498
|
-
filesDeleted:
|
|
499
|
-
modifiedFiles:
|
|
228
|
+
filesPushed: fileNames.length,
|
|
229
|
+
filesDeleted: deleted.length,
|
|
230
|
+
modifiedFiles: fileNames,
|
|
500
231
|
addedFiles: [],
|
|
501
|
-
deletedFiles:
|
|
502
|
-
|
|
232
|
+
deletedFiles: deleted,
|
|
233
|
+
versionId: pushResult.versionId ? String(pushResult.versionId) : undefined,
|
|
503
234
|
};
|
|
504
235
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
headers: { 'Content-Type': 'application/json' },
|
|
510
|
-
body: JSON.stringify({
|
|
511
|
-
projectId: parseInt(this.projectId, 10),
|
|
512
|
-
files: allFiles,
|
|
513
|
-
deletedFiles: changes.deleted,
|
|
514
|
-
}),
|
|
515
|
-
});
|
|
516
|
-
if (!pushResponse.ok) {
|
|
517
|
-
const errorText = await pushResponse.text();
|
|
518
|
-
throw new Error(`Backend push-files failed: ${pushResponse.status} ${errorText}`);
|
|
236
|
+
catch (err) {
|
|
237
|
+
// Push failed — keep files in pending for retry
|
|
238
|
+
console.error(` [AgentSync] Push failed:`, err.message);
|
|
239
|
+
throw err;
|
|
519
240
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
this.diskChecksumCache = null;
|
|
523
|
-
this.backendChecksumCache = null;
|
|
524
|
-
// Mark as synced
|
|
525
|
-
if (pushResult.versionId) {
|
|
526
|
-
this.markSynced(String(pushResult.versionId));
|
|
241
|
+
finally {
|
|
242
|
+
this.pushing = false;
|
|
527
243
|
}
|
|
528
|
-
console.log(` [AgentSync] Pushed ${Object.keys(allFiles).length} files, deleted ${changes.deleted.length}`);
|
|
529
|
-
return {
|
|
530
|
-
success: true,
|
|
531
|
-
filesPushed: Object.keys(allFiles).length,
|
|
532
|
-
filesDeleted: changes.deleted.length,
|
|
533
|
-
modifiedFiles: Object.keys(changes.modified),
|
|
534
|
-
addedFiles: Object.keys(changes.added),
|
|
535
|
-
deletedFiles: changes.deleted,
|
|
536
|
-
versionId: pushResult.versionId ? String(pushResult.versionId) : undefined,
|
|
537
|
-
};
|
|
538
244
|
}
|
|
539
|
-
// ─── Sync Markers
|
|
245
|
+
// ─── Sync Markers (for compatibility with start.ts) ───────
|
|
540
246
|
markSynced(versionId) {
|
|
541
|
-
this.
|
|
542
|
-
this.
|
|
543
|
-
|
|
544
|
-
this.
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
this.syncedBaselineChecksums = computed.checksums;
|
|
551
|
-
this.diskChecksumCache = { ...computed, computedAt: Date.now() };
|
|
552
|
-
console.log(` [AgentSync] Baseline snapshot: ${computed.checksums.size} files`);
|
|
247
|
+
this.lastVersionId = versionId;
|
|
248
|
+
this.lastPushAt = Date.now();
|
|
249
|
+
// Clear any pending changes — we just synced, don't re-push fetched files
|
|
250
|
+
this.pendingChangedFiles.clear();
|
|
251
|
+
if (this.debounceTimer) {
|
|
252
|
+
clearTimeout(this.debounceTimer);
|
|
253
|
+
this.debounceTimer = null;
|
|
254
|
+
}
|
|
255
|
+
console.log(` [AgentSync] Marked synced at version ${versionId}`);
|
|
553
256
|
}
|
|
554
257
|
/** Read a single file from disk (for diff view) */
|
|
555
258
|
async readDiskFile(filePath) {
|
|
@@ -566,8 +269,7 @@ export class AgentSync {
|
|
|
566
269
|
}
|
|
567
270
|
/**
|
|
568
271
|
* Pull files from the backend DB and write them to disk.
|
|
569
|
-
*
|
|
570
|
-
* Files that exist only on disk (not in DB) are left untouched.
|
|
272
|
+
* After writing, clears pending changes (the writes would trigger the watcher).
|
|
571
273
|
*/
|
|
572
274
|
async pullFromBackend() {
|
|
573
275
|
const url = `${this.backendUrl}/api/sandbox/files?projectId=${this.projectId}`;
|
|
@@ -582,8 +284,8 @@ export class AgentSync {
|
|
|
582
284
|
// Ensure directories exist
|
|
583
285
|
const dirsToCreate = new Set();
|
|
584
286
|
for (const file of data.files) {
|
|
585
|
-
const
|
|
586
|
-
dirsToCreate.add(path.dirname(
|
|
287
|
+
const fp = path.join(this.projectDir, file.path);
|
|
288
|
+
dirsToCreate.add(path.dirname(fp));
|
|
587
289
|
}
|
|
588
290
|
for (const dir of dirsToCreate) {
|
|
589
291
|
await fs.mkdir(dir, { recursive: true }).catch(() => { });
|
|
@@ -594,25 +296,25 @@ export class AgentSync {
|
|
|
594
296
|
for (let i = 0; i < data.files.length; i += BATCH_SIZE) {
|
|
595
297
|
const batch = data.files.slice(i, i + BATCH_SIZE);
|
|
596
298
|
await Promise.all(batch.map(async (file) => {
|
|
597
|
-
const
|
|
598
|
-
await fs.writeFile(
|
|
299
|
+
const fp = path.join(this.projectDir, file.path);
|
|
300
|
+
await fs.writeFile(fp, file.content, 'utf-8');
|
|
599
301
|
}));
|
|
600
302
|
written += batch.length;
|
|
601
303
|
}
|
|
602
|
-
//
|
|
603
|
-
this.
|
|
604
|
-
this.
|
|
605
|
-
|
|
606
|
-
this.
|
|
607
|
-
|
|
608
|
-
|
|
304
|
+
// Clear pending changes from the writes and mark synced
|
|
305
|
+
this.lastVersionId = String(data.versionId);
|
|
306
|
+
this.lastPushAt = Date.now();
|
|
307
|
+
this.pendingChangedFiles.clear();
|
|
308
|
+
if (this.debounceTimer) {
|
|
309
|
+
clearTimeout(this.debounceTimer);
|
|
310
|
+
this.debounceTimer = null;
|
|
311
|
+
}
|
|
312
|
+
console.log(` [AgentSync] Pulled ${written} files from backend (version ${data.versionId})`);
|
|
313
|
+
return { success: true, filesWritten: written, versionId: String(data.versionId) };
|
|
609
314
|
}
|
|
610
315
|
// ─── Cleanup ──────────────────────────────────────────────
|
|
611
316
|
destroy() {
|
|
612
317
|
this.stopFileWatcher();
|
|
613
|
-
this.diskChecksumCache = null;
|
|
614
|
-
this.backendChecksumCache = null;
|
|
615
|
-
this.syncedBaselineChecksums = null;
|
|
616
318
|
this.pendingChangedFiles.clear();
|
|
617
319
|
}
|
|
618
320
|
}
|
package/package.json
CHANGED