@posthog/agent 2.3.386 → 2.3.387

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 (44) hide show
  1. package/dist/adapters/claude/session/jsonl-hydration.d.ts +1 -0
  2. package/dist/agent.d.ts +1 -0
  3. package/dist/agent.js +13 -2
  4. package/dist/agent.js.map +1 -1
  5. package/dist/handoff-checkpoint.d.ts +39 -0
  6. package/dist/handoff-checkpoint.js +6679 -0
  7. package/dist/handoff-checkpoint.js.map +1 -0
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +2 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/posthog-api.d.ts +1 -0
  12. package/dist/posthog-api.js +11 -2
  13. package/dist/posthog-api.js.map +1 -1
  14. package/dist/resume.d.ts +3 -1
  15. package/dist/resume.js +41 -4
  16. package/dist/resume.js.map +1 -1
  17. package/dist/server/agent-server.d.ts +5 -15
  18. package/dist/server/agent-server.js +1330 -394
  19. package/dist/server/agent-server.js.map +1 -1
  20. package/dist/server/bin.cjs +1332 -396
  21. package/dist/server/bin.cjs.map +1 -1
  22. package/dist/server/schemas.d.ts +191 -0
  23. package/dist/server/schemas.js +108 -0
  24. package/dist/server/schemas.js.map +1 -0
  25. package/dist/tree-tracker.d.ts +1 -0
  26. package/dist/tree-tracker.js +18 -4
  27. package/dist/tree-tracker.js.map +1 -1
  28. package/dist/types.d.ts +18 -1
  29. package/dist/types.js +5 -0
  30. package/dist/types.js.map +1 -1
  31. package/package.json +9 -1
  32. package/src/acp-extensions.ts +3 -0
  33. package/src/handoff-checkpoint.test.ts +183 -0
  34. package/src/handoff-checkpoint.ts +361 -0
  35. package/src/posthog-api.test.ts +29 -0
  36. package/src/posthog-api.ts +5 -1
  37. package/src/resume.ts +7 -1
  38. package/src/sagas/apply-snapshot-saga.ts +7 -0
  39. package/src/sagas/capture-tree-saga.ts +10 -3
  40. package/src/sagas/resume-saga.ts +32 -0
  41. package/src/sagas/test-fixtures.ts +46 -0
  42. package/src/server/agent-server.ts +74 -1
  43. package/src/server/schemas.ts +21 -2
  44. package/src/types.ts +24 -0
package/dist/types.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { HandoffLocalGitState as HandoffLocalGitState$1, GitHandoffCheckpoint } from '@posthog/git/handoff';
2
+
1
3
  /**
2
4
  * Stored custom notification following ACP extensibility model.
3
5
  * Custom notifications use underscore-prefixed methods (e.g., `_posthog/phase_start`).
@@ -146,5 +148,20 @@ interface TreeSnapshot {
146
148
  interface TreeSnapshotEvent extends TreeSnapshot {
147
149
  device?: DeviceInfo;
148
150
  }
151
+ type HandoffLocalGitState = HandoffLocalGitState$1;
152
+ interface GitCheckpoint extends GitHandoffCheckpoint {
153
+ artifactPath?: string;
154
+ indexArtifactPath?: string;
155
+ }
156
+ interface GitCheckpointEvent extends GitCheckpoint {
157
+ device?: DeviceInfo;
158
+ }
159
+ /**
160
+ * Keeps the emitted `@posthog/agent/types` entrypoint as a runtime ESM module.
161
+ *
162
+ * `export {}` is stripped by tsup in this package, which leaves `dist/types.js`
163
+ * empty and breaks downstream type resolution for the exported subpath.
164
+ */
165
+ declare const AGENT_TYPES_MODULE = true;
149
166
 
