nstantpage-agent 0.8.3 → 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.
@@ -1,12 +1,16 @@
1
1
  /**
2
- * agentSync.ts — File sync logic for the local agent.
2
+ * agentSync.ts — Simplified file sync for the local agent.
3
3
  *
4
- * Mirrors the gateway's syncStatus.ts but runs entirely on the user's machine.
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
- * Also manages an fs.watch file watcher that triggers sync-dirty notifications
9
- * through the tunnel to the gateway (which relays to the frontend WebSocket).
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 readonly DISK_CACHE_TTL_MS;
54
- private readonly BACKEND_CACHE_TTL_MS;
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
- * This fetches ALL project files and overwrites local versions.
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 — File sync logic for the local agent.
2
+ * agentSync.ts — Simplified file sync for the local agent.
3
3
  *
4
- * Mirrors the gateway's syncStatus.ts but runs entirely on the user's machine.
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
- * Also manages an fs.watch file watcher that triggers sync-dirty notifications
9
- * through the tunnel to the gateway (which relays to the frontend WebSocket).
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 (same as gateway syncStatus.ts) ────────────
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,23 +30,9 @@ 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
- return SKIP_DIRS.has(name);
35
+ return SKIP_DIRS.has(name) || name.startsWith('.');
47
36
  if (SKIP_FILES.has(name))
48
37
  return true;
49
38
  if (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
- function isUserCreatedFile(filePath) {
55
- if (filePath.startsWith('src/') || filePath.startsWith('server/') ||
56
- filePath.startsWith('shared/') || filePath.startsWith('public/')) {
57
- return true;
58
- }
59
- const basename = path.basename(filePath);
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 hash.digest('hex');
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
- // Cache TTLs
92
- DISK_CACHE_TTL_MS = 5000;
93
- BACKEND_CACHE_TTL_MS = 15000;
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 }, (eventType, filename) => {
110
- if (!filename || selfWriting)
77
+ this.fileWatcher = watch(this.projectDir, { recursive: true }, (_eventType, filename) => {
78
+ if (!filename)
111
79
  return;
112
- const parts = filename.split(path.sep);
113
- if (parts.some(p => SKIP_DIRS.has(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 changedFiles = [...this.pendingChangedFiles];
124
- this.pendingChangedFiles.clear();
125
- this.markDiskDirty();
126
- console.log(` [AgentSync] File change detected: ${changedFiles.length} file(s) — ${changedFiles.slice(0, 5).join(', ')}${changedFiles.length > 5 ? '...' : ''}`);
127
- }, 500);
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,408 +112,147 @@ export class AgentSync {
146
112
  this.debounceTimer = null;
147
113
  }
148
114
  }
149
- // ─── Disk Dirty State ─────────────────────────────────────
150
- markDiskDirty() {
151
- this.diskDirty = true;
152
- this.diskChecksumCache = null; // Force recompute
153
- // Notify on every change — the debounce timer in the file watcher
154
- // already batches rapid changes, so this fires at most once per debounce window.
155
- if (this.onSyncDirty) {
156
- this.onSyncDirty(this.projectId);
157
- }
158
- }
159
- // ─── Disk Checksums ───────────────────────────────────────
160
- async computeDiskChecksums() {
161
- const checksums = new Map();
162
- const fileContents = new Map();
163
- if (!existsSync(this.projectDir)) {
164
- return { checksums, fileContents, globalChecksum: '' };
165
- }
166
- // Phase 1: Collect all file paths
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
- async getDiskData() {
212
- // Use cache if fresh and not dirty
213
- if (this.diskChecksumCache && !this.diskDirty &&
214
- Date.now() - this.diskChecksumCache.computedAt < this.DISK_CACHE_TTL_MS) {
215
- return this.diskChecksumCache;
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 Checksums ────────────────────────────────────
224
- async fetchBackendChecksums() {
225
- // Check cache first
226
- if (this.backendChecksumCache &&
227
- Date.now() - this.backendChecksumCache.fetchedAt < this.BACKEND_CACHE_TTL_MS) {
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
- checksums: this.backendChecksumCache.checksums,
230
- globalChecksum: this.backendChecksumCache.globalChecksum,
231
- versionId: this.backendChecksumCache.versionId,
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(),
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
- catch (error) {
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 directories
282
- const parts = filePath.split('/');
283
- if (parts.some(p => SKIP_DIRS.has(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
- inSync: true,
299
- direction: 'in-sync',
300
- diskChecksum: diskResult.globalChecksum || null,
301
- backendChecksum: null,
302
- diskFileCount: diskResult.checksums.size,
303
- backendFileCount: 0,
162
+ success: true,
163
+ filesPushed: 0,
164
+ filesDeleted: 0,
304
165
  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(),
166
+ addedFiles: [],
167
+ deletedFiles: [],
168
+ message: 'Push already in progress',
311
169
  };
312
170
  }
313
- const filteredBackend = this.filterBackendChecksums(backendResult.checksums);
314
- const baseline = this.syncedBaselineChecksums;
315
- const modifiedFiles = [];
316
- const diskOnlyFiles = [];
317
- const backendOnlyFiles = [];
318
- if (baseline) {
319
- // Baseline mode: detect user edits since last sync
320
- for (const [filePath, baselineSha] of baseline) {
321
- const currentDiskSha = diskResult.checksums.get(filePath);
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
- 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 });
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
- 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
- }
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
- result.sort((a, b) => a.path.localeCompare(b.path));
449
- return result;
450
- }
451
- // ─── Disk Changes (for push-to-backend) ───────────────────
452
- async getDiskChanges() {
453
- const diskData = await this.getDiskData();
454
- const backendResult = await this.fetchBackendChecksums();
455
- const modified = {};
456
- const added = {};
457
- const deleted = [];
458
- if (!backendResult)
459
- return { modified, added, deleted };
460
- for (const [filePath, backendSha] of backendResult.checksums) {
461
- const diskSha = diskData.checksums.get(filePath);
462
- if (!diskSha) {
463
- deleted.push(filePath);
464
- }
465
- else if (diskSha !== backendSha) {
466
- const content = diskData.fileContents.get(filePath);
467
- if (content !== undefined)
468
- modified[filePath] = content;
469
- }
470
- }
471
- for (const [filePath] of diskData.checksums) {
472
- if (!backendResult.checksums.has(filePath) && isUserCreatedFile(filePath)) {
473
- if (this.syncedBaselineChecksums) {
474
- const baselineSha = this.syncedBaselineChecksums.get(filePath);
475
- if (baselineSha && baselineSha === diskData.checksums.get(filePath)) {
476
- continue; // Unchanged since initial sync — template noise
477
- }
478
- }
479
- const content = diskData.fileContents.get(filePath);
480
- if (content !== undefined)
481
- 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}`);
482
216
  }
483
- }
484
- return { modified, added, deleted };
485
- }
486
- // ─── Push to Backend ──────────────────────────────────────
487
- async pushToBackend() {
488
- const changes = await this.getDiskChanges();
489
- const totalChanges = Object.keys(changes.modified).length +
490
- Object.keys(changes.added).length +
491
- changes.deleted.length;
492
- 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}`);
493
226
  return {
494
227
  success: true,
495
- filesPushed: 0,
496
- filesDeleted: 0,
497
- modifiedFiles: [],
228
+ filesPushed: fileNames.length,
229
+ filesDeleted: deleted.length,
230
+ modifiedFiles: fileNames,
498
231
  addedFiles: [],
499
- deletedFiles: [],
500
- message: 'Already in sync no changes to push',
232
+ deletedFiles: deleted,
233
+ versionId: pushResult.versionId ? String(pushResult.versionId) : undefined,
501
234
  };
502
235
  }
503
- const allFiles = { ...changes.modified, ...changes.added };
504
- const pushUrl = `${this.backendUrl}/api/sandbox/push-files`;
505
- const pushResponse = await fetch(pushUrl, {
506
- method: 'POST',
507
- headers: { 'Content-Type': 'application/json' },
508
- body: JSON.stringify({
509
- projectId: parseInt(this.projectId, 10),
510
- files: allFiles,
511
- deletedFiles: changes.deleted,
512
- }),
513
- });
514
- if (!pushResponse.ok) {
515
- const errorText = await pushResponse.text();
516
- 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;
517
240
  }
518
- const pushResult = await pushResponse.json();
519
- // Invalidate caches
520
- this.diskChecksumCache = null;
521
- this.backendChecksumCache = null;
522
- // Mark as synced
523
- if (pushResult.versionId) {
524
- this.markSynced(String(pushResult.versionId));
241
+ finally {
242
+ this.pushing = false;
525
243
  }
526
- console.log(` [AgentSync] Pushed ${Object.keys(allFiles).length} files, deleted ${changes.deleted.length}`);
527
- return {
528
- success: true,
529
- filesPushed: Object.keys(allFiles).length,
530
- filesDeleted: changes.deleted.length,
531
- modifiedFiles: Object.keys(changes.modified),
532
- addedFiles: Object.keys(changes.added),
533
- deletedFiles: changes.deleted,
534
- versionId: pushResult.versionId ? String(pushResult.versionId) : undefined,
535
- };
536
244
  }
537
- // ─── Sync Markers ────────────────────────────────────────
245
+ // ─── Sync Markers (for compatibility with start.ts) ───────
538
246
  markSynced(versionId) {
539
- this.lastSyncTimestamp = { versionId, timestamp: Date.now() };
540
- this.diskDirty = false;
541
- this.diskChecksumCache = null;
542
- this.backendChecksumCache = null;
543
- // Snapshot current disk checksums as baseline (fire-and-forget)
544
- this.snapshotBaseline().catch(err => console.warn(` [AgentSync] Failed to snapshot baseline:`, err.message));
545
- }
546
- async snapshotBaseline() {
547
- const computed = await this.computeDiskChecksums();
548
- this.syncedBaselineChecksums = computed.checksums;
549
- this.diskChecksumCache = { ...computed, computedAt: Date.now() };
550
- 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}`);
551
256
  }
552
257
  /** Read a single file from disk (for diff view) */
