nstantpage-agent 0.8.4 → 0.8.6
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 +12 -26
- package/dist/agentSync.js +185 -467
- package/package.json +1 -1
package/dist/agentSync.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
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 files change on disk → do a FULL SYNC (same as `nstantpage sync`):
|
|
7
|
+
* 1. POST /api/projects/{id}/sync — new version + clear ALL FullFiles
|
|
8
|
+
* 2. Collect ALL disk files
|
|
9
|
+
* 3. POST /api/sandbox/push-files — push ALL files in batches
|
|
10
|
+
*
|
|
11
|
+
* No checksums, no comparisons, no diffs. Disk wins. Always.
|
|
10
12
|
*/
|
|
11
13
|
export type SyncDirection = 'in-sync' | 'backend-ahead' | 'disk-ahead' | 'diverged';
|
|
12
14
|
export interface SyncStatusResult {
|
|
@@ -42,31 +44,17 @@ export declare class AgentSync {
|
|
|
42
44
|
private projectId;
|
|
43
45
|
private backendUrl;
|
|
44
46
|
private onSyncDirty?;
|
|
45
|
-
private diskDirty;
|
|
46
|
-
private diskChecksumCache;
|
|
47
|
-
private backendChecksumCache;
|
|
48
|
-
private lastSyncTimestamp;
|
|
49
|
-
private syncedBaselineChecksums;
|
|
50
47
|
private fileWatcher;
|
|
51
48
|
private debounceTimer;
|
|
52
49
|
private pendingChangedFiles;
|
|
53
|
-
private
|
|
54
|
-
private
|
|
50
|
+
private syncing;
|
|
51
|
+
private lastVersionId;
|
|
52
|
+
private lastSyncAt;
|
|
55
53
|
constructor(options: AgentSyncOptions);
|
|
56
54
|
startFileWatcher(): void;
|
|
57
55
|
stopFileWatcher(): void;
|
|
58
|
-
private markDiskDirty;
|
|
59
|
-
private computeDiskChecksums;
|
|
60
|
-
private getDiskData;
|
|
61
|
-
private fetchBackendChecksums;
|
|
62
|
-
private filterBackendChecksums;
|
|
63
56
|
getSyncStatus(): Promise<SyncStatusResult>;
|
|
64
57
|
getPerFileStatus(): Promise<PerFileStatus[]>;
|
|
65
|
-
getDiskChanges(): Promise<{
|
|
66
|
-
modified: Record<string, string>;
|
|
67
|
-
added: Record<string, string>;
|
|
68
|
-
deleted: string[];
|
|
69
|
-
}>;
|
|
70
58
|
pushToBackend(): Promise<{
|
|
71
59
|
success: boolean;
|
|
72
60
|
filesPushed: number;
|
|
@@ -78,13 +66,11 @@ export declare class AgentSync {
|
|
|
78
66
|
message?: string;
|
|
79
67
|
}>;
|
|
80
68
|
markSynced(versionId: string): void;
|
|
81
|
-
private snapshotBaseline;
|
|
82
69
|
/** Read a single file from disk (for diff view) */
|
|
83
70
|
readDiskFile(filePath: string): Promise<string | null>;
|
|
84
71
|
/**
|
|
85
72
|
* 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.
|
|
73
|
+
* After writing, clears pending changes (the writes would trigger the watcher).
|
|
88
74
|
*/
|
|
89
75
|
pullFromBackend(): Promise<{
|
|
90
76
|
success: boolean;
|
package/dist/agentSync.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
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 files change on disk → do a FULL SYNC (same as `nstantpage sync`):
|
|
7
|
+
* 1. POST /api/projects/{id}/sync — new version + clear ALL FullFiles
|
|
8
|
+
* 2. Collect ALL disk files
|
|
9
|
+
* 3. POST /api/sandbox/push-files — push ALL files in batches
|
|
10
|
+
*
|
|
11
|
+
* No checksums, no comparisons, no diffs. Disk wins. Always.
|
|
10
12
|
*/
|
|
11
|
-
import crypto from 'crypto';
|
|
12
13
|
import path from 'path';
|
|
13
14
|
import fs from 'fs/promises';
|
|
14
15
|
import { watch, existsSync } from 'fs';
|
|
15
|
-
// ─── Skip Patterns
|
|
16
|
+
// ─── Skip Patterns ────────────────────────────────────────────
|
|
16
17
|
const SKIP_DIRS = new Set([
|
|
17
18
|
'node_modules', '.git', 'dist', 'build', '.next', 'out',
|
|
18
19
|
'.vite-cache', '.cache', '.turbo', 'coverage', 'tools',
|
|
@@ -20,27 +21,20 @@ const SKIP_DIRS = new Set([
|
|
|
20
21
|
]);
|
|
21
22
|
const SKIP_FILES = new Set([
|
|
22
23
|
'.DS_Store', 'Thumbs.db', '.diagramgpt-meta.json',
|
|
23
|
-
'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock',
|
|
24
|
-
'.npmrc', '.gitignore', 'README.md',
|
|
25
24
|
]);
|
|
26
25
|
const SKIP_EXTENSIONS = new Set([
|
|
27
|
-
'.map', '.log', '.tmp', '.swp', '.swo',
|
|
28
|
-
'.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
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',
|
|
26
|
+
'.map', '.log', '.tmp', '.swp', '.swo',
|
|
27
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.tiff',
|
|
28
|
+
'.mp4', '.mp3', '.wav', '.ogg', '.webm', '.avi',
|
|
29
|
+
'.zip', '.tar', '.gz', '.rar', '.7z',
|
|
30
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
31
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
|
|
32
|
+
'.exe', '.dll', '.so', '.dylib',
|
|
33
|
+
'.sqlite', '.db',
|
|
34
|
+
'.svg',
|
|
43
35
|
]);
|
|
36
|
+
/** Max file size to sync (1MB) */
|
|
37
|
+
const MAX_FILE_SIZE = 1_048_576;
|
|
44
38
|
function shouldSkip(name, isDir) {
|
|
45
39
|
if (isDir)
|
|
46
40
|
return SKIP_DIRS.has(name) || name.startsWith('.');
|
|
@@ -48,31 +42,53 @@ function shouldSkip(name, isDir) {
|
|
|
48
42
|
return true;
|
|
49
43
|
if (name.startsWith('.'))
|
|
50
44
|
return true;
|
|
51
|
-
const ext = path.extname(name);
|
|
45
|
+
const ext = path.extname(name).toLowerCase();
|
|
52
46
|
return SKIP_EXTENSIONS.has(ext);
|
|
53
47
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
48
|
+
/** Check if a relative path should be skipped (checks every path component) */
|
|
49
|
+
function shouldSkipPath(relPath) {
|
|
50
|
+
const parts = relPath.split(/[\/\\]/);
|
|
51
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
52
|
+
if (shouldSkip(parts[i], true))
|
|
53
|
+
return true;
|
|
58
54
|
}
|
|
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');
|
|
55
|
+
return shouldSkip(parts[parts.length - 1], false);
|
|
67
56
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
57
|
+
// ─── Collect All Files (same as sync.ts) ──────────────────────
|
|
58
|
+
async function collectAllFiles(baseDir, currentDir) {
|
|
59
|
+
const files = [];
|
|
60
|
+
let entries;
|
|
61
|
+
try {
|
|
62
|
+
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
74
63
|
}
|
|
75
|
-
|
|
64
|
+
catch {
|
|
65
|
+
return files;
|
|
66
|
+
}
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
if (shouldSkip(entry.name, true))
|
|
71
|
+
continue;
|
|
72
|
+
const sub = await collectAllFiles(baseDir, fullPath);
|
|
73
|
+
files.push(...sub);
|
|
74
|
+
}
|
|
75
|
+
else if (entry.isFile()) {
|
|
76
|
+
if (shouldSkip(entry.name, false))
|
|
77
|
+
continue;
|
|
78
|
+
try {
|
|
79
|
+
const stat = await fs.stat(fullPath);
|
|
80
|
+
if (stat.size > MAX_FILE_SIZE)
|
|
81
|
+
continue;
|
|
82
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
83
|
+
if (content.includes('\0'))
|
|
84
|
+
continue; // binary
|
|
85
|
+
const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
|
|
86
|
+
files.push({ relativePath, content });
|
|
87
|
+
}
|
|
88
|
+
catch { /* unreadable — skip */ }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return files;
|
|
76
92
|
}
|
|
77
93
|
export class AgentSync {
|
|
78
94
|
projectDir;
|
|
@@ -80,17 +96,12 @@ export class AgentSync {
|
|
|
80
96
|
backendUrl;
|
|
81
97
|
onSyncDirty;
|
|
82
98
|
// State
|
|
83
|
-
diskDirty = false;
|
|
84
|
-
diskChecksumCache = null;
|
|
85
|
-
backendChecksumCache = null;
|
|
86
|
-
lastSyncTimestamp = null;
|
|
87
|
-
syncedBaselineChecksums = null;
|
|
88
99
|
fileWatcher = null;
|
|
89
100
|
debounceTimer = null;
|
|
90
101
|
pendingChangedFiles = new Set();
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
syncing = false;
|
|
103
|
+
lastVersionId = null;
|
|
104
|
+
lastSyncAt = null;
|
|
94
105
|
constructor(options) {
|
|
95
106
|
this.projectDir = options.projectDir;
|
|
96
107
|
this.projectId = options.projectId;
|
|
@@ -103,28 +114,23 @@ export class AgentSync {
|
|
|
103
114
|
return;
|
|
104
115
|
if (!existsSync(this.projectDir))
|
|
105
116
|
return;
|
|
106
|
-
/** Track whether we're currently writing files (skip self-triggered events) */
|
|
107
|
-
let selfWriting = false;
|
|
108
117
|
try {
|
|
109
|
-
this.fileWatcher = watch(this.projectDir, { recursive: true }, (
|
|
110
|
-
if (!filename
|
|
111
|
-
return;
|
|
112
|
-
const parts = filename.split(path.sep);
|
|
113
|
-
if (parts.some(p => SKIP_DIRS.has(p) || (p.startsWith('.') && p !== '.')))
|
|
118
|
+
this.fileWatcher = watch(this.projectDir, { recursive: true }, (_eventType, filename) => {
|
|
119
|
+
if (!filename)
|
|
114
120
|
return;
|
|
115
|
-
|
|
116
|
-
if (shouldSkip(basename, false))
|
|
121
|
+
if (shouldSkipPath(filename))
|
|
117
122
|
return;
|
|
118
|
-
// Accumulate changed filenames
|
|
119
123
|
this.pendingChangedFiles.add(filename);
|
|
120
124
|
if (this.debounceTimer)
|
|
121
125
|
clearTimeout(this.debounceTimer);
|
|
122
126
|
this.debounceTimer = setTimeout(() => {
|
|
123
|
-
const
|
|
124
|
-
this.pendingChangedFiles.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
const count = this.pendingChangedFiles.size;
|
|
128
|
+
const preview = [...this.pendingChangedFiles].slice(0, 5).join(', ');
|
|
129
|
+
console.log(` [AgentSync] Change detected: ${count} file(s) — ${preview}${count > 5 ? '...' : ''}`);
|
|
130
|
+
if (this.onSyncDirty) {
|
|
131
|
+
this.onSyncDirty(this.projectId);
|
|
132
|
+
}
|
|
133
|
+
}, 1000);
|
|
128
134
|
});
|
|
129
135
|
this.fileWatcher.on('error', (err) => {
|
|
130
136
|
console.warn(` [AgentSync] File watcher error:`, err.message);
|
|
@@ -146,410 +152,126 @@ export class AgentSync {
|
|
|
146
152
|
this.debounceTimer = null;
|
|
147
153
|
}
|
|
148
154
|
}
|
|
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
|
-
}
|
|
155
|
+
// ─── Sync Status ─────────────────────────────────────────
|
|
156
|
+
async getSyncStatus() {
|
|
157
|
+
const pending = this.pendingChangedFiles.size;
|
|
158
|
+
const isDirty = pending > 0 && !this.syncing;
|
|
159
|
+
return {
|
|
160
|
+
inSync: !isDirty,
|
|
161
|
+
direction: isDirty ? 'disk-ahead' : 'in-sync',
|
|
162
|
+
diskChecksum: null,
|
|
163
|
+
backendChecksum: null,
|
|
164
|
+
diskFileCount: 0,
|
|
165
|
+
backendFileCount: 0,
|
|
166
|
+
modifiedFiles: [...this.pendingChangedFiles].sort(),
|
|
167
|
+
diskOnlyFiles: [],
|
|
168
|
+
backendOnlyFiles: [],
|
|
169
|
+
lastSyncedVersionId: this.lastVersionId,
|
|
170
|
+
lastSyncedAt: this.lastSyncAt,
|
|
171
|
+
diskDirty: isDirty,
|
|
172
|
+
computedAt: Date.now(),
|
|
190
173
|
};
|
|
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
174
|
}
|
|
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;
|
|
175
|
+
// ─── Per-File Status ──────────────────────────────────────
|
|
176
|
+
async getPerFileStatus() {
|
|
177
|
+
return [...this.pendingChangedFiles].sort().map(f => ({
|
|
178
|
+
path: f,
|
|
179
|
+
status: 'modified',
|
|
180
|
+
}));
|
|
222
181
|
}
|
|
223
|
-
// ─── Backend
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
182
|
+
// ─── Push to Backend (Full Sync) ──────────────────────────
|
|
183
|
+
//
|
|
184
|
+
// Does EXACTLY what `nstantpage sync` does:
|
|
185
|
+
// 1. POST /api/projects/{id}/sync — new version + clear ALL FullFiles
|
|
186
|
+
// 2. Collect ALL files from disk
|
|
187
|
+
// 3. POST /api/sandbox/push-files — push ALL files in batches of 100
|
|
188
|
+
async pushToBackend() {
|
|
189
|
+
if (this.pendingChangedFiles.size === 0) {
|
|
228
190
|
return {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
fileCount: this.backendChecksumCache.checksums.size,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
try {
|
|
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(),
|
|
191
|
+
success: true, filesPushed: 0, filesDeleted: 0,
|
|
192
|
+
modifiedFiles: [], addedFiles: [], deletedFiles: [],
|
|
193
|
+
message: 'Already in sync — no changes to push',
|
|
256
194
|
};
|
|
257
|
-
return result;
|
|
258
195
|
}
|
|
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) {
|
|
196
|
+
if (this.syncing) {
|
|
297
197
|
return {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
backendChecksum: null,
|
|
302
|
-
diskFileCount: diskResult.checksums.size,
|
|
303
|
-
backendFileCount: 0,
|
|
304
|
-
modifiedFiles: [],
|
|
305
|
-
diskOnlyFiles: [],
|
|
306
|
-
backendOnlyFiles: [],
|
|
307
|
-
lastSyncedVersionId: this.lastSyncTimestamp?.versionId || null,
|
|
308
|
-
lastSyncedAt: this.lastSyncTimestamp?.timestamp || null,
|
|
309
|
-
diskDirty: this.diskDirty,
|
|
310
|
-
computedAt: Date.now(),
|
|
198
|
+
success: true, filesPushed: 0, filesDeleted: 0,
|
|
199
|
+
modifiedFiles: [], addedFiles: [], deletedFiles: [],
|
|
200
|
+
message: 'Sync already in progress',
|
|
311
201
|
};
|
|
312
202
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
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 });
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
if (this.lastSyncTimestamp && this.lastSyncTimestamp.versionId !== backendResult.versionId) {
|
|
418
|
-
for (const [filePath, backendSha] of filteredBackend) {
|
|
419
|
-
if (!baseline.has(filePath) && !diskComputed.checksums.has(filePath)) {
|
|
420
|
-
result.push({ path: filePath, status: 'backend-only', backendSha });
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
else {
|
|
426
|
-
const sortedBackend = [...filteredBackend.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
427
|
-
for (const [filePath, backendSha] of sortedBackend) {
|
|
428
|
-
const diskSha = diskComputed.checksums.get(filePath);
|
|
429
|
-
if (diskSha) {
|
|
430
|
-
result.push({
|
|
431
|
-
path: filePath,
|
|
432
|
-
status: diskSha === backendSha ? 'same' : 'modified',
|
|
433
|
-
diskSha,
|
|
434
|
-
backendSha,
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
else {
|
|
438
|
-
result.push({ path: filePath, status: 'backend-only', backendSha });
|
|
439
|
-
}
|
|
203
|
+
this.syncing = true;
|
|
204
|
+
try {
|
|
205
|
+
// Step 1: Create new version + clear all FullFiles
|
|
206
|
+
const syncRes = await fetch(`${this.backendUrl}/api/projects/${this.projectId}/sync`, {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'Content-Type': 'application/json' },
|
|
209
|
+
});
|
|
210
|
+
if (!syncRes.ok) {
|
|
211
|
+
const text = await syncRes.text().catch(() => '');
|
|
212
|
+
throw new Error(`Sync init failed (${syncRes.status}): ${text}`);
|
|
440
213
|
}
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
214
|
+
const syncData = await syncRes.json();
|
|
215
|
+
console.log(` [AgentSync] New version ${syncData.versionNumber} created (old files cleared)`);
|
|
216
|
+
// Step 2: Collect ALL files from disk
|
|
217
|
+
const allFiles = await collectAllFiles(this.projectDir, this.projectDir);
|
|
218
|
+
// Step 3: Push in batches of 100
|
|
219
|
+
const BATCH_SIZE = 100;
|
|
220
|
+
let totalPushed = 0;
|
|
221
|
+
for (let i = 0; i < allFiles.length; i += BATCH_SIZE) {
|
|
222
|
+
const batch = allFiles.slice(i, i + BATCH_SIZE);
|
|
223
|
+
const filesMap = {};
|
|
224
|
+
for (const f of batch) {
|
|
225
|
+
filesMap[f.relativePath] = f.content;
|
|
445
226
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const deleted = [];
|
|
458
|
-
if (!backendResult)
|
|
459
|
-
return { modified, added, deleted };
|
|
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
|
-
}
|
|
227
|
+
const pushRes = await fetch(`${this.backendUrl}/api/sandbox/push-files`, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'Content-Type': 'application/json' },
|
|
230
|
+
body: JSON.stringify({
|
|
231
|
+
projectId: parseInt(this.projectId, 10),
|
|
232
|
+
files: filesMap,
|
|
233
|
+
}),
|
|
234
|
+
});
|
|
235
|
+
if (!pushRes.ok) {
|
|
236
|
+
const text = await pushRes.text().catch(() => '');
|
|
237
|
+
throw new Error(`Push batch failed (${pushRes.status}): ${text}`);
|
|
480
238
|
}
|
|
481
|
-
|
|
482
|
-
if (content !== undefined)
|
|
483
|
-
added[filePath] = content;
|
|
239
|
+
totalPushed += batch.length;
|
|
484
240
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const totalChanges = Object.keys(changes.modified).length +
|
|
492
|
-
Object.keys(changes.added).length +
|
|
493
|
-
changes.deleted.length;
|
|
494
|
-
if (totalChanges === 0) {
|
|
241
|
+
// Done — clear pending
|
|
242
|
+
this.pendingChangedFiles.clear();
|
|
243
|
+
this.lastVersionId = String(syncData.versionId);
|
|
244
|
+
this.lastSyncAt = Date.now();
|
|
245
|
+
const fileNames = allFiles.map(f => f.relativePath);
|
|
246
|
+
console.log(` [AgentSync] Full sync complete: ${totalPushed} files pushed (version ${syncData.versionNumber})`);
|
|
495
247
|
return {
|
|
496
248
|
success: true,
|
|
497
|
-
filesPushed:
|
|
249
|
+
filesPushed: totalPushed,
|
|
498
250
|
filesDeleted: 0,
|
|
499
|
-
modifiedFiles:
|
|
251
|
+
modifiedFiles: fileNames,
|
|
500
252
|
addedFiles: [],
|
|
501
253
|
deletedFiles: [],
|
|
502
|
-
|
|
254
|
+
versionId: String(syncData.versionId),
|
|
503
255
|
};
|
|
504
256
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
method: 'POST',
|
|
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}`);
|
|
257
|
+
catch (err) {
|
|
258
|
+
console.error(` [AgentSync] Sync failed:`, err.message);
|
|
259
|
+
throw err;
|
|
519
260
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
this.diskChecksumCache = null;
|
|
523
|
-
this.backendChecksumCache = null;
|
|
524
|
-
// Mark as synced
|
|
525
|
-
if (pushResult.versionId) {
|
|
526
|
-
this.markSynced(String(pushResult.versionId));
|
|
261
|
+
finally {
|
|
262
|
+
this.syncing = false;
|
|
527
263
|
}
|
|
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
264
|
}
|
|
539
|
-
// ─── Sync Markers
|
|
265
|
+
// ─── Sync Markers (for compatibility with start.ts) ───────
|
|
540
266
|
markSynced(versionId) {
|
|
541
|
-
this.
|
|
542
|
-
this.
|
|
543
|
-
this.
|
|
544
|
-
this.
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const computed = await this.computeDiskChecksums();
|
|
550
|
-
this.syncedBaselineChecksums = computed.checksums;
|
|
551
|
-
this.diskChecksumCache = { ...computed, computedAt: Date.now() };
|
|
552
|
-
console.log(` [AgentSync] Baseline snapshot: ${computed.checksums.size} files`);
|
|
267
|
+
this.lastVersionId = versionId;
|
|
268
|
+
this.lastSyncAt = Date.now();
|
|
269
|
+
this.pendingChangedFiles.clear();
|
|
270
|
+
if (this.debounceTimer) {
|
|
271
|
+
clearTimeout(this.debounceTimer);
|
|
272
|
+
this.debounceTimer = null;
|
|
273
|
+
}
|
|
274
|
+
console.log(` [AgentSync] Marked synced at version ${versionId}`);
|
|
553
275
|
}
|
|
554
276
|
/** Read a single file from disk (for diff view) */
|
|
555
277
|
async readDiskFile(filePath) {
|
|
@@ -566,8 +288,7 @@ export class AgentSync {
|
|
|
566
288
|
}
|
|
567
289
|
/**
|
|
568
290
|
* 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.
|
|
291
|
+
* After writing, clears pending changes (the writes would trigger the watcher).
|
|
571
292
|
*/
|
|
572
293
|
async pullFromBackend() {
|
|
573
294
|
const url = `${this.backendUrl}/api/sandbox/files?projectId=${this.projectId}`;
|
|
@@ -579,40 +300,37 @@ export class AgentSync {
|
|
|
579
300
|
if (!data.files || data.files.length === 0) {
|
|
580
301
|
return { success: true, filesWritten: 0 };
|
|
581
302
|
}
|
|
582
|
-
// Ensure directories exist
|
|
583
303
|
const dirsToCreate = new Set();
|
|
584
304
|
for (const file of data.files) {
|
|
585
|
-
const
|
|
586
|
-
dirsToCreate.add(path.dirname(
|
|
305
|
+
const fp = path.join(this.projectDir, file.path);
|
|
306
|
+
dirsToCreate.add(path.dirname(fp));
|
|
587
307
|
}
|
|
588
308
|
for (const dir of dirsToCreate) {
|
|
589
309
|
await fs.mkdir(dir, { recursive: true }).catch(() => { });
|
|
590
310
|
}
|
|
591
|
-
// Write files in parallel batches
|
|
592
311
|
const BATCH_SIZE = 50;
|
|
593
312
|
let written = 0;
|
|
594
313
|
for (let i = 0; i < data.files.length; i += BATCH_SIZE) {
|
|
595
314
|
const batch = data.files.slice(i, i + BATCH_SIZE);
|
|
596
315
|
await Promise.all(batch.map(async (file) => {
|
|
597
|
-
const
|
|
598
|
-
await fs.writeFile(
|
|
316
|
+
const fp = path.join(this.projectDir, file.path);
|
|
317
|
+
await fs.writeFile(fp, file.content, 'utf-8');
|
|
599
318
|
}));
|
|
600
319
|
written += batch.length;
|
|
601
320
|
}
|
|
602
|
-
|
|
603
|
-
this.
|
|
604
|
-
this.
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
321
|
+
this.lastVersionId = String(data.versionId);
|
|
322
|
+
this.lastSyncAt = Date.now();
|
|
323
|
+
this.pendingChangedFiles.clear();
|
|
324
|
+
if (this.debounceTimer) {
|
|
325
|
+
clearTimeout(this.debounceTimer);
|
|
326
|
+
this.debounceTimer = null;
|
|
327
|
+
}
|
|
328
|
+
console.log(` [AgentSync] Pulled ${written} files from backend (version ${data.versionId})`);
|
|
329
|
+
return { success: true, filesWritten: written, versionId: String(data.versionId) };
|
|
609
330
|
}
|
|
610
331
|
// ─── Cleanup ──────────────────────────────────────────────
|
|
611
332
|
destroy() {
|
|
612
333
|
this.stopFileWatcher();
|
|
613
|
-
this.diskChecksumCache = null;
|
|
614
|
-
this.backendChecksumCache = null;
|
|
615
|
-
this.syncedBaselineChecksums = null;
|
|
616
334
|
this.pendingChangedFiles.clear();
|
|
617
335
|
}
|
|
618
336
|
}
|
package/package.json
CHANGED