@posthog/agent 2.0.0 → 2.0.2

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 (131) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +221 -219
  3. package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +21 -0
  4. package/dist/adapters/claude/conversion/tool-use-to-acp.js +547 -0
  5. package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -0
  6. package/dist/adapters/claude/permissions/permission-options.d.ts +13 -0
  7. package/dist/adapters/claude/permissions/permission-options.js +117 -0
  8. package/dist/adapters/claude/permissions/permission-options.js.map +1 -0
  9. package/dist/adapters/claude/questions/utils.d.ts +132 -0
  10. package/dist/adapters/claude/questions/utils.js +63 -0
  11. package/dist/adapters/claude/questions/utils.js.map +1 -0
  12. package/dist/adapters/claude/tools.d.ts +18 -0
  13. package/dist/adapters/claude/tools.js +95 -0
  14. package/dist/adapters/claude/tools.js.map +1 -0
  15. package/dist/agent-DBQY1BfC.d.ts +123 -0
  16. package/dist/agent.d.ts +5 -0
  17. package/dist/agent.js +3656 -0
  18. package/dist/agent.js.map +1 -0
  19. package/dist/claude-cli/cli.js +3695 -2746
  20. package/dist/claude-cli/vendor/ripgrep/COPYING +3 -0
  21. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/rg +0 -0
  22. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/ripgrep.node +0 -0
  23. package/dist/claude-cli/vendor/ripgrep/arm64-linux/rg +0 -0
  24. package/dist/claude-cli/vendor/ripgrep/arm64-linux/ripgrep.node +0 -0
  25. package/dist/claude-cli/vendor/ripgrep/x64-darwin/rg +0 -0
  26. package/dist/claude-cli/vendor/ripgrep/x64-darwin/ripgrep.node +0 -0
  27. package/dist/claude-cli/vendor/ripgrep/x64-linux/rg +0 -0
  28. package/dist/claude-cli/vendor/ripgrep/x64-linux/ripgrep.node +0 -0
  29. package/dist/claude-cli/vendor/ripgrep/x64-win32/rg.exe +0 -0
  30. package/dist/claude-cli/vendor/ripgrep/x64-win32/ripgrep.node +0 -0
  31. package/dist/gateway-models.d.ts +24 -0
  32. package/dist/gateway-models.js +93 -0
  33. package/dist/gateway-models.js.map +1 -0
  34. package/dist/index.d.ts +170 -1157
  35. package/dist/index.js +9373 -5135
  36. package/dist/index.js.map +1 -1
  37. package/dist/logger-DDBiMOOD.d.ts +24 -0
  38. package/dist/posthog-api.d.ts +40 -0
  39. package/dist/posthog-api.js +175 -0
  40. package/dist/posthog-api.js.map +1 -0
  41. package/dist/server/agent-server.d.ts +41 -0
  42. package/dist/server/agent-server.js +10503 -0
  43. package/dist/server/agent-server.js.map +1 -0
  44. package/dist/server/bin.d.ts +1 -0
  45. package/dist/server/bin.js +10558 -0
  46. package/dist/server/bin.js.map +1 -0
  47. package/dist/types.d.ts +129 -0
  48. package/dist/types.js +1 -0
  49. package/dist/types.js.map +1 -0
  50. package/package.json +65 -13
  51. package/src/acp-extensions.ts +98 -16
  52. package/src/adapters/acp-connection.ts +494 -0
  53. package/src/adapters/base-acp-agent.ts +150 -0
  54. package/src/adapters/claude/claude-agent.ts +596 -0
  55. package/src/adapters/claude/conversion/acp-to-sdk.ts +102 -0
  56. package/src/adapters/claude/conversion/sdk-to-acp.ts +571 -0
  57. package/src/adapters/claude/conversion/tool-use-to-acp.ts +618 -0
  58. package/src/adapters/claude/hooks.ts +64 -0
  59. package/src/adapters/claude/mcp/tool-metadata.ts +102 -0
  60. package/src/adapters/claude/permissions/permission-handlers.ts +433 -0
  61. package/src/adapters/claude/permissions/permission-options.ts +103 -0
  62. package/src/adapters/claude/plan/utils.ts +56 -0
  63. package/src/adapters/claude/questions/utils.ts +92 -0
  64. package/src/adapters/claude/session/commands.ts +38 -0
  65. package/src/adapters/claude/session/mcp-config.ts +37 -0
  66. package/src/adapters/claude/session/models.ts +12 -0
  67. package/src/adapters/claude/session/options.ts +236 -0
  68. package/src/adapters/claude/tool-meta.ts +143 -0
  69. package/src/adapters/claude/tools.ts +53 -688
  70. package/src/adapters/claude/types.ts +61 -0
  71. package/src/adapters/codex/spawn.ts +130 -0
  72. package/src/agent.ts +96 -587
  73. package/src/execution-mode.ts +43 -0
  74. package/src/gateway-models.ts +135 -0
  75. package/src/index.ts +79 -0
  76. package/src/otel-log-writer.test.ts +105 -0
  77. package/src/otel-log-writer.ts +94 -0
  78. package/src/posthog-api.ts +75 -235
  79. package/src/resume.ts +115 -0
  80. package/src/sagas/apply-snapshot-saga.test.ts +690 -0
  81. package/src/sagas/apply-snapshot-saga.ts +88 -0
  82. package/src/sagas/capture-tree-saga.test.ts +892 -0
  83. package/src/sagas/capture-tree-saga.ts +141 -0
  84. package/src/sagas/resume-saga.test.ts +558 -0
  85. package/src/sagas/resume-saga.ts +332 -0
  86. package/src/sagas/test-fixtures.ts +250 -0
  87. package/src/server/agent-server.test.ts +220 -0
  88. package/src/server/agent-server.ts +748 -0
  89. package/src/server/bin.ts +88 -0
  90. package/src/server/jwt.ts +65 -0
  91. package/src/server/schemas.ts +47 -0
  92. package/src/server/types.ts +13 -0
  93. package/src/server/utils/retry.test.ts +122 -0
  94. package/src/server/utils/retry.ts +61 -0
  95. package/src/server/utils/sse-parser.test.ts +93 -0
  96. package/src/server/utils/sse-parser.ts +46 -0
  97. package/src/session-log-writer.test.ts +140 -0
  98. package/src/session-log-writer.ts +137 -0
  99. package/src/test/assertions.ts +114 -0
  100. package/src/test/controllers/sse-controller.ts +107 -0
  101. package/src/test/fixtures/api.ts +111 -0
  102. package/src/test/fixtures/config.ts +33 -0
  103. package/src/test/fixtures/notifications.ts +92 -0
  104. package/src/test/mocks/claude-sdk.ts +251 -0
  105. package/src/test/mocks/msw-handlers.ts +48 -0
  106. package/src/test/setup.ts +114 -0
  107. package/src/test/wait.ts +41 -0
  108. package/src/tree-tracker.ts +173 -0
  109. package/src/types.ts +54 -137
  110. package/src/utils/acp-content.ts +58 -0
  111. package/src/utils/async-mutex.test.ts +104 -0
  112. package/src/utils/async-mutex.ts +31 -0
  113. package/src/utils/common.ts +15 -0
  114. package/src/utils/gateway.ts +9 -6
  115. package/src/utils/logger.ts +0 -30
  116. package/src/utils/streams.ts +220 -0
  117. package/CLAUDE.md +0 -331
  118. package/src/adapters/claude/claude.ts +0 -1947
  119. package/src/adapters/claude/mcp-server.ts +0 -810
  120. package/src/adapters/claude/utils.ts +0 -267
  121. package/src/adapters/connection.ts +0 -95
  122. package/src/file-manager.ts +0 -273
  123. package/src/git-manager.ts +0 -577
  124. package/src/schemas.ts +0 -241
  125. package/src/session-store.ts +0 -259
  126. package/src/task-manager.ts +0 -163
  127. package/src/todo-manager.ts +0 -180
  128. package/src/tools/registry.ts +0 -134
  129. package/src/tools/types.ts +0 -133
  130. package/src/utils/tapped-stream.ts +0 -60
  131. package/src/worktree-manager.ts +0 -974
