Preserve workspace source transitions and bounded checkout ownership

Validate a persisted next selection against its actual replacement inputs so revoked old sources cannot trap a task. Refresh verified shared checkout ownership to the current receipt under the publication lock, allowing later bounded reuse after an earlier workspace moves.

Co-Authored-By: Paperclip <[email protected]>
This commit is contained in:
DottaandPaperclip committed 2026-10-20 08:09:17 -05:00
1 parent e6a8107641
commit 3d5da6c259
8 files changed
+173 -32

No files matched your search

+12 -19
View File
@@ -950,26 +950,19 @@ requests, pins, preparation state, and idempotency. Shared publication recovery
uses the current receipt and the exact receipt named by the checkout's bounded
ownership marker, with company, origin, ref, path, and pin integrity checks.
A disposable PostgreSQL 18.1 fixture compared the pinned pre-removal commit
`df80616d98119cbf5abd40549794d9980a5fabf6` with the new guards using 8,500
workspaces, 4,800 shared-root records, 5,300 retained sources, and 50 readers.
Two warmups and five measured samples produced these local service medians:
The disposable preparation benchmark compares a pinned pre-removal commit with
the checked-out implementation using 8,500 workspaces, 4,800 shared-root records,
5,300 retained sources, and 50 readers. It reports two warmups and five measured
samples, phase medians/ranges, SQL fingerprints, and canonicalization counts.
Both retired-table query counts must remain zero, and increasing peer history
must not increase preparation authorization or path checks. Record the exact
treatment commit and measured improvement in the change's verification report.
These service measurements exclude network acquisition, copying, environment
acquisition, and provider startup. Reproduce with:
| Phase | Baseline | Current |
| --- | ---: | ---: |
| Initial source guard | 5,978.52 ms | 14.27 ms |
| Before-copy revalidation | 5,808.31 ms | 11.17 ms |
| Publication guard | 2,390.15 ms | 5.92 ms |
| Empty repository admission | 2,823.30 ms | 10.10 ms |
| Recovery binding validation | 3,127.59 ms | 7.48 ms |
| Ready repository reuse | 3,288.67 ms | 45.89 ms |
The sum of the five guard medians fell from 20,127.88 ms to 48.95 ms (99.76%).
This is not an end-to-end startup measurement. Query and canonicalization counts
were identical between small and stress histories; runtime queries to both legacy
root tables were zero. The benchmark excludes network acquisition, copying,
environment acquisition, and provider startup. Reproduce with
`scripts/benchmark-workspace-preparation-history.ts --baseline-ref=df80616d98119cbf5abd40549794d9980a5fabf6`.
```sh
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-workspace-preparation-history.ts --baseline-ref=df80616d98119cbf5abd40549794d9980a5fabf6
```
Workspace-record and workspace-operation `_issuePrivacySources` /
`_runPrivacySources` metadata and their migration 0313 triggers remain app-data
@@ -195,6 +195,7 @@ const support = await getEmbeddedPostgresTestSupport();
expect(await readFile(path.join(checkout, "source.txt"), "utf8")).toBe("uncommitted work\n");
if (process.platform !== "win32") {
const receiptPath = path.join(checkout, ".git", "paperclip-workspace-owner.json");
await rm(receiptPath, { force: true });
await promisify(execFileCallback)("mkfifo", [receiptPath]);
let deadline: ReturnType<typeof setTimeout> | undefined;
try {
@@ -224,7 +225,7 @@ const support = await getEmbeddedPostgresTestSupport();
expect(prepared[0]).toMatchObject({ id: intent.operationId, cwd: path.join(await realpath(cwd), original.relativePath), pinnedCommit: original.pinnedCommit });
expect(await readFile(path.join(prepared[0].cwd, "source.txt"), "utf8")).toBe("uncommitted work\n");
expect(JSON.parse(await readFile(path.join(prepared[0].cwd, ".git", "paperclip-workspace-owner.json"), "utf8")))
.toMatchObject({ repositoryId: original.id, pinnedCommit: original.pinnedCommit });
.toMatchObject({ repositoryId: intent.operationId, pinnedCommit: original.pinnedCommit });
const [differentRefWorkspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Conflicting ref", cwd, mode: "shared_workspace", strategyType: "operator_directory" }).returning();
const [differentRefIssue] = await db.insert(issues).values({ companyId, title: "Different ref", executionWorkspaceId: differentRefWorkspace.id }).returning();
await service.request({ companyId, issueId: differentRefIssue.id, actor, request: { repository: { kind: "catalog", id: "123" }, ref: "release", requestKey: "conflicting-ref" } });
@@ -156,12 +156,58 @@ const support = await getEmbeddedPostgresTestSupport();
expect.objectContaining({ id: next.operationId, cwd: published.cwd, pinnedCommit: published.pinnedCommit }),
]);
expect(JSON.parse(await readFile(path.join(published.cwd, ".git", "paperclip-workspace-owner.json"), "utf8")))
.toMatchObject({ repositoryId: first.operationId, pinnedCommit: published.pinnedCommit });
.toMatchObject({ repositoryId: next.operationId, pinnedCommit: published.pinnedCommit });
const { authorizationService } = await import("../services/authorization.js");
expect((await authorizationService(db).decide({ actor: peer.actor, action: "issue:read",
resource: { type: "issue", companyId: f.companyId, issueId: f.issueId } })).allowed).toBe(false);
});
it("keeps shared checkout ownership current when an earlier workspace moves", async () => {
const f = await fixture(), peer = await standardPeer(f);
await f.request({ kind: "catalog", id: "private-repo" });
await localPrivateClone(f);
const [published] = await f.service.prepareForAdmission(f.admission);
const next = await f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } });
const actual = await vi.importActual<typeof import("../services/managed-repository-checkout.js")>("../services/managed-repository-checkout.js");
acquisition.materialize.mockImplementationOnce(actual.materializeManagedProjectWorkspace);
await f.service.prepareForAdmission(peer.admission);
const moved = path.join(root, `${f.issueId}-moved-after-reuse`);
await mkdir(moved);
await db.update(executionWorkspaces).set({ cwd: moved }).where(eq(executionWorkspaces.id, f.workspace.id));
const latest = await standardPeer(f);
const last = await f.service.request({ companyId: f.companyId, issueId: latest.issueId, actor: latest.actor,
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } });
acquisition.materialize.mockImplementationOnce(actual.materializeManagedProjectWorkspace);
await expect(f.service.prepareForAdmission(latest.admission)).resolves.toEqual([
expect.objectContaining({ id: last.operationId, cwd: published.cwd, pinnedCommit: published.pinnedCommit }),
]);
expect(JSON.parse(await readFile(path.join(published.cwd, ".git", "paperclip-workspace-owner.json"), "utf8")))
.toMatchObject({ repositoryId: last.operationId, pinnedCommit: published.pinnedCommit });
expect((await f.service.list(f.companyId, peer.workspace.id))[0])
.toMatchObject({ id: next.operationId, state: "ready", pinnedCommit: published.pinnedCommit });
expect(await readFile(path.join(published.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
expect(acquisition.managedAuth).not.toHaveBeenCalled();
});
it("does not transfer shared ownership after the current task binding changes", async () => {
const f = await fixture(), peer = await standardPeer(f);
const first = await f.request({ kind: "catalog", id: "private-repo" });
await localPrivateClone(f);
const [published] = await f.service.prepareForAdmission(f.admission);
await f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } });
const actual = await vi.importActual<typeof import("../services/managed-repository-checkout.js")>("../services/managed-repository-checkout.js");
acquisition.materialize.mockImplementationOnce(async (cwd, input) => {
const result = await actual.materializeManagedProjectWorkspace(cwd, input);
await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, peer.issueId));
return result;
});
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/publication binding/);
expect(JSON.parse(await readFile(path.join(published.cwd, ".git", "paperclip-workspace-owner.json"), "utf8")))
.toMatchObject({ repositoryId: first.operationId, pinnedCommit: published.pinnedCommit });
});
it.each(["missing", "malformed", "symlink", "foreign_company", "conflicting_pin"] as const)("rejects unverified shared publication ownership (%s) without overwriting files", async marker => {
const f = await fixture(), peer = await standardPeer(f);
const first = await f.request({ kind: "catalog", id: "private-repo" });
@@ -7,10 +7,11 @@ import request from "supertest";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createDb, executionWorkspaceRepositories, executionWorkspaceRepositoryRoots, heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { createDb, executionWorkspaceRepositories, executionWorkspaceRepositoryRoots, executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { authorizationService, canActorReadExecutionWorkspace } from "../services/authorization.js";
import { assertExecutionWorkspaceRecordAccess, assertTaskWorkspaceExecutionAccess } from "../services/task-workspace-access.js";
import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { taskWorkspaceRoutes } from "../routes/task-workspaces.js";
import { startEmbeddedPostgresTestDatabase, getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
import { addPoisonedWorkspaceHistory, addReadyWorkspaceHistoryRepository, createWorkspacePreparationHistoryFixture } from "./helpers/workspace-preparation-history-fixture.js";
@@ -75,6 +76,71 @@ const support = await getEmbeddedPostgresTestSupport();
await expect(assertExecutionWorkspaceRecordAccess(db, fixture.actor, fixture.companyId, fixture.workspaceId)).rejects.toThrow();
});
it.each(["task_directory", "configured_source", "existing"] as const)("applies a saved %s selection after the old workspace source becomes private", async kind => {
const current = await createWorkspacePreparationHistoryFixture(db, path.join(directory, `escape-${kind}`), {
workspaces: 0, sharedRoots: 0, sources: 0, readers: 0,
});
const [oldProject, nextProject] = await db.insert(projects).values([
{ companyId: current.companyId, name: "Old source" }, { companyId: current.companyId, name: "New source" },
]).returning();
const [oldSource, nextSource] = await db.insert(projectWorkspaces).values([
{ companyId: current.companyId, projectId: oldProject.id, name: "Old source", cwd: current.cwd },
{ companyId: current.companyId, projectId: nextProject.id, name: "New source", cwd: current.cwd },
]).returning();
await db.update(issues).set({ projectWorkspaceId: oldSource.id }).where(eq(issues.id, current.issueId));
await db.update(executionWorkspaces).set({ projectId: oldProject.id, projectWorkspaceId: oldSource.id })
.where(eq(executionWorkspaces.id, current.workspaceId));
const [nextWorkspace] = await db.insert(executionWorkspaces).values({ companyId: current.companyId, name: "New workspace",
sourceIssueId: current.issueId, cwd: current.cwd, mode: "shared_workspace", strategyType: "task_directory" }).returning();
const selection = kind === "task_directory" ? { kind } : kind === "existing" ? { kind, workspaceId: nextWorkspace.id }
: { kind, projectWorkspaceId: nextSource.id, mode: "shared" as const };
const service = executionWorkspaceService(db);
await service.selectTaskWorkspace({ companyId: current.companyId, issueId: current.issueId, actor: current.actor,
selection, expectedBindingRevision: 0, requestKey: randomUUID() });
await db.update(projects).set({ visibility: "private", privacyOwnerUserId: current.principals[1].userId })
.where(eq(projects.id, oldProject.id));
await expect(assertTaskWorkspaceExecutionAccess(db, current.actor, current.companyId, {
issueId: current.issueId, projectWorkspaceId: null,
})).rejects.toThrow();
await expect(service.applyPendingTaskWorkspaceSelection({ ...current.input, actor: current.actor })).resolves.toBe(true);
const [task] = await db.select().from(issues).where(eq(issues.id, current.issueId));
expect(task).toMatchObject({ projectId: null, workspacePendingSelection: null, workspaceSelection: { selection },
executionWorkspaceId: kind === "existing" ? nextWorkspace.id : null,
projectWorkspaceId: kind === "configured_source" ? nextSource.id : null });
});
it("accepts only persisted selection context and keeps current-task and new-source authorization", async () => {
const current = await createWorkspacePreparationHistoryFixture(db, path.join(directory, "selection-authority"), {
workspaces: 0, sharedRoots: 0, sources: 0, readers: 0,
});
const taskDirectory = { kind: "task_directory" as const };
const context = { issueId: current.issueId, persistedSelection: taskDirectory };
await expect(assertTaskWorkspaceExecutionAccess(db, current.actor, current.companyId, context))
.rejects.toThrow("not a current persisted intent");
await db.update(issues).set({ workspacePendingSelection: { version: 1, requestKey: "stale", expectedBindingRevision: 1,
intent: { version: 1, source: "explicit", selection: taskDirectory } } }).where(eq(issues.id, current.issueId));
await expect(assertTaskWorkspaceExecutionAccess(db, current.actor, current.companyId, context))
.rejects.toThrow("not a current persisted intent");
await db.update(issues).set({ workspacePendingSelection: null }).where(eq(issues.id, current.issueId));
const service = executionWorkspaceService(db);
await service.selectTaskWorkspace({ companyId: current.companyId, issueId: current.issueId, actor: current.actor,
selection: taskDirectory, expectedBindingRevision: 0, requestKey: randomUUID() });
await db.update(issues).set({ visibility: "private", privacyRootIssueId: current.issueId,
responsibleUserId: current.principals[1].userId }).where(eq(issues.id, current.issueId));
await expect(service.applyPendingTaskWorkspaceSelection({ ...current.input, actor: current.actor })).rejects.toThrow();
await db.update(issues).set({ visibility: "open", privacyRootIssueId: null, responsibleUserId: current.userId })
.where(eq(issues.id, current.issueId));
const [project] = await db.insert(projects).values({ companyId: current.companyId, name: "Next source" }).returning();
const [source] = await db.insert(projectWorkspaces).values({ companyId: current.companyId, projectId: project.id,
name: "Next source", cwd: current.cwd }).returning();
await service.selectTaskWorkspace({ companyId: current.companyId, issueId: current.issueId, actor: current.actor,
selection: { kind: "configured_source", projectWorkspaceId: source.id, mode: "shared" },
expectedBindingRevision: 1, requestKey: randomUUID() });
await db.update(projects).set({ visibility: "private", privacyOwnerUserId: current.principals[1].userId })
.where(eq(projects.id, project.id));
await expect(service.applyPendingTaskWorkspaceSelection({ ...current.input, actor: current.actor })).rejects.toThrow();
});
it("routes current-task repository requests and new selections independently from full workspace disclosure", async () => {
const app = express();
app.use(express.json());
@@ -384,6 +384,29 @@ export function executionWorkspaceRepositoryService(db: Db) {
const pinnedCommit: string = authorizedPin ?? ownership.pinnedCommit;
if (acquisitionRestricted) await git("cat-file", "-e", `${pinnedCommit}^{commit}`);
const branchName = await git("symbolic-ref", "--quiet", "--short", "HEAD").catch(() => null);
await assertPublicationAllowed(input);
if (!acquisitionRestricted) {
await assertCurrentControllerAcquisition(input);
if (source.catalogRepositoryId) {
const currentCatalog = await catalog(input.companyId, input.responsibleUserId, input.responsibleUserId === "local-board");
const currentSource = resolveAcquisitionSource({ kind: "catalog", id: source.catalogRepositoryId }, currentCatalog, false);
if (currentSource.repositoryIdentity !== row.repositoryIdentity) throw conflict("Repository preparation receipt does not match its source");
}
}
if (ownership?.repositoryId !== row.id || ownership?.pinnedCommit !== pinnedCommit) {
const gitDir = path.join(cwd, ".git");
const metadata = await fs.lstat(gitDir);
if (!metadata.isDirectory() || metadata.isSymbolicLink() || await fs.realpath(gitDir) !== gitDir) {
throw conflict("Repository Git metadata escapes the managed checkout");
}
const temporaryOwnershipPath = path.join(gitDir, `.paperclip-workspace-owner-${randomUUID()}.tmp`);
try {
await fs.writeFile(temporaryOwnershipPath, JSON.stringify({ version: 1, repositoryId: row.id, pinnedCommit }), { flag: "wx", mode: 0o600 });
// Keep bounded ownership lookup attached to a receipt for this root,
// even if the workspace that originally published it later moves.
await fs.rename(temporaryOwnershipPath, ownershipPath);
} finally { await fs.rm(temporaryOwnershipPath, { force: true }); }
}
await db.update(executionWorkspaceRepositories).set({ state: "ready", pinnedCommit, branchName, failureCode: null, updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id));
prepared.push({ id: row.id, cwd, repoUrl: row.repoUrl, relativePath: row.relativePath, pinnedCommit, branchName });
} catch (error) {
+1 -2
View File
@@ -1470,8 +1470,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
if (!input.issueId) throw forbidden("Persisted workspace selection requires a current task");
await assertTaskWorkspaceExecutionAccess(reader, input.actor, input.companyId, {
issueId: input.issueId,
...(input.selection.kind === "existing" ? { workspaceId: input.selection.workspaceId } : {}),
...(input.selection.kind === "configured_source" ? { projectWorkspaceId: input.selection.projectWorkspaceId } : {}),
persistedSelection: input.selection,
});
return validateSelection(input, reader, true);
}
@@ -53,7 +53,7 @@ export type TaskWorkspaceCreationTrustContext = {
export async function assertSelectedWorkspaceTrust(db: Db | DbTransaction, input: {
companyId: string; sourceProjectId: string | null; actor: AuthorizationActor;
issueId?: string | null; sourceWorkspaceId?: string; assigneeAgentId?: string | null; taskCreation?: TaskWorkspaceCreationTrustContext;
issueId?: string | null; sourceWorkspaceId?: string | null; assigneeAgentId?: string | null; taskCreation?: TaskWorkspaceCreationTrustContext;
}) {
if (!input.sourceProjectId) return;
const [selectedSource] = await db.select({ id: projects.id, companyId: projects.companyId,
@@ -71,7 +71,7 @@ export async function assertSelectedWorkspaceTrust(db: Db | DbTransaction, input
const agentId = input.actor.type === "agent" ? input.actor.agentId : issue?.assigneeAgentId ?? input.assigneeAgentId;
const [agent] = agentId ? await db.select().from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, input.companyId))) : [];
const [project] = issue?.projectId ? await db.select().from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, input.companyId))) : [];
const workspaceId = input.sourceWorkspaceId ?? issue?.executionWorkspaceId;
const workspaceId = input.sourceWorkspaceId === undefined ? issue?.executionWorkspaceId : input.sourceWorkspaceId;
const [workspace] = workspaceId ? await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, input.companyId))) : [];
const [source] = workspace?.projectId ? await db.select().from(projects).where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, input.companyId))) : [];
if ((agentId && !agent) || (runId && (!run || run.agentId !== agentId || !issue || !runRetainsIssueIdentity(run, issue.id)))) throw incompatible();
+19 -6
View File
@@ -7,6 +7,8 @@ import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./a
import { canonicalExecutionUuid } from "./execution-identity.js";
import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns } from "./task-repository-acquisition.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { isDeepStrictEqual } from "node:util";
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection } from "@paperclipai/shared";
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
@@ -54,7 +56,7 @@ export async function assertTaskWorkspaceExecutionAccess(
db: Db | DbTransaction,
actor: AuthorizationActor,
companyId: string,
input: { issueId: string; workspaceId?: string | null; projectWorkspaceId?: string | null },
input: { issueId: string; workspaceId?: string | null; projectWorkspaceId?: string | null; persistedSelection?: TaskWorkspaceSelection },
) {
if (actor.companyId && actor.companyId !== companyId) throw forbidden("Task workspace belongs to another company");
const [task] = await db.select({
@@ -83,16 +85,27 @@ export async function assertTaskWorkspaceExecutionAccess(
const selection = task.workspaceSelection?.selection;
const pending = task.workspacePendingSelection;
const pendingSelection = pending?.expectedBindingRevision === task.workspaceBindingRevision ? pending.intent.selection : undefined;
const [workspace] = input.workspaceId ? await db.select({
const effectiveSelection = input.persistedSelection !== undefined
? taskWorkspaceSelectionSchema.parse(input.persistedSelection) : undefined;
if (effectiveSelection && (input.workspaceId != null || input.projectWorkspaceId != null
|| (!isDeepStrictEqual(effectiveSelection, selection) && !isDeepStrictEqual(effectiveSelection, pendingSelection)))) {
throw forbidden("Task workspace selection is not a current persisted intent");
}
const workspaceId = effectiveSelection
? effectiveSelection.kind === "existing" ? effectiveSelection.workspaceId : null
: input.workspaceId;
const [workspace] = workspaceId ? await db.select({
id: executionWorkspaces.id, companyId: executionWorkspaces.companyId,
projectId: executionWorkspaces.projectId, projectWorkspaceId: executionWorkspaces.projectWorkspaceId,
}).from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, companyId))) : [];
if (input.workspaceId && (!workspace || (task.executionWorkspaceId !== workspace.id
}).from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId))) : [];
if (workspaceId && (!workspace || (task.executionWorkspaceId !== workspace.id
&& !(selection?.kind === "existing" && selection.workspaceId === workspace.id)
&& !(pendingSelection?.kind === "existing" && pendingSelection.workspaceId === workspace.id)))) {
throw forbidden("Task workspace binding is unavailable");
}
const selectedSourceId = input.projectWorkspaceId ?? workspace?.projectWorkspaceId ?? task.projectWorkspaceId;
const selectedSourceId = effectiveSelection
? effectiveSelection.kind === "configured_source" ? effectiveSelection.projectWorkspaceId : workspace?.projectWorkspaceId ?? null
: input.projectWorkspaceId ?? workspace?.projectWorkspaceId ?? task.projectWorkspaceId;
if (input.projectWorkspaceId && input.projectWorkspaceId !== task.projectWorkspaceId
&& input.projectWorkspaceId !== workspace?.projectWorkspaceId
&& !(selection?.kind === "configured_source" && selection.projectWorkspaceId === input.projectWorkspaceId)
@@ -114,6 +127,6 @@ export async function assertTaskWorkspaceExecutionAccess(
run: run ? { companyId: run.companyId, executionPolicy: run.contextSnapshot?.executionPolicy } : null });
if (trust.kind === "denied") throw forbidden(trust.detail);
await assertSelectedWorkspaceTrust(db, { companyId, actor, issueId: task.id,
sourceProjectId: sourceProjectId ?? null, sourceWorkspaceId: workspace?.id });
sourceProjectId: sourceProjectId ?? null, sourceWorkspaceId: effectiveSelection ? workspace?.id ?? null : workspace?.id });
return { task, workspace, source };
}