merge: preserve Claude behavior with canonical task-workspace source

Retain both scoped Storybook reviews and source lists, regenerate the index, preserve Claude acceptance docs, and refresh only ignored shared/SDK JavaScript declarations. UI/server types and189 creation/form controls pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 21:58:43 -05:00
commit cf1fa2e982
240 files changed
+192172 -1485

No files matched your search

@@ -266,6 +266,8 @@ describe("native external chat workspace boundary", () => {
path.join(instanceRoot, "workspaces", agentId),
]) {
const oldInput = { ...execution, workspace: { cwd } };
const admittedScope = { ...scopeA, taskRoot: cwd };
expect(nativeChatWorkspaceCwd(admittedScope, null, false)).toBeNull();
expect(
nativeChatWorkspaceMatches({
scope: scopeA,
@@ -275,6 +277,8 @@ describe("native external chat workspace boundary", () => {
).toBe(false);
expect(oldInput.workspace.cwd).toBe(cwd);
}
const legacyTaskRoot = path.join(instanceRoot, "chat-workspaces", companyId, agentId, issueA);
expect(nativeChatWorkspaceCwd({ ...scopeA, taskRoot: legacyTaskRoot }, null, false)).toBe(legacyTaskRoot);
});
it("fails closed if an existing task directory is a symlink to another task", async () => {
@@ -1,7 +1,7 @@
import { lstat, mkdir, realpath } from "node:fs/promises";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { chatConversations, issues, type Db } from "@paperclipai/db";
import { chatConversations, issues, executionWorkspaces, projectWorkspaces, type Db } from "@paperclipai/db";
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
export type NativeChatWorkspaceScope = {
@@ -30,6 +30,7 @@ export async function findNativeChatWorkspaceScope(
agentId: string;
issueId: string | null;
instanceRoot?: string;
admittedCwd?: string | null;
},
): Promise<NativeChatWorkspaceScope | null> {
if (
@@ -43,6 +44,19 @@ export async function findNativeChatWorkspaceScope(
id: issues.id,
projectId: issues.projectId,
originKind: issues.originKind,
selection: issues.workspaceSelection,
configuredProjectId: projectWorkspaces.projectId,
hasConfiguredDefault: sql<boolean>`exists (
select 1 from ${projectWorkspaces}
where ${projectWorkspaces.projectId} = ${issues.projectId}
and ${projectWorkspaces.companyId} = ${input.companyId}
and (nullif(btrim(${projectWorkspaces.cwd}), '') is not null
or nullif(btrim(${projectWorkspaces.repoUrl}), '') is not null)
)`,
boundWorkspaceId: executionWorkspaces.id,
boundProjectId: executionWorkspaces.projectId,
boundSourceIssueId: executionWorkspaces.sourceIssueId,
boundCwd: executionWorkspaces.cwd,
hasConversation: sql<boolean>`exists (
select 1 from ${chatConversations}
where ${chatConversations.issueId} = ${issues.id}
@@ -50,6 +64,8 @@ export async function findNativeChatWorkspaceScope(
)`,
})
.from(issues)
.leftJoin(projectWorkspaces, and(eq(projectWorkspaces.id, issues.projectWorkspaceId), eq(projectWorkspaces.companyId, input.companyId)))
.leftJoin(executionWorkspaces, and(eq(executionWorkspaces.id, issues.executionWorkspaceId), eq(executionWorkspaces.companyId, input.companyId)))
.where(
and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)),
)
@@ -64,23 +80,18 @@ export async function findNativeChatWorkspaceScope(
companyId: input.companyId,
agentId: input.agentId,
issueId: issue.id,
projectId: issue.projectId,
projectId: issue.boundWorkspaceId ? issue.boundProjectId : issue.selection?.selection.kind === "task_directory" ? null : issue.configuredProjectId ?? (issue.hasConfiguredDefault ? issue.projectId : null),
instanceRoot,
// This must be a sibling of agent homes, not a child readable by an old
// provider thread whose immutable permission root is the entire agent home.
taskRoot: path.join(
instanceRoot,
"chat-workspaces",
segment(input.companyId),
segment(input.agentId),
segment(issue.id),
),
taskRoot: issue.boundSourceIssueId === issue.id && issue.boundCwd
? issue.boundCwd : input.admittedCwd ?? path.join(instanceRoot, "isolated-workspaces", segment(input.companyId), segment(issue.id)),
};
}
export type NativeChatProjectWorkspace = {
companyId: string;
projectId: string;
projectId: string | null;
sourceIssueId: string | null;
mode: string;
strategyType: string;
@@ -89,13 +100,26 @@ export type NativeChatProjectWorkspace = {
providerRef: string | null;
};
function isNativeChatTaskRoot(scope: NativeChatWorkspaceScope): boolean {
const relative = path.relative(scope.instanceRoot, scope.taskRoot);
const allowedTask = path.join("isolated-workspaces", segment(scope.companyId), segment(scope.issueId));
const legacyParts = relative.split(path.sep);
return relative === allowedTask || (
legacyParts.length === 4 && legacyParts[0] === "chat-workspaces" &&
legacyParts[1] === scope.companyId && /^[a-zA-Z0-9_-]+$/.test(legacyParts[2]!) && legacyParts[3] === scope.issueId
);
}
/** No implicit move from an intentionally configured repository to an empty cwd. */
export function nativeChatWorkspaceCwd(
scope: NativeChatWorkspaceScope,
workspace: NativeChatProjectWorkspace | null,
reuseExisting: boolean,
): string | null {
if (!scope.projectId) return workspace === null ? scope.taskRoot : null;
if (!scope.projectId) return isNativeChatTaskRoot(scope) && (workspace === null || (
workspace.companyId === scope.companyId && workspace.sourceIssueId === scope.issueId &&
workspace.projectId === null && workspace.cwd === scope.taskRoot && ["active", "idle"].includes(workspace.status)
)) ? scope.taskRoot : null;
if (
!reuseExisting ||
!workspace ||
@@ -120,12 +144,9 @@ export async function materializeNativeChatTaskRoot(
if (scope.projectId)
throw new Error("native_chat_workspace_project_requires_isolation");
let cursor = scope.instanceRoot;
for (const part of [
"chat-workspaces",
segment(scope.companyId),
segment(scope.agentId),
segment(scope.issueId),
]) {
const relative = path.relative(scope.instanceRoot, scope.taskRoot);
if (!isNativeChatTaskRoot(scope)) throw new Error("native_chat_workspace_path_not_isolated");
for (const part of relative.split(path.sep)) {
cursor = path.join(cursor, part);
await mkdir(cursor, { mode: 0o700 }).catch(
(error: NodeJS.ErrnoException) => {
@@ -80,7 +80,7 @@ describe("native runner performance trace", () => {
});
expect(ingress).toEqual({
name: "question_response.to_run_created",
parentName: "task.run",
parentName: "task.provider_session",
startedAtMs: answeredAtMs,
endedAtMs: answeredAtMs + 10,
});
@@ -120,7 +120,7 @@ describe("native runner performance trace", () => {
clock.mockRestore();
}
expect(
events.find((event) => event.payload?.span === "task.run.measured")
events.find((event) => event.payload?.span === "task.provider_session.measured")
?.payload,
).toMatchObject({ durationMs: 19_000 });
expect(original.createdAt).toBe(new Date(1_000).toISOString());
@@ -144,7 +144,7 @@ describe("native runner performance trace", () => {
}),
).toEqual({
name: "comment.to_run_created",
parentName: "task.run",
parentName: "task.provider_session",
startedAtMs: 1_000,
endedAtMs: 3_000,
});
@@ -210,7 +210,7 @@ describe("native runner performance trace", () => {
},
});
const prepare = trace.start("task.prepare", {
parentName: "task.run",
parentName: "task.provider_session",
startedAtMs: starts.preparationStartedAtMs,
});
await trace.end(prepare, { endedAtMs: attemptStartedAtMs + 50 });
@@ -263,7 +263,7 @@ describe("native runner performance trace", () => {
schema: "paperclip.run-performance-span.v1",
traceSchemaVersion: NATIVE_RUN_TRACE_SCHEMA_VERSION,
span: "runner.transport.selected",
parentSpan: "task.run",
parentSpan: "task.provider_session",
startOffsetMs: 125,
durationMs: 0,
outcome: "ok",
@@ -304,7 +304,7 @@ describe("native runner performance trace", () => {
const { traceContext, spans } = createRecordingTraceContext();
const trace = createNativeRunTrace({ runId: "skills-run", startedAtMs: 100,
traceContext, onEvent: async () => { throw new Error("run log unavailable"); } });
const preparation = trace.start("task.prepare", { parentName: "task.run", startedAtMs: 100 });
const preparation = trace.start("task.prepare", { parentName: "task.provider_session", startedAtMs: 100 });
await expect(trace.record({ name: "skills.prepare", parentName: "task.prepare", startedAtMs: 120, endedAtMs: 170 })).resolves.toBeUndefined();
await trace.end(preparation, { endedAtMs: 200 });
expect(spans.find((span) => span.name === "skills.prepare")).toMatchObject({ name: "skills.prepare", parentName: "task.prepare", endedAtMs: 170 });
@@ -350,7 +350,7 @@ describe("native runner performance trace", () => {
});
const prepare = trace.start("task.prepare", {
parentName: "task.run",
parentName: "task.provider_session",
startedAtMs: 1_010,
});
const environment = trace.start("environment.startup", {
@@ -367,7 +367,7 @@ describe("native runner performance trace", () => {
await trace.end(prepare, { endedAtMs: 1_040 });
const execute = trace.start("native.session.execute", {
parentName: "task.run",
parentName: "task.provider_session",
startedAtMs: 1_050,
});
const startup = trace.start("runner.session.startup", {
@@ -417,11 +417,11 @@ describe("native runner performance trace", () => {
const parentOf = (name: string) =>
spans.find((span) => span.name === name)?.parentName;
expect(parentOf("task.run")).toBeNull();
expect(parentOf("task.prepare")).toBe("task.run");
expect(parentOf("task.provider_session")).toBeNull();
expect(parentOf("task.prepare")).toBe("task.provider_session");
expect(parentOf("environment.startup")).toBe("task.prepare");
expect(parentOf("environment.acquire")).toBe("environment.startup");
expect(parentOf("native.session.execute")).toBe("task.run");
expect(parentOf("native.session.execute")).toBe("task.provider_session");
expect(parentOf("runner.session.startup")).toBe("native.session.execute");
expect(parentOf("runner.artifact.prepare")).toBe("runner.session.startup");
expect(parentOf("runner.artifact.discover")).toBe(
@@ -437,10 +437,10 @@ describe("native runner performance trace", () => {
expect(parentOf("provider.dynamic")).toBe("agent.turn");
expect(parentOf("provider.turn.queue")).toBe("agent.turn");
expect(spans.filter((span) => span.name === "task.run")).toHaveLength(1);
expect(spans.some((span) => span.name === "task.run.measured")).toBe(false);
expect(spans.filter((span) => span.name === "task.provider_session")).toHaveLength(1);
expect(spans.some((span) => span.name === "task.provider_session.measured")).toBe(false);
expect(
events.some((event) => event.payload?.span === "task.run.measured"),
events.some((event) => event.payload?.span === "task.provider_session.measured"),
).toBe(true);
});
});
@@ -9,7 +9,7 @@ import {
} from "../../instrumentation.js";
export const NATIVE_RUN_SPAN_EVENT_TYPE = "run.performance.span";
export const NATIVE_RUN_TRACE_SCHEMA_VERSION = 2;
export const NATIVE_RUN_TRACE_SCHEMA_VERSION = 3;
export type NativeRunSpanOutcome = "ok" | "failed";
@@ -41,7 +41,7 @@ export function buildNativeWakeIngressSpan(input: {
if (answeredAt !== null && Number.isFinite(answeredAt) && answeredAt >= 0) {
return {
name: "question_response.to_run_created",
parentName: "task.run",
parentName: "task.provider_session",
startedAtMs: answeredAt,
endedAtMs: Math.max(answeredAt, input.runCreatedAtMs),
};
@@ -60,7 +60,7 @@ export function buildNativeWakeIngressSpan(input: {
? null
: {
name: "comment.to_run_created",
parentName: "task.run",
parentName: "task.provider_session",
startedAtMs: createdAt,
endedAtMs: Math.max(createdAt, input.runCreatedAtMs),
};
@@ -77,13 +77,13 @@ export function buildNativeHeartbeatPreparationSpans(input: {
return [
{
name: "heartbeat.queue",
parentName: "task.run",
parentName: "task.provider_session",
startedAtMs: input.runCreatedAtMs,
endedAtMs: Math.max(input.runCreatedAtMs, input.runStartedAtMs),
},
{
name: "heartbeat.prepare_before_environment",
parentName: "task.run",
parentName: "task.provider_session",
// A same-run resume retains startedAt for wall-time accounting; it is
// not the beginning of this dispatch attempt's preparation.
startedAtMs: input.attemptStartedAtMs,
@@ -94,7 +94,7 @@ export function buildNativeHeartbeatPreparationSpans(input: {
},
{
name: "heartbeat.prepare_after_environment",
parentName: "task.run",
parentName: "task.provider_session",
startedAtMs: Math.min(
input.nativeDispatchAtMs,
input.environmentRealizeEndedAtMs,
@@ -210,7 +210,7 @@ export function createNativeRunTrace(input: {
const runIdHash = hashedId(input.runId);
let rootSpan: SpanHandle;
try {
rootSpan = tracing.tracer.startSpan("task.run", {
rootSpan = tracing.tracer.startSpan("task.provider_session", {
startTime: traceStartedAtMs,
attributes: {
"paperclip.task.run.run_id": runIdHash,
@@ -249,19 +249,19 @@ export function createNativeRunTrace(input: {
? { name: active.name, context: active.context }
: runtimeParentScope && !runtimeParentScope.ended
? { name: runtimeParentScope.name, context: runtimeParentScope.context }
: { name: "task.run", context: rootContext };
: { name: "task.provider_session", context: rootContext };
};
const parentFor = (
parentName?: string,
): { name: string; context: unknown } => {
if (parentName && parentName !== "task.run") {
if (parentName && parentName !== "task.provider_session") {
const named = namedScopes.get(parentName);
if (named) return { name: named.name, context: named.context };
return currentParent();
}
if (parentName === "task.run")
return { name: "task.run", context: rootContext };
if (parentName === "task.provider_session")
return { name: "task.provider_session", context: rootContext };
return currentParent();
};
@@ -298,7 +298,7 @@ export function createNativeRunTrace(input: {
schema: "paperclip.run-performance-span.v1",
traceSchemaVersion: NATIVE_RUN_TRACE_SCHEMA_VERSION,
span: span.name,
parentSpan: span.parentName ?? "task.run",
parentSpan: span.parentName ?? "task.provider_session",
startOffsetMs: startedAtMs - traceStartedAtMs,
durationMs,
outcome,
@@ -508,8 +508,8 @@ export function createNativeRunTrace(input: {
// Preserve the historical run-log record without emitting a duplicate
// full-width child span into the OpenTelemetry waterfall.
await emitCompletedSpanEvent({
name: "task.run.measured",
parentName: "task.run",
name: "task.provider_session.measured",
parentName: "task.provider_session",
startedAtMs: traceStartedAtMs,
endedAtMs,
outcome,
@@ -541,7 +541,7 @@ export async function recordFailedSkillPreparation(input: {
}): Promise<void> {
try {
const trace = createNativeRunTrace(input);
const preparation = trace.start("task.prepare", { parentName: "task.run", startedAtMs: input.startedAtMs });
const preparation = trace.start("task.prepare", { parentName: "task.provider_session", startedAtMs: input.startedAtMs });
const endedAtMs = Date.now();
await trace.record({ name: "skills.prepare", parentName: "task.prepare", startedAtMs: input.startedAtMs, endedAtMs, outcome: "failed" });
await trace.end(preparation, { endedAtMs, outcome: "failed" });
@@ -5851,7 +5851,7 @@ describe("native resumed preparation timing", () => {
const payloadFor = (span: string) =>
events.find((event) => event.payload?.span === span)?.payload;
expect(payloadFor("question_response.to_run_created")).toMatchObject({
parentSpan: "task.run",
parentSpan: "task.provider_session",
durationMs: 50,
startOffsetMs: 0,
});
@@ -5859,7 +5859,7 @@ describe("native resumed preparation timing", () => {
durationMs: 30,
startOffsetMs: 70,
});
expect(payloadFor("task.run.measured")).toMatchObject({ durationMs: 100 });
expect(payloadFor("task.provider_session.measured")).toMatchObject({ durationMs: 100 });
});
it("uses attempt-local preparation in the executor without truncating run elapsed time", async () => {
@@ -5913,7 +5913,7 @@ describe("native resumed preparation timing", () => {
durationMs: 1_000,
startOffsetMs: 0,
});
expect(payloadFor("task.run.measured")).toMatchObject({
expect(payloadFor("task.provider_session.measured")).toMatchObject({
durationMs: 984_050,
});
});
@@ -7696,7 +7696,7 @@ async function executePaperclipNativeSessionWithinScope(
});
const toolTrace = createNativeToolTrace(trace);
const taskPrepareScope = trace.start("task.prepare", {
parentName: "task.run",
parentName: "task.provider_session",
startedAtMs: preparationStarts.preparationStartedAtMs,
});
const environmentSpans = preparationSpans.filter(
@@ -7724,7 +7724,7 @@ async function executePaperclipNativeSessionWithinScope(
await trace.record({
...span,
parentName: rootMilestone
? "task.run"
? "task.provider_session"
: environmentSpans.includes(span)
? "environment.startup"
: "task.prepare",
@@ -8283,7 +8283,7 @@ async function executePaperclipNativeSessionWithinScope(
outcome,
});
taskSettleScope = trace.start("task.settle", {
parentName: "task.run",
parentName: "task.provider_session",
startedAtMs: milestoneAtMs,
});
trace.activate(taskSettleScope);
@@ -8953,7 +8953,7 @@ async function executePaperclipNativeSessionWithinScope(
});
return result;
},
{ parentName: "task.run" },
{ parentName: "task.provider_session" },
);
// A detached consumer can resolve successfully after its stream closes.
// Only the replacement controller may settle the run or certify accounting.
@@ -9057,7 +9057,7 @@ async function executePaperclipNativeSessionWithinScope(
).slice(-4_096);
if (!taskSettleScope) {
taskSettleScope = trace.start("task.settle", {
parentName: "task.run",
parentName: "task.provider_session",
startedAtMs: failedAtMs,
});
}
@@ -1,3 +1,5 @@
import type { WorkspaceCheckpointMetrics } from "@paperclipai/adapter-utils/workspace-checkpoint";
import type { RuntimeSpanRunner } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
import fs from "node:fs/promises";
import { createReadStream } from "node:fs";
import path from "node:path";
@@ -31,8 +33,9 @@ import { parseObject } from "../../adapters/utils.js";
import type { NativeRestartRecoveryClaim } from "./native-restart-recovery.js";
const LEGACY_DESCRIPTOR_SCHEMA = "paperclip.native-workspace-sync/v1";
const DESCRIPTOR_SCHEMA = "paperclip.native-workspace-sync/v2";
type DescriptorSchema = typeof DESCRIPTOR_SCHEMA | typeof LEGACY_DESCRIPTOR_SCHEMA;
const PREVIOUS_DESCRIPTOR_SCHEMA = "paperclip.native-workspace-sync/v2";
const DESCRIPTOR_SCHEMA = "paperclip.native-workspace-sync/v3";
type DescriptorSchema = typeof DESCRIPTOR_SCHEMA | typeof PREVIOUS_DESCRIPTOR_SCHEMA | typeof LEGACY_DESCRIPTOR_SCHEMA;
const STAMP_SCHEMA = "paperclip.native-workspace-stamp/v1";
const STATE_ROOT_NAME = "native-workspace-sync";
const DESCRIPTOR_NAME = "descriptor";
@@ -61,6 +64,7 @@ interface NativeWorkspaceSyncDescriptor {
baselineSha256: string;
baseline: SerializedDirectorySnapshot;
gitSnapshot: GitWorkspaceSnapshot | null;
repositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
seed: {
workspaceArchiveSha256: string;
gitArchiveSha256: string | null;
@@ -190,6 +194,7 @@ async function verifiedDurableSeed(input: {
runId: string;
seed: NonNullable<NativeWorkspaceSyncDescriptor["seed"]>;
gitSnapshot: GitWorkspaceSnapshot | null;
repositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
}): Promise<WorkspaceDurableSeedPaths> {
try {
const paths = durableSeedPaths(input.runId);
@@ -203,7 +208,7 @@ async function verifiedDurableSeed(input: {
) {
throw new Error("workspace_sync_out_unrecoverable");
}
if (input.gitSnapshot) {
if (input.gitSnapshot || input.repositories?.length) {
const gitStat = await fs.lstat(paths.gitArchivePath);
if (
gitStat.isSymbolicLink() ||
@@ -219,7 +224,7 @@ async function verifiedDurableSeed(input: {
return {
workspaceArchivePath: paths.workspaceArchivePath,
workspaceArchiveSha256: input.seed.workspaceArchiveSha256,
gitArchivePath: input.gitSnapshot ? paths.gitArchivePath : null,
gitArchivePath: input.gitSnapshot || input.repositories?.length ? paths.gitArchivePath : null,
gitArchiveSha256: input.seed.gitArchiveSha256,
};
} catch (error) {
@@ -281,7 +286,7 @@ export function readNativeWorkspaceSyncReference(
): NativeWorkspaceSyncReference | null {
const candidate = parseObject(value);
if (
(candidate.schema !== DESCRIPTOR_SCHEMA && candidate.schema !== LEGACY_DESCRIPTOR_SCHEMA) ||
(candidate.schema !== DESCRIPTOR_SCHEMA && candidate.schema !== PREVIOUS_DESCRIPTOR_SCHEMA && candidate.schema !== LEGACY_DESCRIPTOR_SCHEMA) ||
(candidate.state !== "prepared" && candidate.state !== "finalized") ||
typeof candidate.descriptorSha256 !== "string" ||
!/^[0-9a-f]{64}$/.test(candidate.descriptorSha256) ||
@@ -360,6 +365,7 @@ async function persistSnapshotManifests(runId: string, snapshot: NonNullable<Pre
if (baseline.ignoredPaths && isPathManifest(baseline.ignoredPaths)) yield baseline.ignoredPaths;
}
yield* gitManifests(snapshot.gitSnapshot);
for (const repo of snapshot.repositories ?? []) yield* gitManifests(repo.snapshot);
};
const copies = new Map<string, string>();
for (const manifest of manifests()) {
@@ -385,6 +391,7 @@ async function persistSnapshotManifests(runId: string, snapshot: NonNullable<Pre
// Ownership tracks original scratch directories, never the new durable path.
await disposeDirectorySnapshot(snapshot.baseline);
await disposeGitWorkspaceSnapshot(snapshot.gitSnapshot);
for (const repo of snapshot.repositories ?? []) await disposeGitWorkspaceSnapshot(repo.snapshot);
}
function parseGitSnapshot(
@@ -394,7 +401,7 @@ function parseGitSnapshot(
if (value === null) return null;
const candidate = parseObject(value);
const paths = [candidate.overlayPaths, candidate.deletedPaths, candidate.ignoredPaths];
if (typeof candidate.headCommit !== "string" || (candidate.branchName !== null && typeof candidate.branchName !== "string")
if ((candidate.originUrl !== undefined && candidate.originUrl !== null && typeof candidate.originUrl !== "string") || typeof candidate.headCommit !== "string" || (candidate.branchName !== null && typeof candidate.branchName !== "string")
|| !paths.every((entries) => validManifestShape(entries) || (Array.isArray(entries) && entries.every((entry) =>
typeof entry === "string" && entry.length > 0 && !entry.includes("\0") && !path.posix.isAbsolute(entry) && !path.win32.isAbsolute(entry)
&& !entry.split(/[\\/]/).some((segment) => segment === ".." || segment === "."))))) return undefined;
@@ -414,6 +421,7 @@ function parseGitSnapshot(
return {
headCommit: candidate.headCommit,
branchName: candidate.branchName as string | null,
...(candidate.originUrl !== undefined ? { originUrl: candidate.originUrl as string | null } : {}),
overlayPaths: candidate.overlayPaths as WorkspacePaths,
deletedPaths: candidate.deletedPaths as WorkspacePaths,
ignoredPaths: candidate.ignoredPaths as WorkspacePaths,
@@ -464,7 +472,7 @@ async function readDescriptor(input: {
const binding = parseObject(candidate.binding);
const rawBaseline = parseObject(candidate.baseline);
let baseline: DirectorySnapshot | null;
if (candidate.schema === DESCRIPTOR_SCHEMA && rawBaseline.version === 2
if ((candidate.schema === DESCRIPTOR_SCHEMA || candidate.schema === PREVIOUS_DESCRIPTOR_SCHEMA) && rawBaseline.version === 2
&& Array.isArray(rawBaseline.exclude) && rawBaseline.exclude.every((entry) => typeof entry === "string")
&& validManifestShape(rawBaseline.entries) && rawBaseline.entries.category === "baseline"
&& (rawBaseline.ignoredPaths === undefined || validManifestShape(rawBaseline.ignoredPaths))) {
@@ -475,6 +483,12 @@ async function readDescriptor(input: {
baseline = parseDirectorySnapshot(candidate.baseline);
}
const gitSnapshot = parseGitSnapshot(candidate.gitSnapshot);
const repositoryContainer = candidate.repositories === undefined ? null : parseGitSnapshot({
headCommit: "", branchName: null, overlayPaths: [], deletedPaths: [], ignoredPaths: [], repositories: candidate.repositories,
});
if (candidate.repositories !== undefined && (!repositoryContainer || candidate.schema !== DESCRIPTOR_SCHEMA || gitSnapshot)) throw new Error("native_workspace_sync_repositories_invalid");
const repositories = repositoryContainer?.repositories;
for (const repo of repositories ?? []) for (const manifest of gitManifests(repo.snapshot)) await verifyManifest(input.runId, manifest);
for (const manifest of gitManifests(gitSnapshot ?? null)) await verifyManifest(input.runId, manifest);
const rawSeed =
candidate.seed === null || candidate.seed === undefined
@@ -509,7 +523,7 @@ async function readDescriptor(input: {
? null
: undefined;
if (
(candidate.schema !== DESCRIPTOR_SCHEMA && candidate.schema !== LEGACY_DESCRIPTOR_SCHEMA) ||
(candidate.schema !== DESCRIPTOR_SCHEMA && candidate.schema !== PREVIOUS_DESCRIPTOR_SCHEMA && candidate.schema !== LEGACY_DESCRIPTOR_SCHEMA) ||
(candidate.state !== "prepared" && candidate.state !== "finalized") ||
!baseline ||
gitSnapshot === undefined ||
@@ -560,6 +574,7 @@ async function readDescriptor(input: {
baselineSha256: candidate.baselineSha256,
baseline: candidate.baseline as SerializedDirectorySnapshot,
gitSnapshot,
...(repositories?.length ? { repositories } : {}),
seed,
createdAt: candidate.createdAt,
finalizedAt:
@@ -681,6 +696,7 @@ async function remoteStampMatches(input: {
target: Extract<AdapterExecutionTarget, { transport: "sandbox" }>;
expected: Record<string, unknown>;
gitSnapshot?: GitWorkspaceSnapshot | null;
repositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
}): Promise<boolean> {
if (!input.target.runner) return false;
const stampPath = path.posix.join(
@@ -699,11 +715,15 @@ async function remoteStampMatches(input: {
gitChecks.push(snapshot.branchName === null
? `(${git} symbolic-ref --quiet HEAD >/dev/null 2>&1; test $? -eq 1)`
: `test "$(${git} symbolic-ref --quiet --short HEAD)" = ${shellQuote(snapshot.branchName)}`);
if (snapshot.originUrl !== undefined) gitChecks.push(snapshot.originUrl === null
? `! ${git} config --get remote.origin.url >/dev/null 2>&1`
: `test "$(${git} config --get remote.origin.url)" = ${shellQuote(snapshot.originUrl)}`);
for (const repository of snapshot.repositories ?? []) {
checkGit(path.posix.join(remoteDir, repository.path), repository.snapshot);
}
};
if (input.gitSnapshot) checkGit(input.target.remoteCwd, input.gitSnapshot);
for (const repo of input.repositories ?? []) checkGit(path.posix.join(input.target.remoteCwd, repo.path), repo.snapshot);
const result = await input.target.runner.execute({
command: input.target.shellCommand ?? "sh",
args: [
@@ -767,7 +787,12 @@ async function prepareRuntime(input: {
mode: WorkspaceInboundMode;
baseline?: DirectorySnapshot;
gitSnapshot?: GitWorkspaceSnapshot | null;
repositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
durableSeed?: WorkspaceDurableSeedPaths;
seedCacheDirectory?: string;
seedCacheCompanyDirectory?: string;
onPhase?: RuntimeSpanRunner;
onCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise<void>;
}): Promise<PreparedAdapterExecutionTargetRuntime> {
return prepareAdapterExecutionTargetRuntime({
runId: input.runId,
@@ -779,6 +804,12 @@ async function prepareRuntime(input: {
workspaceDurableSeed: input.durableSeed,
workspaceBaseline: input.baseline,
workspaceGitSnapshot: input.gitSnapshot,
workspaceRepositories: input.repositories ?? (input.baseline ? [] : undefined),
workspaceCheckpoint: true,
onWorkspaceCheckpoint: input.onCheckpoint,
workspaceSeedCacheDirectory: input.seedCacheDirectory,
workspaceSeedCacheCompanyDirectory: input.seedCacheCompanyDirectory,
runtimeSpan: input.onPhase,
});
}
@@ -789,8 +820,11 @@ async function finalizePreparedRuntime(input: {
runtime: PreparedAdapterExecutionTargetRuntime;
descriptor: NativeWorkspaceSyncDescriptor;
assertOwnership?: () => Promise<void>;
onPhase?: RuntimeSpanRunner;
onCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise<void>;
}): Promise<NativeWorkspaceSyncReference> {
await input.runtime.restoreWorkspace();
if (input.onPhase) await input.onPhase("workspace.restore", () => input.runtime.restoreWorkspace());
else await input.runtime.restoreWorkspace();
await input.assertOwnership?.();
const finalSnapshot =
await import("@paperclipai/adapter-utils/workspace-restore-merge").then(
@@ -836,6 +870,8 @@ export async function prepareNativeWorkspaceSync(input: {
restartRecovery?: NativeRestartRecoveryClaim;
sameRunRecovery?: boolean;
resourceDisposition?: NativeWorkspaceResourceDisposition;
onPhase?: RuntimeSpanRunner;
onCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise<void>;
}): Promise<PreparedNativeWorkspaceSync | null> {
if (input.target?.kind !== "remote" || input.target.transport !== "sandbox") {
return null;
@@ -896,6 +932,7 @@ export async function prepareNativeWorkspaceSync(input: {
runId: input.runId,
seed: existing.descriptor.seed,
gitSnapshot: existing.descriptor.gitSnapshot,
repositories: existing.descriptor.repositories,
})
: (() => {
throw new Error("workspace_sync_out_unrecoverable");
@@ -904,10 +941,13 @@ export async function prepareNativeWorkspaceSync(input: {
runtime = await prepareRuntime({
runId: input.runId,
target,
onPhase: input.onPhase,
onCheckpoint: input.onCheckpoint,
workspaceLocalDir: input.workspaceLocalDir,
mode,
baseline: existing.baseline,
gitSnapshot: existing.descriptor.gitSnapshot,
repositories: existing.descriptor.repositories,
durableSeed,
});
descriptor = {
@@ -934,8 +974,12 @@ export async function prepareNativeWorkspaceSync(input: {
runtime = await prepareRuntime({
runId: input.runId,
target,
onPhase: input.onPhase,
onCheckpoint: input.onCheckpoint,
workspaceLocalDir: input.workspaceLocalDir,
mode,
seedCacheCompanyDirectory: path.join(resolvePaperclipInstanceRoot(), STATE_ROOT_NAME, "generations", requireSafeSegment(input.companyId, "company_id")),
seedCacheDirectory: path.join(resolvePaperclipInstanceRoot(), STATE_ROOT_NAME, "generations", requireSafeSegment(input.companyId, "company_id"), requireSafeSegment(input.workspaceId, "workspace_id")),
durableSeed: {
workspaceArchivePath: seedPaths.workspaceArchivePath,
gitArchivePath: seedPaths.gitArchivePath,
@@ -951,7 +995,7 @@ export async function prepareNativeWorkspaceSync(input: {
);
const verifiedWarmAdoption =
priorStamp.hostSha256 === currentHostSha256 &&
(await remoteStampMatches({ target, expected: priorStamp, gitSnapshot: currentSnapshot.gitSnapshot }));
(await remoteStampMatches({ target, expected: priorStamp, gitSnapshot: currentSnapshot.gitSnapshot, repositories: currentSnapshot.repositories }));
if (verifiedWarmAdoption) {
mode = "adopt_remote";
} else {
@@ -963,6 +1007,9 @@ export async function prepareNativeWorkspaceSync(input: {
mode,
baseline: currentSnapshot.baseline,
gitSnapshot: currentSnapshot.gitSnapshot,
repositories: currentSnapshot.repositories,
onPhase: input.onPhase,
onCheckpoint: input.onCheckpoint,
durableSeed: {
workspaceArchivePath: seedPaths.workspaceArchivePath,
gitArchivePath: seedPaths.gitArchivePath,
@@ -977,7 +1024,7 @@ export async function prepareNativeWorkspaceSync(input: {
const workspaceArchiveSha256 = await sha256File(
seedPaths.workspaceArchivePath,
);
const gitArchiveSha256 = snapshot.gitSnapshot
const gitArchiveSha256 = snapshot.gitSnapshot || snapshot.repositories?.length
? await sha256File(seedPaths.gitArchivePath)
: null;
descriptor = {
@@ -995,6 +1042,7 @@ export async function prepareNativeWorkspaceSync(input: {
baselineSha256: directorySnapshotSha256(snapshot.baseline),
baseline: serializeDirectorySnapshot(snapshot.baseline),
gitSnapshot: snapshot.gitSnapshot,
...(snapshot.repositories?.length ? { repositories: snapshot.repositories } : {}),
seed: { workspaceArchiveSha256, gitArchiveSha256 },
createdAt: now,
finalizedAt: null,
@@ -1021,6 +1069,8 @@ export async function prepareNativeWorkspaceSync(input: {
runtime: preparedRuntime,
descriptor,
assertOwnership,
onPhase: input.onPhase,
onCheckpoint: input.onCheckpoint,
})
.then((finalizedReference) => {
reference = finalizedReference;
@@ -1041,11 +1091,35 @@ export async function prepareNativeWorkspaceSync(input: {
}
}
export async function readNativeWorkspaceSyncLocalCwd(input: {
companyId: string;
runId: string;
workspaceId: string;
reference: NativeWorkspaceSyncReference;
}): Promise<string> {
const existing = await readDescriptor(input);
try {
if (existing.descriptor.binding.companyId !== input.companyId ||
existing.descriptor.binding.workspaceId !== input.workspaceId) {
throw new Error("native_workspace_sync_descriptor_binding_mismatch");
}
return existing.descriptor.binding.localCwd;
} finally {
await disposeDirectorySnapshot(existing.baseline);
await disposeGitWorkspaceSnapshot(existing.descriptor.gitSnapshot);
for (const repository of existing.descriptor.repositories ?? []) {
await disposeGitWorkspaceSnapshot(repository.snapshot);
}
}
}
export async function resumeNativeWorkspaceSync(input: {
db: Db;
runId: string;
target: AdapterExecutionTarget;
assertOwnership?: () => Promise<void>;
onPhase?: RuntimeSpanRunner;
onCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise<void>;
}): Promise<boolean> {
if (input.target.kind !== "remote" || input.target.transport !== "sandbox") {
throw new Error("workspace_sync_out_unrecoverable");
@@ -1094,10 +1168,13 @@ export async function resumeNativeWorkspaceSync(input: {
const runtime = await prepareRuntime({
runId: input.runId,
target: input.target,
onPhase: input.onPhase,
onCheckpoint: input.onCheckpoint,
workspaceLocalDir: existing.descriptor.binding.localCwd,
mode: "adopt_remote",
baseline: existing.baseline,
gitSnapshot: existing.descriptor.gitSnapshot,
repositories: existing.descriptor.repositories,
});
await finalizePreparedRuntime({
db: input.db,
@@ -1106,6 +1183,8 @@ export async function resumeNativeWorkspaceSync(input: {
runtime,
descriptor: existing.descriptor,
assertOwnership: input.assertOwnership,
onPhase: input.onPhase,
onCheckpoint: input.onCheckpoint,
});
return true;
}
@@ -103,7 +103,10 @@ describe("PaperclipRunnerToolAuthority", () => {
issueId,
runId,
});
expect(authority.definitions()).toHaveLength(44);
expect(authority.definitions()).toHaveLength(48);
expect(authority.definitions().map(tool => tool.name)).toEqual(expect.arrayContaining([
"get_workspace", "list_workspaces", "select_workspace", "prepare_repository",
]));
expect(authority.definitions().map(tool => tool.name)).not.toContain("read_chat_attachment");
expect(authority.definitions().map(tool => tool.name)).not.toContain("read_current_wake_comments");
const questions = authority.definitions().find(tool => tool.name === "request_human_input")!;
@@ -1,3 +1,4 @@
import { callTaskWorkspaceTool } from "../task-workspace-tools.js";
import { TaskAttachmentReadCache, TASK_ATTACHMENT_DEFINITIONS, TASK_ATTACHMENT_SCHEMAS, listTaskAttachments, readTaskAttachment } from "./runner-task-attachments.js";
import { RUNNER_BRIDGE_SCHEMAS, runnerBridgeDefinitions, readAssignedSkill, executeWorkspaceTool, readWorkspaceUploadFile, settleRunnerBridgeRead, assertRunnerWorkspacePathAllowed } from "./runner-bridge-tools.js";
import type { NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js";
@@ -110,7 +111,7 @@ const IMPLEMENTED_OPERATIONS = new Set([
"search_api", "call_api", "hire_agent",
"get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title", "set_task_monitor",
"request_human_input",
"create_skill", "update_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
"get_workspace", "list_workspaces", "select_workspace", "prepare_repository", "create_skill", "update_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
"list_documents", "read_document", "list_document_revisions", "write_document",
"list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context",
]);
@@ -507,6 +508,15 @@ export class PaperclipRunnerToolAuthority {
if (!apiUrl || !token) throw new Error("Skill tool authentication is unavailable");
return callUpdateSkillTool({ arguments: input, apiUrl, token, companyId: this.binding.companyId });
}
case "get_workspace":
case "list_workspaces":
case "select_workspace":
case "prepare_repository": {
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId);
if (!apiUrl || !token) throw new Error("Workspace tool authentication is unavailable");
return callTaskWorkspaceTool({ name: call.tool, arguments: input, apiUrl, token, companyId: this.binding.companyId, issueId: this.binding.issueId, workspaceRoot: this.binding.workspaceRoot });
}
case "create_project":
case "list_project_repositories":
case "list_projects": {
@@ -1219,6 +1229,7 @@ export class PaperclipRunnerToolAuthority {
const createInput = {
id: issueId,
projectId,
workspaceSelectionActor: this.#privacyActor(context.run),
...(trust.kind === "low_trust_review" ? {
executionPolicy: { ...normalizeIssueExecutionPolicy({ authorizationPolicy: { trustPreset: trust.preset, trustBoundary: trust.boundary } }) },
sourceTrust: buildLowTrustSourceTrust({ issueId, runId: this.binding.runId, agentId: this.binding.agentId }),