150
- export type { AgentConfig, AgentMode, ArtifactType, DeviceInfo, FileChange, FileStatus, LogLevel, OnLogCallback, OtelTransportConfig, PostHogAPIConfig, ProcessSpawnedCallback, StoredEntry, StoredNotification, Task, TaskExecutionOptions, TaskRun, TaskRunArtifact, TaskRunEnvironment, TaskRunStatus, TreeSnapshot, TreeSnapshotEvent };
167
+ export { AGENT_TYPES_MODULE, type AgentConfig, type AgentMode, type ArtifactType, type DeviceInfo, type FileChange, type FileStatus, type GitCheckpoint, type GitCheckpointEvent, type HandoffLocalGitState, type LogLevel, type OnLogCallback, type OtelTransportConfig, type PostHogAPIConfig, type ProcessSpawnedCallback, type StoredEntry, type StoredNotification, type Task, type TaskExecutionOptions, type TaskRun, type TaskRunArtifact, type TaskRunEnvironment, type TaskRunStatus, type TreeSnapshot, type TreeSnapshotEvent };
package/dist/types.js CHANGED
@@ -1 +1,6 @@
1
+ // src/types.ts
2
+ var AGENT_TYPES_MODULE = true;
3
+ export {
4
+ AGENT_TYPES_MODULE
5
+ };
1
6
  //# sourceMappingURL=types.js.map
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type {\n GitHandoffCheckpoint,\n HandoffLocalGitState as GitHandoffLocalGitState,\n} from \"@posthog/git/handoff\";\n\n/**\n * Stored custom notification following ACP extensibility model.\n * Custom notifications use underscore-prefixed methods (e.g., `_posthog/phase_start`).\n * See: https://agentclientprotocol.com/docs/extensibility\n */\nexport interface StoredNotification {\n type: \"notification\";\n /** When this notification was stored */\n timestamp: string;\n /** JSON-RPC 2.0 notification (no id field = notification, not request) */\n notification: {\n jsonrpc: \"2.0\";\n method: string;\n params?: Record<string, unknown>;\n };\n}\n\n/**\n * Type alias for stored log entries.\n */\nexport type StoredEntry = StoredNotification;\n\n// PostHog Task model (matches PostHog Code's OpenAPI schema)\nexport interface Task {\n id: string;\n task_number?: number;\n slug?: string;\n title: string;\n description: string;\n origin_product:\n | \"error_tracking\"\n | \"eval_clusters\"\n | \"user_created\"\n | \"support_queue\"\n | \"session_summaries\";\n github_integration?: number | null;\n repository: string; // Format: \"organization/repository\" (e.g., \"posthog/posthog-js\")\n json_schema?: Record<string, unknown> | null; // JSON schema for task output validation\n created_at: string;\n updated_at: string;\n created_by?: {\n id: number;\n uuid: string;\n distinct_id: string;\n first_name: string;\n email: string;\n };\n latest_run?: TaskRun;\n}\n\n// Log entry structure for TaskRun.log\n\nexport type ArtifactType =\n | \"plan\"\n | \"context\"\n | \"reference\"\n | \"output\"\n | \"artifact\"\n | \"tree_snapshot\"\n | \"user_attachment\";\n\nexport interface TaskRunArtifact {\n id?: string;\n name: string;\n type: ArtifactType;\n source?: string;\n size?: number;\n content_type?: string;\n storage_path?: string;\n uploaded_at?: string;\n}\n\nexport type TaskRunStatus =\n | \"not_started\"\n | \"queued\"\n | \"in_progress\"\n | \"completed\"\n | \"failed\"\n | \"cancelled\";\n\nexport type TaskRunEnvironment = \"local\" | \"cloud\";\n\n// TaskRun model - represents individual execution runs of tasks\nexport interface TaskRun {\n id: string;\n task: string; // Task ID\n team: number;\n branch: string | null;\n stage: string | null; // Current stage (e.g., 'research', 'plan', 'build')\n environment: TaskRunEnvironment;\n status: TaskRunStatus;\n log_url: string;\n error_message: string | null;\n output: Record<string, unknown> | null; // Structured output (PR URL, commit SHA, etc.)\n state: Record<string, unknown>; // Intermediate run state (defaults to {}, never null)\n artifacts?: TaskRunArtifact[];\n created_at: string;\n updated_at: string;\n completed_at: string | null;\n}\n\nexport interface ProcessSpawnedCallback {\n onProcessSpawned?: (info: {\n pid: number;\n command: string;\n sessionId?: string;\n }) => void;\n onProcessExited?: (pid: number) => void;\n onMcpServersReady?: (serverNames: string[]) => void;\n}\n\nexport interface TaskExecutionOptions {\n repositoryPath?: string;\n adapter?: \"claude\" | \"codex\";\n model?: string;\n gatewayUrl?: string;\n codexBinaryPath?: string;\n instructions?: string;\n processCallbacks?: ProcessSpawnedCallback;\n /** Callback invoked when the agent calls the create_output tool for structured output */\n onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;\n}\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nexport type OnLogCallback = (\n level: LogLevel,\n scope: string,\n message: string,\n data?: unknown,\n) => void;\n\nexport interface PostHogAPIConfig {\n apiUrl: string;\n getApiKey: () => string | Promise<string>;\n refreshApiKey?: () => string | Promise<string>;\n projectId: number;\n userAgent?: string;\n}\n\nexport interface OtelTransportConfig {\n /** PostHog ingest host, e.g., \"https://us.i.posthog.com\" */\n host: string;\n /** Project API key */\n apiKey: string;\n /** Override the logs endpoint path (default: /i/v1/logs) */\n logsPath?: string;\n}\n\nexport interface AgentConfig {\n posthog?: PostHogAPIConfig;\n /** OTEL transport config for shipping logs to PostHog Logs */\n otelTransport?: OtelTransportConfig;\n /** Skip session log persistence (e.g. for preview sessions with no real task) */\n skipLogPersistence?: boolean;\n /** Local cache path for instant log loading (e.g., ~/.posthog-code) */\n localCachePath?: string;\n /**\n * Annotate files the agent reads with PostHog enrichment (event volume,\n * flag rollout/staleness, experiment links). Defaults to enabled when\n * `posthog` config is present; set `{ enabled: false }` to opt out.\n */\n enricher?: { enabled?: boolean };\n debug?: boolean;\n onLog?: OnLogCallback;\n}\n\n// Device info for tracking where work happens\nexport interface DeviceInfo {\n type: \"local\" | \"cloud\";\n name?: string;\n}\n\n// Agent execution mode - for tracking interactive vs background runs, when backgrounded an agent will continue working without asking questions\nexport type AgentMode = \"interactive\" | \"background\";\n\n// Git file status codes\nexport type FileStatus = \"A\" | \"M\" | \"D\";\n\nexport interface FileChange {\n path: string;\n status: FileStatus;\n}\n\n// Tree snapshot - what TreeTracker captures\nexport interface TreeSnapshot {\n treeHash: string;\n baseCommit: string | null;\n archiveUrl?: string;\n changes: FileChange[];\n timestamp: string;\n interrupted?: boolean;\n}\n\n// Tree snapshot event - includes device info when sent as notification\nexport interface TreeSnapshotEvent extends TreeSnapshot {\n device?: DeviceInfo;\n}\n\nexport type HandoffLocalGitState = GitHandoffLocalGitState;\n\nexport interface GitCheckpoint extends GitHandoffCheckpoint {\n artifactPath?: string;\n indexArtifactPath?: string;\n}\n\nexport interface GitCheckpointEvent extends GitCheckpoint {\n device?: DeviceInfo;\n}\n\n/**\n * Keeps the emitted `@posthog/agent/types` entrypoint as a runtime ESM module.\n *\n * `export {}` is stripped by tsup in this package, which leaves `dist/types.js`\n * empty and breaks downstream type resolution for the exported subpath.\n */\nexport const AGENT_TYPES_MODULE = true;\n"],"mappings":";AA6NO,IAAM,qBAAqB;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.386",
3
+ "version": "2.3.387",
4
4
  "repository": "https://github.com/PostHog/code",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -60,6 +60,10 @@
