mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Move workspace diff into plugin
This commit is contained in:
1 parent
09ea87fb12
commit
ce6795803e
29 files changed
+161
-1316
No files matched your search
+15
-5
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { z } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export const workspaceDiffViewSchema = z.enum(["working-tree", "head"]);
|
||||
|
||||
@@ -40,7 +40,7 @@ function normalizePathQuery(value: unknown): string[] {
|
||||
if (typeof entry !== "string") return [];
|
||||
return entry
|
||||
.split(",")
|
||||
.map((path) => path.trim())
|
||||
.map((filePath) => filePath.trim())
|
||||
.filter(Boolean);
|
||||
});
|
||||
}
|
||||
@@ -115,8 +115,8 @@ export const workspaceDiffStatsSchema = z.object({
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffResponseSchema = z.object({
|
||||
workspaceId: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
workspaceId: z.string(),
|
||||
companyId: z.string(),
|
||||
view: workspaceDiffViewSchema,
|
||||
baseRef: z.string().nullable(),
|
||||
headSha: z.string().nullable(),
|
||||
@@ -129,4 +129,14 @@ export const workspaceDiffResponseSchema = z.object({
|
||||
truncated: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
export type WorkspaceDiffQuery = z.infer<typeof workspaceDiffQuerySchema>;
|
||||
export type WorkspaceDiffView = z.infer<typeof workspaceDiffViewSchema>;
|
||||
export type WorkspaceDiffFileStatus = z.infer<typeof workspaceDiffFileStatusSchema>;
|
||||
export type WorkspaceDiffPatchKind = z.infer<typeof workspaceDiffPatchKindSchema>;
|
||||
export type WorkspaceDiffWarningCode = z.infer<typeof workspaceDiffWarningCodeSchema>;
|
||||
export type WorkspaceDiffQueryOptions = z.infer<typeof workspaceDiffQuerySchema>;
|
||||
export type WorkspaceDiffWarning = z.infer<typeof workspaceDiffWarningSchema>;
|
||||
export type WorkspaceDiffCaps = z.infer<typeof workspaceDiffCapsSchema>;
|
||||
export type WorkspaceDiffFilePatch = z.infer<typeof workspaceDiffFilePatchSchema>;
|
||||
export type WorkspaceDiffFile = z.infer<typeof workspaceDiffFileSchema>;
|
||||
export type WorkspaceDiffStats = z.infer<typeof workspaceDiffStatsSchema>;
|
||||
export type WorkspaceDiffResponse = z.infer<typeof workspaceDiffResponseSchema>;
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
} from "./contracts.js";
|
||||
|
||||
export type DiffRenderMode = "unified" | "split";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Workspace Changes",
|
||||
description: "Adds a Changes tab to execution workspaces using Paperclip's host diff API and @pierre/diffs.",
|
||||
description: "Adds a Changes tab to execution and project workspaces using plugin-local Git diff computation and @pierre/diffs.",
|
||||
author: "Paperclip",
|
||||
categories: ["workspace", "ui"],
|
||||
capabilities: [
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
|
||||
import type { WorkspaceDiffResponse } from "@paperclipai/plugin-sdk";
|
||||
import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs";
|
||||
import type { PatchDiffProps } from "@pierre/diffs/react";
|
||||
import { useFileDiffInstance } from "@pierre/diffs/react";
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
type DiffPatchViewModel,
|
||||
type DiffRenderMode,
|
||||
} from "../diff-model.js";
|
||||
import type { WorkspaceDiffResponse } from "../contracts.js";
|
||||
|
||||
type WorkspaceDiffData = WorkspaceDiffResponse;
|
||||
type WorkspacePatchDiffOptions = PatchDiffProps<undefined>["options"];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import type { WorkspaceDiffQueryOptions } from "@paperclipai/plugin-sdk";
|
||||
import { workspaceDiffQuerySchema } from "./contracts.js";
|
||||
import { workspaceDiffService } from "./workspace-diff.js";
|
||||
|
||||
const PLUGIN_NAME = "workspace-diff";
|
||||
@@ -8,29 +8,6 @@ function readString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function readPaths(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((entry) => {
|
||||
if (typeof entry !== "string") return [];
|
||||
const trimmed = entry.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function readDiffOptions(params: Record<string, unknown>): WorkspaceDiffQueryOptions {
|
||||
const view = params.view === "head" ? "head" : "working-tree";
|
||||
const baseRef = readString(params.baseRef) || null;
|
||||
const includeUntracked = typeof params.includeUntracked === "boolean"
|
||||
? params.includeUntracked
|
||||
: true;
|
||||
return {
|
||||
view,
|
||||
baseRef,
|
||||
includeUntracked,
|
||||
paths: readPaths(params.paths),
|
||||
};
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
||||
@@ -57,15 +34,15 @@ const plugin = definePlugin({
|
||||
id: workspace.id,
|
||||
companyId,
|
||||
cwd: workspace.path,
|
||||
baseRef: null,
|
||||
}, readDiffOptions(params));
|
||||
baseRef: workspace.defaultRef ?? workspace.repoRef ?? null,
|
||||
}, workspaceDiffQuerySchema.parse(params));
|
||||
}
|
||||
|
||||
const workspace = await ctx.executionWorkspaces.get(workspaceId, companyId);
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
return workspaceDiff.getDiff(workspace, readDiffOptions(params));
|
||||
return workspaceDiff.getDiff(workspace, workspaceDiffQuerySchema.parse(params));
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
WorkspaceDiffCaps,
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
WorkspaceDiffWarningCode,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
} from "./contracts.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -51,7 +51,7 @@ function warning(code: WorkspaceDiffWarningCode, message: string, filePath: stri
|
||||
|
||||
function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record<string, unknown> = {}) {
|
||||
const error = new Error(message);
|
||||
Object.assign(error, { code, details: { code, ...details } });
|
||||
Object.assign(error, { code, status: 422, details: { code, ...details } });
|
||||
return error;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { workspaceDiffQuerySchema, workspaceDiffResponseSchema } from "../src/contracts.js";
|
||||
import { diffResponse } from "./fixtures.js";
|
||||
|
||||
describe("workspace diff plugin contracts", () => {
|
||||
it("normalizes query options from plugin data parameters", () => {
|
||||
expect(workspaceDiffQuerySchema.parse({
|
||||
view: "head",
|
||||
baseRef: " main ",
|
||||
includeUntracked: "false",
|
||||
path: ["src/app.ts, README.md", "packages/shared/src/index.ts"],
|
||||
})).toEqual({
|
||||
view: "head",
|
||||
baseRef: "main",
|
||||
includeUntracked: false,
|
||||
paths: ["src/app.ts", "README.md", "packages/shared/src/index.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("validates the plugin-owned response shape", () => {
|
||||
expect(workspaceDiffResponseSchema.parse(diffResponse())).toMatchObject({
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
stats: { fileCount: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "@paperclipai/plugin-sdk";
|
||||
import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "../src/contracts.js";
|
||||
|
||||
export function changedFile(overrides: Partial<WorkspaceDiffFile> = {}): WorkspaceDiffFile {
|
||||
return {
|
||||
|
||||
@@ -25,6 +25,7 @@ async function createGitWorkspace() {
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 1;\n");
|
||||
await git(root, ["add", "src/app.ts"]);
|
||||
await git(root, ["commit", "-m", "initial"]);
|
||||
await git(root, ["branch", "-M", "main"]);
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -106,6 +107,47 @@ describe("workspace diff plugin", () => {
|
||||
})).resolves.toMatchObject({ files: [], truncated: false });
|
||||
});
|
||||
|
||||
it("fetches project workspace diffs from generic project workspace metadata", async () => {
|
||||
const root = await createGitWorkspace();
|
||||
await git(root, ["checkout", "-b", "feature"]);
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 3;\n");
|
||||
await git(root, ["add", "src/app.ts"]);
|
||||
await git(root, ["commit", "-m", "project workspace change"]);
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
|
||||
expect(projectId).toBe("project-1");
|
||||
expect(companyId).toBe("company-1");
|
||||
return [{
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
name: "Primary",
|
||||
path: root,
|
||||
repoUrl: null,
|
||||
repoRef: "feature",
|
||||
defaultRef: "main",
|
||||
isPrimary: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}];
|
||||
};
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
entityType: "project_workspace",
|
||||
view: "head",
|
||||
includeUntracked: false,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
baseRef: "main",
|
||||
stats: { fileCount: 1 },
|
||||
files: [expect.objectContaining({ path: "src/app.ts" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a clear bridge error when required context is missing", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
+45
-63
@@ -5,8 +5,9 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { ExecutionWorkspace, WorkspaceDiffQueryOptions } from "@paperclipai/shared";
|
||||
import { WORKSPACE_DIFF_CAPS, workspaceDiffService } from "../services/workspace-diff.js";
|
||||
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
|
||||
import type { WorkspaceDiffQueryOptions } from "../src/contracts.js";
|
||||
import { WORKSPACE_DIFF_CAPS, workspaceDiffService } from "../src/workspace-diff.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const tempDirs = new Set<string>();
|
||||
@@ -16,7 +17,7 @@ async function runGit(cwd: string, args: string[]) {
|
||||
}
|
||||
|
||||
async function createTempRepo() {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-diff-"));
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-"));
|
||||
tempDirs.add(repoRoot);
|
||||
await runGit(repoRoot, ["init"]);
|
||||
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||
@@ -32,34 +33,19 @@ async function createTempRepo() {
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function createWorkspace(cwd: string | null, overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
|
||||
const now = new Date();
|
||||
function createWorkspace(cwd: string | null, overrides: Partial<PluginExecutionWorkspaceMetadata> = {}): PluginExecutionWorkspaceMetadata {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
companyId: randomUUID(),
|
||||
projectId: randomUUID(),
|
||||
projectWorkspaceId: null,
|
||||
sourceIssueId: null,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Diff workspace",
|
||||
status: "active",
|
||||
path: cwd,
|
||||
cwd,
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: "feature",
|
||||
providerType: "git_worktree",
|
||||
providerRef: cwd,
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: now,
|
||||
openedAt: now,
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
providerMetadata: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -81,7 +67,7 @@ afterEach(async () => {
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
describe("workspaceDiffService", () => {
|
||||
describe("plugin workspace diff service", () => {
|
||||
it("returns staged, unstaged, renamed, deleted, untracked, binary, and oversized working-tree changes", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\nstaged\n", "utf8");
|
||||
@@ -98,34 +84,12 @@ describe("workspaceDiffService", () => {
|
||||
const byPath = new Map(diff.files.map((file) => [file.path, file]));
|
||||
|
||||
expect(diff.view).toBe("working-tree");
|
||||
expect(byPath.get("tracked-staged.txt")).toMatchObject({
|
||||
staged: true,
|
||||
unstaged: false,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
});
|
||||
expect(byPath.get("tracked-staged.txt")).toMatchObject({ staged: true, unstaged: false, status: "modified", additions: 1 });
|
||||
expect(byPath.get("tracked-staged.txt")?.patches.map((patch) => patch.kind)).toEqual(["staged"]);
|
||||
expect(byPath.get("tracked-unstaged.txt")).toMatchObject({
|
||||
staged: false,
|
||||
unstaged: true,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
});
|
||||
expect(byPath.get("renamed.txt")).toMatchObject({
|
||||
oldPath: "rename-me.txt",
|
||||
staged: true,
|
||||
status: "renamed",
|
||||
});
|
||||
expect(byPath.get("delete-me.txt")).toMatchObject({
|
||||
unstaged: true,
|
||||
status: "deleted",
|
||||
deletions: 1,
|
||||
});
|
||||
expect(byPath.get("untracked.txt")).toMatchObject({
|
||||
untracked: true,
|
||||
status: "untracked",
|
||||
additions: 1,
|
||||
});
|
||||
expect(byPath.get("tracked-unstaged.txt")).toMatchObject({ staged: false, unstaged: true, status: "modified", additions: 1 });
|
||||
expect(byPath.get("renamed.txt")).toMatchObject({ oldPath: "rename-me.txt", staged: true, status: "renamed" });
|
||||
expect(byPath.get("delete-me.txt")).toMatchObject({ unstaged: true, status: "deleted", deletions: 1 });
|
||||
expect(byPath.get("untracked.txt")).toMatchObject({ untracked: true, status: "untracked", additions: 1 });
|
||||
expect(byPath.get("untracked.txt")?.patches[0]?.patch).toContain("+brand new");
|
||||
expect(byPath.get("empty-untracked.txt")?.patches[0]?.patch).toBe([
|
||||
"diff --git a/empty-untracked.txt b/empty-untracked.txt",
|
||||
@@ -134,18 +98,9 @@ describe("workspaceDiffService", () => {
|
||||
"+++ b/empty-untracked.txt",
|
||||
"",
|
||||
].join("\n"));
|
||||
expect(byPath.get("binary.bin")).toMatchObject({
|
||||
binary: true,
|
||||
unstaged: true,
|
||||
});
|
||||
expect(byPath.get("oversized.txt")).toMatchObject({
|
||||
oversized: true,
|
||||
untracked: true,
|
||||
});
|
||||
expect(diff.warnings.map((item) => item.code)).toEqual(expect.arrayContaining([
|
||||
"binary_file",
|
||||
"file_oversized",
|
||||
]));
|
||||
expect(byPath.get("binary.bin")).toMatchObject({ binary: true, unstaged: true });
|
||||
expect(byPath.get("oversized.txt")).toMatchObject({ oversized: true, untracked: true });
|
||||
expect(diff.warnings.map((item) => item.code)).toEqual(expect.arrayContaining(["binary_file", "file_oversized"]));
|
||||
}, 20_000);
|
||||
|
||||
it("returns head diffs against the requested base ref", async () => {
|
||||
@@ -173,12 +128,39 @@ describe("workspaceDiffService", () => {
|
||||
expect(diff.files[0]?.patches.map((patch) => patch.kind)).toEqual(["head"]);
|
||||
}, 20_000);
|
||||
|
||||
it("surfaces missing cwd, non-git, invalid base refs, and unsafe path filters as 422 errors", async () => {
|
||||
it("filters changed files by relative workspace paths", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\none\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\ntwo\n", "utf8");
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(
|
||||
createWorkspace(repoRoot),
|
||||
workingTreeQuery({ paths: ["tracked-staged.txt"] }),
|
||||
);
|
||||
|
||||
expect(diff.paths).toEqual(["tracked-staged.txt"]);
|
||||
expect(diff.files.map((file) => file.path)).toEqual(["tracked-staged.txt"]);
|
||||
}, 20_000);
|
||||
|
||||
it("applies output caps to large workspace responses", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
for (let index = 0; index < WORKSPACE_DIFF_CAPS.maxFiles + 1; index += 1) {
|
||||
await fs.writeFile(path.join(repoRoot, `untracked-${String(index).padStart(3, "0")}.txt`), "", "utf8");
|
||||
}
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
|
||||
|
||||
expect(diff.files).toHaveLength(WORKSPACE_DIFF_CAPS.maxFiles);
|
||||
expect(diff.truncated).toBe(true);
|
||||
expect(diff.warnings).toContainEqual(expect.objectContaining({ code: "file_count_truncated" }));
|
||||
}, 20_000);
|
||||
|
||||
it("surfaces missing cwd, non-git, invalid base refs, and unsafe path filters as plugin errors", async () => {
|
||||
const svc = workspaceDiffService();
|
||||
await expect(svc.getDiff(createWorkspace(null), workingTreeQuery()))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "missing_cwd" } });
|
||||
|
||||
const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-diff-non-git-"));
|
||||
const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-non-git-"));
|
||||
tempDirs.add(nonGitDir);
|
||||
await expect(svc.getDiff(createWorkspace(nonGitDir), workingTreeQuery()))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "non_git_workspace" } });
|
||||
@@ -246,15 +246,6 @@ export type {
|
||||
PluginEntityQuery,
|
||||
PluginWorkspace,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffCaps,
|
||||
WorkspaceDiffFileStatus,
|
||||
WorkspaceDiffPatchKind,
|
||||
WorkspaceDiffQueryOptions,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
WorkspaceDiffWarningCode,
|
||||
Company,
|
||||
Project,
|
||||
Issue,
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface TestHarness {
|
||||
issueComments?: IssueComment[];
|
||||
agents?: Agent[];
|
||||
goals?: Goal[];
|
||||
projectWorkspaces?: PluginWorkspace[];
|
||||
executionWorkspaces?: PluginExecutionWorkspaceMetadata[];
|
||||
}): void;
|
||||
setConfig(config: Record<string, unknown>): void;
|
||||
@@ -2058,6 +2059,11 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
}
|
||||
for (const row of input.agents ?? []) agents.set(row.id, row);
|
||||
for (const row of input.goals ?? []) goals.set(row.id, row);
|
||||
for (const row of input.projectWorkspaces ?? []) {
|
||||
const list = projectWorkspaces.get(row.projectId) ?? [];
|
||||
list.push(row);
|
||||
projectWorkspaces.set(row.projectId, list);
|
||||
}
|
||||
for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row);
|
||||
},
|
||||
setConfig(config) {
|
||||
|
||||
@@ -39,15 +39,6 @@ import type {
|
||||
RoutineRun,
|
||||
Agent,
|
||||
Goal,
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffCaps,
|
||||
WorkspaceDiffFileStatus,
|
||||
WorkspaceDiffPatchKind,
|
||||
WorkspaceDiffQueryOptions,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
WorkspaceDiffWarningCode,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -129,15 +120,6 @@ export type {
|
||||
IssueSurfaceVisibility,
|
||||
Agent,
|
||||
Goal,
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffCaps,
|
||||
WorkspaceDiffFileStatus,
|
||||
WorkspaceDiffPatchKind,
|
||||
WorkspaceDiffQueryOptions,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
WorkspaceDiffWarningCode,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -362,6 +344,12 @@ export interface PluginWorkspace {
|
||||
name: string;
|
||||
/** Absolute filesystem path to the workspace directory. */
|
||||
path: string;
|
||||
/** Repository URL, when known. */
|
||||
repoUrl: string | null;
|
||||
/** Checkout/ref requested for the workspace, when known. */
|
||||
repoRef: string | null;
|
||||
/** Default comparison ref for workspace tooling, when known. */
|
||||
defaultRef: string | null;
|
||||
/** Whether this is the project's primary workspace. */
|
||||
isPrimary: boolean;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
|
||||
@@ -356,17 +356,6 @@ export type {
|
||||
WorkspaceOperation,
|
||||
WorkspaceOperationPhase,
|
||||
WorkspaceOperationStatus,
|
||||
WorkspaceDiffCaps,
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffFileStatus,
|
||||
WorkspaceDiffPatchKind,
|
||||
WorkspaceDiffQueryOptions,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffStats,
|
||||
WorkspaceDiffView,
|
||||
WorkspaceDiffWarning,
|
||||
WorkspaceDiffWarningCode,
|
||||
WorkspaceRuntimeDesiredState,
|
||||
WorkspaceRealizationRecord,
|
||||
WorkspaceRealizationRequest,
|
||||
@@ -836,17 +825,6 @@ export {
|
||||
executionWorkspaceCloseLinkedIssueSchema,
|
||||
executionWorkspaceCloseReadinessSchema,
|
||||
executionWorkspaceCloseReadinessStateSchema,
|
||||
workspaceDiffCapsSchema,
|
||||
workspaceDiffFilePatchSchema,
|
||||
workspaceDiffFileSchema,
|
||||
workspaceDiffFileStatusSchema,
|
||||
workspaceDiffPatchKindSchema,
|
||||
workspaceDiffQuerySchema,
|
||||
workspaceDiffResponseSchema,
|
||||
workspaceDiffStatsSchema,
|
||||
workspaceDiffViewSchema,
|
||||
workspaceDiffWarningCodeSchema,
|
||||
workspaceDiffWarningSchema,
|
||||
issueDocumentFormatSchema,
|
||||
issueDocumentKeySchema,
|
||||
upsertIssueDocumentSchema,
|
||||
@@ -873,7 +851,6 @@ export {
|
||||
type CreateIssueWorkProduct,
|
||||
type UpdateIssueWorkProduct,
|
||||
type UpdateExecutionWorkspace,
|
||||
type WorkspaceDiffQuery,
|
||||
type IssueDocumentFormat,
|
||||
type UpsertIssueDocument,
|
||||
type RestoreIssueDocumentRevision,
|
||||
|
||||
@@ -130,19 +130,6 @@ export type {
|
||||
ProjectExecutionWorkspaceDefaultMode,
|
||||
IssueExecutionWorkspaceSettings,
|
||||
} from "./workspace-runtime.js";
|
||||
export type {
|
||||
WorkspaceDiffCaps,
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffFileStatus,
|
||||
WorkspaceDiffPatchKind,
|
||||
WorkspaceDiffQueryOptions,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffStats,
|
||||
WorkspaceDiffView,
|
||||
WorkspaceDiffWarning,
|
||||
WorkspaceDiffWarningCode,
|
||||
} from "./workspace-diff.js";
|
||||
export type {
|
||||
WorkspaceOperation,
|
||||
WorkspaceOperationPhase,
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
export type WorkspaceDiffView = "working-tree" | "head";
|
||||
|
||||
export type WorkspaceDiffFileStatus =
|
||||
| "added"
|
||||
| "modified"
|
||||
| "deleted"
|
||||
| "renamed"
|
||||
| "copied"
|
||||
| "type_changed"
|
||||
| "untracked"
|
||||
| "unknown";
|
||||
|
||||
export type WorkspaceDiffPatchKind = "staged" | "unstaged" | "head" | "untracked";
|
||||
|
||||
export type WorkspaceDiffWarningCode =
|
||||
| "base_ref_missing"
|
||||
| "base_ref_invalid"
|
||||
| "binary_file"
|
||||
| "file_count_truncated"
|
||||
| "file_oversized"
|
||||
| "git_command_failed"
|
||||
| "missing_cwd"
|
||||
| "non_git_workspace"
|
||||
| "patch_truncated"
|
||||
| "path_filter_invalid"
|
||||
| "workspace_path_invalid";
|
||||
|
||||
export interface WorkspaceDiffQueryOptions {
|
||||
view: WorkspaceDiffView;
|
||||
baseRef: string | null;
|
||||
includeUntracked: boolean;
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
export interface WorkspaceDiffWarning {
|
||||
code: WorkspaceDiffWarningCode;
|
||||
message: string;
|
||||
path: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceDiffCaps {
|
||||
maxFiles: number;
|
||||
maxFileBytes: number;
|
||||
maxPatchBytes: number;
|
||||
maxTotalPatchBytes: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceDiffFilePatch {
|
||||
kind: WorkspaceDiffPatchKind;
|
||||
patch: string | null;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
binary: boolean;
|
||||
oversized: boolean;
|
||||
truncated: boolean;
|
||||
warnings: WorkspaceDiffWarning[];
|
||||
}
|
||||
|
||||
export interface WorkspaceDiffFile {
|
||||
path: string;
|
||||
oldPath: string | null;
|
||||
status: WorkspaceDiffFileStatus;
|
||||
staged: boolean;
|
||||
unstaged: boolean;
|
||||
untracked: boolean;
|
||||
binary: boolean;
|
||||
oversized: boolean;
|
||||
truncated: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
sizeBytes: number | null;
|
||||
patches: WorkspaceDiffFilePatch[];
|
||||
warnings: WorkspaceDiffWarning[];
|
||||
}
|
||||
|
||||
export interface WorkspaceDiffStats {
|
||||
fileCount: number;
|
||||
stagedFileCount: number;
|
||||
unstagedFileCount: number;
|
||||
untrackedFileCount: number;
|
||||
binaryFileCount: number;
|
||||
oversizedFileCount: number;
|
||||
truncatedFileCount: number;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceDiffResponse {
|
||||
workspaceId: string;
|
||||
companyId: string;
|
||||
view: WorkspaceDiffView;
|
||||
baseRef: string | null;
|
||||
headSha: string | null;
|
||||
includeUntracked: boolean;
|
||||
paths: string[];
|
||||
files: WorkspaceDiffFile[];
|
||||
stats: WorkspaceDiffStats;
|
||||
warnings: WorkspaceDiffWarning[];
|
||||
caps: WorkspaceDiffCaps;
|
||||
truncated: boolean;
|
||||
}
|
||||
@@ -265,21 +265,6 @@ export {
|
||||
type UpdateExecutionWorkspace,
|
||||
} from "./execution-workspace.js";
|
||||
|
||||
export {
|
||||
workspaceDiffCapsSchema,
|
||||
workspaceDiffFilePatchSchema,
|
||||
workspaceDiffFileSchema,
|
||||
workspaceDiffFileStatusSchema,
|
||||
workspaceDiffPatchKindSchema,
|
||||
workspaceDiffQuerySchema,
|
||||
workspaceDiffResponseSchema,
|
||||
workspaceDiffStatsSchema,
|
||||
workspaceDiffViewSchema,
|
||||
workspaceDiffWarningCodeSchema,
|
||||
workspaceDiffWarningSchema,
|
||||
type WorkspaceDiffQuery,
|
||||
} from "./workspace-diff.js";
|
||||
|
||||
export {
|
||||
createGoalSchema,
|
||||
updateGoalSchema,
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { workspaceDiffQuerySchema, workspaceDiffResponseSchema } from "./workspace-diff.js";
|
||||
|
||||
describe("workspace diff validators", () => {
|
||||
it("normalizes query options from route query strings", () => {
|
||||
expect(workspaceDiffQuerySchema.parse({
|
||||
view: "head",
|
||||
baseRef: " main ",
|
||||
includeUntracked: "false",
|
||||
path: ["server/src/index.ts", "ui/src/App.tsx,packages/shared/src/index.ts"],
|
||||
})).toEqual({
|
||||
view: "head",
|
||||
baseRef: "main",
|
||||
includeUntracked: false,
|
||||
paths: ["server/src/index.ts", "ui/src/App.tsx", "packages/shared/src/index.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("validates the response contract", () => {
|
||||
const response = workspaceDiffResponseSchema.parse({
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
view: "working-tree",
|
||||
baseRef: null,
|
||||
headSha: "abc123",
|
||||
includeUntracked: true,
|
||||
paths: [],
|
||||
files: [{
|
||||
path: "README.md",
|
||||
oldPath: null,
|
||||
status: "modified",
|
||||
staged: true,
|
||||
unstaged: false,
|
||||
untracked: false,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
sizeBytes: 12,
|
||||
patches: [{
|
||||
kind: "staged",
|
||||
patch: "diff --git a/README.md b/README.md\n",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
}],
|
||||
warnings: [],
|
||||
}],
|
||||
stats: {
|
||||
fileCount: 1,
|
||||
stagedFileCount: 1,
|
||||
unstagedFileCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
binaryFileCount: 0,
|
||||
oversizedFileCount: 0,
|
||||
truncatedFileCount: 0,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
warnings: [],
|
||||
caps: {
|
||||
maxFiles: 200,
|
||||
maxFileBytes: 524288,
|
||||
maxPatchBytes: 262144,
|
||||
maxTotalPatchBytes: 1048576,
|
||||
},
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
expect(response.files[0]?.patches[0]?.kind).toBe("staged");
|
||||
});
|
||||
});
|
||||
@@ -12,10 +12,6 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockWorkspaceDiffService = vi.hoisted(() => ({
|
||||
getDiff: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({
|
||||
listForExecutionWorkspace: vi.fn(),
|
||||
createRecorder: vi.fn(),
|
||||
@@ -26,7 +22,6 @@ const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
vi.mock("../services/index.js", () => ({
|
||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||
logActivity: mockLogActivity,
|
||||
workspaceDiffService: () => mockWorkspaceDiffService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
@@ -61,37 +56,6 @@ describe.sequential("execution workspace routes", () => {
|
||||
},
|
||||
]);
|
||||
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
|
||||
mockWorkspaceDiffService.getDiff.mockResolvedValue({
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
repoRoot: "/tmp/repo",
|
||||
cwd: "/tmp/repo",
|
||||
view: "working-tree",
|
||||
baseRef: null,
|
||||
headSha: "abc123",
|
||||
includeUntracked: true,
|
||||
paths: [],
|
||||
files: [],
|
||||
stats: {
|
||||
fileCount: 0,
|
||||
stagedFileCount: 0,
|
||||
unstagedFileCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
binaryFileCount: 0,
|
||||
oversizedFileCount: 0,
|
||||
truncatedFileCount: 0,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
warnings: [],
|
||||
caps: {
|
||||
maxFiles: 200,
|
||||
maxFileBytes: 524288,
|
||||
maxPatchBytes: 262144,
|
||||
maxTotalPatchBytes: 1048576,
|
||||
},
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses summary mode for lightweight workspace lookups", async () => {
|
||||
@@ -117,58 +81,4 @@ describe.sequential("execution workspace routes", () => {
|
||||
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("parses diff query options after enforcing workspace company access", async () => {
|
||||
const workspace = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
sourceIssueId: null,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Feature workspace",
|
||||
status: "active",
|
||||
cwd: "/tmp/repo",
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: "feature",
|
||||
providerType: "git_worktree",
|
||||
providerRef: "/tmp/repo",
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: new Date(),
|
||||
openedAt: new Date(),
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockExecutionWorkspaceService.getById.mockResolvedValue(workspace);
|
||||
|
||||
const res = await request(createApp())
|
||||
.get("/api/execution-workspaces/11111111-1111-4111-8111-111111111111/diff?view=head&baseRef=main&includeUntracked=false&path=server/src/index.ts&path=ui/src/App.tsx");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockWorkspaceDiffService.getDiff).toHaveBeenCalledWith(workspace, {
|
||||
view: "head",
|
||||
baseRef: "main",
|
||||
includeUntracked: false,
|
||||
paths: ["server/src/index.ts", "ui/src/App.tsx"],
|
||||
});
|
||||
});
|
||||
|
||||
it("denies diff access when the workspace belongs to another company", async () => {
|
||||
mockExecutionWorkspaceService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-2",
|
||||
});
|
||||
|
||||
const res = await request(createApp(["company-1"]))
|
||||
.get("/api/execution-workspaces/11111111-1111-4111-8111-111111111111/diff");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockWorkspaceDiffService.getDiff).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,6 @@ const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockWorkspaceDiffService = vi.hoisted(() => ({
|
||||
getDiff: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -47,10 +44,6 @@ vi.mock("../services/secrets.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/workspace-diff.js", () => ({
|
||||
workspaceDiffService: () => mockWorkspaceDiffService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/workspace-runtime.js", () => ({
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
@@ -77,10 +70,6 @@ function registerModuleMocks() {
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/workspace-diff.js", () => ({
|
||||
workspaceDiffService: () => mockWorkspaceDiffService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/workspace-runtime.js", () => ({
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
@@ -161,37 +150,6 @@ describe("project env routes", () => {
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockProjectService.createWorkspace.mockResolvedValue(null);
|
||||
mockProjectService.listWorkspaces.mockResolvedValue([]);
|
||||
mockWorkspaceDiffService.getDiff.mockResolvedValue({
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
repoRoot: "/tmp/project",
|
||||
cwd: "/tmp/project",
|
||||
view: "head",
|
||||
baseRef: "origin/master",
|
||||
headSha: "abc123",
|
||||
includeUntracked: false,
|
||||
paths: [],
|
||||
files: [],
|
||||
stats: {
|
||||
fileCount: 0,
|
||||
stagedFileCount: 0,
|
||||
unstagedFileCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
binaryFileCount: 0,
|
||||
oversizedFileCount: 0,
|
||||
truncatedFileCount: 0,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
warnings: [],
|
||||
caps: {
|
||||
maxFiles: 200,
|
||||
maxFileBytes: 524288,
|
||||
maxPatchBytes: 262144,
|
||||
maxTotalPatchBytes: 1048576,
|
||||
},
|
||||
truncated: false,
|
||||
});
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||
});
|
||||
@@ -261,49 +219,4 @@ describe("project env routes", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns project workspace diffs with explicit upstream refs", async () => {
|
||||
mockProjectService.getById.mockResolvedValue(buildProject());
|
||||
mockProjectService.listWorkspaces.mockResolvedValue([
|
||||
{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
name: "Primary",
|
||||
sourceType: "local_path",
|
||||
cwd: "/tmp/project",
|
||||
repoUrl: null,
|
||||
repoRef: "master",
|
||||
defaultRef: "origin/master",
|
||||
visibility: "default",
|
||||
setupCommand: null,
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: null,
|
||||
metadata: null,
|
||||
runtimeConfig: null,
|
||||
isPrimary: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/projects/project-1/workspaces/workspace-1/diff?view=head&baseRef=origin/master&includeUntracked=false");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockWorkspaceDiffService.getDiff).toHaveBeenCalledWith({
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
cwd: "/tmp/project",
|
||||
baseRef: "origin/master",
|
||||
}, {
|
||||
view: "head",
|
||||
baseRef: "origin/master",
|
||||
includeUntracked: false,
|
||||
paths: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,6 @@ const mockEnvironmentService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockWorkspaceDiffService = vi.hoisted(() => ({}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn());
|
||||
@@ -42,7 +41,6 @@ vi.mock("../services/index.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
workspaceDiffService: () => mockWorkspaceDiffService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
@@ -69,7 +67,6 @@ function registerWorkspaceRouteMocks() {
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
workspaceDiffService: () => mockWorkspaceDiffService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
findWorkspaceCommandDefinition,
|
||||
matchWorkspaceRuntimeServiceToCommand,
|
||||
updateExecutionWorkspaceSchema,
|
||||
workspaceDiffQuerySchema,
|
||||
workspaceRuntimeControlTargetSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { executionWorkspaceService, logActivity, workspaceDiffService, workspaceOperationService } from "../services/index.js";
|
||||
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||
import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
|
||||
import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace-runtime-config.js";
|
||||
@@ -37,7 +36,6 @@ const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
|
||||
export function executionWorkspaceRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = executionWorkspaceService(db);
|
||||
const diffSvc = workspaceDiffService();
|
||||
const workspaceOperationsSvc = workspaceOperationService(db);
|
||||
|
||||
router.get("/companies/:companyId/execution-workspaces", async (req, res) => {
|
||||
@@ -67,18 +65,6 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
res.json(workspace);
|
||||
});
|
||||
|
||||
router.get("/execution-workspaces/:id/diff", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const workspace = await svc.getById(id);
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: "Execution workspace not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, workspace.companyId);
|
||||
const query = workspaceDiffQuerySchema.parse(req.query);
|
||||
res.json(await diffSvc.getDiff(workspace, query));
|
||||
});
|
||||
|
||||
router.get("/execution-workspaces/:id/close-readiness", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const workspace = await svc.getById(id);
|
||||
|
||||
@@ -8,14 +8,12 @@ import {
|
||||
matchWorkspaceRuntimeServiceToCommand,
|
||||
updateProjectSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
workspaceDiffQuerySchema,
|
||||
workspaceRuntimeControlTargetSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
|
||||
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||
import { workspaceDiffService } from "../services/workspace-diff.js";
|
||||
import { conflict, forbidden } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import {
|
||||
@@ -44,7 +42,6 @@ export function projectRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = projectService(db);
|
||||
const secretsSvc = secretService(db);
|
||||
const diffSvc = workspaceDiffService();
|
||||
const workspaceOperations = workspaceOperationService(db);
|
||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
const environmentsSvc = environmentService(db);
|
||||
@@ -342,29 +339,6 @@ export function projectRoutes(db: Db) {
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/projects/:id/workspaces/:workspaceId/diff", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
const project = await svc.getById(id);
|
||||
if (!project) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, project.companyId);
|
||||
const workspace = (await svc.listWorkspaces(id)).find((entry) => entry.id === workspaceId);
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: "Project workspace not found" });
|
||||
return;
|
||||
}
|
||||
const query = workspaceDiffQuerySchema.parse(req.query);
|
||||
res.json(await diffSvc.getDiff({
|
||||
id: workspace.id,
|
||||
companyId: workspace.companyId,
|
||||
cwd: workspace.cwd,
|
||||
baseRef: workspace.defaultRef ?? workspace.repoRef ?? null,
|
||||
}, query));
|
||||
});
|
||||
|
||||
async function handleProjectWorkspaceRuntimeCommand(req: Request, res: Response) {
|
||||
const id = req.params.id as string;
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
|
||||
@@ -49,7 +49,6 @@ export { instanceSettingsService } from "./instance-settings.js";
|
||||
export { companyPortabilityService } from "./company-portability.js";
|
||||
export { environmentService } from "./environments.js";
|
||||
export { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
export { workspaceDiffService } from "./workspace-diff.js";
|
||||
export { workspaceOperationService } from "./workspace-operations.js";
|
||||
export { workProductService } from "./work-products.js";
|
||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
|
||||
@@ -1148,6 +1148,9 @@ export function buildHostServices(
|
||||
projectId: row.projectId,
|
||||
name,
|
||||
path,
|
||||
repoUrl: row.repoUrl,
|
||||
repoRef: row.repoRef,
|
||||
defaultRef: row.defaultRef,
|
||||
isPrimary: row.isPrimary,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
@@ -1167,6 +1170,9 @@ export function buildHostServices(
|
||||
projectId: project.id,
|
||||
name,
|
||||
path,
|
||||
repoUrl: row?.repoUrl ?? project.codebase.repoUrl,
|
||||
repoRef: row?.repoRef ?? project.codebase.repoRef,
|
||||
defaultRef: row?.defaultRef ?? project.codebase.defaultRef,
|
||||
isPrimary: true,
|
||||
createdAt: (row?.createdAt ?? project.createdAt).toISOString(),
|
||||
updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(),
|
||||
@@ -1190,6 +1196,9 @@ export function buildHostServices(
|
||||
projectId: project.id,
|
||||
name,
|
||||
path,
|
||||
repoUrl: row?.repoUrl ?? project.codebase.repoUrl,
|
||||
repoRef: row?.repoRef ?? project.codebase.repoRef,
|
||||
defaultRef: row?.defaultRef ?? project.codebase.defaultRef,
|
||||
isPrimary: true,
|
||||
createdAt: (row?.createdAt ?? project.createdAt).toISOString(),
|
||||
updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(),
|
||||
|
||||
@@ -1,690 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
ExecutionWorkspace,
|
||||
WorkspaceDiffCaps,
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffFileStatus,
|
||||
WorkspaceDiffPatchKind,
|
||||
WorkspaceDiffQueryOptions,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
WorkspaceDiffWarningCode,
|
||||
} from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const WORKSPACE_DIFF_CAPS: WorkspaceDiffCaps = {
|
||||
maxFiles: 200,
|
||||
maxFileBytes: 512 * 1024,
|
||||
maxPatchBytes: 256 * 1024,
|
||||
maxTotalPatchBytes: 1024 * 1024,
|
||||
};
|
||||
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
const GIT_LIST_MAX_BUFFER = 2 * 1024 * 1024;
|
||||
|
||||
interface GitStatusEntry {
|
||||
status: WorkspaceDiffFileStatus;
|
||||
path: string;
|
||||
oldPath: string | null;
|
||||
}
|
||||
|
||||
type DiffScope = "staged" | "unstaged" | "head";
|
||||
|
||||
interface MutableWorkspaceDiffFile extends WorkspaceDiffFile {
|
||||
patchScopes: DiffScope[];
|
||||
}
|
||||
|
||||
interface PatchBudget {
|
||||
totalPatchBytes: number;
|
||||
}
|
||||
|
||||
type WorkspaceDiffTarget = Pick<ExecutionWorkspace, "id" | "companyId" | "cwd" | "baseRef">;
|
||||
|
||||
function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning {
|
||||
return { code, message, path: filePath };
|
||||
}
|
||||
|
||||
function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record<string, unknown> = {}) {
|
||||
return unprocessable(message, { code, ...details });
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
async function runGit(cwd: string, args: string[], maxBuffer = GIT_LIST_MAX_BUFFER) {
|
||||
try {
|
||||
return await execFileAsync("git", ["-C", cwd, ...args], {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer,
|
||||
});
|
||||
} catch (error) {
|
||||
const stderr = typeof (error as { stderr?: unknown }).stderr === "string"
|
||||
? String((error as { stderr?: unknown }).stderr).trim()
|
||||
: "";
|
||||
const message = stderr || toErrorMessage(error);
|
||||
throw workspaceDiffError("git_command_failed", message, { args });
|
||||
}
|
||||
}
|
||||
|
||||
async function realDirectory(value: string, code: WorkspaceDiffWarningCode) {
|
||||
if (!path.isAbsolute(value)) {
|
||||
throw workspaceDiffError(code, "Execution workspace path must be absolute", { cwd: value });
|
||||
}
|
||||
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
stat = await fs.stat(value);
|
||||
} catch {
|
||||
throw workspaceDiffError(code, "Execution workspace path does not exist", { cwd: value });
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
throw workspaceDiffError(code, "Execution workspace path is not a directory", { cwd: value });
|
||||
}
|
||||
return await fs.realpath(value);
|
||||
}
|
||||
|
||||
function isWithinDirectory(childPath: string, parentPath: string) {
|
||||
const relative = path.relative(parentPath, childPath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function resolveWorkspacePaths(workspace: WorkspaceDiffTarget) {
|
||||
if (!workspace.cwd?.trim()) {
|
||||
throw workspaceDiffError(
|
||||
"missing_cwd",
|
||||
"Execution workspace needs a local path before Paperclip can inspect diffs",
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
}
|
||||
|
||||
const cwd = await realDirectory(workspace.cwd.trim(), "workspace_path_invalid");
|
||||
let repoRoot: string;
|
||||
try {
|
||||
repoRoot = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||
} catch {
|
||||
throw workspaceDiffError(
|
||||
"non_git_workspace",
|
||||
"Execution workspace path is not inside a git repository",
|
||||
{ workspaceId: workspace.id, cwd },
|
||||
);
|
||||
}
|
||||
|
||||
const repoRootReal = await realDirectory(repoRoot, "non_git_workspace");
|
||||
if (!isWithinDirectory(cwd, repoRootReal)) {
|
||||
throw workspaceDiffError(
|
||||
"workspace_path_invalid",
|
||||
"Execution workspace path resolved outside its git repository",
|
||||
{ workspaceId: workspace.id, cwd, repoRoot: repoRootReal },
|
||||
);
|
||||
}
|
||||
|
||||
return { cwd, repoRoot: repoRootReal };
|
||||
}
|
||||
|
||||
function normalizePathFilter(rawPath: string) {
|
||||
const value = rawPath.trim().replaceAll("\\", "/");
|
||||
if (!value || value === ".") return null;
|
||||
if (value.includes("\0") || value.startsWith("/")) {
|
||||
throw workspaceDiffError("path_filter_invalid", "Path filters must be relative workspace paths", { path: rawPath });
|
||||
}
|
||||
const normalized = path.posix.normalize(value);
|
||||
if (
|
||||
normalized === "." ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
normalized.includes("/../")
|
||||
) {
|
||||
throw workspaceDiffError(
|
||||
"path_filter_invalid",
|
||||
"Path filters must not contain traversal segments",
|
||||
{ path: rawPath },
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePathFilters(paths: string[]) {
|
||||
return Array.from(new Set(paths.map(normalizePathFilter).filter((value): value is string => Boolean(value))));
|
||||
}
|
||||
|
||||
function statusFromGitStatus(status: string): WorkspaceDiffFileStatus {
|
||||
if (status.startsWith("R")) return "renamed";
|
||||
if (status.startsWith("C")) return "copied";
|
||||
switch (status[0]) {
|
||||
case "A":
|
||||
return "added";
|
||||
case "D":
|
||||
return "deleted";
|
||||
case "M":
|
||||
return "modified";
|
||||
case "T":
|
||||
return "type_changed";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function parseNameStatus(output: string): GitStatusEntry[] {
|
||||
const tokens = output.split("\0").filter(Boolean);
|
||||
const entries: GitStatusEntry[] = [];
|
||||
let index = 0;
|
||||
while (index < tokens.length) {
|
||||
const statusCode = tokens[index++] ?? "";
|
||||
if (!statusCode) continue;
|
||||
if (statusCode.startsWith("R") || statusCode.startsWith("C")) {
|
||||
const oldPath = tokens[index++] ?? "";
|
||||
const newPath = tokens[index++] ?? "";
|
||||
if (newPath) {
|
||||
entries.push({
|
||||
status: statusFromGitStatus(statusCode),
|
||||
path: newPath,
|
||||
oldPath: oldPath || null,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = tokens[index++] ?? "";
|
||||
if (filePath) {
|
||||
entries.push({
|
||||
status: statusFromGitStatus(statusCode),
|
||||
path: filePath,
|
||||
oldPath: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function readDiffNameStatus(cwd: string, scopeArgs: string[], paths: string[]) {
|
||||
const result = await runGit(cwd, [
|
||||
"diff",
|
||||
"--name-status",
|
||||
"-z",
|
||||
"--no-ext-diff",
|
||||
"--find-renames",
|
||||
...scopeArgs,
|
||||
"--",
|
||||
...paths,
|
||||
]);
|
||||
return parseNameStatus(result.stdout);
|
||||
}
|
||||
|
||||
async function readUntrackedPaths(cwd: string, paths: string[]) {
|
||||
const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...paths]);
|
||||
return result.stdout.split("\0").filter(Boolean);
|
||||
}
|
||||
|
||||
function ensureFile(
|
||||
files: Map<string, MutableWorkspaceDiffFile>,
|
||||
filePath: string,
|
||||
status: WorkspaceDiffFileStatus,
|
||||
oldPath: string | null,
|
||||
) {
|
||||
const existing = files.get(filePath);
|
||||
if (existing) {
|
||||
if (existing.status === "unknown" || status === "renamed" || status === "copied") {
|
||||
existing.status = status;
|
||||
}
|
||||
if (!existing.oldPath && oldPath) existing.oldPath = oldPath;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const file: MutableWorkspaceDiffFile = {
|
||||
path: filePath,
|
||||
oldPath,
|
||||
status,
|
||||
staged: false,
|
||||
unstaged: false,
|
||||
untracked: false,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
sizeBytes: null,
|
||||
patches: [],
|
||||
warnings: [],
|
||||
patchScopes: [],
|
||||
};
|
||||
files.set(filePath, file);
|
||||
return file;
|
||||
}
|
||||
|
||||
function addStatusEntries(
|
||||
files: Map<string, MutableWorkspaceDiffFile>,
|
||||
entries: GitStatusEntry[],
|
||||
scope: DiffScope,
|
||||
) {
|
||||
for (const entry of entries) {
|
||||
const file = ensureFile(files, entry.path, entry.status, entry.oldPath);
|
||||
if (scope === "staged") file.staged = true;
|
||||
else if (scope === "unstaged") file.unstaged = true;
|
||||
if (!file.patchScopes.includes(scope)) file.patchScopes.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNumstat(output: string) {
|
||||
const line = output.split(/\r?\n/).find(Boolean);
|
||||
if (!line) return { additions: 0, deletions: 0, binary: false };
|
||||
const [additionsRaw, deletionsRaw] = line.split(/\t/);
|
||||
if (additionsRaw === "-" || deletionsRaw === "-") {
|
||||
return { additions: 0, deletions: 0, binary: true };
|
||||
}
|
||||
return {
|
||||
additions: Number.parseInt(additionsRaw ?? "0", 10) || 0,
|
||||
deletions: Number.parseInt(deletionsRaw ?? "0", 10) || 0,
|
||||
binary: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function readNumstat(cwd: string, scopeArgs: string[], filePath: string) {
|
||||
const result = await runGit(cwd, [
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--no-ext-diff",
|
||||
"--find-renames",
|
||||
...scopeArgs,
|
||||
"--",
|
||||
filePath,
|
||||
], 128 * 1024);
|
||||
return parseNumstat(result.stdout);
|
||||
}
|
||||
|
||||
async function statWorkspaceFile(repoRoot: string, filePath: string) {
|
||||
const target = path.resolve(repoRoot, filePath);
|
||||
if (!isWithinDirectory(target, repoRoot)) return null;
|
||||
try {
|
||||
const stat = await fs.stat(target);
|
||||
return stat.isFile() ? stat.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isMaxBufferError(error: unknown) {
|
||||
return typeof error === "object"
|
||||
&& error !== null
|
||||
&& "code" in error
|
||||
&& (error as { code?: unknown }).code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
||||
}
|
||||
|
||||
async function readPatchOutput(cwd: string, args: string[]) {
|
||||
try {
|
||||
return await execFileAsync("git", ["-C", cwd, ...args], {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: WORKSPACE_DIFF_CAPS.maxPatchBytes + 64 * 1024,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMaxBufferError(error)) {
|
||||
return null;
|
||||
}
|
||||
const stderr = typeof (error as { stderr?: unknown }).stderr === "string"
|
||||
? String((error as { stderr?: unknown }).stderr).trim()
|
||||
: "";
|
||||
throw workspaceDiffError("git_command_failed", stderr || toErrorMessage(error), { args });
|
||||
}
|
||||
}
|
||||
|
||||
function reservePatchBytes(
|
||||
patch: string,
|
||||
budget: PatchBudget,
|
||||
filePath: string,
|
||||
warnings: WorkspaceDiffWarning[],
|
||||
) {
|
||||
const patchBytes = Buffer.byteLength(patch, "utf8");
|
||||
if (patchBytes > WORKSPACE_DIFF_CAPS.maxPatchBytes) {
|
||||
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", filePath));
|
||||
return null;
|
||||
}
|
||||
if (budget.totalPatchBytes + patchBytes > WORKSPACE_DIFF_CAPS.maxTotalPatchBytes) {
|
||||
warnings.push(warning("patch_truncated", "Workspace diff exceeded the total patch cap.", filePath));
|
||||
return null;
|
||||
}
|
||||
budget.totalPatchBytes += patchBytes;
|
||||
return patch;
|
||||
}
|
||||
|
||||
async function buildTrackedPatch(input: {
|
||||
cwd: string;
|
||||
repoRoot: string;
|
||||
filePath: string;
|
||||
kind: WorkspaceDiffPatchKind;
|
||||
scopeArgs: string[];
|
||||
budget: PatchBudget;
|
||||
}): Promise<WorkspaceDiffFilePatch> {
|
||||
const warnings: WorkspaceDiffWarning[] = [];
|
||||
const numstat = await readNumstat(input.cwd, input.scopeArgs, input.filePath);
|
||||
const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath);
|
||||
|
||||
if (numstat.binary) {
|
||||
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
|
||||
return {
|
||||
kind: input.kind,
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: true,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (sizeBytes !== null && sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
|
||||
warnings.push(warning("file_oversized", "File is too large to include a text patch.", input.filePath));
|
||||
return {
|
||||
kind: input.kind,
|
||||
patch: null,
|
||||
additions: numstat.additions,
|
||||
deletions: numstat.deletions,
|
||||
binary: false,
|
||||
oversized: true,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const patchOutput = await readPatchOutput(input.cwd, [
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--find-renames",
|
||||
"--unified=3",
|
||||
...input.scopeArgs,
|
||||
"--",
|
||||
input.filePath,
|
||||
]);
|
||||
if (!patchOutput) {
|
||||
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", input.filePath));
|
||||
return {
|
||||
kind: input.kind,
|
||||
patch: null,
|
||||
additions: numstat.additions,
|
||||
deletions: numstat.deletions,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: true,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const patch = reservePatchBytes(patchOutput.stdout, input.budget, input.filePath, warnings);
|
||||
return {
|
||||
kind: input.kind,
|
||||
patch,
|
||||
additions: numstat.additions,
|
||||
deletions: numstat.deletions,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: patch === null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function isProbablyBinary(buffer: Buffer) {
|
||||
return buffer.subarray(0, Math.min(buffer.length, 8_000)).includes(0);
|
||||
}
|
||||
|
||||
function countAddedLines(content: string) {
|
||||
if (content.length === 0) return 0;
|
||||
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
|
||||
}
|
||||
|
||||
function buildUntrackedPatch(filePath: string, content: string) {
|
||||
const lines = content.length === 0 ? [] : content.split("\n");
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
const lineCount = countAddedLines(content);
|
||||
const header = [
|
||||
`diff --git a/${filePath} b/${filePath}`,
|
||||
"new file mode 100644",
|
||||
"--- /dev/null",
|
||||
`+++ b/${filePath}`,
|
||||
];
|
||||
if (lineCount === 0) return `${header.join("\n")}\n`;
|
||||
const hunkLines = lines.map((line) => `+${line}`).join("\n");
|
||||
return [...header, `@@ -0,0 +1,${lineCount} @@`, hunkLines, ""].join("\n");
|
||||
}
|
||||
|
||||
async function buildUntrackedFilePatch(input: {
|
||||
repoRoot: string;
|
||||
filePath: string;
|
||||
budget: PatchBudget;
|
||||
}): Promise<WorkspaceDiffFilePatch> {
|
||||
const warnings: WorkspaceDiffWarning[] = [];
|
||||
const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath);
|
||||
if (sizeBytes === null) {
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
if (sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
|
||||
warnings.push(warning("file_oversized", "Untracked file is too large to include a text patch.", input.filePath));
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: true,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const buffer = await fs.readFile(path.join(input.repoRoot, input.filePath));
|
||||
if (isProbablyBinary(buffer)) {
|
||||
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: true,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const content = buffer.toString("utf8");
|
||||
const patch = reservePatchBytes(buildUntrackedPatch(input.filePath, content), input.budget, input.filePath, warnings);
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch,
|
||||
additions: countAddedLines(content),
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: patch === null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function applyPatchToFile(file: MutableWorkspaceDiffFile, patch: WorkspaceDiffFilePatch, sizeBytes: number | null) {
|
||||
file.patches.push(patch);
|
||||
file.additions += patch.additions;
|
||||
file.deletions += patch.deletions;
|
||||
file.binary = file.binary || patch.binary;
|
||||
file.oversized = file.oversized || patch.oversized;
|
||||
file.truncated = file.truncated || patch.truncated;
|
||||
file.warnings.push(...patch.warnings);
|
||||
if (file.sizeBytes === null && sizeBytes !== null) file.sizeBytes = sizeBytes;
|
||||
}
|
||||
|
||||
function finalizeStats(files: WorkspaceDiffFile[]) {
|
||||
return {
|
||||
fileCount: files.length,
|
||||
stagedFileCount: files.filter((file) => file.staged).length,
|
||||
unstagedFileCount: files.filter((file) => file.unstaged).length,
|
||||
untrackedFileCount: files.filter((file) => file.untracked).length,
|
||||
binaryFileCount: files.filter((file) => file.binary).length,
|
||||
oversizedFileCount: files.filter((file) => file.oversized).length,
|
||||
truncatedFileCount: files.filter((file) => file.truncated).length,
|
||||
additions: files.reduce((sum, file) => sum + file.additions, 0),
|
||||
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveHeadSha(cwd: string) {
|
||||
try {
|
||||
return (await runGit(cwd, ["rev-parse", "HEAD"], 128 * 1024)).stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) {
|
||||
const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null;
|
||||
if (!resolvedBaseRef) {
|
||||
throw workspaceDiffError(
|
||||
"base_ref_missing",
|
||||
"A baseRef query parameter or execution workspace baseRef is required for head diffs",
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
}
|
||||
try {
|
||||
await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${resolvedBaseRef}^{commit}`], {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: 128 * 1024,
|
||||
});
|
||||
} catch {
|
||||
throw workspaceDiffError(
|
||||
"base_ref_invalid",
|
||||
`Could not resolve baseRef "${resolvedBaseRef}" in this workspace`,
|
||||
{ workspaceId: workspace.id, baseRef: resolvedBaseRef },
|
||||
);
|
||||
}
|
||||
return resolvedBaseRef;
|
||||
}
|
||||
|
||||
async function collectFiles(input: {
|
||||
cwd: string;
|
||||
workspace: WorkspaceDiffTarget;
|
||||
query: WorkspaceDiffQueryOptions;
|
||||
paths: string[];
|
||||
}) {
|
||||
const files = new Map<string, MutableWorkspaceDiffFile>();
|
||||
let baseRef: string | null = null;
|
||||
|
||||
if (input.query.view === "head") {
|
||||
baseRef = await resolveBaseRef(input.cwd, input.query.baseRef, input.workspace);
|
||||
addStatusEntries(
|
||||
files,
|
||||
await readDiffNameStatus(input.cwd, [`${baseRef}...HEAD`], input.paths),
|
||||
"head",
|
||||
);
|
||||
} else {
|
||||
addStatusEntries(files, await readDiffNameStatus(input.cwd, ["--cached"], input.paths), "staged");
|
||||
addStatusEntries(files, await readDiffNameStatus(input.cwd, [], input.paths), "unstaged");
|
||||
if (input.query.includeUntracked) {
|
||||
for (const untrackedPath of await readUntrackedPaths(input.cwd, input.paths)) {
|
||||
const file = ensureFile(files, untrackedPath, "untracked", null);
|
||||
file.untracked = true;
|
||||
if (!file.patchScopes.includes("unstaged")) file.patchScopes.push("unstaged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { files, baseRef };
|
||||
}
|
||||
|
||||
export function workspaceDiffService() {
|
||||
return {
|
||||
async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise<WorkspaceDiffResponse> {
|
||||
const { cwd, repoRoot } = await resolveWorkspacePaths(workspace);
|
||||
const paths = normalizePathFilters(query.paths);
|
||||
const warnings: WorkspaceDiffWarning[] = [];
|
||||
const { files: filesByPath, baseRef } = await collectFiles({ cwd, workspace, query, paths });
|
||||
const allFiles = Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path));
|
||||
const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles);
|
||||
if (allFiles.length > cappedFiles.length) {
|
||||
warnings.push(warning(
|
||||
"file_count_truncated",
|
||||
`Workspace diff includes ${allFiles.length} files, so only the first ${WORKSPACE_DIFF_CAPS.maxFiles} are returned.`,
|
||||
));
|
||||
}
|
||||
|
||||
const patchBudget: PatchBudget = { totalPatchBytes: 0 };
|
||||
for (const file of cappedFiles) {
|
||||
if (query.view === "head") {
|
||||
const patch = await buildTrackedPatch({
|
||||
cwd,
|
||||
repoRoot,
|
||||
filePath: file.path,
|
||||
kind: "head",
|
||||
scopeArgs: [`${baseRef}...HEAD`],
|
||||
budget: patchBudget,
|
||||
});
|
||||
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.staged) {
|
||||
const patch = await buildTrackedPatch({
|
||||
cwd,
|
||||
repoRoot,
|
||||
filePath: file.path,
|
||||
kind: "staged",
|
||||
scopeArgs: ["--cached"],
|
||||
budget: patchBudget,
|
||||
});
|
||||
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
||||
}
|
||||
if (file.unstaged) {
|
||||
const patch = await buildTrackedPatch({
|
||||
cwd,
|
||||
repoRoot,
|
||||
filePath: file.path,
|
||||
kind: "unstaged",
|
||||
scopeArgs: [],
|
||||
budget: patchBudget,
|
||||
});
|
||||
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
||||
}
|
||||
if (file.untracked) {
|
||||
const patch = await buildUntrackedFilePatch({
|
||||
repoRoot,
|
||||
filePath: file.path,
|
||||
budget: patchBudget,
|
||||
});
|
||||
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
||||
}
|
||||
}
|
||||
|
||||
const files = cappedFiles.map(({ patchScopes: _patchScopes, ...file }) => file);
|
||||
const patchWarnings = files.flatMap((file) => file.warnings);
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
companyId: workspace.companyId,
|
||||
view: query.view,
|
||||
baseRef,
|
||||
headSha: await resolveHeadSha(cwd),
|
||||
includeUntracked: query.includeUntracked,
|
||||
paths,
|
||||
files,
|
||||
stats: finalizeStats(files),
|
||||
warnings: [...warnings, ...patchWarnings],
|
||||
caps: WORKSPACE_DIFF_CAPS,
|
||||
truncated: warnings.some((item) => item.code === "file_count_truncated")
|
||||
|| files.some((file) => file.truncated),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -27,16 +27,4 @@ describe("executionWorkspacesApi.listSummaries", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("requests workspace diffs with query options", async () => {
|
||||
await executionWorkspacesApi.getDiff("workspace-1", {
|
||||
view: "head",
|
||||
baseRef: "main",
|
||||
includeUntracked: false,
|
||||
paths: ["server/src/index.ts", "packages/shared/src/index.ts"],
|
||||
});
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/execution-workspaces/workspace-1/diff?view=head&baseRef=main&includeUntracked=false&path=server%2Fsrc%2Findex.ts&path=packages%2Fshared%2Fsrc%2Findex.ts",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,6 @@ import type {
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceSummary,
|
||||
ExecutionWorkspaceCloseReadiness,
|
||||
WorkspaceDiffQueryOptions,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceOperation,
|
||||
WorkspaceRuntimeControlTarget,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -53,19 +51,6 @@ export const executionWorkspacesApi = {
|
||||
return api.get<ExecutionWorkspace[]>(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
|
||||
getDiff: (id: string, options: Partial<WorkspaceDiffQueryOptions> = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options.view) params.set("view", options.view);
|
||||
if (options.baseRef) params.set("baseRef", options.baseRef);
|
||||
if (typeof options.includeUntracked === "boolean") {
|
||||
params.set("includeUntracked", String(options.includeUntracked));
|
||||
}
|
||||
for (const filePath of options.paths ?? []) {
|
||||
params.append("path", filePath);
|
||||
}
|
||||
const qs = params.toString();
|
||||
return api.get<WorkspaceDiffResponse>(`/execution-workspaces/${id}/diff${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
getCloseReadiness: (id: string) =>
|
||||
api.get<ExecutionWorkspaceCloseReadiness>(`/execution-workspaces/${id}/close-readiness`),
|
||||
listWorkspaceOperations: (id: string) =>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type {
|
||||
Project,
|
||||
ProjectWorkspace,
|
||||
WorkspaceDiffQueryOptions,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceOperation,
|
||||
WorkspaceRuntimeControlTarget,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -35,24 +33,6 @@ export const projectsApi = {
|
||||
projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`),
|
||||
data,
|
||||
),
|
||||
getWorkspaceDiff: (
|
||||
projectId: string,
|
||||
workspaceId: string,
|
||||
options: Partial<WorkspaceDiffQueryOptions> = {},
|
||||
companyId?: string,
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options.view) params.set("view", options.view);
|
||||
if (options.baseRef) params.set("baseRef", options.baseRef);
|
||||
if (typeof options.includeUntracked === "boolean") {
|
||||
params.set("includeUntracked", String(options.includeUntracked));
|
||||
}
|
||||
for (const filePath of options.paths ?? []) {
|
||||
params.append("path", filePath);
|
||||
}
|
||||
const suffix = `/workspaces/${encodeURIComponent(workspaceId)}/diff${params.toString() ? `?${params}` : ""}`;
|
||||
return api.get<WorkspaceDiffResponse>(projectPath(projectId, companyId, suffix));
|
||||
},
|
||||
controlWorkspaceRuntimeServices: (
|
||||
projectId: string,
|
||||
workspaceId: string,
|
||||
|
||||
Reference in new issue
Block a user