farming-code 2.2.8 → 2.2.11

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.
Files changed (102) hide show
  1. package/README.md +36 -23
  2. package/README.zh_cn.md +36 -23
  3. package/THIRD_PARTY_NOTICES.md +11 -2
  4. package/backend/acp-checkpoint-store.js +198 -0
  5. package/backend/acp-runtime.js +306 -83
  6. package/backend/acp-session-state.js +202 -6
  7. package/backend/acp-transcript.js +112 -0
  8. package/backend/agent-activity.js +6 -157
  9. package/backend/agent-manager.js +1482 -730
  10. package/backend/agent-provider-session.js +17 -242
  11. package/backend/agent-runtime-binding.js +219 -0
  12. package/backend/agent-session-history.js +66 -1
  13. package/backend/auth.js +79 -6
  14. package/backend/codex-models.js +81 -84
  15. package/backend/codex-session-archive.js +45 -0
  16. package/backend/codex-terminal-profile.js +500 -0
  17. package/backend/codex-transcript-sanitizer.js +12 -0
  18. package/backend/codex-transcript.js +230 -8
  19. package/backend/config-manager.js +30 -0
  20. package/backend/control-api.js +192 -17
  21. package/backend/farming-net-pass.js +285 -0
  22. package/backend/farming-net-registry.js +112 -0
  23. package/backend/farming-net-server.js +298 -0
  24. package/backend/farming-session-store.js +5 -13
  25. package/backend/git-worktree-info.js +181 -0
  26. package/backend/local-session-engine.js +411 -186
  27. package/backend/main-page-session.js +5 -2
  28. package/backend/native-pty-controller-generation.js +106 -0
  29. package/backend/native-pty-host-client.js +275 -7
  30. package/backend/native-pty-host-identity.js +86 -0
  31. package/backend/native-pty-host.js +813 -114
  32. package/backend/native-session-engine.js +100 -28
  33. package/backend/packaged-node-pty.js +22 -2
  34. package/backend/provider-adapters.js +253 -0
  35. package/backend/provider-session-service.js +241 -0
  36. package/backend/runtime-observation.js +81 -0
  37. package/backend/server.js +294 -86
  38. package/backend/session-engine-bridge.js +21 -2
  39. package/backend/session-engine-router.js +1 -1
  40. package/backend/session-engine.js +1 -1
  41. package/backend/session-stream-protocol.js +185 -0
  42. package/backend/storage-layout.js +55 -0
  43. package/backend/terminal-attach-checkpoint.js +74 -0
  44. package/backend/terminal-exit-quiescence.js +39 -0
  45. package/backend/terminal-reducer-flow-control.js +97 -0
  46. package/backend/terminal-screen-state.js +11 -2
  47. package/backend/terminal-screen-worker-pool.js +59 -6
  48. package/backend/terminal-screen-worker-thread.js +97 -57
  49. package/backend/terminal-screen-worker.js +133 -51
  50. package/backend/terminal-state-serialization.js +127 -0
  51. package/backend/terminal-status.js +23 -4
  52. package/backend/usage-monitor.js +81 -4
  53. package/backend/workspace-directory.js +152 -0
  54. package/backend/workspace-file-router.js +182 -76
  55. package/backend/workspace-file-service.js +319 -4
  56. package/backend/workspace-root-registry.js +164 -0
  57. package/dist/assets/App-CHYpgECZ.js +208 -0
  58. package/dist/assets/{FileEditorMarkdownPreview-elKWc8Im.js → FileEditorMarkdownPreview--Cri80nQ.js} +92 -92
  59. package/dist/assets/FileEditorPane-DGJlEKdf.js +2 -0
  60. package/dist/assets/IconGlyphs-Cc14sepw.js +1 -0
  61. package/dist/assets/ProjectFilesSection-Cz1wG4NT.js +12 -0
  62. package/dist/assets/{ReviewPage-BaXu1ZdX.js → ReviewPage-LonRc6Fz.js} +1 -1
  63. package/dist/assets/code-dark-B1pAtO_P.css +1 -0
  64. package/dist/assets/file-icons-Bw2qd5iT.js +1 -0
  65. package/dist/assets/{index-BrbljRqn.js → index-miO2r9IZ.js} +3 -3
  66. package/dist/assets/main-DNe0jPw-.css +1 -0
  67. package/dist/assets/workspace-editor-model-BQol4qbA.js +1 -0
  68. package/dist/assets/workspace-editor-monaco-5gFbeDPF.js +1 -0
  69. package/dist/assets/workspace-editor-monaco-xPv8JzHH.js +4 -0
  70. package/dist/assets/workspace-view-state-DvYG_9PH.js +7 -0
  71. package/dist/assets/workspace-working-copy-D8-s_Sgh.js +1 -0
  72. package/dist/index.html +1 -1
  73. package/frontend/farming-net/app.css +625 -0
  74. package/frontend/farming-net/app.js +268 -0
  75. package/frontend/farming-net/index.html +86 -0
  76. package/frontend/reading-anchor.js +198 -0
  77. package/frontend/session-bridge.js +12 -3
  78. package/frontend/session-modal-bridge.js +5 -12
  79. package/frontend/skins/crt/app.js +1976 -793
  80. package/frontend/skins/crt/index.html +313 -23
  81. package/frontend/skins/crt/styles/billing.css +294 -223
  82. package/frontend/skins/crt/styles/monochrome-green.css +7 -2
  83. package/frontend/terminal-replay.js +372 -0
  84. package/package.json +10 -3
  85. package/shared/browser-protocol.d.ts +5 -0
  86. package/shared/browser-protocol.js +130 -0
  87. package/dist/assets/App-8dYAM6ql.js +0 -124
  88. package/dist/assets/FileEditorPane-RWiFD2cq.js +0 -5
  89. package/dist/assets/IconGlyphs-DfL0EBnj.js +0 -1
  90. package/dist/assets/ProjectFilesSection-Q4PDsWmM.js +0 -12
  91. package/dist/assets/code-dark-CDkOQAtK.css +0 -1
  92. package/dist/assets/file-icons-EFUGSSwf.js +0 -1
  93. package/dist/assets/main-D073SnW4.css +0 -1
  94. package/dist/assets/qoder-C9LmmOSf.svg +0 -1
  95. package/dist/assets/qoder-Cf9gl0Y5.svg +0 -1
  96. package/dist/assets/qoder-gHCinseV.svg +0 -1
  97. package/dist/assets/workspace-view-state-CTyDzk2D.js +0 -1
  98. package/dist/assets/zsh-CLpveKlF.svg +0 -1
  99. package/dist/assets/zsh-FxSpMPbz.svg +0 -1
  100. /package/dist/assets/{api-D1lyBYIQ.js → api-D8nyOEbz.js} +0 -0
  101. /package/dist/assets/{core-ZlAPicox.js → core-D0LFJkDt.js} +0 -0
  102. /package/dist/assets/{useWorkspaceMenuKeyboard-CneKAZUJ.js → useWorkspaceMenuKeyboard-Brws6Ar9.js} +0 -0
