mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
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:
1 parent
e6a8107641
commit
3d5da6c259
8 files changed
+173
-32
No files matched your search
+12
-19
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user