@dench.com/cli 2.4.0 → 2.4.1
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/fs-daemon.ts +56 -5
- package/package.json +1 -1
package/fs-daemon.ts
CHANGED
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
* `lastSyncedHash`, we download from Convex Storage to /workspace.
|
|
16
16
|
* • Per-path 300ms trailing debounce on uploads (batches tight write
|
|
17
17
|
* loops like editor saves).
|
|
18
|
-
* • Optimistic conflict via `expectedPreviousHash`. On 409, we
|
|
19
|
-
*
|
|
18
|
+
* • Optimistic conflict via `expectedPreviousHash`. On 409, we stash the
|
|
19
|
+
* local copy at the next free `<name> (N).<ext>` sibling (reusing an
|
|
20
|
+
* existing sibling that already holds the same bytes), then re-pull the
|
|
21
|
+
* latest canonical version. Surfaced in UI.
|
|
20
22
|
* • Per-path circuit breaker: if a path syncs >5 times in 10s, pause
|
|
21
23
|
* sync on that path and log loudly to stderr (the live state of
|
|
22
24
|
* active breakers is exposed via `dench fs status`).
|
|
@@ -58,7 +60,7 @@ import {
|
|
|
58
60
|
unlink,
|
|
59
61
|
writeFile,
|
|
60
62
|
} from "node:fs/promises";
|
|
61
|
-
import { dirname, relative } from "node:path";
|
|
63
|
+
import { basename, dirname, extname, join, relative } from "node:path";
|
|
62
64
|
import { ConvexClient, ConvexHttpClient } from "convex/browser";
|
|
63
65
|
import { makeFunctionReference } from "convex/server";
|
|
64
66
|
import { decodeInlineTextOrUndefined } from "./fs-daemon-binary";
|
|
@@ -149,6 +151,56 @@ function sha256(bytes: Uint8Array): string {
|
|
|
149
151
|
return createHash("sha256").update(bytes).digest("hex");
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
// Stash a conflicting local copy at a sibling path, using an incrementing
|
|
155
|
+
// suffix (`foo (1).txt`, `foo (2).txt`, …) instead of a timestamp so
|
|
156
|
+
// repeated conflicts produce a clean, stable series rather than an
|
|
157
|
+
// ever-growing pile of `.conflicted-<ts>` files. The file is written
|
|
158
|
+
// locally; the daemon's normal watcher path syncs it to Convex like any
|
|
159
|
+
// other file. Returns the local path written.
|
|
160
|
+
//
|
|
161
|
+
// Two properties keep the deterministic suffix safe:
|
|
162
|
+
// • Content-aware reuse — if a `(N)` slot already holds these exact bytes,
|
|
163
|
+
// the copy is already stashed, so we return it instead of minting yet
|
|
164
|
+
// another sibling. This is what keeps repeated conflicts on the same
|
|
165
|
+
// unsynced content from piling up (the whole point of the change), and
|
|
166
|
+
// it also means an unrelated remote `(N)` file mirrored to local disk is
|
|
167
|
+
// skipped rather than overwritten.
|
|
168
|
+
// • Atomic claim — otherwise we write with O_EXCL (`wx`), so two handlers
|
|
169
|
+
// racing for the same slot can't clobber each other; the loser gets
|
|
170
|
+
// EEXIST and advances to the next N.
|
|
171
|
+
async function stashConflictCopy(args: {
|
|
172
|
+
absPath: string;
|
|
173
|
+
bytes: Uint8Array;
|
|
174
|
+
hash: string;
|
|
175
|
+
}): Promise<string> {
|
|
176
|
+
const { absPath, bytes, hash } = args;
|
|
177
|
+
const dir = dirname(absPath);
|
|
178
|
+
const ext = extname(absPath);
|
|
179
|
+
const stem = basename(absPath, ext);
|
|
180
|
+
for (let i = 1; i <= 1000; i++) {
|
|
181
|
+
const candidate = join(dir, `${stem} (${i})${ext}`);
|
|
182
|
+
const existing = await readFile(candidate).catch(() => null);
|
|
183
|
+
if (existing) {
|
|
184
|
+
// Already holds our bytes → nothing to do. Different content → this
|
|
185
|
+
// slot belongs to something else, try the next one.
|
|
186
|
+
if (sha256(existing) === hash) return candidate;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
await writeFile(candidate, bytes, { flag: "wx" });
|
|
191
|
+
return candidate;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Every increment up to the cap is taken — fall back to a timestamp, which
|
|
198
|
+
// is effectively unique, so we still stash the bytes somewhere.
|
|
199
|
+
const fallback = join(dir, `${stem} (${Date.now()})${ext}`);
|
|
200
|
+
await writeFile(fallback, bytes);
|
|
201
|
+
return fallback;
|
|
202
|
+
}
|
|
203
|
+
|
|
152
204
|
function normalizePosixWorkspacePath(input: string): string {
|
|
153
205
|
let cleaned = input.replace(/\\/g, "/").replace(/\/+/g, "/");
|
|
154
206
|
if (cleaned.startsWith("/workspace/")) {
|
|
@@ -772,8 +824,7 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
|
|
|
772
824
|
if (result.versionConflict) {
|
|
773
825
|
// Stash the local copy in a sibling file, then re-pull the remote
|
|
774
826
|
// version into the canonical path.
|
|
775
|
-
const conflicted =
|
|
776
|
-
await writeFile(conflicted, bytes);
|
|
827
|
+
const conflicted = await stashConflictCopy({ absPath, bytes, hash });
|
|
777
828
|
console.warn(
|
|
778
829
|
`[dench-fs-daemon] version conflict for ${posixPath}; local copy stashed at ${conflicted}`,
|
|
779
830
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dench.com/cli",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "Dench agent workspace CLI. v2 unifies auth behind `dench signin`; the legacy `dench login` / `dench onboard` / `dench setup` / `dench what-can-i-do` / `dench register` commands now error with a redirect.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|