60
60
  "types": "./dist/resume.d.ts",
61
61
  "import": "./dist/resume.js"
62
62
  },
63
+ "./handoff-checkpoint": {
64
+ "types": "./dist/handoff-checkpoint.d.ts",
65
+ "import": "./dist/handoff-checkpoint.js"
66
+ },
63
67
  "./tree-tracker": {
64
68
  "types": "./dist/tree-tracker.d.ts",
65
69
  "import": "./dist/tree-tracker.js"
@@ -67,6 +71,10 @@
67
71
  "./server": {
68
72
  "types": "./dist/server/agent-server.d.ts",
69
73
  "import": "./dist/server/agent-server.js"
74
+ },
75
+ "./server/schemas": {
76
+ "types": "./dist/server/schemas.d.ts",
77
+ "import": "./dist/server/schemas.js"
70
78
  }
71
79
  },
72
80
  "bin": {
@@ -37,6 +37,9 @@ export const POSTHOG_NOTIFICATIONS = {
37
37
  /** Tree state snapshot captured (git tree hash + file archive) */
38
38
  TREE_SNAPSHOT: "_posthog/tree_snapshot",
39
39
 
40
+ /** Git checkpoint captured for handoff */
41
+ GIT_CHECKPOINT: "_posthog/git_checkpoint",
42
+
40
43
  /** Agent mode changed (interactive/background) */
41
44
  MODE_CHANGE: "_posthog/mode_change",
42
45
 
@@ -0,0 +1,183 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { HandoffCheckpointTracker } from "./handoff-checkpoint";
3
+ import {
4
+ cloneTestRepo,
5
+ createTestRepo,
6
+ type TestRepo,
7
+ } from "./sagas/test-fixtures";
8
+ import type { HandoffLocalGitState } from "./types";
9
+
10
+ interface BundleStore {
11
+ artifacts: Record<string, string>;
12
+ storagePath: string;
13
+ manifest: Array<{ storage_path: string }>;
14
+ }
15
+
16
+ interface HandoffRepos {
17
+ cloudRepo: TestRepo;
18
+ localRepo: TestRepo;
19
+ branch: string;
20
+ localGitState: HandoffLocalGitState;
21
+ }
22
+
23
+ const WORKTREE_FILES = ["tracked.txt", "unstaged.txt", "untracked.txt"];
24
+
25
+ function createMockApi(store: BundleStore) {
26
+ return {
27
+ uploadTaskArtifacts: async (
28
+ _taskId: string,
29
+ _runId: string,
30
+ artifacts: Array<{
31
+ name: string;
32
+ content: string;
33
+ }>,
34
+ ) => {
35
+ const uploaded = artifacts.map((artifact, index) => {
36
+ const storagePath = `${store.storagePath}-${store.manifest.length + index}-${artifact.name}`;
37
+ store.artifacts[storagePath] = artifact.content;
38
+ return { storage_path: storagePath };
39
+ });
40
+ for (const entry of uploaded) {
41
+ store.manifest.push(entry);
42
+ }
43
+ return store.manifest;
44
+ },
45
+ downloadArtifact: async (
46
+ _taskId: string,
47
+ _runId: string,
48
+ artifactPath: string,
49
+ ) => {
50
+ const contentBase64 = store.artifacts[artifactPath];
51
+ if (!contentBase64) return null;
52
+ const buffer = Buffer.from(contentBase64, "utf-8");
53
+ return buffer.buffer.slice(
54
+ buffer.byteOffset,
55
+ buffer.byteOffset + buffer.byteLength,
56
+ );
57
+ },
58
+ };
59
+ }
60
+
61
+ function createBundleStore(): BundleStore {
62
+ return {
63
+ storagePath: "gs://bucket/handoff",
64
+ artifacts: {},
65
+ manifest: [
66
+ {
67
+ storage_path: "gs://bucket/handoff-0-existing-tree_snapshot.tar.gz",
68
+ },
69
+ ],
70
+ };
71
+ }
72
+
73
+ function createTracker(
74
+ repositoryPath: string,
75
+ apiClient: ReturnType<typeof createMockApi>,
76
+ ) {
77
+ return new HandoffCheckpointTracker({
78
+ repositoryPath,
79
+ taskId: "task-1",
80
+ runId: "run-1",
81
+ apiClient: apiClient as never,
82
+ });
83
+ }
84
+
85
+ async function seedCloudRepo(repo: TestRepo): Promise<void> {
86
+ await repo.writeFile("tracked.txt", "base\n");
87
+ await repo.writeFile("unstaged.txt", "base unstaged\n");
88
+ await repo.git(["add", "tracked.txt", "unstaged.txt"]);
89
+ await repo.git(["commit", "-m", "Add tracked files"]);
90
+ }
91
+
92
+ async function prepareHandoffRepos(
93
+ cleanups: Array<() => Promise<void>>,
94
+ ): Promise<HandoffRepos> {
95
+ const cloudRepo = await createTestRepo("handoff-cloud");
96
+ cleanups.push(cloudRepo.cleanup);
97
+ await seedCloudRepo(cloudRepo);
98
+
99
+ const localRepo = await cloneTestRepo(cloudRepo.path, "handoff-local");
100
+ cleanups.push(localRepo.cleanup);
101
+
102
+ const branch = await cloudRepo.git(["rev-parse", "--abbrev-ref", "HEAD"]);
103
+ const localHead = await localRepo.git(["rev-parse", "HEAD"]);
104
+ const upstreamHead = await localRepo.git(["rev-parse", `origin/${branch}`]);
105
+
106
+ return {
107
+ cloudRepo,
108
+ localRepo,
109
+ branch,
110
+ localGitState: {
111
+ head: localHead,
112
+ branch,
113
+ upstreamHead,
114
+ upstreamRemote: "origin",
115
+ upstreamMergeRef: `refs/heads/${branch}`,
116
+ },
117
+ };
118
+ }
119
+
120
+ async function makeCloudChanges(repo: TestRepo): Promise<void> {
121
+ await repo.writeFile("committed.txt", "cloud commit\n");
122
+ await repo.git(["add", "committed.txt"]);
123
+ await repo.git(["commit", "-m", "Cloud commit"]);
124
+
125
+ await repo.writeFile("tracked.txt", "staged change\n");
126
+ await repo.git(["add", "tracked.txt"]);
127
+ await repo.writeFile("unstaged.txt", "unstaged change\n");
128
+ await repo.writeFile("untracked.txt", "untracked\n");
129
+ }
130
+
131
+ async function mirrorRestoredWorktree(
132
+ cloudRepo: TestRepo,
133
+ localRepo: TestRepo,
134
+ ): Promise<void> {
135
+ for (const file of WORKTREE_FILES) {
136
+ await localRepo.writeFile(file, await cloudRepo.readFile(file));
137
+ }
138
+ }
139
+
140
+ describe("HandoffCheckpointTracker", () => {
141
+ const cleanups: Array<() => Promise<void>> = [];
142
+
143
+ afterEach(async () => {
144
+ await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
145
+ });
146
+
147
+ it("restores head commit and index state for handoff replay", async () => {
148
+ const { cloudRepo, localRepo, branch, localGitState } =
149
+ await prepareHandoffRepos(cleanups);
150
+ await makeCloudChanges(cloudRepo);
151
+
152
+ const store = createBundleStore();
153
+ const apiClient = createMockApi(store);
154
+ const captureTracker = createTracker(cloudRepo.path, apiClient);
155
+
156
+ const checkpoint = await captureTracker.captureForHandoff(localGitState);
157
+
158
+ expect(checkpoint).not.toBeNull();
159
+ if (!checkpoint) return;
160
+ expect(Object.keys(store.artifacts).length).toBeGreaterThan(0);
161
+
162
+ const applyTracker = createTracker(localRepo.path, apiClient);
163
+ await applyTracker.applyFromHandoff(checkpoint);
164
+
165
+ // The handoff service restores files separately via tree_snapshot.
166
+ // Mirror that here so the restored git index can be validated.
167
+ await mirrorRestoredWorktree(cloudRepo, localRepo);
168
+
169
+ expect(await localRepo.git(["rev-parse", "HEAD"])).toBe(checkpoint.head);
170
+ expect(await localRepo.git(["rev-parse", "--abbrev-ref", "HEAD"])).toBe(
171
+ branch,
172
+ );
173
+ expect(await localRepo.readFile("committed.txt")).toBe("cloud commit\n");
174
+ expect(await localRepo.readFile("tracked.txt")).toBe("staged change\n");
175
+ expect(await localRepo.readFile("unstaged.txt")).toBe("unstaged change\n");
176
+ expect(await localRepo.readFile("untracked.txt")).toBe("untracked\n");
177
+
178
+ const status = await localRepo.git(["status", "--porcelain"]);
179
+ expect(status).toContain("M tracked.txt");
180
+ expect(status).toContain(" M unstaged.txt");
181
+ expect(status).toContain("?? untracked.txt");
182
+ });
183
+ });
@@ -0,0 +1,361 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ type GitHandoffBranchDivergence,
5
+ type GitHandoffCheckpoint,
6
+ GitHandoffTracker,
7
+ } from "@posthog/git/handoff";
8
+ import type { PostHogAPIClient } from "./posthog-api";
9
+ import type { GitCheckpoint, HandoffLocalGitState } from "./types";
10
+ import { Logger } from "./utils/logger";
11
+
12
+ export interface HandoffCheckpointTrackerConfig {
13
+ repositoryPath: string;
14
+ taskId: string;
15
+ runId: string;
16
+ apiClient?: PostHogAPIClient;
17
+ logger?: Logger;
18
+ }
19
+
20
+ type ArtifactTransfer<T extends object = {}> = T & {
21
+ rawBytes: number;
22
+ wireBytes: number;
23
+ };
24
+
25
+ type UploadedArtifact = ArtifactTransfer<{ storagePath?: string }>;
26
+ type DownloadedArtifact = ArtifactTransfer<{ filePath: string }>;
27
+
28
+ type ArtifactKey = "pack" | "index";
29
+ type ArtifactSlotMap<T extends object> = Partial<
30
+ Record<ArtifactKey, ArtifactTransfer<T>>
31
+ >;
32
+
33
+ interface UploadArtifactSpec {
34
+ key: ArtifactKey;
35
+ filePath?: string;
36
+ name: string;
37
+ contentType: string;
38
+ }
39
+
40
+ interface DownloadArtifactSpec {
41
+ key: ArtifactKey;
42
+ storagePath?: string;
43
+ filePath: string;
44
+ label: string;
45
+ }
46
+
47
+ type Uploads = ArtifactSlotMap<{ storagePath?: string }>;
48
+ type Downloads = ArtifactSlotMap<{ filePath: string }>;
49
+
50
+ export class HandoffCheckpointTracker {
51
+ private repositoryPath: string;
52
+ private taskId: string;
53
+ private runId: string;
54
+ private apiClient?: PostHogAPIClient;
55
+ private logger: Logger;
56
+
57
+ constructor(config: HandoffCheckpointTrackerConfig) {
58
+ this.repositoryPath = config.repositoryPath;
59
+ this.taskId = config.taskId;
60
+ this.runId = config.runId;
61
+ this.apiClient = config.apiClient;
62
+ this.logger =
63
+ config.logger ||
64
+ new Logger({ debug: false, prefix: "[HandoffCheckpointTracker]" });
65
+ }
66
+
67
+ async captureForHandoff(
68
+ localGitState?: HandoffLocalGitState,
69
+ ): Promise<GitCheckpoint | null> {
70
+ if (!this.apiClient) {
71
+ throw new Error(
72
+ "Cannot capture handoff checkpoint: API client not configured",
73
+ );
74
+ }
75
+
76
+ const gitTracker = this.createGitTracker();
77
+ const capture = await gitTracker.captureForHandoff(localGitState);
78
+
79
+ try {
80
+ const uploads = await this.uploadArtifacts([
81
+ {
82
+ key: "pack",
83
+ filePath: capture.headPack?.path,
84
+ name: `handoff/${capture.checkpoint.checkpointId}.pack`,
85
+ contentType: "application/x-git-packed-objects",
86
+ },
87
+ {
88
+ key: "index",
89
+ filePath: capture.indexFile.path,
90
+ name: `handoff/${capture.checkpoint.checkpointId}.index`,
91
+ contentType: "application/octet-stream",
92
+ },
93
+ ]);
94
+
95
+ this.logCaptureMetrics(capture.checkpoint, uploads);
96
+
97
+ return {
98
+ ...capture.checkpoint,
99
+ artifactPath: uploads.pack?.storagePath,
100
+ indexArtifactPath: uploads.index?.storagePath,
101
+ };
102
+ } finally {
103
+ await this.removeIfPresent(capture.headPack?.path);
104
+ await this.removeIfPresent(capture.indexFile.path);
105
+ }
106
+ }
107
+
108
+ async applyFromHandoff(
109
+ checkpoint: GitCheckpoint,
110
+ options?: {
111
+ localGitState?: HandoffLocalGitState;
112
+ onDivergedBranch?: (
113
+ divergence: GitHandoffBranchDivergence,
114
+ ) => Promise<boolean>;
115
+ },
116
+ ): Promise<void> {
117
+ if (!this.apiClient) {
118
+ throw new Error(
119
+ "Cannot apply handoff checkpoint: API client not configured",
120
+ );
121
+ }
122
+
123
+ const gitTracker = this.createGitTracker();
124
+ const tmpDir = join(this.repositoryPath, ".posthog", "tmp");
125
+ await mkdir(tmpDir, { recursive: true });
126
+
127
+ const packPath = join(tmpDir, `${checkpoint.checkpointId}.pack`);
128
+ const indexPath = join(tmpDir, `${checkpoint.checkpointId}.index`);
129
+
130
+ try {
131
+ const downloads = await this.downloadArtifacts([
132
+ {
133
+ key: "pack",
134
+ storagePath: checkpoint.artifactPath,
135
+ filePath: packPath,
136
+ label: "handoff pack",
137
+ },
138
+ {
139
+ key: "index",
140
+ storagePath: checkpoint.indexArtifactPath,
141
+ filePath: indexPath,
142
+ label: "handoff index",
143
+ },
144
+ ]);
145
+
146
+ const applyResult = await gitTracker.applyFromHandoff({
147
+ checkpoint: this.toGitCheckpoint(checkpoint),
148
+ headPackPath: downloads.pack?.filePath,
149
+ indexPath: downloads.index?.filePath,
150
+ localGitState: options?.localGitState,
151
+ onDivergedBranch: options?.onDivergedBranch,
152
+ });
153
+
154
+ this.logApplyMetrics(checkpoint, downloads, applyResult.totalBytes);
155
+ } finally {
156
+ await this.removeIfPresent(packPath);
157
+ await this.removeIfPresent(indexPath);
158
+ }
159
+ }
160
+
161
+ private toGitCheckpoint(checkpoint: GitCheckpoint): GitHandoffCheckpoint {
162
+ return {
163
+ checkpointId: checkpoint.checkpointId,
164
+ commit: checkpoint.commit,
165
+ checkpointRef: checkpoint.checkpointRef,
166
+ headRef: checkpoint.headRef,
167
+ head: checkpoint.head,
168
+ branch: checkpoint.branch,
169
+ indexTree: checkpoint.indexTree,
170
+ worktreeTree: checkpoint.worktreeTree,
171
+ timestamp: checkpoint.timestamp,
172
+ upstreamRemote: checkpoint.upstreamRemote ?? null,
173
+ upstreamMergeRef: checkpoint.upstreamMergeRef ?? null,
174
+ remoteUrl: checkpoint.remoteUrl ?? null,
175
+ };
176
+ }
177
+
178
+ private async uploadArtifactFile(
179
+ filePath: string,
180
+ name: string,
181
+ contentType: string,
182
+ ): Promise<UploadedArtifact> {
183
+ if (!this.apiClient) {
184
+ return { rawBytes: 0, wireBytes: 0 };
185
+ }
186
+
187
+ const content = await readFile(filePath);
188
+ const base64Content = content.toString("base64");
189
+ const artifacts = await this.apiClient.uploadTaskArtifacts(
190
+ this.taskId,
191
+ this.runId,
192
+ [
193
+ {
194
+ name,
195
+ type: "artifact",
196
+ content: base64Content,
197
+ content_type: contentType,
198
+ },
199
+ ],
200
+ );
201
+
202
+ return {
203
+ storagePath: artifacts.at(-1)?.storage_path,
204
+ rawBytes: content.byteLength,
205
+ wireBytes: Buffer.byteLength(base64Content, "utf-8"),
206
+ };
207
+ }
208
+
209
+ private async uploadArtifacts(specs: UploadArtifactSpec[]): Promise<Uploads> {
210
+ const uploads = await Promise.all(
211
+ specs.map(async (spec) => {
212
+ if (!spec.filePath) {
213
+ return [spec.key, undefined] as const;
214
+ }
215
+ return [
216
+ spec.key,
217
+ await this.uploadArtifactFile(
218
+ spec.filePath,
219
+ spec.name,
220
+ spec.contentType,
221
+ ),
222
+ ] as const;
223
+ }),
224
+ );
225
+
226
+ return Object.fromEntries(uploads) as Uploads;
227
+ }
228
+
229
+ private async downloadArtifactToFile(
230
+ artifactPath: string,
231
+ filePath: string,
232
+ label: string,
233
+ ): Promise<DownloadedArtifact> {
234
+ if (!this.apiClient) {
235
+ throw new Error(`Cannot download ${label}: API client not configured`);
236
+ }
237
+
238
+ const arrayBuffer = await this.apiClient.downloadArtifact(
239
+ this.taskId,
240
+ this.runId,
241
+ artifactPath,
242
+ );
243
+ if (!arrayBuffer) {
244
+ throw new Error(`Failed to download ${label}`);
245
+ }
246
+
247
+ const base64Content = Buffer.from(arrayBuffer).toString("utf-8");
248
+ const binaryContent = Buffer.from(base64Content, "base64");
249
+ await writeFile(filePath, binaryContent);
250
+ return {
251
+ filePath,
252
+ rawBytes: binaryContent.byteLength,
253
+ wireBytes: arrayBuffer.byteLength,
254
+ };
255
+ }
256
+
257
+ private async downloadArtifacts(
258
+ specs: DownloadArtifactSpec[],
259
+ ): Promise<Downloads> {
260
+ const downloads = await Promise.all(
261
+ specs.map(async (spec) => {
262
+ if (!spec.storagePath) {
263
+ return [spec.key, undefined] as const;
264
+ }
265
+ return [
266
+ spec.key,
267
+ await this.downloadArtifactToFile(
268
+ spec.storagePath,
269
+ spec.filePath,
270
+ spec.label,
271
+ ),
272
+ ] as const;
273
+ }),
274
+ );
275
+
276
+ return Object.fromEntries(downloads) as Downloads;
277
+ }
278
+
279
+ private createGitTracker(): GitHandoffTracker {
280
+ return new GitHandoffTracker({
281
+ repositoryPath: this.repositoryPath,
282
+ logger: this.logger,
283
+ });
284
+ }
285
+
286
+ private logCaptureMetrics(
287
+ checkpoint: GitHandoffCheckpoint,
288
+ uploads: Uploads,
289
+ ): void {
290
+ this.logger.info("Captured handoff checkpoint", {
291
+ checkpointId: checkpoint.checkpointId,
292
+ branch: checkpoint.branch,
293
+ head: checkpoint.head,
294
+ artifactPath: uploads.pack?.storagePath,
295
+ indexArtifactPath: uploads.index?.storagePath,
296
+ ...this.buildMetricPayload(uploads),
297
+ });
298
+ }
299
+
300
+ private logApplyMetrics(
301
+ checkpoint: GitCheckpoint,
302
+ downloads: Downloads,
303
+ totalBytes: number,
304
+ ): void {
305
+ this.logger.info("Applied handoff checkpoint", {
306
+ checkpointId: checkpoint.checkpointId,
307
+ commit: checkpoint.commit,
308
+ branch: checkpoint.branch,
309
+ head: checkpoint.head,
310
+ packBytes: downloads.pack?.rawBytes ?? 0,
311
+ packWireBytes: downloads.pack?.wireBytes ?? 0,
312
+ indexBytes: downloads.index?.rawBytes ?? 0,
313
+ indexWireBytes: downloads.index?.wireBytes ?? 0,
314
+ totalBytes,
315
+ totalWireBytes: this.sumWireBytes(downloads.pack, downloads.index),
316
+ });
317
+ }
318
+
319
+ private buildMetricPayload(metrics: ArtifactSlotMap<object>): {
320
+ packBytes: number;
321
+ packWireBytes: number;
322
+ indexBytes: number;
323
+ indexWireBytes: number;
324
+ totalBytes: number;
325
+ totalWireBytes: number;
326
+ } {
327
+ return {
328
+ packBytes: metrics.pack?.rawBytes ?? 0,
329
+ packWireBytes: metrics.pack?.wireBytes ?? 0,
330
+ indexBytes: metrics.index?.rawBytes ?? 0,
331
+ indexWireBytes: metrics.index?.wireBytes ?? 0,
332
+ totalBytes: this.sumRawBytes(metrics.pack, metrics.index),
333
+ totalWireBytes: this.sumWireBytes(metrics.pack, metrics.index),
334
+ };
335
+ }
336
+
337
+ private sumRawBytes(
338
+ ...artifacts: Array<{ rawBytes: number } | undefined>
339
+ ): number {
340
+ return artifacts.reduce(
341
+ (total, artifact) => total + (artifact?.rawBytes ?? 0),
342
+ 0,
343
+ );
344
+ }
345
+
346
+ private sumWireBytes(
347
+ ...artifacts: Array<{ wireBytes: number } | undefined>
348
+ ): number {
349
+ return artifacts.reduce(
350
+ (total, artifact) => total + (artifact?.wireBytes ?? 0),
351
+ 0,
352
+ );
353
+ }
354
+
355
+ private async removeIfPresent(filePath: string | undefined): Promise<void> {
356
+ if (!filePath) {
357
+ return;
358
+ }
359
+ await rm(filePath, { force: true }).catch(() => {});
360
+ }
361
+ }
@@ -77,4 +77,33 @@ describe("PostHogAPIClient", () => {
77
77
  }),
78
78
  );
