@fieldwangai/agentflow 0.1.87 → 0.1.88

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.
@@ -10,6 +10,14 @@ import { appendRunLogLine } from "./run-events.mjs";
10
10
  import { writeWithPrefix } from "./terminal.mjs";
11
11
  import { t } from "./i18n.mjs";
12
12
  import { readMergedEnvObject } from "./user-env.mjs";
13
+ import {
14
+ createCursorApiKeyAttempts,
15
+ cursorApiKeyCooldownMinutes,
16
+ cursorApiKeyEnv,
17
+ cursorApiKeyLabel,
18
+ isCursorQuotaError,
19
+ markCursorApiKeyQuotaBlocked,
20
+ } from "./cursor-api-key-pool.mjs";
13
21
  import { outputNodeBasename } from "../pipeline/get-exec-id.mjs";
14
22
 
15
23
  function shouldPassCursorModelArg(model) {
@@ -23,6 +31,45 @@ function childEnv(options = {}, extra = {}) {
23
31
  return { ...process.env, ...readMergedEnvObject(userId), ...optEnv, ...extra };
24
32
  }
25
33
 
34
+ function cursorAttemptOptions(options = {}) {
35
+ const baseEnv = childEnv(options);
36
+ const attempts = Array.isArray(options._agentflowCursorApiKeyAttempts)
37
+ ? options._agentflowCursorApiKeyAttempts
38
+ : createCursorApiKeyAttempts(baseEnv);
39
+ const attemptIndex = Math.max(0, Number(options._agentflowCursorApiKeyAttemptIndex || 0) || 0);
40
+ const selection = attempts[attemptIndex];
41
+ return { baseEnv, attempts, attemptIndex, selection };
42
+ }
43
+
44
+ function nextCursorAttemptOptions(options = {}, attempts = [], attemptIndex = 0) {
45
+ return {
46
+ ...options,
47
+ _agentflowCursorApiKeyAttempts: attempts,
48
+ _agentflowCursorApiKeyAttemptIndex: attemptIndex + 1,
49
+ };
50
+ }
51
+
52
+ function cursorResultErrorText(event) {
53
+ if (!event || typeof event !== "object") return "";
54
+ const candidates = [
55
+ event.result,
56
+ event.message,
57
+ event.error,
58
+ event.error?.message,
59
+ event.error?.error,
60
+ event.data?.error,
61
+ event.data?.message,
62
+ ];
63
+ for (const value of candidates) {
64
+ if (typeof value === "string" && value.trim()) return value;
65
+ if (value && typeof value === "object") {
66
+ const nested = value.message || value.error || value.result;
67
+ if (typeof nested === "string" && nested.trim()) return nested;
68
+ }
69
+ }
70
+ return "";
71
+ }
72
+
26
73
  function writeAgentTextArtifacts(absResultPath, absRunDir, instanceId, text) {
27
74
  const body = String(text ?? "").trim();
28
75
  if (!body) return;
@@ -298,6 +345,12 @@ export function runCursorAgentForNode(
298
345
  const rawPrefix = options.outputPrefix != null ? `[${options.outputPrefix}] ` : "";
299
346
  const coloredPrefix = rawPrefix && options.prefixColor ? options.prefixColor(rawPrefix) : rawPrefix;
300
347
  const agentContentColor = options.contentColor ?? ((line) => chalk.gray(line));
348
+ const {
349
+ baseEnv: cursorBaseEnv,
350
+ attempts: cursorAttempts,
351
+ attemptIndex: cursorAttemptIndex,
352
+ selection: cursorSelection,
353
+ } = cursorAttemptOptions(options);
301
354
 
302
355
  return new Promise((resolve, reject) => {
303
356
  const agentCmd = process.env.CURSOR_AGENT_CMD || "agent";
@@ -310,6 +363,15 @@ export function runCursorAgentForNode(
310
363
  if (options.flowName && options.uuid) {
311
364
  const argvLog = args.slice(0, -1).concat([`(prompt ${args[args.length - 1].length} chars)`]);
312
365
  appendRunLogLine(workspaceRoot, options.flowName, options.uuid, "cli-raw", `Cursor CLI 完整参数: ${agentCmd} ${JSON.stringify(argvLog)}`);
366
+ if (cursorAttempts.length > 1) {
367
+ appendRunLogLine(
368
+ workspaceRoot,
369
+ options.flowName,
370
+ options.uuid,
371
+ "cli-raw",
372
+ `Cursor API Key 尝试: ${cursorApiKeyLabel(cursorSelection)} / ${cursorAttempts.length}`,
373
+ );
374
+ }
313
375
  appendRunLogLine(
314
376
  workspaceRoot,
315
377
  options.flowName,
@@ -324,11 +386,12 @@ export function runCursorAgentForNode(
324
386
  cwd: execWorkspaceRoot,
325
387
  stdio: ["ignore", "pipe", useStderrInherit ? "inherit" : "pipe"],
326
388
  shell: false,
327
- env: childEnv(options),
389
+ env: childEnv(options, cursorApiKeyEnv(cursorSelection)),
328
390
  });
329
391
 
330
392
  let lastResult = null;
331
393
  let hadError = false;
394
+ let hadToolActivity = false;
332
395
  const assistantTextChunks = [];
333
396
  const STDERR_CAP_BYTES = 1024 * 1024;
334
397
  const stderrChunks = [];
@@ -419,6 +482,7 @@ export function runCursorAgentForNode(
419
482
  if (out) writeStdout(out);
420
483
  }
421
484
  } else if (event.type === "tool_call") {
485
+ hadToolActivity = true;
422
486
  const toolName =
423
487
  event.tool_call && typeof event.tool_call === "object" ? Object.keys(event.tool_call)[0] ?? "?" : "?";
424
488
  const subtype = event.subtype ?? "";
@@ -438,6 +502,7 @@ export function runCursorAgentForNode(
438
502
  } catch (_) {
439
503
  let out;
440
504
  if (line.includes('"type":"tool_call"') || line.includes('"type": "tool_call"')) {
505
+ hadToolActivity = true;
441
506
  let subtype = "?";
442
507
  try {
443
508
  const ev = JSON.parse(line);
@@ -480,9 +545,27 @@ export function runCursorAgentForNode(
480
545
  if (coloredPrefix && stderrLineBuffer) {
481
546
  writeWithPrefix(process.stderr, stderrLineBuffer.endsWith("\n") ? stderrLineBuffer : stderrLineBuffer + "\n", coloredPrefix);
482
547
  }
548
+ const retryCursorQuota = (errorText) => {
549
+ if (!cursorSelection) return false;
550
+ if (cursorAttemptIndex >= cursorAttempts.length - 1) return false;
551
+ if (hadToolActivity) return false;
552
+ if (!isCursorQuotaError(errorText)) return false;
553
+ markCursorApiKeyQuotaBlocked(cursorSelection, cursorApiKeyCooldownMinutes(cursorBaseEnv));
554
+ const nextOptions = nextCursorAttemptOptions(options, cursorAttempts, cursorAttemptIndex);
555
+ const line = `[agentflow] Cursor API Key ${cursorApiKeyLabel(cursorSelection)} reached quota, retrying ${cursorAttemptIndex + 2}/${cursorAttempts.length}...\n`;
556
+ writeStdout(line);
557
+ if (flowName && uuid) appendRunLogLine(workspaceRoot, flowName, uuid, "cli-raw", line.trim());
558
+ runCursorAgentForNode(
559
+ workspaceRoot,
560
+ { promptPath, nodeContext, taskBody, intermediatePath, resultPathRel, subagent, instanceId },
561
+ nextOptions,
562
+ ).then(resolve).catch(reject);
563
+ return true;
564
+ };
483
565
  if (code !== 0 && lastResult == null) {
484
566
  const stderr = Buffer.concat(stderrChunks).toString("utf-8");
485
567
  const stderrTail = stderr ? stderr.trim().slice(-1200) : "";
568
+ if (retryCursorQuota(stderrTail)) return;
486
569
  const autoOnly =
487
570
  /named models unavailable/i.test(stderrTail) ||
488
571
  (/free plans?/i.test(stderrTail) && /only use auto/i.test(stderrTail)) ||
@@ -506,7 +589,9 @@ export function runCursorAgentForNode(
506
589
  return;
507
590
  }
508
591
  if (hadError || (lastResult && lastResult.is_error)) {
509
- reject(new Error(lastResult?.result || "Agent reported error."));
592
+ const errorText = cursorResultErrorText(lastResult) || "Agent reported error.";
593
+ if (retryCursorQuota(errorText)) return;
594
+ reject(new Error(errorText));
510
595
  return;
511
596
  }
512
597
  writeAgentTextArtifacts(absResultPath, absRunDir, instanceId, assistantTextChunks.join("") || lastResult?.result || "");
@@ -1225,6 +1310,12 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
1225
1310
  const ws = path.resolve(cliWorkspace);
1226
1311
  const model = normalizeCursorModelForCli(options.model ?? process.env.CURSOR_AGENT_MODEL ?? null);
1227
1312
  const agentCmd = process.env.CURSOR_AGENT_CMD || "agent";
1313
+ const {
1314
+ baseEnv: cursorBaseEnv,
1315
+ attempts: cursorAttempts,
1316
+ attemptIndex: cursorAttemptIndex,
1317
+ selection: cursorSelection,
1318
+ } = cursorAttemptOptions(options);
1228
1319
  // Web UI Composer 需要能无交互执行本机 curl 等命令来刷新画布。
1229
1320
  const args = ["--print", "--output-format", "stream-json", "--trust", "--sandbox", "disabled", "--workspace", ws];
1230
1321
  const approveMcps = process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "0" && process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "false";
@@ -1238,11 +1329,12 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
1238
1329
  cwd: ws,
1239
1330
  stdio: ["ignore", "pipe", useStderrInherit ? "inherit" : "pipe"],
1240
1331
  shell: false,
1241
- env: childEnv(options),
1332
+ env: childEnv(options, cursorApiKeyEnv(cursorSelection)),
1242
1333
  });
1243
1334
 
1244
1335
  let lastResult = null;
1245
1336
  let hadError = false;
1337
+ let hadToolActivity = false;
1246
1338
  const STDERR_CAP_BYTES = 1024 * 1024;
1247
1339
  const stderrChunks = [];
1248
1340
  let stderrTotalBytes = 0;
@@ -1254,6 +1346,13 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
1254
1346
  } catch (_) {}
1255
1347
  };
1256
1348
 
1349
+ if (cursorAttempts.length > 1) {
1350
+ emit({
1351
+ type: "status",
1352
+ line: `Cursor API Key ${cursorApiKeyLabel(cursorSelection)} / ${cursorAttempts.length}`,
1353
+ });
1354
+ }
1355
+
1257
1356
  if (!useStderrInherit) {
1258
1357
  child.stderr.on("data", (chunk) => {
1259
1358
  const s = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
@@ -1314,6 +1413,7 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
1314
1413
  emit({ type: "status", line: t("runner.generating_reply") });
1315
1414
  }
1316
1415
  } else if (event.type === "tool_call") {
1416
+ hadToolActivity = true;
1317
1417
  const toolName =
1318
1418
  event.tool_call && typeof event.tool_call === "object" ? Object.keys(event.tool_call)[0] ?? "?" : "?";
1319
1419
  const subtype = event.subtype ?? "";
@@ -1347,6 +1447,7 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
1347
1447
  } catch (_) {
1348
1448
  emit({ type: "raw", source: "cursor", stream: "stdout", eventType: "line", text: rawTraceText(line) });
1349
1449
  if (line.includes('"type":"tool_call"') || line.includes('"type": "tool_call"')) {
1450
+ hadToolActivity = true;
1350
1451
  let subtype = "?";
1351
1452
  try {
1352
1453
  const ev = JSON.parse(line);
@@ -1389,9 +1490,28 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
1389
1490
  const rest = stderrComposerBuffer.trim();
1390
1491
  emit({ type: "status", line: `[stderr] ${truncateComposerLine(rest)}` });
1391
1492
  }
1493
+ const retryCursorQuota = (errorText) => {
1494
+ if (!cursorSelection) return false;
1495
+ if (cursorAttemptIndex >= cursorAttempts.length - 1) return false;
1496
+ if (hadToolActivity) return false;
1497
+ if (!isCursorQuotaError(errorText)) return false;
1498
+ markCursorApiKeyQuotaBlocked(cursorSelection, cursorApiKeyCooldownMinutes(cursorBaseEnv));
1499
+ emit({
1500
+ type: "status",
1501
+ line: `Cursor API Key ${cursorApiKeyLabel(cursorSelection)} reached quota, retrying ${cursorAttemptIndex + 2}/${cursorAttempts.length}`,
1502
+ });
1503
+ const next = runCursorAgentWithPrompt(
1504
+ cliWorkspace,
1505
+ promptText,
1506
+ nextCursorAttemptOptions(options, cursorAttempts, cursorAttemptIndex),
1507
+ );
1508
+ next.finished.then(resolve).catch(reject);
1509
+ return true;
1510
+ };
1392
1511
  if (code !== 0 && lastResult == null) {
1393
1512
  const stderr = Buffer.concat(stderrChunks).toString("utf-8");
1394
1513
  const stderrTail = stderr ? stderr.trim().slice(-1200) : "";
1514
+ if (retryCursorQuota(stderrTail)) return;
1395
1515
  const err = new Error(`Cursor CLI exited ${code}. ${stderrTail || "No result event received."}`);
1396
1516
  err.cursorStderrTail = stderrTail;
1397
1517
  emit({ type: "status", line: truncateComposerLine(err.message) });
@@ -1399,7 +1519,8 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
1399
1519
  return;
1400
1520
  }
1401
1521
  if (hadError || (lastResult && lastResult.is_error)) {
1402
- const msg = lastResult?.result || "Agent reported error.";
1522
+ const msg = cursorResultErrorText(lastResult) || "Agent reported error.";
1523
+ if (retryCursorQuota(msg)) return;
1403
1524
  emit({ type: "status", line: truncateComposerLine(msg) });
1404
1525
  reject(new Error(msg));
1405
1526
  return;
@@ -0,0 +1,70 @@
1
+ let nextCursorApiKeyCursor = 0;
2
+ const cursorApiKeyBlockedUntil = new Map();
3
+
4
+ export function parseCursorApiKeyPool(value = "") {
5
+ const raw = String(value || "").trim();
6
+ if (!raw) return [];
7
+ try {
8
+ const parsed = JSON.parse(raw);
9
+ if (Array.isArray(parsed)) {
10
+ return parsed
11
+ .map((item) => {
12
+ if (typeof item === "string") return item.trim();
13
+ if (item && typeof item === "object") return String(item.key || "").trim();
14
+ return "";
15
+ })
16
+ .filter(Boolean);
17
+ }
18
+ } catch {
19
+ // Legacy comma-separated format.
20
+ }
21
+ return raw.split(",").map((key) => key.trim()).filter(Boolean);
22
+ }
23
+
24
+ export function createCursorApiKeyAttempts(env = {}) {
25
+ const keys = parseCursorApiKeyPool(env.CURSOR_API_KEYS);
26
+ if (keys.length === 0) return [undefined];
27
+
28
+ const now = Date.now();
29
+ const candidates = keys
30
+ .map((key, index) => ({ key, index, total: keys.length }))
31
+ .filter((selection) => (cursorApiKeyBlockedUntil.get(selection.index) || 0) <= now);
32
+ const effective = candidates.length > 0 ? candidates : keys.map((key, index) => ({ key, index, total: keys.length }));
33
+ const start = nextCursorApiKeyCursor % effective.length;
34
+ nextCursorApiKeyCursor += 1;
35
+ return [...effective.slice(start), ...effective.slice(0, start)];
36
+ }
37
+
38
+ export function cursorApiKeyEnv(selection) {
39
+ return selection && selection.key ? { CURSOR_API_KEY: selection.key } : {};
40
+ }
41
+
42
+ export function markCursorApiKeyQuotaBlocked(selection, cooldownMinutes = 30) {
43
+ if (!selection || !Number.isFinite(selection.index)) return;
44
+ const minutes = Math.max(1, Number(cooldownMinutes) || 30);
45
+ cursorApiKeyBlockedUntil.set(selection.index, Date.now() + minutes * 60 * 1000);
46
+ }
47
+
48
+ export function cursorApiKeyLabel(selection) {
49
+ if (!selection) return "default";
50
+ return `${selection.index + 1}/${selection.total}`;
51
+ }
52
+
53
+ export function isCursorQuotaError(error = "") {
54
+ const text = String(error || "");
55
+ if (!text) return false;
56
+ return [
57
+ /\b429\b/i,
58
+ /rate[_\s-]*limit/i,
59
+ /too many requests/i,
60
+ /quota/i,
61
+ /usage\s+limit/i,
62
+ /limit\s+(?:exceeded|reached)/i,
63
+ /exceeded\s+(?:your\s+)?limit/i,
64
+ /ActionRequiredError/i,
65
+ ].some((pattern) => pattern.test(text));
66
+ }
67
+
68
+ export function cursorApiKeyCooldownMinutes(env = {}) {
69
+ return Math.max(1, Number(env.AGENTFLOW_CURSOR_API_KEY_COOLDOWN_MINUTES || env.CURSOR_API_KEY_COOLDOWN_MINUTES || 30) || 30);
70
+ }
@@ -1682,6 +1682,103 @@ function writeUserWorkspaces(userCtx = {}, entries = []) {
1682
1682
  return workspaces;
1683
1683
  }
1684
1684
 
1685
+ function redactWorkspaceSecret(text = "", secret = "") {
1686
+ let out = String(text || "");
1687
+ const raw = String(secret || "");
1688
+ if (!raw) return out;
1689
+ out = out.split(raw).join("<redacted>");
1690
+ try {
1691
+ out = out.split(encodeURIComponent(raw)).join("<redacted>");
1692
+ } catch {
1693
+ // ignore invalid encoding edge cases
1694
+ }
1695
+ return out;
1696
+ }
1697
+
1698
+ function workspaceRepoUrlWithCredential(repoUrl = "", credential = "") {
1699
+ const token = String(credential || "").trim();
1700
+ if (!token) return String(repoUrl || "").trim();
1701
+ try {
1702
+ const u = new URL(String(repoUrl || "").trim());
1703
+ if (!/^https?:$/.test(u.protocol)) return String(repoUrl || "").trim();
1704
+ if (!u.username) u.username = "oauth2";
1705
+ u.password = token;
1706
+ return u.toString();
1707
+ } catch {
1708
+ return String(repoUrl || "").trim();
1709
+ }
1710
+ }
1711
+
1712
+ function gitWorkspaceCommandOrThrow(args, cwd, label, secret = "") {
1713
+ const result = runGit(args, cwd);
1714
+ if (result.status !== 0) {
1715
+ const message = redactWorkspaceSecret(result.stderr || result.stdout || result.error?.message || "unknown error", secret);
1716
+ throw new Error(`${label} failed: ${message}`);
1717
+ }
1718
+ return {
1719
+ stdout: redactWorkspaceSecret(result.stdout || "", secret),
1720
+ stderr: redactWorkspaceSecret(result.stderr || "", secret),
1721
+ };
1722
+ }
1723
+
1724
+ function syncGitWorkspace(entry = {}, userCtx = {}) {
1725
+ const workspace = normalizeWorkspaceEntry(entry, 0, userCtx);
1726
+ if (!workspace || workspace.kind !== "git") throw new Error("只能拉取 Git 工作区");
1727
+ if (!workspace.repoUrl) throw new Error("Git 工作区缺少 repoUrl");
1728
+ const env = readMergedEnvObject(userCtx.userId || "");
1729
+ const token = workspace.credentialRef ? String(env[workspace.credentialRef] || "").trim() : "";
1730
+ const repoUrl = workspaceRepoUrlWithCredential(workspace.repoUrl, token);
1731
+ const targetDir = path.resolve(workspace.path);
1732
+ const parentDir = path.dirname(targetDir);
1733
+ fs.mkdirSync(parentDir, { recursive: true });
1734
+
1735
+ const lines = [];
1736
+ let changed = false;
1737
+ if (fs.existsSync(path.join(targetDir, ".git"))) {
1738
+ const originalRemote = runGit(["remote", "get-url", "origin"], targetDir).stdout.trim();
1739
+ try {
1740
+ if (token) gitWorkspaceCommandOrThrow(["remote", "set-url", "origin", repoUrl], targetDir, "git remote set-url", token);
1741
+ const before = runGit(["rev-parse", "HEAD"], targetDir).stdout.trim();
1742
+ gitWorkspaceCommandOrThrow(["fetch", "origin", "--prune"], targetDir, "git fetch", token);
1743
+ if (workspace.branch) {
1744
+ const checkout = runGit(["checkout", workspace.branch], targetDir);
1745
+ if (checkout.status !== 0) {
1746
+ gitWorkspaceCommandOrThrow(["checkout", "-b", workspace.branch, `origin/${workspace.branch}`], targetDir, "git checkout", token);
1747
+ }
1748
+ gitWorkspaceCommandOrThrow(["pull", "--ff-only", "origin", workspace.branch], targetDir, "git pull", token);
1749
+ } else {
1750
+ gitWorkspaceCommandOrThrow(["pull", "--ff-only"], targetDir, "git pull", token);
1751
+ }
1752
+ const after = runGit(["rev-parse", "HEAD"], targetDir).stdout.trim();
1753
+ changed = before !== after;
1754
+ lines.push(changed ? `updated ${before.slice(0, 8)} -> ${after.slice(0, 8)}` : `already up to date ${after.slice(0, 8)}`);
1755
+ } finally {
1756
+ if (token && originalRemote) runGit(["remote", "set-url", "origin", originalRemote], targetDir);
1757
+ }
1758
+ } else {
1759
+ if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
1760
+ throw new Error(`目标路径已存在但不是 Git 仓库:${targetDir}`);
1761
+ }
1762
+ const args = ["clone"];
1763
+ if (workspace.branch) args.push("--branch", workspace.branch);
1764
+ args.push(repoUrl, targetDir);
1765
+ gitWorkspaceCommandOrThrow(args, parentDir, "git clone", token);
1766
+ const commit = runGit(["rev-parse", "HEAD"], targetDir).stdout.trim();
1767
+ changed = true;
1768
+ lines.push(`cloned ${commit.slice(0, 8)}`);
1769
+ if (token) runGit(["remote", "set-url", "origin", workspace.repoUrl], targetDir);
1770
+ }
1771
+ const branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"], targetDir).stdout.trim();
1772
+ const commit = runGit(["rev-parse", "HEAD"], targetDir).stdout.trim();
1773
+ return {
1774
+ workspace: normalizeWorkspaceEntry({ ...workspace, path: targetDir }, 0, userCtx),
1775
+ changed,
1776
+ branch,
1777
+ commit,
1778
+ message: lines.join("\n"),
1779
+ };
1780
+ }
1781
+
1685
1782
  function listConfiguredWorkspaces(root, scopedRoot, userCtx = {}) {
1686
1783
  const currentRoot = path.resolve(scopedRoot || root);
1687
1784
  const homeRoot = path.resolve(os.homedir());
@@ -7667,7 +7764,7 @@ export function startUiServer({
7667
7764
 
7668
7765
  if (req.method === "POST" && url.pathname === "/api/workspaces") {
7669
7766
  try {
7670
- const payload = await readJson(req);
7767
+ const payload = JSON.parse(await readBody(req));
7671
7768
  const customWorkspaces = writeUserWorkspaces(userCtx, payload?.workspaces || payload?.customWorkspaces || []);
7672
7769
  json(res, 200, {
7673
7770
  path: userWorkspacesPath(userCtx),
@@ -7680,6 +7777,29 @@ export function startUiServer({
7680
7777
  return;
7681
7778
  }
7682
7779
 
7780
+ if (req.method === "POST" && url.pathname === "/api/workspaces/sync") {
7781
+ try {
7782
+ const payload = JSON.parse(await readBody(req));
7783
+ const id = String(payload?.id || "").trim();
7784
+ const workspaces = readUserWorkspaces(userCtx);
7785
+ const workspace = workspaces.find((entry) => String(entry.id || "") === id);
7786
+ if (!workspace) {
7787
+ json(res, 404, { error: "工作区不存在" });
7788
+ return;
7789
+ }
7790
+ const result = syncGitWorkspace(workspace, userCtx);
7791
+ json(res, 200, {
7792
+ ok: true,
7793
+ ...result,
7794
+ workspaces: listConfiguredWorkspaces(root, root, userCtx),
7795
+ customWorkspaces: readUserWorkspaces(userCtx),
7796
+ });
7797
+ } catch (e) {
7798
+ json(res, 500, { error: (e && e.message) || String(e) });
7799
+ }
7800
+ return;
7801
+ }
7802
+
7683
7803
  if (req.method === "GET" && url.pathname === "/api/workspace/graph") {
7684
7804
  try {
7685
7805
  const scoped = resolveWorkspaceScopeRoot(root, {