@@ -0,0 +1,127 @@
1
+ const TERMINAL_STATE_VERSION = 1;
2
+ const MAX_SERIALIZED_TERMINAL_STATE_BYTES = 32 * 1024 * 1024;
3
+ const MAX_REPLAY_EVENT_BYTES = 8 * 1024 * 1024;
4
+
5
+ function finitePositiveInteger(value, fallback) {
6
+ const parsed = Math.floor(Number(value));
7
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
8
+ }
9
+
10
+ function normalizeReplayEvent(replayEvent) {
11
+ const events = Array.isArray(replayEvent?.events) ? replayEvent.events : [];
12
+ if (events.length !== 1) {
13
+ throw new Error('Serialized terminal state requires exactly one replay event');
14
+ }
15
+ const event = events[0] || {};
16
+ const data = typeof event.data === 'string' ? event.data : '';
17
+ if (Buffer.byteLength(data, 'utf8') > MAX_REPLAY_EVENT_BYTES) {
18
+ throw new Error('Serialized terminal replay event exceeds the size limit');
19
+ }
20
+ return {
21
+ events: [{
22
+ data,
23
+ cols: finitePositiveInteger(event.cols, 80),
24
+ rows: finitePositiveInteger(event.rows, 30),
25
+ }],
26
+ };
27
+ }
28
+
29
+ function normalizeTerminalStateEntry(entry) {
30
+ if (!entry || typeof entry !== 'object') {
31
+ throw new Error('Serialized terminal state entry must be an object');
32
+ }
33
+ const id = typeof entry.id === 'string' ? entry.id.trim() : '';
34
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/.test(id)) {
35
+ throw new Error('Serialized terminal state has an invalid session id');
36
+ }
37
+ const metadata = entry.metadata && typeof entry.metadata === 'object' && !Array.isArray(entry.metadata)
38
+ ? { ...entry.metadata }
39
+ : {};
40
+ const processDetails = entry.processDetails && typeof entry.processDetails === 'object'
41
+ ? entry.processDetails
42
+ : {};
43
+ const processLaunchConfig = entry.processLaunchConfig && typeof entry.processLaunchConfig === 'object'
44
+ ? entry.processLaunchConfig
45
+ : {};
46
+ return {
47
+ id,
48
+ metadata: {
49
+ ...metadata,
50
+ agentId: id,
51
+ },
52
+ processDetails: {
53
+ cwd: typeof processDetails.cwd === 'string' ? processDetails.cwd : '',
54
+ title: typeof processDetails.title === 'string' ? processDetails.title : '',
55
+ },
56
+ processLaunchConfig: {
57
+ command: typeof processLaunchConfig.command === 'string' ? processLaunchConfig.command : '',
58
+ args: Array.isArray(processLaunchConfig.args)
59
+ ? processLaunchConfig.args.filter(arg => typeof arg === 'string')
60
+ : [],
61
+ category: typeof processLaunchConfig.category === 'string' ? processLaunchConfig.category : '',
62
+ },
63
+ replayEvent: normalizeReplayEvent(entry.replayEvent),
64
+ timestamp: Number.isFinite(Number(entry.timestamp))
65
+ ? Math.max(0, Math.floor(Number(entry.timestamp)))
66
+ : Date.now(),
67
+ };
68
+ }
69
+
70
+ function serializeTerminalState(entries) {
71
+ const state = (Array.isArray(entries) ? entries : []).map(normalizeTerminalStateEntry);
72
+ const ids = new Set();
73
+ for (const entry of state) {
74
+ if (ids.has(entry.id)) {
75
+ throw new Error(`Serialized terminal state contains duplicate session id ${entry.id}`);
76
+ }
77
+ ids.add(entry.id);
78
+ }
79
+ const serialized = JSON.stringify({
80
+ version: TERMINAL_STATE_VERSION,
81
+ state,
82
+ });
83
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_SERIALIZED_TERMINAL_STATE_BYTES) {
84
+ throw new Error('Serialized terminal state exceeds the size limit');
85
+ }
86
+ return serialized;
87
+ }
88
+
89
+ function deserializeTerminalState(serialized) {
90
+ if (typeof serialized !== 'string' || !serialized) return [];
91
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_SERIALIZED_TERMINAL_STATE_BYTES) {
92
+ throw new Error('Serialized terminal state exceeds the size limit');
93
+ }
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(serialized);
97
+ } catch {
98
+ throw new Error('Serialized terminal state is not valid JSON');
99
+ }
100
+ if (!parsed || typeof parsed !== 'object' || parsed.version !== TERMINAL_STATE_VERSION || !Array.isArray(parsed.state)) {
101
+ throw new Error('Serialized terminal state has an unsupported format or version');
102
+ }
103
+ const state = parsed.state.map(normalizeTerminalStateEntry);
104
+ const ids = new Set();
105
+ for (const entry of state) {
106
+ if (ids.has(entry.id)) {
107
+ throw new Error(`Serialized terminal state contains duplicate session id ${entry.id}`);
108
+ }
109
+ ids.add(entry.id);
110
+ }
111
+ return state;
112
+ }
113
+
114
+ function terminalReplayText(entry) {
115
+ const normalized = normalizeTerminalStateEntry(entry);
116
+ return normalized.replayEvent.events[0].data;
117
+ }
118
+
119
+ module.exports = {
120
+ MAX_REPLAY_EVENT_BYTES,
121
+ MAX_SERIALIZED_TERMINAL_STATE_BYTES,
122
+ TERMINAL_STATE_VERSION,
123
+ deserializeTerminalState,
124
+ normalizeTerminalStateEntry,
125
+ serializeTerminalState,
126
+ terminalReplayText,
127
+ };
@@ -150,13 +150,12 @@ function latestTerminalKindFromText(title, previewText) {
150
150
 
151
151
  function inferKindFromText(title, previewText, command) {
152
152
  const commandName = executableName(command).toLowerCase();
153
- if (commandName === 'claude') return 'claude';
154
- if (commandName === 'codex') return 'codex';
155
153
  if (DIRECT_PROCESS_AGENTS.has(commandName)) return 'process';
156
- if (commandName && !SHELL_COMMANDS.has(commandName)) return 'process';
157
-
154
+ if (commandName && !SHELL_COMMANDS.has(commandName) && commandName !== 'codex' && commandName !== 'claude') return 'process';
158
155
  const terminalKind = latestTerminalKindFromText(title, previewText);
159
156
  if (terminalKind) return terminalKind;
157
+ if (commandName === 'claude') return 'claude';
158
+ if (commandName === 'codex') return 'codex';
160
159
  if (SHELL_COMMANDS.has(commandName)) return 'shell';
161
160
  return commandName ? 'process' : 'unknown';
162
161
  }
@@ -350,7 +349,27 @@ function deriveTerminalStatus(options = {}) {
350
349
  return status;
351
350
  }
352
351
 
352
+ function terminalInputReady(options = {}) {
353
+ const status = deriveTerminalStatus(options);
354
+ if (status.activity !== 'idle') return false;
355
+
356
+ const previewText = typeof options.previewText === 'string' ? options.previewText : '';
357
+ if (status.kind === 'codex') {
358
+ const text = stripTerminalControlSequences(previewText).replace(/\r/g, '').toLowerCase();
359
+ return lastCodexIdleFooterIndex(text) >= 0 || codexBlockedIndex(text) >= 0;
360
+ }
361
+ if (status.kind === 'claude') {
362
+ const text = stripTerminalControlSequences(previewText).replace(/\r/g, '');
363
+ return /(?:^|\n)\s*❯(?:\s|$)/u.test(text);
364
+ }
365
+ if (status.kind === 'shell') {
366
+ return options.terminalBusy === false || terminalTextLooksIdleShellPrompt(previewText);
367
+ }
368
+ return Boolean(stripTerminalControlSequences(previewText).trim());
369
+ }
370
+
353
371
  module.exports = {
354
372
  deriveTerminalStatus,
373
+ terminalInputReady,
355
374
  terminalTextLooksIdleShellPrompt,
356
375
  };
@@ -13,6 +13,7 @@ const USAGE_TIMELINE_WINDOW_MS = 60 * 60 * 1000;
13
13
  const USAGE_TIMELINE_BUCKET_COUNT = 30;
14
14
  const USAGE_DAILY_DAYS = 52 * 7;
15
15
  const USAGE_DAILY_CACHE_MS = 5 * 60 * 1000;
16
+ const USAGE_LIVE_DAY_CACHE_MS = 5 * 1000;
16
17
  const JSONL_FILE_LIMIT = 60;
17
18
  const JSONL_SCAN_LIMIT = 2000;
18
19
  const DAILY_JSONL_FILE_LIMIT = 5000;
@@ -847,6 +848,7 @@ async function collectOpenCodeDailyEvents(homePaths, options = {}) {
847
848
  const sessions = [];
848
849
  const seenSessionIds = new Set();
849
850
  let successfulHomes = 0;
851
+ let successfulExports = 0;
850
852
  let partial = false;
851
853
  let reason = '';
852
854
 
@@ -880,6 +882,7 @@ async function collectOpenCodeDailyEvents(homePaths, options = {}) {
880
882
  const cacheKey = `${session.openCodeHome}:${session.id}`;
881
883
  const cached = openCodeSessionEventCache.get(cacheKey);
882
884
  if (cached?.updatedAt === session.updatedAt) {
885
+ successfulExports += 1;
883
886
  events.push(...cached.events);
884
887
  continue;
885
888
  }
@@ -890,6 +893,7 @@ async function collectOpenCodeDailyEvents(homePaths, options = {}) {
890
893
  );
891
894
  const exported = JSON.parse(String(result?.stdout || '{}'));
892
895
  const sessionEvents = openCodeTokenEventsFromExport(exported, { cutoffMs, now });
896
+ successfulExports += 1;
893
897
  openCodeSessionEventCache.set(cacheKey, { updatedAt: session.updatedAt, events: sessionEvents });
894
898
  events.push(...sessionEvents);
895
899
  } catch (error) {
@@ -906,9 +910,11 @@ async function collectOpenCodeDailyEvents(homePaths, options = {}) {
906
910
  return {
907
911
  events,
908
912
  partial,
909
- available: successfulHomes > 0,
913
+ available: successfulHomes === homePaths.length
914
+ && (sessions.length === 0 || successfulExports === sessions.length),
910
915
  reason,
911
916
  sessionCount: sessions.length,
917
+ exportCount: successfulExports,
912
918
  };
913
919
  }
914
920
 
@@ -980,6 +986,8 @@ async function collectUsageHistory(options = {}) {
980
986
  available: openCode.available,
981
987
  homeCount: openCodeHomes.length,
982
988
  sessionCount: openCode.sessionCount,
989
+ exportCount: openCode.exportCount,
990
+ partial: openCode.partial,
983
991
  source: 'opencode session export',
984
992
  ...(openCode.reason ? { reason: openCode.reason } : {}),
985
993
  },
@@ -1195,10 +1203,13 @@ class UsageMonitor {
1195
1203
  this.dailyDays = options.dailyDays ?? USAGE_DAILY_DAYS;
1196
1204
  this.dailyCacheMs = options.dailyCacheMs ?? USAGE_DAILY_CACHE_MS;
1197
1205
  this.dailyCache = { value: null, fetchedAt: 0, pending: null };
1206
+ this.liveDayCacheMs = options.liveDayCacheMs ?? USAGE_LIVE_DAY_CACHE_MS;
1207
+ this.liveDayCache = { date: '', value: null, fetchedAt: 0, pending: null };
1198
1208
  }
1199
1209
 
1200
1210
  invalidateDailyCache() {
1201
1211
  this.dailyCache.fetchedAt = 0;
1212
+ this.liveDayCache.fetchedAt = 0;
1202
1213
  }
1203
1214
 
1204
1215
  getDailyUsage(options = {}) {
@@ -1232,8 +1243,60 @@ class UsageMonitor {
1232
1243
  }
1233
1244
 
1234
1245
  async getUsageDay(date, options = {}) {
1246
+ const now = options.now ?? Date.now();
1247
+ if (options.live === true && String(date || '').trim() === localDateKey(now)) {
1248
+ const liveDate = String(date).trim();
1249
+ const dailyFallback = this.dailyCache.value?.providerEvents
1250
+ ? buildUsageDayDetail(this.dailyCache.value.providerEvents, { date: liveDate })
1251
+ : null;
1252
+ const cachedFallback = this.liveDayCache.date === liveDate
1253
+ ? this.liveDayCache.value
1254
+ : null;
1255
+ const fallback = cachedFallback || dailyFallback;
1256
+ const recoverWithFallback = error => {
1257
+ if (!fallback) throw error;
1258
+ if (this.liveDayCache.date === liveDate) {
1259
+ this.liveDayCache.value = fallback;
1260
+ this.liveDayCache.fetchedAt = now;
1261
+ }
1262
+ return fallback;
1263
+ };
1264
+ if (
1265
+ options.fresh !== true
1266
+ && this.liveDayCache.date === liveDate
1267
+ && this.liveDayCache.value
1268
+ && now - this.liveDayCache.fetchedAt < this.liveDayCacheMs
1269
+ ) {
1270
+ return this.liveDayCache.value;
1271
+ }
1272
+ if (this.liveDayCache.pending && this.liveDayCache.date === liveDate) {
1273
+ return this.liveDayCache.pending.catch(recoverWithFallback);
1274
+ }
1275
+ this.liveDayCache.date = liveDate;
1276
+ const pending = collectUsageHistory({
1277
+ codexHome: this.codexHome,
1278
+ claudeHome: this.claudeHome,
1279
+ openCodeHome: this.openCodeHome,
1280
+ qoderHome: this.qoderHome,
1281
+ providerHomes: this.getProviderHomes ? this.getProviderHomes() : undefined,
1282
+ openCodeCommandRunner: this.openCodeCommandRunner,
1283
+ now,
1284
+ days: 1,
1285
+ }).then(history => {
1286
+ const detail = buildUsageDayDetail(history.providerEvents, { date: liveDate });
1287
+ if (this.liveDayCache.date === liveDate) {
1288
+ this.liveDayCache.value = detail;
1289
+ this.liveDayCache.fetchedAt = now;
1290
+ }
1291
+ return detail;
1292
+ }).finally(() => {
1293
+ if (this.liveDayCache.pending === pending) this.liveDayCache.pending = null;
1294
+ });
1295
+ this.liveDayCache.pending = pending;
1296
+ return pending.catch(recoverWithFallback);
1297
+ }
1235
1298
  const history = await this.getDailyUsage({
1236
- now: options.now,
1299
+ now,
1237
1300
  force: options.fresh === true,
1238
1301
  });
1239
1302
  return buildUsageDayDetail(history.providerEvents, { date });
@@ -1272,6 +1335,7 @@ class UsageMonitor {
1272
1335
  claude: claudeUsage.tokenEvents,
1273
1336
  opencode: openCodeUsage.tokenEvents,
1274
1337
  }, { now, windowMs: historyWindowMs });
1338
+ const openCodeCoverage = history.coverage.find(entry => entry.provider === 'opencode');
1275
1339
  const qoderCoverage = history.coverage.find(entry => entry.provider === 'qoder');
1276
1340
 
1277
1341
  return {
@@ -1297,13 +1361,25 @@ class UsageMonitor {
1297
1361
  {
1298
1362
  provider: 'opencode',
1299
1363
  providerName: 'OpenCode',
1300
- auth: { available: true, status: 'Local session export', source: 'opencode session export' },
1364
+ auth: {
1365
+ available: openCodeCoverage?.available === true,
1366
+ status: openCodeCoverage?.available === true
1367
+ ? 'Local session export'
1368
+ : openCodeCoverage?.reason || 'OpenCode unavailable',
1369
+ source: 'opencode session export',
1370
+ },
1301
1371
  quota: {
1302
1372
  available: false,
1303
1373
  source: 'opencode session export',
1304
1374
  reason: 'OpenCode session exports do not expose quota remaining.',
1305
1375
  },
1306
- tokenUsage: openCodeUsage.tokenUsage,
1376
+ tokenUsage: openCodeCoverage?.available === true
1377
+ ? openCodeUsage.tokenUsage
1378
+ : {
1379
+ ...openCodeUsage.tokenUsage,
1380
+ available: false,
1381
+ reason: openCodeCoverage?.reason || 'OpenCode token usage is unavailable.',
1382
+ },
1307
1383
  },
1308
1384
  {
1309
1385
  provider: 'qoder',
@@ -1340,6 +1416,7 @@ module.exports = {
1340
1416
  USAGE_TIMELINE_BUCKET_COUNT,
1341
1417
  USAGE_DAILY_DAYS,
1342
1418
  USAGE_DAILY_CACHE_MS,
1419
+ USAGE_LIVE_DAY_CACHE_MS,
1343
1420
  UsageMonitor,
1344
1421
  buildUsageTimeline,
1345
1422
  buildDailyUsage,
@@ -0,0 +1,152 @@
1
+ const express = require('express');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ const CREATE_FORBIDDEN_CODES = new Set(['EACCES', 'EPERM', 'EROFS']);
7
+ const INVALID_PATH_CODES = new Set(['EINVAL', 'ENAMETOOLONG']);
8
+
9
+ function resolveWorkspaceDirectory(value, homeDir = process.env.HOME || os.homedir()) {
10
+ const input = typeof value === 'string' ? value.trim() : '';
11
+ if (!input) return '';
12
+ const expanded = input === '~'
13
+ ? homeDir
14
+ : input.startsWith('~/')
15
+ ? path.join(homeDir, input.slice(2))
16
+ : input;
17
+ return path.resolve(expanded);
18
+ }
19
+
20
+ function workspaceDirectoryError(error, workspace) {
21
+ const code = error?.code || '';
22
+ if (CREATE_FORBIDDEN_CODES.has(code)) {
23
+ return {
24
+ status: 403,
25
+ body: {
26
+ status: 'rejected',
27
+ code: 'workspace-create-forbidden',
28
+ workspace,
29
+ message: `Farming does not have permission to create this directory: ${workspace}`,
30
+ },
31
+ };
32
+ }
33
+ if (code === 'ENOTDIR') {
34
+ return {
35
+ status: 409,
36
+ body: {
37
+ status: 'rejected',
38
+ code: 'workspace-parent-not-directory',
39
+ workspace,
40
+ message: `A parent path is not a directory: ${workspace}`,
41
+ },
42
+ };
43
+ }
44
+ if (code === 'EEXIST') {
45
+ return {
46
+ status: 409,
47
+ body: {
48
+ status: 'rejected',
49
+ code: 'workspace-not-directory',
50
+ workspace,
51
+ message: `Workspace path is not a directory: ${workspace}`,
52
+ },
53
+ };
54
+ }
55
+ if (INVALID_PATH_CODES.has(code)) {
56
+ return {
57
+ status: 400,
58
+ body: {
59
+ status: 'rejected',
60
+ code: 'workspace-invalid-path',
61
+ workspace,
62
+ message: 'Workspace path is invalid',
63
+ },
64
+ };
65
+ }
66
+ return {
67
+ status: 500,
68
+ body: {
69
+ status: 'rejected',
70
+ code: 'workspace-create-failed',
71
+ workspace,
72
+ message: `Failed to create workspace directory: ${workspace}`,
73
+ },
74
+ };
75
+ }
76
+
77
+ async function prepareWorkspaceDirectory(value, options = {}) {
78
+ const workspace = resolveWorkspaceDirectory(value, options.homeDir);
79
+ if (!workspace) {
80
+ return {
81
+ status: 400,
82
+ body: {
83
+ status: 'rejected',
84
+ code: 'workspace-path-required',
85
+ workspace: '',
86
+ message: 'Workspace path is required',
87
+ },
88
+ };
89
+ }
90
+
91
+ const fileSystem = options.fileSystem || fs.promises;
92
+ try {
93
+ const stat = await fileSystem.stat(workspace);
94
+ if (!stat.isDirectory()) {
95
+ return {
96
+ status: 409,
97
+ body: {
98
+ status: 'rejected',
99
+ code: 'workspace-not-directory',
100
+ workspace,
101
+ message: `Workspace path is not a directory: ${workspace}`,
102
+ },
103
+ };
104
+ }
105
+ return { status: 200, body: { status: 'ready', workspace } };
106
+ } catch (error) {
107
+ if (error?.code !== 'ENOENT') return workspaceDirectoryError(error, workspace);
108
+ }
109
+
110
+ if (options.create !== true) {
111
+ return {
112
+ status: 409,
113
+ body: {
114
+ status: 'missing',
115
+ code: 'workspace-not-found',
116
+ workspace,
117
+ message: `Workspace directory does not exist: ${workspace}`,
118
+ },
119
+ };
120
+ }
121
+
122
+ try {
123
+ await fileSystem.mkdir(workspace, { recursive: true });
124
+ const stat = await fileSystem.stat(workspace);
125
+ if (!stat.isDirectory()) {
126
+ return workspaceDirectoryError({ code: 'EEXIST' }, workspace);
127
+ }
128
+ return { status: 201, body: { status: 'created', workspace } };
129
+ } catch (error) {
130
+ return workspaceDirectoryError(error, workspace);
131
+ }
132
+ }
133
+
134
+ function createWorkspaceDirectoryRouter(options = {}) {
135
+ const router = express.Router();
136
+ router.use(express.json({ limit: '8kb' }));
137
+ router.post('/prepare', async (req, res) => {
138
+ const result = await prepareWorkspaceDirectory(req.body?.workspace, {
139
+ create: req.body?.create === true,
140
+ fileSystem: options.fileSystem,
141
+ homeDir: options.homeDir,
142
+ });
143
+ res.status(result.status).json(result.body);
144
+ });
145
+ return router;
146
+ }
147
+
148
+ module.exports = {
149
+ createWorkspaceDirectoryRouter,
150
+ prepareWorkspaceDirectory,
151
+ resolveWorkspaceDirectory,
152
+ };