79
79
  });
80
+
81
+ it("returns only the artifacts created by the current upload request", async () => {
82
+ const client = new PostHogAPIClient({
83
+ apiUrl: "https://app.posthog.com",
84
+ getApiKey: vi.fn().mockResolvedValue("token"),
85
+ projectId: 1,
86
+ });
87
+
88
+ mockFetch.mockResolvedValueOnce({
89
+ ok: true,
90
+ json: vi.fn().mockResolvedValue({
91
+ artifacts: [
92
+ { storage_path: "gs://bucket/existing.tar.gz", name: "existing" },
93
+ { storage_path: "gs://bucket/new-1.pack", name: "new-1" },
94
+ { storage_path: "gs://bucket/new-2.index", name: "new-2" },
95
+ ],
96
+ }),
97
+ });
98
+
99
+ const artifacts = await client.uploadTaskArtifacts("task-1", "run-1", [
100
+ { name: "new-1", type: "artifact", content: "AAA" },
101
+ { name: "new-2", type: "artifact", content: "BBB" },
102
+ ]);
103
+
104
+ expect(artifacts).toEqual([
105
+ { storage_path: "gs://bucket/new-1.pack", name: "new-1" },
106
+ { storage_path: "gs://bucket/new-2.index", name: "new-2" },
107
+ ]);
108
+ });
80
109
  });
@@ -230,7 +230,11 @@ export class PostHogAPIClient {
230
230
  },
231
231
  );
232
232
 
233
- return response.artifacts ?? [];
233
+ const manifest = response.artifacts ?? [];
234
+
235
+ // The backend returns the full run artifact manifest after each upload.
236
+ // Callers want the artifacts corresponding to this upload request only.
237
+ return manifest.slice(-artifacts.length);
234
238
  }
235
239
 
236
240
  /**