@makerbi/remodex 1.3.8 → 1.3.9
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/package.json +2 -2
- package/src/bridge.js +112 -30
- package/src/ios-app-compatibility.js +2 -2
- package/src/macos-launch-agent.js +5 -0
- package/src/project-handler.js +134 -0
- package/src/workspace-checkpoints.js +406 -0
- package/src/workspace-handler.js +129 -18
- package/README.md +0 -483
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// FILE: workspace-checkpoints.js
|
|
2
|
+
// Purpose: Captures, diffs, and restores hidden Git checkpoints for turn-scoped undo.
|
|
3
|
+
// Layer: Bridge workspace support
|
|
4
|
+
// Exports: workspaceCheckpointCapture, workspaceCheckpointCopy, workspaceCheckpointDiff,
|
|
5
|
+
// workspaceCheckpointRestorePreview, workspaceCheckpointRestoreApply
|
|
6
|
+
// Depends on: child_process, fs, os, path, ./git-handler
|
|
7
|
+
|
|
8
|
+
const { execFile } = require("child_process");
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const os = require("os");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const { promisify } = require("util");
|
|
13
|
+
const { gitStatus } = require("./git-handler");
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
17
|
+
const CHECKPOINT_REFS_PREFIX = "refs/remodex/checkpoints";
|
|
18
|
+
|
|
19
|
+
async function workspaceCheckpointCapture(repoRoot, params) {
|
|
20
|
+
const checkpoint = resolveCheckpointDescriptor(params);
|
|
21
|
+
const commit = await captureGitCheckpoint(repoRoot, checkpoint.ref);
|
|
22
|
+
return {
|
|
23
|
+
repoRoot,
|
|
24
|
+
checkpointRef: checkpoint.ref,
|
|
25
|
+
checkpointKind: checkpoint.kind,
|
|
26
|
+
commit,
|
|
27
|
+
threadId: checkpoint.threadId,
|
|
28
|
+
turnId: checkpoint.turnId || undefined,
|
|
29
|
+
messageId: checkpoint.messageId || undefined,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function workspaceCheckpointCopy(repoRoot, params) {
|
|
34
|
+
const sourceRef = resolveCheckpointRef(params, "source");
|
|
35
|
+
const target = resolveCheckpointDescriptor(params, "target");
|
|
36
|
+
const sourceCommit = await resolveCheckpointCommit(repoRoot, sourceRef);
|
|
37
|
+
if (!sourceCommit) {
|
|
38
|
+
return {
|
|
39
|
+
copied: false,
|
|
40
|
+
repoRoot,
|
|
41
|
+
sourceCheckpointRef: sourceRef,
|
|
42
|
+
checkpointRef: target.ref,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await git(repoRoot, "update-ref", target.ref, sourceCommit);
|
|
47
|
+
return {
|
|
48
|
+
copied: true,
|
|
49
|
+
repoRoot,
|
|
50
|
+
sourceCheckpointRef: sourceRef,
|
|
51
|
+
checkpointRef: target.ref,
|
|
52
|
+
checkpointKind: target.kind,
|
|
53
|
+
commit: sourceCommit,
|
|
54
|
+
threadId: target.threadId,
|
|
55
|
+
turnId: target.turnId || undefined,
|
|
56
|
+
messageId: target.messageId || undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function workspaceCheckpointDiff(repoRoot, params) {
|
|
61
|
+
const fromRef = resolveCheckpointRef(params, "from");
|
|
62
|
+
const toRef = resolveCheckpointRef(params, "to");
|
|
63
|
+
const [fromCommit, toCommit] = await Promise.all([
|
|
64
|
+
resolveCheckpointCommit(repoRoot, fromRef),
|
|
65
|
+
resolveCheckpointCommit(repoRoot, toRef),
|
|
66
|
+
]);
|
|
67
|
+
if (!fromCommit || !toCommit) {
|
|
68
|
+
throw workspaceCheckpointError(
|
|
69
|
+
"checkpoint_missing",
|
|
70
|
+
"One of the requested workspace checkpoints is unavailable."
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const diff = await git(repoRoot, "diff", "--patch", "--minimal", "--no-color", fromCommit, toCommit);
|
|
75
|
+
return {
|
|
76
|
+
repoRoot,
|
|
77
|
+
fromCheckpointRef: fromRef,
|
|
78
|
+
toCheckpointRef: toRef,
|
|
79
|
+
diff,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function workspaceCheckpointRestorePreview(repoRoot, params) {
|
|
84
|
+
const checkpointRef = resolveCheckpointRef(params, "target");
|
|
85
|
+
const commit = await resolveCheckpointCommit(repoRoot, checkpointRef);
|
|
86
|
+
if (!commit) {
|
|
87
|
+
throw workspaceCheckpointError(
|
|
88
|
+
"checkpoint_missing",
|
|
89
|
+
"The requested workspace checkpoint is unavailable."
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const [changedFiles, stagedFiles, untrackedFiles] = await Promise.all([
|
|
94
|
+
changedFilesAgainstCommit(repoRoot, commit),
|
|
95
|
+
stagedFilesInRepo(repoRoot),
|
|
96
|
+
untrackedFilesInRepo(repoRoot),
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
canRestore: true,
|
|
101
|
+
repoRoot,
|
|
102
|
+
checkpointRef,
|
|
103
|
+
commit,
|
|
104
|
+
affectedFiles: uniqueSorted([...changedFiles, ...untrackedFiles]),
|
|
105
|
+
stagedFiles,
|
|
106
|
+
untrackedFiles,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function workspaceCheckpointRestoreApply(repoRoot, params) {
|
|
111
|
+
if (params.confirmDestructiveRestore !== true) {
|
|
112
|
+
throw workspaceCheckpointError(
|
|
113
|
+
"restore_confirmation_required",
|
|
114
|
+
"Checkpoint restore requires explicit destructive-restore confirmation."
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const preview = await workspaceCheckpointRestorePreview(repoRoot, params);
|
|
119
|
+
const expectedTargetCommit = firstNonEmptyString([params.expectedTargetCommit]);
|
|
120
|
+
if (expectedTargetCommit && preview.commit !== expectedTargetCommit) {
|
|
121
|
+
throw workspaceCheckpointError(
|
|
122
|
+
"checkpoint_changed",
|
|
123
|
+
"The workspace checkpoint changed after preview. Review the restore again before applying it."
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const backup = backupDescriptorForRestore(params);
|
|
128
|
+
const backupCommit = await captureGitCheckpoint(repoRoot, backup.ref);
|
|
129
|
+
|
|
130
|
+
await git(repoRoot, "restore", "--source", preview.commit, "--worktree", "--staged", "--", ".");
|
|
131
|
+
await git(repoRoot, "clean", "-fd", "--", ".");
|
|
132
|
+
if (await hasHeadCommit(repoRoot)) {
|
|
133
|
+
await git(repoRoot, "reset", "--quiet", "--", ".");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const status = await gitStatus(repoRoot).catch(() => null);
|
|
137
|
+
return {
|
|
138
|
+
success: true,
|
|
139
|
+
repoRoot,
|
|
140
|
+
checkpointRef: preview.checkpointRef,
|
|
141
|
+
backupCheckpointRef: backup.ref,
|
|
142
|
+
backupCommit,
|
|
143
|
+
restoredFiles: preview.affectedFiles,
|
|
144
|
+
status,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resolveCheckpointDescriptor(params, prefix = "") {
|
|
149
|
+
const checkpointRef = firstNonEmptyString([
|
|
150
|
+
params[`${prefix}CheckpointRef`],
|
|
151
|
+
prefix ? null : params.checkpointRef,
|
|
152
|
+
]);
|
|
153
|
+
if (checkpointRef) {
|
|
154
|
+
return {
|
|
155
|
+
ref: validateCheckpointRef(checkpointRef),
|
|
156
|
+
kind: firstNonEmptyString([params[`${prefix}CheckpointKind`], params.checkpointKind]) || "custom",
|
|
157
|
+
threadId: firstNonEmptyString([params.threadId]) || "",
|
|
158
|
+
turnId: firstNonEmptyString([params.turnId]) || null,
|
|
159
|
+
messageId: firstNonEmptyString([params.messageId]) || null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const threadId = requireIdentifier(params.threadId, "threadId");
|
|
164
|
+
const kind = firstNonEmptyString([
|
|
165
|
+
params[`${prefix}CheckpointKind`],
|
|
166
|
+
params[`${prefix}Kind`],
|
|
167
|
+
params.checkpointKind,
|
|
168
|
+
params.kind,
|
|
169
|
+
]) || "turnEnd";
|
|
170
|
+
const turnId = firstNonEmptyString([params[`${prefix}TurnId`], params.turnId]);
|
|
171
|
+
const messageId = firstNonEmptyString([params[`${prefix}MessageId`], params.messageId]);
|
|
172
|
+
const ref = checkpointRefFor({ threadId, kind, turnId, messageId });
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
ref,
|
|
176
|
+
kind,
|
|
177
|
+
threadId,
|
|
178
|
+
turnId: turnId || null,
|
|
179
|
+
messageId: messageId || null,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resolveCheckpointRef(params, prefix) {
|
|
184
|
+
const directRef = firstNonEmptyString([
|
|
185
|
+
params[`${prefix}CheckpointRef`],
|
|
186
|
+
params[`${prefix}Ref`],
|
|
187
|
+
prefix === "target" ? params.checkpointRef : null,
|
|
188
|
+
]);
|
|
189
|
+
if (directRef) {
|
|
190
|
+
return validateCheckpointRef(directRef);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return resolveCheckpointDescriptor(params, prefix).ref;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function checkpointRefFor({ threadId, kind, turnId, messageId }) {
|
|
197
|
+
const threadKey = encodeRefSegment(threadId);
|
|
198
|
+
switch (kind) {
|
|
199
|
+
case "messageStart":
|
|
200
|
+
return `${CHECKPOINT_REFS_PREFIX}/${threadKey}/message-start/${encodeRefSegment(
|
|
201
|
+
requireIdentifier(messageId, "messageId")
|
|
202
|
+
)}`;
|
|
203
|
+
case "turnStart":
|
|
204
|
+
return `${CHECKPOINT_REFS_PREFIX}/${threadKey}/turn-start/${encodeRefSegment(
|
|
205
|
+
requireIdentifier(turnId, "turnId")
|
|
206
|
+
)}`;
|
|
207
|
+
case "turnEnd":
|
|
208
|
+
case "turn":
|
|
209
|
+
return `${CHECKPOINT_REFS_PREFIX}/${threadKey}/turn/${encodeRefSegment(
|
|
210
|
+
requireIdentifier(turnId, "turnId")
|
|
211
|
+
)}`;
|
|
212
|
+
case "restoreBackup":
|
|
213
|
+
return `${CHECKPOINT_REFS_PREFIX}/${threadKey}/restore-backup/${Date.now()}-${Math.random()
|
|
214
|
+
.toString(16)
|
|
215
|
+
.slice(2)}`;
|
|
216
|
+
default:
|
|
217
|
+
throw workspaceCheckpointError(
|
|
218
|
+
"invalid_checkpoint_kind",
|
|
219
|
+
`Unsupported workspace checkpoint kind: ${kind}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function backupDescriptorForRestore(params) {
|
|
225
|
+
const threadId = requireIdentifier(params.threadId, "threadId");
|
|
226
|
+
const ref = checkpointRefFor({ threadId, kind: "restoreBackup" });
|
|
227
|
+
return {
|
|
228
|
+
ref,
|
|
229
|
+
kind: "restoreBackup",
|
|
230
|
+
threadId,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function captureGitCheckpoint(repoRoot, checkpointRef) {
|
|
235
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "remodex-checkpoint-"));
|
|
236
|
+
const tempIndexPath = path.join(tempDir, `index-${process.pid}-${Date.now()}`);
|
|
237
|
+
const env = {
|
|
238
|
+
...process.env,
|
|
239
|
+
GIT_INDEX_FILE: tempIndexPath,
|
|
240
|
+
GIT_AUTHOR_NAME: "Remodex",
|
|
241
|
+
GIT_AUTHOR_EMAIL: "remodex@users.noreply.github.com",
|
|
242
|
+
GIT_COMMITTER_NAME: "Remodex",
|
|
243
|
+
GIT_COMMITTER_EMAIL: "remodex@users.noreply.github.com",
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
if (await hasHeadCommit(repoRoot)) {
|
|
248
|
+
await git(repoRoot, "read-tree", "HEAD", { env });
|
|
249
|
+
}
|
|
250
|
+
await git(repoRoot, "add", "-A", "--", ".", { env });
|
|
251
|
+
const treeOid = (await git(repoRoot, "write-tree", { env })).trim();
|
|
252
|
+
if (!treeOid) {
|
|
253
|
+
throw workspaceCheckpointError(
|
|
254
|
+
"checkpoint_capture_failed",
|
|
255
|
+
"Git did not produce a checkpoint tree."
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const message = `remodex checkpoint ref=${checkpointRef}`;
|
|
260
|
+
const commitOid = (await git(repoRoot, "commit-tree", treeOid, "-m", message, { env })).trim();
|
|
261
|
+
if (!commitOid) {
|
|
262
|
+
throw workspaceCheckpointError(
|
|
263
|
+
"checkpoint_capture_failed",
|
|
264
|
+
"Git did not produce a checkpoint commit."
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await git(repoRoot, "update-ref", checkpointRef, commitOid);
|
|
269
|
+
return commitOid;
|
|
270
|
+
} finally {
|
|
271
|
+
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function resolveCheckpointCommit(repoRoot, checkpointRef) {
|
|
276
|
+
const result = await gitResult(
|
|
277
|
+
repoRoot,
|
|
278
|
+
["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`],
|
|
279
|
+
{ allowNonZeroExit: true }
|
|
280
|
+
);
|
|
281
|
+
if (result.code !== 0) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
const commit = result.stdout.trim();
|
|
285
|
+
return commit || null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function hasHeadCommit(repoRoot) {
|
|
289
|
+
const result = await gitResult(repoRoot, ["rev-parse", "--verify", "--quiet", "HEAD"], {
|
|
290
|
+
allowNonZeroExit: true,
|
|
291
|
+
});
|
|
292
|
+
return result.code === 0 && result.stdout.trim().length > 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function changedFilesAgainstCommit(repoRoot, commit) {
|
|
296
|
+
const output = await git(repoRoot, "diff", "--name-only", commit, "--");
|
|
297
|
+
return output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function stagedFilesInRepo(repoRoot) {
|
|
301
|
+
const output = await git(repoRoot, "diff", "--name-only", "--cached");
|
|
302
|
+
return output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function untrackedFilesInRepo(repoRoot) {
|
|
306
|
+
const output = await git(repoRoot, "ls-files", "--others", "--exclude-standard");
|
|
307
|
+
return output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function validateCheckpointRef(checkpointRef) {
|
|
311
|
+
const normalized = String(checkpointRef || "").trim();
|
|
312
|
+
if (!normalized.startsWith(`${CHECKPOINT_REFS_PREFIX}/`)) {
|
|
313
|
+
throw workspaceCheckpointError(
|
|
314
|
+
"invalid_checkpoint_ref",
|
|
315
|
+
"Workspace checkpoint refs must stay inside the Remodex checkpoint namespace."
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (normalized.includes("..") || normalized.includes(" ")) {
|
|
319
|
+
throw workspaceCheckpointError(
|
|
320
|
+
"invalid_checkpoint_ref",
|
|
321
|
+
"Workspace checkpoint ref contains invalid path segments."
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return normalized;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function requireIdentifier(value, name) {
|
|
328
|
+
const normalized = typeof value === "string" ? value.trim() : "";
|
|
329
|
+
if (!normalized) {
|
|
330
|
+
throw workspaceCheckpointError(
|
|
331
|
+
"missing_checkpoint_identifier",
|
|
332
|
+
`Workspace checkpoint requires ${name}.`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return normalized;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function encodeRefSegment(value) {
|
|
339
|
+
return Buffer.from(requireIdentifier(value, "checkpoint segment"), "utf8")
|
|
340
|
+
.toString("base64")
|
|
341
|
+
.replaceAll("+", "-")
|
|
342
|
+
.replaceAll("/", "_")
|
|
343
|
+
.replaceAll("=", "");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function uniqueSorted(values) {
|
|
347
|
+
return [...new Set(values.filter(Boolean))].sort();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function firstNonEmptyString(candidates) {
|
|
351
|
+
for (const candidate of candidates) {
|
|
352
|
+
if (typeof candidate !== "string") {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const trimmed = candidate.trim();
|
|
356
|
+
if (trimmed) {
|
|
357
|
+
return trimmed;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function git(repoRoot, ...args) {
|
|
364
|
+
let options = {};
|
|
365
|
+
if (args.length && typeof args[args.length - 1] === "object" && !Array.isArray(args[args.length - 1])) {
|
|
366
|
+
options = args.pop();
|
|
367
|
+
}
|
|
368
|
+
const result = await gitResult(repoRoot, args, options);
|
|
369
|
+
return result.stdout;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function gitResult(repoRoot, args, options = {}) {
|
|
373
|
+
try {
|
|
374
|
+
const { stdout, stderr } = await execFileAsync("git", args, {
|
|
375
|
+
cwd: repoRoot,
|
|
376
|
+
timeout: GIT_TIMEOUT_MS,
|
|
377
|
+
env: options.env || process.env,
|
|
378
|
+
});
|
|
379
|
+
return { code: 0, stdout, stderr };
|
|
380
|
+
} catch (err) {
|
|
381
|
+
if (options.allowNonZeroExit) {
|
|
382
|
+
return {
|
|
383
|
+
code: typeof err.code === "number" ? err.code : 1,
|
|
384
|
+
stdout: err.stdout || "",
|
|
385
|
+
stderr: err.stderr || err.message || "",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const message = (err.stderr || err.message || "git command failed").trim();
|
|
389
|
+
throw workspaceCheckpointError("git_failed", message);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function workspaceCheckpointError(errorCode, userMessage) {
|
|
394
|
+
const err = new Error(userMessage);
|
|
395
|
+
err.errorCode = errorCode;
|
|
396
|
+
err.userMessage = userMessage;
|
|
397
|
+
return err;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
module.exports = {
|
|
401
|
+
workspaceCheckpointCapture,
|
|
402
|
+
workspaceCheckpointCopy,
|
|
403
|
+
workspaceCheckpointDiff,
|
|
404
|
+
workspaceCheckpointRestorePreview,
|
|
405
|
+
workspaceCheckpointRestoreApply,
|
|
406
|
+
};
|
package/src/workspace-handler.js
CHANGED
|
@@ -11,6 +11,13 @@ const path = require("path");
|
|
|
11
11
|
const { promisify } = require("util");
|
|
12
12
|
const { resolveCodexGeneratedImagesRoot } = require("./codex-home");
|
|
13
13
|
const { gitStatus } = require("./git-handler");
|
|
14
|
+
const {
|
|
15
|
+
workspaceCheckpointCapture,
|
|
16
|
+
workspaceCheckpointCopy,
|
|
17
|
+
workspaceCheckpointDiff,
|
|
18
|
+
workspaceCheckpointRestoreApply,
|
|
19
|
+
workspaceCheckpointRestorePreview,
|
|
20
|
+
} = require("./workspace-checkpoints");
|
|
14
21
|
|
|
15
22
|
const execFileAsync = promisify(execFile);
|
|
16
23
|
const GIT_TIMEOUT_MS = 30_000;
|
|
@@ -18,6 +25,9 @@ const MAX_IMAGE_READ_BYTES = 8 * 1024 * 1024;
|
|
|
18
25
|
const MAX_IMAGE_PREVIEW_READ_BYTES = 2 * 1024 * 1024;
|
|
19
26
|
const MIN_IMAGE_PREVIEW_PIXEL_DIMENSION = 128;
|
|
20
27
|
const MAX_IMAGE_PREVIEW_PIXEL_DIMENSION = 3_200;
|
|
28
|
+
const IMAGE_PREVIEW_RETRY_SCALE = 0.75;
|
|
29
|
+
const IMAGE_PREVIEW_TOOL_TIMEOUT_MS = 5_000;
|
|
30
|
+
const IMAGE_PREVIEW_TOTAL_TIMEOUT_MS = 15_000;
|
|
21
31
|
const IMAGE_MIME_TYPES_BY_EXTENSION = new Map([
|
|
22
32
|
[".jpg", "image/jpeg"],
|
|
23
33
|
[".jpeg", "image/jpeg"],
|
|
@@ -76,6 +86,16 @@ async function handleWorkspaceMethod(method, params) {
|
|
|
76
86
|
const repoRoot = await resolveRepoRoot(cwd);
|
|
77
87
|
|
|
78
88
|
switch (method) {
|
|
89
|
+
case "workspace/checkpointCapture":
|
|
90
|
+
return withRepoMutationLock(repoRoot, () => workspaceCheckpointCapture(repoRoot, params));
|
|
91
|
+
case "workspace/checkpointCopy":
|
|
92
|
+
return withRepoMutationLock(repoRoot, () => workspaceCheckpointCopy(repoRoot, params));
|
|
93
|
+
case "workspace/checkpointDiff":
|
|
94
|
+
return workspaceCheckpointDiff(repoRoot, params);
|
|
95
|
+
case "workspace/checkpointRestorePreview":
|
|
96
|
+
return workspaceCheckpointRestorePreview(repoRoot, params);
|
|
97
|
+
case "workspace/checkpointRestoreApply":
|
|
98
|
+
return withRepoMutationLock(repoRoot, () => workspaceCheckpointRestoreApply(repoRoot, params));
|
|
79
99
|
case "workspace/revertPatchPreview":
|
|
80
100
|
return workspaceRevertPatchPreview(repoRoot, params);
|
|
81
101
|
case "workspace/revertPatchApply":
|
|
@@ -85,7 +105,7 @@ async function handleWorkspaceMethod(method, params) {
|
|
|
85
105
|
}
|
|
86
106
|
}
|
|
87
107
|
|
|
88
|
-
// Reads
|
|
108
|
+
// Reads recognized local image files from the bound repo, Codex image cache, or host temp screenshot folders.
|
|
89
109
|
async function workspaceReadImage(params) {
|
|
90
110
|
const requestedPath = firstNonEmptyString([params.path, params.filePath, params.localPath]);
|
|
91
111
|
if (!requestedPath) {
|
|
@@ -112,13 +132,16 @@ async function workspaceReadImage(params) {
|
|
|
112
132
|
throw workspaceError("image_not_found", "The image file no longer exists on this Mac.");
|
|
113
133
|
}
|
|
114
134
|
|
|
115
|
-
const
|
|
116
|
-
|
|
135
|
+
const [realRepoRoot, realTempRoots] = await Promise.all([
|
|
136
|
+
cwd ? resolveRepoRoot(cwd).then(realpathOrNull).catch(() => null) : null,
|
|
137
|
+
realTemporaryImageRoots(),
|
|
138
|
+
]);
|
|
117
139
|
const isAllowed =
|
|
118
140
|
(realRepoRoot && isPathInside(realImagePath, realRepoRoot))
|
|
119
|
-
|| (realGeneratedImagesRoot && isPathInside(realImagePath, realGeneratedImagesRoot))
|
|
141
|
+
|| (realGeneratedImagesRoot && isPathInside(realImagePath, realGeneratedImagesRoot))
|
|
142
|
+
|| realTempRoots.some((tempRoot) => isPathInside(realImagePath, tempRoot));
|
|
120
143
|
if (!isAllowed) {
|
|
121
|
-
throw workspaceError("image_path_not_allowed", "Only images in this workspace
|
|
144
|
+
throw workspaceError("image_path_not_allowed", "Only images in this workspace, Codex generated images, or temporary screenshot files can be previewed.");
|
|
122
145
|
}
|
|
123
146
|
|
|
124
147
|
const stat = await fs.promises.stat(realImagePath);
|
|
@@ -153,7 +176,7 @@ async function workspaceReadImage(params) {
|
|
|
153
176
|
}
|
|
154
177
|
|
|
155
178
|
const data = maxPixelDimension
|
|
156
|
-
? await readPreviewImageData(realImagePath, maxPixelDimension)
|
|
179
|
+
? await readPreviewImageData(realImagePath, maxPixelDimension, stat.size)
|
|
157
180
|
: await fs.promises.readFile(realImagePath);
|
|
158
181
|
return {
|
|
159
182
|
...result,
|
|
@@ -173,31 +196,113 @@ function normalizedPreviewPixelDimension(params) {
|
|
|
173
196
|
);
|
|
174
197
|
}
|
|
175
198
|
|
|
176
|
-
async function
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
199
|
+
async function realTemporaryImageRoots() {
|
|
200
|
+
const candidates = [
|
|
201
|
+
os.tmpdir(),
|
|
202
|
+
process.env.TMPDIR,
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
if (process.platform === "darwin") {
|
|
206
|
+
candidates.push("/tmp");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const roots = await Promise.all(
|
|
210
|
+
Array.from(new Set(candidates.filter(Boolean))).map((candidate) => realpathOrNull(candidate))
|
|
211
|
+
);
|
|
212
|
+
return Array.from(new Set(roots.filter(Boolean)));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function readPreviewImageData(imagePath, maxPixelDimension, originalByteLength) {
|
|
216
|
+
if (!usesSipsImagePreview()) {
|
|
217
|
+
if (originalByteLength <= MAX_IMAGE_PREVIEW_READ_BYTES) {
|
|
218
|
+
return fs.promises.readFile(imagePath);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
throw workspaceError(
|
|
222
|
+
"image_preview_unsupported_platform",
|
|
223
|
+
"This computer cannot resize image previews yet. Try a smaller image or open it on the computer."
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let sawConversionFailure = false;
|
|
228
|
+
const previewDeadline = Date.now() + IMAGE_PREVIEW_TOTAL_TIMEOUT_MS;
|
|
229
|
+
for (const candidateDimension of previewPixelDimensionCandidates(maxPixelDimension)) {
|
|
230
|
+
const remainingTimeoutMs = previewDeadline - Date.now();
|
|
231
|
+
if (remainingTimeoutMs <= 0) {
|
|
232
|
+
throw workspaceError(
|
|
233
|
+
"image_preview_timed_out",
|
|
234
|
+
"This image preview took too long to resize. Try a smaller image or open it on the computer."
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const previewData = await downsampleImageWithSips(
|
|
240
|
+
imagePath,
|
|
241
|
+
candidateDimension,
|
|
242
|
+
Math.min(IMAGE_PREVIEW_TOOL_TIMEOUT_MS, remainingTimeoutMs)
|
|
243
|
+
);
|
|
244
|
+
if (previewData && previewData.length > 0 && previewData.length <= MAX_IMAGE_PREVIEW_READ_BYTES) {
|
|
245
|
+
return previewData;
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (isImagePreviewTimeoutError(err)) {
|
|
249
|
+
throw workspaceError(
|
|
250
|
+
"image_preview_timed_out",
|
|
251
|
+
"This image preview took too long to resize. Try a smaller image or open it on the computer."
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
sawConversionFailure = true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (sawConversionFailure) {
|
|
181
259
|
throw workspaceError(
|
|
182
260
|
"image_preview_failed",
|
|
183
261
|
"This image could not be converted into a lightweight phone preview."
|
|
184
262
|
);
|
|
185
263
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
264
|
+
|
|
265
|
+
throw workspaceError(
|
|
266
|
+
"image_preview_too_large",
|
|
267
|
+
"This image preview is still too large to send to the phone."
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function previewPixelDimensionCandidates(maxPixelDimension) {
|
|
272
|
+
const dimensions = [];
|
|
273
|
+
let next = maxPixelDimension;
|
|
274
|
+
while (next >= MIN_IMAGE_PREVIEW_PIXEL_DIMENSION) {
|
|
275
|
+
dimensions.push(next);
|
|
276
|
+
if (next === MIN_IMAGE_PREVIEW_PIXEL_DIMENSION) {
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
next = Math.max(
|
|
280
|
+
MIN_IMAGE_PREVIEW_PIXEL_DIMENSION,
|
|
281
|
+
Math.floor(next * IMAGE_PREVIEW_RETRY_SCALE)
|
|
190
282
|
);
|
|
191
283
|
}
|
|
192
|
-
|
|
284
|
+
|
|
285
|
+
// Hard-to-compress previews can stay oversized after one resize; these checkpoints keep retry behavior predictable.
|
|
286
|
+
for (const checkpoint of [1024, 768, 512, 384, 256, MIN_IMAGE_PREVIEW_PIXEL_DIMENSION]) {
|
|
287
|
+
if (checkpoint <= maxPixelDimension) {
|
|
288
|
+
dimensions.push(checkpoint);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return Array.from(new Set(dimensions)).sort((a, b) => b - a);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function usesSipsImagePreview() {
|
|
296
|
+
const normalizedPlatform = String(process.platform || "").trim().toLowerCase();
|
|
297
|
+
return normalizedPlatform === "darwin" || normalizedPlatform === "macos" || normalizedPlatform === "mac";
|
|
193
298
|
}
|
|
194
299
|
|
|
195
|
-
async function downsampleImageWithSips(imagePath, maxPixelDimension) {
|
|
300
|
+
async function downsampleImageWithSips(imagePath, maxPixelDimension, timeoutMs = IMAGE_PREVIEW_TOOL_TIMEOUT_MS) {
|
|
196
301
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "remodex-image-preview-"));
|
|
197
302
|
const outputPath = path.join(tempDir, `preview${path.extname(imagePath) || ".png"}`);
|
|
198
303
|
try {
|
|
199
304
|
await execFileAsync("sips", ["-Z", String(maxPixelDimension), imagePath, "--out", outputPath], {
|
|
200
|
-
timeout:
|
|
305
|
+
timeout: Math.max(1, Math.floor(timeoutMs)),
|
|
201
306
|
maxBuffer: 1024 * 1024,
|
|
202
307
|
});
|
|
203
308
|
return await fs.promises.readFile(outputPath);
|
|
@@ -206,6 +311,12 @@ async function downsampleImageWithSips(imagePath, maxPixelDimension) {
|
|
|
206
311
|
}
|
|
207
312
|
}
|
|
208
313
|
|
|
314
|
+
function isImagePreviewTimeoutError(err) {
|
|
315
|
+
return err?.code === "ETIMEDOUT"
|
|
316
|
+
|| (err?.killed === true && err?.signal === "SIGTERM")
|
|
317
|
+
|| /timed out|timeout/i.test(String(err?.message || ""));
|
|
318
|
+
}
|
|
319
|
+
|
|
209
320
|
function isUnchangedImageRead(params, stat, maxPixelDimension) {
|
|
210
321
|
const cachedByteLength = Number(params.ifByteLength);
|
|
211
322
|
const cachedMtimeMs = Number(params.ifMtimeMs);
|