@veris-ai/daytona-opencode 0.1.0

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 (106) hide show
  1. package/.opencode/plugin/daytona/core/logger.d.ts +15 -0
  2. package/.opencode/plugin/daytona/core/logger.js +65 -0
  3. package/.opencode/plugin/daytona/core/logger.js.map +1 -0
  4. package/.opencode/plugin/daytona/core/logger.ts +71 -0
  5. package/.opencode/plugin/daytona/core/project-data-storage.d.ts +63 -0
  6. package/.opencode/plugin/daytona/core/project-data-storage.js +213 -0
  7. package/.opencode/plugin/daytona/core/project-data-storage.js.map +1 -0
  8. package/.opencode/plugin/daytona/core/project-data-storage.ts +234 -0
  9. package/.opencode/plugin/daytona/core/session-manager.d.ts +74 -0
  10. package/.opencode/plugin/daytona/core/session-manager.js +441 -0
  11. package/.opencode/plugin/daytona/core/session-manager.js.map +1 -0
  12. package/.opencode/plugin/daytona/core/session-manager.ts +471 -0
  13. package/.opencode/plugin/daytona/core/toast.d.ts +47 -0
  14. package/.opencode/plugin/daytona/core/toast.js +70 -0
  15. package/.opencode/plugin/daytona/core/toast.js.map +1 -0
  16. package/.opencode/plugin/daytona/core/toast.ts +96 -0
  17. package/.opencode/plugin/daytona/core/types.d.ts +53 -0
  18. package/.opencode/plugin/daytona/core/types.js +12 -0
  19. package/.opencode/plugin/daytona/core/types.js.map +1 -0
  20. package/.opencode/plugin/daytona/core/types.ts +72 -0
  21. package/.opencode/plugin/daytona/git/host-git-manager.d.ts +58 -0
  22. package/.opencode/plugin/daytona/git/host-git-manager.js +342 -0
  23. package/.opencode/plugin/daytona/git/host-git-manager.js.map +1 -0
  24. package/.opencode/plugin/daytona/git/host-git-manager.ts +372 -0
  25. package/.opencode/plugin/daytona/git/index.d.ts +5 -0
  26. package/.opencode/plugin/daytona/git/index.js +6 -0
  27. package/.opencode/plugin/daytona/git/index.js.map +1 -0
  28. package/.opencode/plugin/daytona/git/index.ts +6 -0
  29. package/.opencode/plugin/daytona/git/sandbox-git-manager.d.ts +22 -0
  30. package/.opencode/plugin/daytona/git/sandbox-git-manager.js +80 -0
  31. package/.opencode/plugin/daytona/git/sandbox-git-manager.js.map +1 -0
  32. package/.opencode/plugin/daytona/git/sandbox-git-manager.ts +92 -0
  33. package/.opencode/plugin/daytona/git/session-git-manager.d.ts +54 -0
  34. package/.opencode/plugin/daytona/git/session-git-manager.js +184 -0
  35. package/.opencode/plugin/daytona/git/session-git-manager.js.map +1 -0
  36. package/.opencode/plugin/daytona/git/session-git-manager.ts +203 -0
  37. package/.opencode/plugin/daytona/index.d.ts +125 -0
  38. package/.opencode/plugin/daytona/index.js +60 -0
  39. package/.opencode/plugin/daytona/index.js.map +1 -0
  40. package/.opencode/plugin/daytona/index.ts +73 -0
  41. package/.opencode/plugin/daytona/plugins/custom-tools.d.ts +122 -0
  42. package/.opencode/plugin/daytona/plugins/custom-tools.js +18 -0
  43. package/.opencode/plugin/daytona/plugins/custom-tools.js.map +1 -0
  44. package/.opencode/plugin/daytona/plugins/custom-tools.ts +21 -0
  45. package/.opencode/plugin/daytona/plugins/session-events.d.ts +10 -0
  46. package/.opencode/plugin/daytona/plugins/session-events.js +63 -0
  47. package/.opencode/plugin/daytona/plugins/session-events.js.map +1 -0
  48. package/.opencode/plugin/daytona/plugins/session-events.ts +61 -0
  49. package/.opencode/plugin/daytona/plugins/system-transform.d.ts +10 -0
  50. package/.opencode/plugin/daytona/plugins/system-transform.js +38 -0
  51. package/.opencode/plugin/daytona/plugins/system-transform.js.map +1 -0
  52. package/.opencode/plugin/daytona/plugins/system-transform.ts +43 -0
  53. package/.opencode/plugin/daytona/tools/bash.d.ts +19 -0
  54. package/.opencode/plugin/daytona/tools/bash.js +42 -0
  55. package/.opencode/plugin/daytona/tools/bash.js.map +1 -0
  56. package/.opencode/plugin/daytona/tools/bash.ts +51 -0
  57. package/.opencode/plugin/daytona/tools/edit.d.ts +21 -0
  58. package/.opencode/plugin/daytona/tools/edit.js +33 -0
  59. package/.opencode/plugin/daytona/tools/edit.js.map +1 -0
  60. package/.opencode/plugin/daytona/tools/edit.ts +44 -0
  61. package/.opencode/plugin/daytona/tools/get-preview-url.d.ts +17 -0
  62. package/.opencode/plugin/daytona/tools/get-preview-url.js +17 -0
  63. package/.opencode/plugin/daytona/tools/get-preview-url.js.map +1 -0
  64. package/.opencode/plugin/daytona/tools/get-preview-url.ts +26 -0
  65. package/.opencode/plugin/daytona/tools/git-sync.d.ts +12 -0
  66. package/.opencode/plugin/daytona/tools/git-sync.js +26 -0
  67. package/.opencode/plugin/daytona/tools/git-sync.js.map +1 -0
  68. package/.opencode/plugin/daytona/tools/git-sync.ts +36 -0
  69. package/.opencode/plugin/daytona/tools/glob.d.ts +17 -0
  70. package/.opencode/plugin/daytona/tools/glob.js +21 -0
  71. package/.opencode/plugin/daytona/tools/glob.js.map +1 -0
  72. package/.opencode/plugin/daytona/tools/glob.ts +30 -0
  73. package/.opencode/plugin/daytona/tools/grep.d.ts +17 -0
  74. package/.opencode/plugin/daytona/tools/grep.js +29 -0
  75. package/.opencode/plugin/daytona/tools/grep.js.map +1 -0
  76. package/.opencode/plugin/daytona/tools/grep.ts +39 -0
  77. package/.opencode/plugin/daytona/tools/ls.d.ts +17 -0
  78. package/.opencode/plugin/daytona/tools/ls.js +22 -0
  79. package/.opencode/plugin/daytona/tools/ls.js.map +1 -0
  80. package/.opencode/plugin/daytona/tools/ls.ts +32 -0
  81. package/.opencode/plugin/daytona/tools/multiedit.d.ts +25 -0
  82. package/.opencode/plugin/daytona/tools/multiedit.js +37 -0
  83. package/.opencode/plugin/daytona/tools/multiedit.js.map +1 -0
  84. package/.opencode/plugin/daytona/tools/multiedit.ts +54 -0
  85. package/.opencode/plugin/daytona/tools/read.d.ts +17 -0
  86. package/.opencode/plugin/daytona/tools/read.js +18 -0
  87. package/.opencode/plugin/daytona/tools/read.js.map +1 -0
  88. package/.opencode/plugin/daytona/tools/read.ts +27 -0
  89. package/.opencode/plugin/daytona/tools/veris-receipt.d.ts +26 -0
  90. package/.opencode/plugin/daytona/tools/veris-receipt.js +76 -0
  91. package/.opencode/plugin/daytona/tools/veris-receipt.js.map +1 -0
  92. package/.opencode/plugin/daytona/tools/veris-receipt.ts +103 -0
  93. package/.opencode/plugin/daytona/tools/write.d.ts +19 -0
  94. package/.opencode/plugin/daytona/tools/write.js +18 -0
  95. package/.opencode/plugin/daytona/tools/write.js.map +1 -0
  96. package/.opencode/plugin/daytona/tools/write.ts +27 -0
  97. package/.opencode/plugin/daytona/tools.d.ts +119 -0
  98. package/.opencode/plugin/daytona/tools.js +35 -0
  99. package/.opencode/plugin/daytona/tools.js.map +1 -0
  100. package/.opencode/plugin/daytona/tools.ts +45 -0
  101. package/.opencode/plugin/index.d.ts +9 -0
  102. package/.opencode/plugin/index.js +10 -0
  103. package/.opencode/plugin/index.js.map +1 -0
  104. package/.opencode/plugin/index.ts +11 -0
  105. package/README.md +87 -0
  106. package/package.json +52 -0
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import type { LogLevel } from './types';
6
+ export declare function setLogFilePath(path: string): void;
7
+ declare class Logger {
8
+ private get logFile();
9
+ log(message: string, level?: LogLevel): void;
10
+ info(message: string): void;
11
+ error(message: string): void;
12
+ warn(message: string): void;
13
+ }
14
+ export declare const logger: Logger;
15
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ /**
6
+ * Logger class for handling plugin logging
7
+ */
8
+ import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
9
+ import { dirname } from 'path';
10
+ import { LOG_LEVEL_INFO, LOG_LEVEL_ERROR, LOG_LEVEL_WARN } from './types';
11
+ let logFilePath;
12
+ export function setLogFilePath(path) {
13
+ logFilePath = path;
14
+ }
15
+ class Logger {
16
+ get logFile() {
17
+ if (!logFilePath)
18
+ throw new Error('Logger file path not set. Call setLogFilePath(path) before use.');
19
+ return logFilePath;
20
+ }
21
+ log(message, level = LOG_LEVEL_INFO) {
22
+ // Ensure log directory exists
23
+ try {
24
+ mkdirSync(dirname(this.logFile), { recursive: true });
25
+ }
26
+ catch (err) {
27
+ // Directory may already exist, ignore
28
+ }
29
+ // Trim by byte length (not characters) so the 1MB target holds for non-ASCII logs
30
+ try {
31
+ const stats = statSync(this.logFile);
32
+ const maxSize = 3 * 1024 * 1024;
33
+ const keepSize = 1024 * 1024;
34
+ if (stats.size > maxSize) {
35
+ const buffer = readFileSync(this.logFile);
36
+ const trimmed = buffer.subarray(buffer.length - keepSize);
37
+ // Drop partial first line so we don't start mid-log
38
+ const firstNewline = trimmed.indexOf('\n');
39
+ writeFileSync(this.logFile, firstNewline >= 0 ? trimmed.subarray(firstNewline + 1) : trimmed);
40
+ }
41
+ }
42
+ catch (err) {
43
+ // File may not exist yet, ignore
44
+ }
45
+ const timestamp = new Date().toISOString();
46
+ const logEntry = `[${timestamp}] [${level}] ${message}\n`;
47
+ try {
48
+ appendFileSync(this.logFile, logEntry);
49
+ }
50
+ catch (err) {
51
+ // Best-effort logging: never let a write failure crash the caller
52
+ }
53
+ }
54
+ info(message) {
55
+ this.log(message, LOG_LEVEL_INFO);
56
+ }
57
+ error(message) {
58
+ this.log(message, LOG_LEVEL_ERROR);
59
+ }
60
+ warn(message) {
61
+ this.log(message, LOG_LEVEL_WARN);
62
+ }
63
+ }
64
+ export const logger = new Logger();
65
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.js","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;GAEG;AAEH,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,IAAI,CAAA;AACrF,OAAO,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAE9B,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAEzE,IAAI,WAA+B,CAAA;AAEnC,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,WAAW,GAAG,IAAI,CAAA;AACpB,CAAC;AAED,MAAM,MAAM;IACV,IAAY,OAAO;QACjB,IAAI,CAAC,WAAW;YAAE,MAAM,IAAI,KAAK,CAAC,iEAAiE,CAAC,CAAA;QACpG,OAAO,WAAW,CAAA;IACpB,CAAC;IAED,GAAG,CAAC,OAAe,EAAE,QAAkB,cAAc;QACnD,8BAA8B;QAC9B,IAAI,CAAC;YACH,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;QACvD,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,sCAAsC;QACxC,CAAC;QACD,kFAAkF;QAClF,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;YACpC,MAAM,OAAO,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAA;YAC/B,MAAM,QAAQ,GAAG,IAAI,GAAG,IAAI,CAAA;YAC5B,IAAI,KAAK,CAAC,IAAI,GAAG,OAAO,EAAE,CAAC;gBACzB,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;gBACzC,MAAM,OAAO,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,GAAG,QAAQ,CAAC,CAAA;gBACzD,oDAAoD;gBACpD,MAAM,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;gBAC1C,aAAa,CAAC,IAAI,CAAC,OAAO,EAAE,YAAY,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,YAAY,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAA;YAC/F,CAAC;QACH,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,iCAAiC;QACnC,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;QAC1C,MAAM,QAAQ,GAAG,IAAI,SAAS,MAAM,KAAK,KAAK,OAAO,IAAI,CAAA;QACzD,IAAI,CAAC;YACH,cAAc,CAAC,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAA;QACxC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,kEAAkE;QACpE,CAAC;IACH,CAAC;IAED,IAAI,CAAC,OAAe;QAClB,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,cAAc,CAAC,CAAA;IACnC,CAAC;IAED,KAAK,CAAC,OAAe;QACnB,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,eAAe,CAAC,CAAA;IACpC,CAAC;IAED,IAAI,CAAC,OAAe;QAClB,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,cAAc,CAAC,CAAA;IACnC,CAAC;CACF;AAED,MAAM,CAAC,MAAM,MAAM,GAAG,IAAI,MAAM,EAAE,CAAA"}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Logger class for handling plugin logging
8
+ */
9
+
10
+ import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'
11
+ import { dirname } from 'path'
12
+ import type { LogLevel } from './types'
13
+ import { LOG_LEVEL_INFO, LOG_LEVEL_ERROR, LOG_LEVEL_WARN } from './types'
14
+
15
+ let logFilePath: string | undefined
16
+
17
+ export function setLogFilePath(path: string) {
18
+ logFilePath = path
19
+ }
20
+
21
+ class Logger {
22
+ private get logFile() {
23
+ if (!logFilePath) throw new Error('Logger file path not set. Call setLogFilePath(path) before use.')
24
+ return logFilePath
25
+ }
26
+
27
+ log(message: string, level: LogLevel = LOG_LEVEL_INFO): void {
28
+ // Ensure log directory exists
29
+ try {
30
+ mkdirSync(dirname(this.logFile), { recursive: true })
31
+ } catch (err) {
32
+ // Directory may already exist, ignore
33
+ }
34
+ // Trim by byte length (not characters) so the 1MB target holds for non-ASCII logs
35
+ try {
36
+ const stats = statSync(this.logFile)
37
+ const maxSize = 3 * 1024 * 1024
38
+ const keepSize = 1024 * 1024
39
+ if (stats.size > maxSize) {
40
+ const buffer = readFileSync(this.logFile)
41
+ const trimmed = buffer.subarray(buffer.length - keepSize)
42
+ // Drop partial first line so we don't start mid-log
43
+ const firstNewline = trimmed.indexOf('\n')
44
+ writeFileSync(this.logFile, firstNewline >= 0 ? trimmed.subarray(firstNewline + 1) : trimmed)
45
+ }
46
+ } catch (err) {
47
+ // File may not exist yet, ignore
48
+ }
49
+ const timestamp = new Date().toISOString()
50
+ const logEntry = `[${timestamp}] [${level}] ${message}\n`
51
+ try {
52
+ appendFileSync(this.logFile, logEntry)
53
+ } catch (err) {
54
+ // Best-effort logging: never let a write failure crash the caller
55
+ }
56
+ }
57
+
58
+ info(message: string): void {
59
+ this.log(message, LOG_LEVEL_INFO)
60
+ }
61
+
62
+ error(message: string): void {
63
+ this.log(message, LOG_LEVEL_ERROR)
64
+ }
65
+
66
+ warn(message: string): void {
67
+ this.log(message, LOG_LEVEL_WARN)
68
+ }
69
+ }
70
+
71
+ export const logger = new Logger()
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import type { ProjectSessionData, SessionInfo } from './types';
6
+ export declare class ProjectDataStorage {
7
+ private readonly storageDir;
8
+ constructor(storageDir: string);
9
+ /**
10
+ * Get the file path for a project's session data.
11
+ * encodeURIComponent gives a reversible, collision-free encoding that also
12
+ * strips path separators, so a projectId can't traverse outside storageDir
13
+ * and distinct ids can't collide onto the same file.
14
+ */
15
+ private getProjectFilePath;
16
+ /**
17
+ * List known project IDs from storage, decoded to the canonical form used by callers.
18
+ * Filenames that can't be decoded (e.g. hand-created files with invalid percent escapes)
19
+ * are skipped: exposing them would return an id that can't round-trip through
20
+ * getProjectFilePath, causing subsequent load/save/remove to silently target the wrong file.
21
+ */
22
+ private listProjectIds;
23
+ /**
24
+ * Load project session data from disk
25
+ */
26
+ load(projectId: string): ProjectSessionData | null;
27
+ /**
28
+ * Get a session for a project. If not found in the requested project, search all other
29
+ * projects on disk and, if found, migrate it into the requested project.
30
+ */
31
+ getSession(projectId: string, worktree: string, sessionId: string): SessionInfo | undefined;
32
+ /**
33
+ * Read-only lookup of a session across all project files. Unlike getSession, this never
34
+ * migrates or writes, so it is safe to use on the delete path.
35
+ */
36
+ findSession(sessionId: string): {
37
+ projectId: string;
38
+ worktree: string;
39
+ session: SessionInfo;
40
+ } | undefined;
41
+ /**
42
+ * Save project session data to disk.
43
+ *
44
+ * `storageKey` identifies WHICH FILE on disk to write (round-trips through
45
+ * getProjectFilePath). `projectData.projectId` is the CANONICAL id written into
46
+ * the JSON body — kept separate so callers who reached a file via a filename-derived
47
+ * key (e.g. findSession → removeSession for legacy files) don't clobber the
48
+ * pre-existing canonical id with the storage key.
49
+ */
50
+ save(storageKey: string, projectData: ProjectSessionData): void;
51
+ /**
52
+ * Get branch number for a sandbox
53
+ */
54
+ getBranchNumberForSandbox(projectId: string, sandboxId: string): number | undefined;
55
+ /**
56
+ * Update a single session in the project file
57
+ */
58
+ updateSession(projectId: string, worktree: string, sessionId: string, sandboxId: string, branchNumber?: number): void;
59
+ /**
60
+ * Remove a session from the project file
61
+ */
62
+ removeSession(projectId: string, worktree: string, sessionId: string): void;
63
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ /**
6
+ * Handles file storage operations for project session data
7
+ * Stores data per-project in ~/.local/share/opencode/storage/daytona/{projectId}.json
8
+ */
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { logger } from './logger';
12
+ export class ProjectDataStorage {
13
+ storageDir;
14
+ constructor(storageDir) {
15
+ this.storageDir = storageDir;
16
+ // Ensure storage directory exists
17
+ if (!existsSync(this.storageDir)) {
18
+ mkdirSync(this.storageDir, { recursive: true });
19
+ }
20
+ }
21
+ /**
22
+ * Get the file path for a project's session data.
23
+ * encodeURIComponent gives a reversible, collision-free encoding that also
24
+ * strips path separators, so a projectId can't traverse outside storageDir
25
+ * and distinct ids can't collide onto the same file.
26
+ */
27
+ getProjectFilePath(projectId) {
28
+ return join(this.storageDir, `${encodeURIComponent(projectId)}.json`);
29
+ }
30
+ /**
31
+ * List known project IDs from storage, decoded to the canonical form used by callers.
32
+ * Filenames that can't be decoded (e.g. hand-created files with invalid percent escapes)
33
+ * are skipped: exposing them would return an id that can't round-trip through
34
+ * getProjectFilePath, causing subsequent load/save/remove to silently target the wrong file.
35
+ */
36
+ listProjectIds() {
37
+ try {
38
+ const ids = [];
39
+ for (const name of readdirSync(this.storageDir)) {
40
+ if (!name.endsWith('.json'))
41
+ continue;
42
+ const encoded = name.slice(0, -'.json'.length);
43
+ try {
44
+ ids.push(decodeURIComponent(encoded));
45
+ }
46
+ catch {
47
+ logger.warn(`Skipping project data file with undecodable name: ${name}`);
48
+ }
49
+ }
50
+ return ids;
51
+ }
52
+ catch (err) {
53
+ logger.error(`Failed to list project data files: ${err}`);
54
+ return [];
55
+ }
56
+ }
57
+ /**
58
+ * Load project session data from disk
59
+ */
60
+ load(projectId) {
61
+ const filePath = this.getProjectFilePath(projectId);
62
+ try {
63
+ if (existsSync(filePath)) {
64
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
65
+ }
66
+ }
67
+ catch (err) {
68
+ logger.error(`Failed to load project data for ${projectId}: ${err}`);
69
+ }
70
+ return null;
71
+ }
72
+ /**
73
+ * Get a session for a project. If not found in the requested project, search all other
74
+ * projects on disk and, if found, migrate it into the requested project.
75
+ */
76
+ getSession(projectId, worktree, sessionId) {
77
+ const current = this.load(projectId);
78
+ const currentSession = current?.sessions?.[sessionId];
79
+ if (currentSession) {
80
+ return currentSession;
81
+ }
82
+ // Look in other projects and migrate if found.
83
+ for (const otherProjectId of this.listProjectIds()) {
84
+ if (otherProjectId === projectId)
85
+ continue;
86
+ const otherData = this.load(otherProjectId);
87
+ const found = otherData?.sessions?.[sessionId];
88
+ if (!found)
89
+ continue;
90
+ const destination = current ?? {
91
+ projectId,
92
+ worktree,
93
+ sessions: {},
94
+ };
95
+ // Write the destination first and confirm it landed on disk, so a write
96
+ // failure can never delete the source before the copy is safely persisted.
97
+ destination.sessions[sessionId] = found;
98
+ // Prefer the worktree for the project we're actually operating on.
99
+ destination.worktree = worktree;
100
+ this.save(projectId, destination);
101
+ if (!this.load(projectId)?.sessions?.[sessionId]) {
102
+ logger.error(`Migration of session ${sessionId} to project ${projectId} did not persist; leaving source intact`);
103
+ return found;
104
+ }
105
+ // Destination is safe; now remove from the source (best effort).
106
+ try {
107
+ delete otherData.sessions[sessionId];
108
+ this.save(otherProjectId, otherData);
109
+ }
110
+ catch (err) {
111
+ logger.warn(`Failed to remove session ${sessionId} from project ${otherProjectId}: ${err}`);
112
+ }
113
+ logger.info(`Migrated session ${sessionId} from project ${otherProjectId} to project ${projectId}`);
114
+ return found;
115
+ }
116
+ return undefined;
117
+ }
118
+ /**
119
+ * Read-only lookup of a session across all project files. Unlike getSession, this never
120
+ * migrates or writes, so it is safe to use on the delete path.
121
+ */
122
+ findSession(sessionId) {
123
+ for (const projectId of this.listProjectIds()) {
124
+ const data = this.load(projectId);
125
+ const session = data?.sessions?.[sessionId];
126
+ if (session && data) {
127
+ // Return the filename-derived projectId (the value that maps back to the file we
128
+ // just loaded), NOT data.projectId. The delete cleanup path passes this value to
129
+ // removeSession → load → getProjectFilePath; if we returned the raw canonical id
130
+ // and it doesn't round-trip identically (e.g. legacy files with a different
131
+ // sanitization scheme), the cleanup would silently target a different file and
132
+ // leave the stale mapping behind.
133
+ return { projectId, worktree: data.worktree, session };
134
+ }
135
+ }
136
+ return undefined;
137
+ }
138
+ /**
139
+ * Save project session data to disk.
140
+ *
141
+ * `storageKey` identifies WHICH FILE on disk to write (round-trips through
142
+ * getProjectFilePath). `projectData.projectId` is the CANONICAL id written into
143
+ * the JSON body — kept separate so callers who reached a file via a filename-derived
144
+ * key (e.g. findSession → removeSession for legacy files) don't clobber the
145
+ * pre-existing canonical id with the storage key.
146
+ */
147
+ save(storageKey, projectData) {
148
+ const filePath = this.getProjectFilePath(storageKey);
149
+ try {
150
+ writeFileSync(filePath, JSON.stringify(projectData, null, 2));
151
+ logger.info(`Saved project data for ${projectData.projectId}`);
152
+ }
153
+ catch (err) {
154
+ logger.error(`Failed to save project data for ${projectData.projectId} at ${filePath}: ${err}`);
155
+ }
156
+ }
157
+ /**
158
+ * Get branch number for a sandbox
159
+ */
160
+ getBranchNumberForSandbox(projectId, sandboxId) {
161
+ const projectData = this.load(projectId);
162
+ if (!projectData) {
163
+ return undefined;
164
+ }
165
+ const session = Object.values(projectData.sessions).find((s) => s.sandboxId === sandboxId);
166
+ return session?.branchNumber;
167
+ }
168
+ /**
169
+ * Update a single session in the project file
170
+ */
171
+ updateSession(projectId, worktree, sessionId, sandboxId, branchNumber) {
172
+ const projectData = this.load(projectId) || {
173
+ projectId,
174
+ worktree,
175
+ sessions: {},
176
+ };
177
+ const now = Date.now();
178
+ if (!projectData.sessions[sessionId]) {
179
+ projectData.sessions[sessionId] = {
180
+ sandboxId,
181
+ ...(branchNumber !== undefined ? { branchNumber } : {}),
182
+ created: now,
183
+ lastAccessed: now,
184
+ };
185
+ }
186
+ else {
187
+ projectData.sessions[sessionId].sandboxId = sandboxId;
188
+ projectData.sessions[sessionId].lastAccessed = now;
189
+ // Only update branch number if it wasn't set before
190
+ if (projectData.sessions[sessionId].branchNumber === undefined) {
191
+ if (branchNumber !== undefined) {
192
+ projectData.sessions[sessionId].branchNumber = branchNumber;
193
+ }
194
+ }
195
+ }
196
+ // Refresh worktree from the current call (state that can change over time), but
197
+ // NEVER refresh projectData.projectId — that's identity, set at creation, and
198
+ // preserving it is the whole point of save()'s two-parameter API.
199
+ projectData.worktree = worktree;
200
+ this.save(projectId, projectData);
201
+ }
202
+ /**
203
+ * Remove a session from the project file
204
+ */
205
+ removeSession(projectId, worktree, sessionId) {
206
+ const projectData = this.load(projectId);
207
+ if (projectData && projectData.sessions[sessionId]) {
208
+ delete projectData.sessions[sessionId];
209
+ this.save(projectId, projectData);
210
+ }
211
+ }
212
+ }
213
+ //# sourceMappingURL=project-data-storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"project-data-storage.js","sourceRoot":"","sources":["project-data-storage.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Handles file storage operations for project session data
8
+ * Stores data per-project in ~/.local/share/opencode/storage/daytona/{projectId}.json
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'
12
+ import { join } from 'path'
13
+ import { logger } from './logger'
14
+ import type { ProjectSessionData, SessionInfo } from './types'
15
+
16
+ export class ProjectDataStorage {
17
+ private readonly storageDir: string
18
+
19
+ constructor(storageDir: string) {
20
+ this.storageDir = storageDir
21
+
22
+ // Ensure storage directory exists
23
+ if (!existsSync(this.storageDir)) {
24
+ mkdirSync(this.storageDir, { recursive: true })
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Get the file path for a project's session data.
30
+ * encodeURIComponent gives a reversible, collision-free encoding that also
31
+ * strips path separators, so a projectId can't traverse outside storageDir
32
+ * and distinct ids can't collide onto the same file.
33
+ */
34
+ private getProjectFilePath(projectId: string): string {
35
+ return join(this.storageDir, `${encodeURIComponent(projectId)}.json`)
36
+ }
37
+
38
+ /**
39
+ * List known project IDs from storage, decoded to the canonical form used by callers.
40
+ * Filenames that can't be decoded (e.g. hand-created files with invalid percent escapes)
41
+ * are skipped: exposing them would return an id that can't round-trip through
42
+ * getProjectFilePath, causing subsequent load/save/remove to silently target the wrong file.
43
+ */
44
+ private listProjectIds(): string[] {
45
+ try {
46
+ const ids: string[] = []
47
+ for (const name of readdirSync(this.storageDir)) {
48
+ if (!name.endsWith('.json')) continue
49
+ const encoded = name.slice(0, -'.json'.length)
50
+ try {
51
+ ids.push(decodeURIComponent(encoded))
52
+ } catch {
53
+ logger.warn(`Skipping project data file with undecodable name: ${name}`)
54
+ }
55
+ }
56
+ return ids
57
+ } catch (err) {
58
+ logger.error(`Failed to list project data files: ${err}`)
59
+ return []
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Load project session data from disk
65
+ */
66
+ load(projectId: string): ProjectSessionData | null {
67
+ const filePath = this.getProjectFilePath(projectId)
68
+ try {
69
+ if (existsSync(filePath)) {
70
+ return JSON.parse(readFileSync(filePath, 'utf-8')) as ProjectSessionData
71
+ }
72
+ } catch (err) {
73
+ logger.error(`Failed to load project data for ${projectId}: ${err}`)
74
+ }
75
+ return null
76
+ }
77
+
78
+ /**
79
+ * Get a session for a project. If not found in the requested project, search all other
80
+ * projects on disk and, if found, migrate it into the requested project.
81
+ */
82
+ getSession(projectId: string, worktree: string, sessionId: string): SessionInfo | undefined {
83
+ const current = this.load(projectId)
84
+ const currentSession = current?.sessions?.[sessionId]
85
+ if (currentSession) {
86
+ return currentSession
87
+ }
88
+
89
+ // Look in other projects and migrate if found.
90
+ for (const otherProjectId of this.listProjectIds()) {
91
+ if (otherProjectId === projectId) continue
92
+
93
+ const otherData = this.load(otherProjectId)
94
+ const found = otherData?.sessions?.[sessionId]
95
+ if (!found) continue
96
+
97
+ const destination: ProjectSessionData = current ?? {
98
+ projectId,
99
+ worktree,
100
+ sessions: {},
101
+ }
102
+
103
+ // Write the destination first and confirm it landed on disk, so a write
104
+ // failure can never delete the source before the copy is safely persisted.
105
+ destination.sessions[sessionId] = found
106
+ // Prefer the worktree for the project we're actually operating on.
107
+ destination.worktree = worktree
108
+ this.save(projectId, destination)
109
+
110
+ if (!this.load(projectId)?.sessions?.[sessionId]) {
111
+ logger.error(`Migration of session ${sessionId} to project ${projectId} did not persist; leaving source intact`)
112
+ return found
113
+ }
114
+
115
+ // Destination is safe; now remove from the source (best effort).
116
+ try {
117
+ delete otherData!.sessions[sessionId]
118
+ this.save(otherProjectId, otherData!)
119
+ } catch (err) {
120
+ logger.warn(`Failed to remove session ${sessionId} from project ${otherProjectId}: ${err}`)
121
+ }
122
+
123
+ logger.info(`Migrated session ${sessionId} from project ${otherProjectId} to project ${projectId}`)
124
+ return found
125
+ }
126
+
127
+ return undefined
128
+ }
129
+
130
+ /**
131
+ * Read-only lookup of a session across all project files. Unlike getSession, this never
132
+ * migrates or writes, so it is safe to use on the delete path.
133
+ */
134
+ findSession(sessionId: string): { projectId: string; worktree: string; session: SessionInfo } | undefined {
135
+ for (const projectId of this.listProjectIds()) {
136
+ const data = this.load(projectId)
137
+ const session = data?.sessions?.[sessionId]
138
+ if (session && data) {
139
+ // Return the filename-derived projectId (the value that maps back to the file we
140
+ // just loaded), NOT data.projectId. The delete cleanup path passes this value to
141
+ // removeSession → load → getProjectFilePath; if we returned the raw canonical id
142
+ // and it doesn't round-trip identically (e.g. legacy files with a different
143
+ // sanitization scheme), the cleanup would silently target a different file and
144
+ // leave the stale mapping behind.
145
+ return { projectId, worktree: data.worktree, session }
146
+ }
147
+ }
148
+ return undefined
149
+ }
150
+
151
+ /**
152
+ * Save project session data to disk.
153
+ *
154
+ * `storageKey` identifies WHICH FILE on disk to write (round-trips through
155
+ * getProjectFilePath). `projectData.projectId` is the CANONICAL id written into
156
+ * the JSON body — kept separate so callers who reached a file via a filename-derived
157
+ * key (e.g. findSession → removeSession for legacy files) don't clobber the
158
+ * pre-existing canonical id with the storage key.
159
+ */
160
+ save(storageKey: string, projectData: ProjectSessionData): void {
161
+ const filePath = this.getProjectFilePath(storageKey)
162
+ try {
163
+ writeFileSync(filePath, JSON.stringify(projectData, null, 2))
164
+ logger.info(`Saved project data for ${projectData.projectId}`)
165
+ } catch (err) {
166
+ logger.error(`Failed to save project data for ${projectData.projectId} at ${filePath}: ${err}`)
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Get branch number for a sandbox
172
+ */
173
+ getBranchNumberForSandbox(projectId: string, sandboxId: string): number | undefined {
174
+ const projectData = this.load(projectId)
175
+ if (!projectData) {
176
+ return undefined
177
+ }
178
+ const session = Object.values(projectData.sessions).find((s) => s.sandboxId === sandboxId)
179
+ return session?.branchNumber
180
+ }
181
+
182
+ /**
183
+ * Update a single session in the project file
184
+ */
185
+ updateSession(
186
+ projectId: string,
187
+ worktree: string,
188
+ sessionId: string,
189
+ sandboxId: string,
190
+ branchNumber?: number,
191
+ ): void {
192
+ const projectData = this.load(projectId) || {
193
+ projectId,
194
+ worktree,
195
+ sessions: {},
196
+ }
197
+
198
+ const now = Date.now()
199
+ if (!projectData.sessions[sessionId]) {
200
+ projectData.sessions[sessionId] = {
201
+ sandboxId,
202
+ ...(branchNumber !== undefined ? { branchNumber } : {}),
203
+ created: now,
204
+ lastAccessed: now,
205
+ }
206
+ } else {
207
+ projectData.sessions[sessionId].sandboxId = sandboxId
208
+ projectData.sessions[sessionId].lastAccessed = now
209
+ // Only update branch number if it wasn't set before
210
+ if (projectData.sessions[sessionId].branchNumber === undefined) {
211
+ if (branchNumber !== undefined) {
212
+ projectData.sessions[sessionId].branchNumber = branchNumber
213
+ }
214
+ }
215
+ }
216
+
217
+ // Refresh worktree from the current call (state that can change over time), but
218
+ // NEVER refresh projectData.projectId — that's identity, set at creation, and
219
+ // preserving it is the whole point of save()'s two-parameter API.
220
+ projectData.worktree = worktree
221
+ this.save(projectId, projectData)
222
+ }
223
+
224
+ /**
225
+ * Remove a session from the project file
226
+ */
227
+ removeSession(projectId: string, worktree: string, sessionId: string): void {
228
+ const projectData = this.load(projectId)
229
+ if (projectData && projectData.sessions[sessionId]) {
230
+ delete projectData.sessions[sessionId]
231
+ this.save(projectId, projectData)
232
+ }
233
+ }
234
+ }