@@ -0,0 +1,251 @@
1
+ import type {
2
+ Query,
3
+ SDKMessage,
4
+ SDKResultError,
5
+ SDKResultSuccess,
6
+ } from "@anthropic-ai/claude-agent-sdk";
7
+ import type { Mock } from "vitest";
8
+ import { vi } from "vitest";
9
+
10
+ export interface MockQueryHelpers {
11
+ sendMessage: (message: SDKMessage) => void;
12
+ complete: (result?: SDKResultSuccess) => void;
13
+ sendError: (result: SDKResultError) => void;
14
+ simulateError: (error: Error) => void;
15
+ queueError: (error: Error) => void;
16
+ simulateTimeout: () => void;
17
+ isAborted: () => boolean;
18
+ }
19
+
20
+ export interface MockQuery extends Omit<Query, "interrupt"> {
21
+ _mockHelpers: MockQueryHelpers;
22
+ _abortController: AbortController;
23
+ interrupt: Mock<() => Promise<void>>;
24
+ }
25
+
26
+ export interface CreateMockQueryOptions {
27
+ abortController?: AbortController;
28
+ }
29
+
30
+ export function createMockQuery(
31
+ options: CreateMockQueryOptions = {},
32
+ ): MockQuery {
33
+ const abortController = options.abortController ?? new AbortController();
34
+ let resolveNext: ((value: IteratorResult<SDKMessage, void>) => void) | null =
35
+ null;
36
+ let rejectNext: ((error: Error) => void) | null = null;
37
+ let isDone = false;
38
+ let queuedError: Error | null = null;
39
+ let isTimedOut = false;
40
+
41
+ const createNextPromise = (): Promise<IteratorResult<SDKMessage, void>> => {
42
+ if (isDone) {
43
+ return Promise.resolve({ value: undefined, done: true as const });
44
+ }
45
+ if (queuedError) {
46
+ const error = queuedError;
47
+ queuedError = null;
48
+ return Promise.reject(error);
49
+ }
50
+ if (isTimedOut) {
51
+ return new Promise(() => {});
52
+ }
53
+ return new Promise((resolve, reject) => {
54
+ resolveNext = resolve;
55
+ rejectNext = reject;
56
+
57
+ abortController.signal.addEventListener("abort", () => {
58
+ isDone = true;
59
+ resolve({ value: undefined, done: true as const });
60
+ });
61
+ });
62
+ };
63
+
64
+ const mockQuery: MockQuery = {
65
+ next: vi.fn(() => createNextPromise()),
66
+ return: vi.fn(() => {
67
+ isDone = true;
68
+ return Promise.resolve({ value: undefined, done: true as const });
69
+ }),
70
+ throw: vi.fn((error: Error) => {
71
+ isDone = true;
72
+ return Promise.reject(error);
73
+ }),
74
+ [Symbol.asyncIterator]() {
75
+ return this;
76
+ },
77
+ interrupt: vi.fn(async () => {
78
+ abortController.abort();
79
+ isDone = true;
80
+ if (resolveNext) {
81
+ resolveNext({ value: undefined, done: true as const });
82
+ resolveNext = null;
83
+ rejectNext = null;
84
+ }
85
+ }),
86
+ setPermissionMode: vi.fn().mockResolvedValue(undefined),
87
+ setModel: vi.fn().mockResolvedValue(undefined),
88
+ setMaxThinkingTokens: vi.fn().mockResolvedValue(undefined),
89
+ supportedCommands: vi.fn().mockResolvedValue([]),
90
+ supportedModels: vi.fn().mockResolvedValue([]),
91
+ mcpServerStatus: vi.fn().mockResolvedValue([]),
92
+ accountInfo: vi.fn().mockResolvedValue({}),
93
+ rewindFiles: vi.fn().mockResolvedValue({ canRewind: false }),
94
+ setMcpServers: vi
95
+ .fn()
96
+ .mockResolvedValue({ added: [], removed: [], errors: {} }),
97
+ streamInput: vi.fn().mockResolvedValue(undefined),
98
+ [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined),
99
+ _abortController: abortController,
100
+ _mockHelpers: {
101
+ sendMessage(message: SDKMessage) {
102
+ if (resolveNext && !isDone) {
103
+ resolveNext({ value: message, done: false });
104
+ resolveNext = null;
105
+ rejectNext = null;
106
+ }
107
+ },
108
+ complete(result?: SDKResultSuccess) {
109
+ isDone = true;
110
+ if (resolveNext) {
111
+ if (result) {
112
+ resolveNext({ value: result, done: false });
113
+ } else {
114
+ resolveNext({ value: undefined, done: true });
115
+ }
116
+ resolveNext = null;
117
+ rejectNext = null;
118
+ }
119
+ },
120
+ sendError(result: SDKResultError) {
121
+ isDone = true;
122
+ if (resolveNext) {
123
+ resolveNext({ value: result, done: false });
124
+ resolveNext = null;
125
+ rejectNext = null;
126
+ }
127
+ },
128
+ simulateError(error: Error) {
129
+ if (rejectNext) {
130
+ rejectNext(error);
131
+ resolveNext = null;
132
+ rejectNext = null;
133
+ }
134
+ },
135
+ queueError(error: Error) {
136
+ queuedError = error;
137
+ },
138
+ simulateTimeout() {
139
+ isTimedOut = true;
140
+ },
141
+ isAborted() {
142
+ return abortController.signal.aborted;
143
+ },
144
+ },
145
+ };
146
+
147
+ return mockQuery;
148
+ }
149
+
150
+ export function createSuccessResult(
151
+ overrides: Partial<SDKResultSuccess> = {},
152
+ ): SDKResultSuccess {
153
+ return {
154
+ type: "result",
155
+ subtype: "success",
156
+ duration_ms: 100,
157
+ duration_api_ms: 50,
158
+ is_error: false,
159
+ num_turns: 1,
160
+ result: "Done",
161
+ total_cost_usd: 0.01,
162
+ usage: {
163
+ input_tokens: 100,
164
+ output_tokens: 50,
165
+ cache_read_input_tokens: 0,
166
+ cache_creation_input_tokens: 0,
167
+ cache_creation: {
168
+ ephemeral_1h_input_tokens: 0,
169
+ ephemeral_5m_input_tokens: 0,
170
+ },
171
+ server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
172
+ service_tier: "standard",
173
+ },
174
+ modelUsage: {},
175
+ permission_denials: [],
176
+ uuid: crypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`,
177
+ session_id: "test-session",
178
+ ...overrides,
179
+ };
180
+ }
181
+
182
+ export function createErrorResult(
183
+ overrides: Partial<SDKResultError> = {},
184
+ ): SDKResultError {
185
+ return {
186
+ type: "result",
187
+ subtype: "error_during_execution",
188
+ is_error: true,
189
+ errors: ["Test error"],
190
+ duration_ms: 100,
191
+ duration_api_ms: 50,
192
+ num_turns: 1,
193
+ total_cost_usd: 0.01,
194
+ usage: {
195
+ input_tokens: 100,
196
+ output_tokens: 50,
197
+ cache_read_input_tokens: 0,
198
+ cache_creation_input_tokens: 0,
199
+ cache_creation: {
200
+ ephemeral_1h_input_tokens: 0,
201
+ ephemeral_5m_input_tokens: 0,
202
+ },
203
+ server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
204
+ service_tier: "standard",
205
+ },
206
+ modelUsage: {},
207
+ permission_denials: [],
208
+ uuid: crypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`,
209
+ session_id: "test-session",
210
+ ...overrides,
211
+ };
212
+ }
213
+
214
+ export function createInitMessage(sessionId = "test-session"): SDKMessage {
215
+ return {
216
+ type: "system",
217
+ subtype: "init",
218
+ agents: [],
219
+ apiKeySource: "user",
220
+ betas: [],
221
+ claude_code_version: "1.0.0",
222
+ cwd: "/tmp",
223
+ tools: [],
224
+ mcp_servers: [],
225
+ model: "claude-sonnet-4-5-20250929",
226
+ permissionMode: "default",
227
+ slash_commands: [],
228
+ output_style: "default",
229
+ skills: [],
230
+ plugins: [],
231
+ uuid: crypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`,
232
+ session_id: sessionId,
233
+ };
234
+ }
235
+
236
+ export interface MockQueryRef {
237
+ current: MockQuery | null;
238
+ }
239
+
240
+ export function createClaudeSdkMock(mockQueryRef: MockQueryRef) {
241
+ return {
242
+ query: vi.fn(() => {
243
+ const mq = createMockQuery();
244
+ mockQueryRef.current = mq;
245
+ setTimeout(() => {
246
+ mq._mockHelpers.sendMessage(createInitMessage());
247
+ }, 10);
248
+ return mq;
249
+ }),
250
+ };
251
+ }
@@ -0,0 +1,48 @@
1
+ import { HttpResponse, http } from "msw";
2
+ import { SseController } from "../controllers/sse-controller.js";
3
+
4
+ export { SseController };
5
+
6
+ type AnyHttpResponse = Response | ReturnType<typeof HttpResponse.json>;
7
+
8
+ export interface PostHogHandlersOptions {
9
+ baseUrl?: string;
10
+ onAppendLog?: (entries: unknown[]) => void;
11
+ getTaskRun?: () => unknown;
12
+ appendLogResponse?: () => AnyHttpResponse;
13
+ }
14
+
15
+ export function createPostHogHandlers(options: PostHogHandlersOptions = {}) {
16
+ const {
17
+ baseUrl = "http://localhost:8000",
18
+ onAppendLog,
19
+ getTaskRun,
20
+ appendLogResponse,
21
+ } = options;
22
+
23
+ return [
24
+ // POST /append_log/ - Agent log entries
25
+ http.post(
26
+ `${baseUrl}/api/projects/:projectId/tasks/:taskId/runs/:runId/append_log/`,
27
+ async ({ request }) => {
28
+ if (appendLogResponse) {
29
+ return appendLogResponse();
30
+ }
31
+ const body = (await request.json()) as { entries: unknown[] };
32
+ if (body.entries?.length > 0) {
33
+ onAppendLog?.(body.entries);
34
+ }
35
+ return HttpResponse.json({});
36
+ },
37
+ ),
38
+
39
+ // GET /runs/:runId - Fetch task run details
40
+ http.get(
41
+ `${baseUrl}/api/projects/:projectId/tasks/:taskId/runs/:runId`,
42
+ () => {
43
+ const taskRun = getTaskRun?.() ?? { log_url: "" };
44
+ return HttpResponse.json(taskRun);
45
+ },
46
+ ),
47
+ ];
48
+ }
@@ -0,0 +1,114 @@
1
+ import { type SetupServerApi, setupServer } from "msw/node";
2
+ import { AgentServer } from "../server/agent-server.js";
3
+ import { SseController } from "./controllers/sse-controller.js";
4
+ import { createTestRepo, type TestRepo } from "./fixtures/api.js";
5
+ import {
6
+ type AgentServerConfig,
7
+ createAgentServerConfig,
8
+ } from "./fixtures/config.js";
9
+ import { createPostHogHandlers } from "./mocks/msw-handlers.js";
10
+
11
+ export interface TestContext {
12
+ repo: TestRepo;
13
+ sseController: SseController;
14
+ appendLogCalls: unknown[][];
15
+ server: SetupServerApi;
16
+ agentServer: AgentServer;
17
+ config: AgentServerConfig;
18
+ createAgentServer: (overrides?: Partial<AgentServerConfig>) => AgentServer;
19
+ resetSseController: () => SseController;
20
+ cleanup: () => Promise<void>;
21
+ }
22
+
23
+ export interface CreateTestContextOptions {
24
+ configOverrides?: Partial<AgentServerConfig>;
25
+ autoStart?: boolean;
26
+ }
27
+
28
+ export async function createTestContext(
29
+ options: CreateTestContextOptions = {},
30
+ ): Promise<TestContext> {
31
+ const repo = await createTestRepo("agent-server");
32
+ let sseController = new SseController();
33
+ const appendLogCalls: unknown[][] = [];
34
+
35
+ const server = setupServer(
36
+ ...createPostHogHandlers({
37
+ baseUrl: "http://localhost:8000",
38
+ onAppendLog: (entries) => appendLogCalls.push(entries),
39
+ }),
40
+ );
41
+
42
+ server.listen({ onUnhandledRequest: "bypass" });
43
+
44
+ const config = createAgentServerConfig(repo, options.configOverrides);
45
+
46
+ const agentServer = new AgentServer(config);
47
+
48
+ const createAgentServer = (overrides: Partial<AgentServerConfig> = {}) => {
49
+ return new AgentServer({
50
+ ...config,
51
+ ...overrides,
52
+ });
53
+ };
54
+
55
+ const resetSseController = () => {
56
+ sseController.close();
57
+ sseController = new SseController();
58
+ return sseController;
59
+ };
60
+
61
+ const cleanup = async () => {
62
+ sseController.close();
63
+ server.close();
64
+ await repo.cleanup();
65
+ };
66
+
67
+ return {
68
+ repo,
69
+ sseController,
70
+ appendLogCalls,
71
+ server,
72
+ agentServer,
73
+ config,
74
+ createAgentServer,
75
+ resetSseController,
76
+ cleanup,
77
+ };
78
+ }
79
+
80
+ export {
81
+ expectNoNotification,
82
+ expectNotification,
83
+ findNotification,
84
+ hasNotification,
85
+ } from "./assertions.js";
86
+ export {
87
+ createMockApiClient,
88
+ createTaskRun,
89
+ createTestRepo,
90
+ type TestRepo,
91
+ } from "./fixtures/api.js";
92
+ export { createAgentServerConfig } from "./fixtures/config.js";
93
+ export {
94
+ createAgentChunk,
95
+ createNotification,
96
+ createStatusNotification,
97
+ createToolCall,
98
+ createToolResult,
99
+ createTreeSnapshotNotification,
100
+ createUserMessage,
101
+ } from "./fixtures/notifications.js";
102
+ export {
103
+ createErrorResult,
104
+ createInitMessage,
105
+ createMockQuery,
106
+ createSuccessResult,
107
+ type MockQuery,
108
+ } from "./mocks/claude-sdk.js";
109
+ export { createPostHogHandlers, SseController } from "./mocks/msw-handlers.js";
110
+ export {
111
+ waitForArrayLength,
112
+ waitForCallCount,
113
+ waitForCondition,
114
+ } from "./wait.js";
@@ -0,0 +1,41 @@
1
+ export interface WaitOptions {
2
+ timeout?: number;
3
+ interval?: number;
4
+ }
5
+
6
+ export async function waitForCondition(
7
+ condition: () => boolean | Promise<boolean>,
8
+ options: WaitOptions = {},
9
+ ): Promise<void> {
10
+ const { timeout = 1000, interval = 20 } = options;
11
+ const start = Date.now();
12
+
13
+ while (true) {
14
+ const result = await condition();
15
+ if (result) {
16
+ return;
17
+ }
18
+
19
+ if (Date.now() - start > timeout) {
20
+ throw new Error(`Condition not met within ${timeout}ms`);
21
+ }
22
+
23
+ await new Promise((r) => setTimeout(r, interval));
24
+ }
25
+ }
26
+
27
+ export async function waitForArrayLength<T>(
28
+ getArray: () => T[],
29
+ minLength: number,
30
+ options: WaitOptions = {},
31
+ ): Promise<void> {
32
+ await waitForCondition(() => getArray().length >= minLength, options);
33
+ }
34
+
35
+ export async function waitForCallCount(
36
+ mockFn: { mock: { calls: unknown[][] } },
37
+ minCalls: number,
38
+ options: WaitOptions = {},
39
+ ): Promise<void> {
40
+ await waitForCondition(() => mockFn.mock.calls.length >= minCalls, options);
41
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * TreeTracker - Git tree-based state capture for cloud/local sync
3
+ *
4
+ * Captures the entire working state as a git tree hash + archive:
5
+ * - Atomic state snapshots (no partial syncs)
6
+ * - Efficient delta detection using git's diffing
7
+ * - Simpler resume logic (restore tree, continue)
8
+ *
9
+ * Uses Saga pattern for atomic operations with automatic rollback on failure.
10
+ * Uses a temporary git index to avoid modifying the user's staging area.
11
+ */
12
+
13
+ import { isCommitOnRemote as gitIsCommitOnRemote } from "@twig/git/queries";
14
+ import type { PostHogAPIClient } from "./posthog-api.js";
15
+ import { ApplySnapshotSaga } from "./sagas/apply-snapshot-saga.js";
16
+ import { CaptureTreeSaga } from "./sagas/capture-tree-saga.js";
17
+ import type { TreeSnapshot } from "./types.js";
18
+ import { Logger } from "./utils/logger.js";
19
+
20
+ export type { TreeSnapshot };
21
+
22
+ export interface TreeTrackerConfig {
23
+ repositoryPath: string;
24
+ taskId: string;
25
+ runId: string;
26
+ apiClient?: PostHogAPIClient;
27
+ logger?: Logger;
28
+ }
29
+
30
+ export class TreeTracker {
31
+ private repositoryPath: string;
32
+ private taskId: string;
33
+ private runId: string;
34
+ private apiClient?: PostHogAPIClient;
35
+ private logger: Logger;
36
+ private lastTreeHash: string | null = null;
37
+
38
+ constructor(config: TreeTrackerConfig) {
39
+ this.repositoryPath = config.repositoryPath;
40
+ this.taskId = config.taskId;
41
+ this.runId = config.runId;
42
+ this.apiClient = config.apiClient;
43
+ this.logger =
44
+ config.logger || new Logger({ debug: false, prefix: "[TreeTracker]" });
45
+ }
46
+
47
+ /**
48
+ * Capture current working tree state as a snapshot.
49
+ * Uses a temporary index to avoid modifying user's staging area.
50
+ * Uses Saga pattern for atomic operation with automatic cleanup on failure.
51
+ */
52
+ async captureTree(options?: {
53
+ interrupted?: boolean;
54
+ }): Promise<TreeSnapshot | null> {
55
+ const saga = new CaptureTreeSaga(this.logger);
56
+
57
+ const result = await saga.run({
58
+ repositoryPath: this.repositoryPath,
59
+ taskId: this.taskId,
60
+ runId: this.runId,
61
+ apiClient: this.apiClient,
62
+ lastTreeHash: this.lastTreeHash,
63
+ interrupted: options?.interrupted,
64
+ });
65
+
66
+ if (!result.success) {
67
+ this.logger.error("Failed to capture tree", {
68
+ error: result.error,
69
+ failedStep: result.failedStep,
70
+ });
71
+ throw new Error(
72
+ `Failed to capture tree at step '${result.failedStep}': ${result.error}`,
73
+ );
74
+ }
75
+
76
+ // Only update lastTreeHash on success
77
+ if (result.data.newTreeHash !== null) {
78
+ this.lastTreeHash = result.data.newTreeHash;
79
+ }
80
+
81
+ return result.data.snapshot;
82
+ }
83
+
84
+ /**
85
+ * Download and apply a tree snapshot.
86
+ * Uses Saga pattern for atomic operation with rollback on failure.
87
+ */
88
+ async applyTreeSnapshot(snapshot: TreeSnapshot): Promise<void> {
89
+ if (!this.apiClient) {
90
+ throw new Error("Cannot apply snapshot: API client not configured");
91
+ }
92
+
93
+ if (!snapshot.archiveUrl) {
94
+ this.logger.warn("Cannot apply snapshot: no archive URL", {
95
+ treeHash: snapshot.treeHash,
96
+ changes: snapshot.changes.length,
97
+ });
98
+ throw new Error("Cannot apply snapshot: no archive URL");
99
+ }
100
+
101
+ const saga = new ApplySnapshotSaga(this.logger);
102
+
103
+ const result = await saga.run({
104
+ snapshot,
105
+ repositoryPath: this.repositoryPath,
106
+ apiClient: this.apiClient,
107
+ taskId: this.taskId,
108
+ runId: this.runId,
109
+ });
110
+
111
+ if (!result.success) {
112
+ this.logger.error("Failed to apply tree snapshot", {
113
+ error: result.error,
114
+ failedStep: result.failedStep,
115
+ treeHash: snapshot.treeHash,
116
+ });
117
+ throw new Error(
118
+ `Failed to apply snapshot at step '${result.failedStep}': ${result.error}`,
119
+ );
120
+ }
121
+
122
+ // Only update lastTreeHash on success
123
+ this.lastTreeHash = result.data.treeHash;
124
+ }
125
+
126
+ /**
127
+ * Get the last captured tree hash.
128
+ */
129
+ getLastTreeHash(): string | null {
130
+ return this.lastTreeHash;
131
+ }
132
+
133
+ /**
134
+ * Set the last tree hash (used when resuming).
135
+ */
136
+ setLastTreeHash(hash: string | null): void {
137
+ this.lastTreeHash = hash;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Check if a commit is available on any remote branch.
143
+ * Used to validate that cloud can fetch the base commit during handoff.
144
+ */
145
+ export async function isCommitOnRemote(
146
+ commit: string,
147
+ cwd: string,
148
+ ): Promise<boolean> {
149
+ return gitIsCommitOnRemote(cwd, commit);
150
+ }
151
+
152
+ /**
153
+ * Validate that a snapshot can be handed off to cloud execution.
154
+ * Cloud needs to be able to fetch the baseCommit from a remote.
155
+ *
156
+ * @throws Error if the snapshot cannot be restored on cloud
157
+ */
158
+ export async function validateForCloudHandoff(
159
+ snapshot: TreeSnapshot,
160
+ repositoryPath: string,
161
+ ): Promise<void> {
162
+ if (!snapshot.baseCommit) {
163
+ throw new Error("Cannot hand off to cloud: no base commit");
164
+ }
165
+
166
+ const onRemote = await isCommitOnRemote(snapshot.baseCommit, repositoryPath);
167
+ if (!onRemote) {
168
+ throw new Error(
169
+ `Cannot hand off to cloud: commit ${snapshot.baseCommit.slice(0, 7)} is not pushed. ` +
170
+ `Run 'git push' to push your branch first.`,
171
+ );
172
+ }
173
+ }