553
258
  async readDiskFile(filePath) {
@@ -564,8 +269,7 @@ export class AgentSync {
564
269
  }
565
270
  /**
566
271
  * Pull files from the backend DB and write them to disk.
567
- * This fetches ALL project files and overwrites local versions.
568
- * Files that exist only on disk (not in DB) are left untouched.
272
+ * After writing, clears pending changes (the writes would trigger the watcher).
569
273
  */
570
274
  async pullFromBackend() {
571
275
  const url = `${this.backendUrl}/api/sandbox/files?projectId=${this.projectId}`;
@@ -580,8 +284,8 @@ export class AgentSync {
580
284
  // Ensure directories exist
581
285
  const dirsToCreate = new Set();
582
286
  for (const file of data.files) {
583
- const filePath = path.join(this.projectDir, file.path);
584
- dirsToCreate.add(path.dirname(filePath));
287
+ const fp = path.join(this.projectDir, file.path);
288
+ dirsToCreate.add(path.dirname(fp));
585
289
  }
586
290
  for (const dir of dirsToCreate) {
587
291
  await fs.mkdir(dir, { recursive: true }).catch(() => { });
@@ -592,25 +296,25 @@ export class AgentSync {
592
296
  for (let i = 0; i < data.files.length; i += BATCH_SIZE) {
593
297
  const batch = data.files.slice(i, i + BATCH_SIZE);
594
298
  await Promise.all(batch.map(async (file) => {
595
- const filePath = path.join(this.projectDir, file.path);
596
- await fs.writeFile(filePath, file.content, 'utf-8');
299
+ const fp = path.join(this.projectDir, file.path);
300
+ await fs.writeFile(fp, file.content, 'utf-8');
597
301
  }));
598
302
  written += batch.length;
599
303
  }
600
- // Invalidate caches and re-establish baseline
601
- this.diskChecksumCache = null;
602
- this.backendChecksumCache = null;
603
- const versionId = String(data.versionId);
604
- this.markSynced(versionId);
605
- console.log(` [AgentSync] Pulled ${written} files from backend (version ${versionId})`);
606
- return { success: true, filesWritten: written, versionId };
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) };
607
314
  }
608
315
  // ─── Cleanup ──────────────────────────────────────────────
609
316
  destroy() {
610
317
  this.stopFileWatcher();
611
- this.diskChecksumCache = null;
612
- this.backendChecksumCache = null;
613
- this.syncedBaselineChecksums = null;
614
318
  this.pendingChangedFiles.clear();
615
319
  }
616
320
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nstantpage-agent",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Local development agent for nstantpage.com — run your projects locally, preview in the cloud. Replaces cloud containers for faster builds.",
5
5
  "type": "module",
6
6
  "bin": {