Skip to content

Commit 3057a01

Browse files
authored
feat: list generated deliverables in the slash file picker (#163)
## Summary - merges durable uploads and generated outputs in the `/` project-file picker - inserts an unambiguous `/deliverables/<output-id>/<filename>` reference for generated files - restores only selected deliverables from checksum-verified R2 storage before model execution - keeps opening the picker metadata-only, so it does not start Daytona ## Architecture The agent worker joins generated outputs to their owning run and project under signed tenant context. The web picker consumes one discriminated upload/deliverable catalog. When a generated reference is submitted, the run validates project ownership plus R2 identity/checksum metadata and materializes that exact object into the project workspace through the leased ProjectSandbox. ## Decisions made | Decision | Choice | Reason | |---|---|---| | Reference identity | Full output UUID in the project path | Duplicate filenames remain exact and cross-project references fail closed | | Restore timing | On submit, not on menu open | The slash menu remains fast and never wakes a sandbox | | Source of truth | Postgres metadata plus R2 bytes | Workspace files can disappear across idle recovery without losing referability | | Schema | Discriminated `upload` / `deliverable` entries | Existing upload metadata stays precise while generated files expose only durable fields | ## Edge cases handled - duplicate deliverable filenames across runs - stale, missing, cross-user, or cross-project output references - R2 identity, MIME type, size, and checksum mismatches - generated files missing from an idle or recreated workspace - multiple references in one message, bounded to ten unique outputs - generated source paths remain read-only through file mutation tools ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` - `pnpm architecture:check` - `pnpm turbo skills:build` Production browser QA will be completed after merge and deployment against the existing research project containing two durable PDF deliverables.
1 parent eb300c3 commit 3057a01

20 files changed

Lines changed: 453 additions & 27 deletions

apps/agent-worker/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ existence before minting a one-hour HMAC capability; the public signed download
3333
streaming second hop. Expiring capabilities and internal R2 keys are never stored in transcripts or
3434
returned by artifact tools.
3535

36+
The project file catalog merges uploaded-file metadata with the newest durable generated-output
37+
records without starting Daytona. Generated entries use an ID-backed
38+
`deliverables/<output-id>/<filename>` path, so duplicate names remain unambiguous. When a user
39+
actually references one, the run rechecks user/project ownership, validates the R2 object's exact
40+
identity and checksum metadata, and restores only that output to its deterministic workspace path
41+
before model execution. Opening the slash menu therefore stays cheap, while old Deliverables remain
42+
usable after sandbox idle stops or workspace-file loss.
43+
3644
User uploads are durable project files rather than prompt text. The authenticated project-file
3745
route accepts one bounded raw file at a time, validates its filename, extension, UTF-8 or binary
3846
signature, and tenant/project write state, then derives deterministic file and version UUIDs from

apps/agent-worker/src/durable-objects/agent-run-artifacts.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,10 @@ import {
1515
} from "@cheatcode/observability";
1616
import type { ArtifactUploadInput, ArtifactUploadResult } from "@cheatcode/sandbox-contracts";
1717
import type { AgentRunId, ProjectId, ThreadId, UserId } from "@cheatcode/types";
18-
import { ArtifactKindSchema } from "@cheatcode/types/artifacts";
18+
import { ArtifactKindSchema, GENERATED_OUTPUT_MAX_BYTES } from "@cheatcode/types/artifacts";
1919
import { closeDatabaseBestEffort } from "./db-close";
2020

2121
const ARTIFACT_DIGEST_DOMAIN = "cheatcode:artifact-upload:v2";
22-
const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
2322
const MAX_CONTENT_TYPE_LENGTH = 255;
2423
const VALID_CONTENT_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/iu;
2524

@@ -291,8 +290,10 @@ function assertArtifactUpload(artifact: ArtifactUploadInput): void {
291290
if (!(artifact.data instanceof Uint8Array)) {
292291
throw invalidArtifact("Artifact data must be binary");
293292
}
294-
if (artifact.data.byteLength === 0 || artifact.data.byteLength > MAX_ARTIFACT_BYTES) {
295-
throw invalidArtifact(`Artifact data must be between 1 byte and ${MAX_ARTIFACT_BYTES} bytes`);
293+
if (artifact.data.byteLength === 0 || artifact.data.byteLength > GENERATED_OUTPUT_MAX_BYTES) {
294+
throw invalidArtifact(
295+
`Artifact data must be between 1 byte and ${GENERATED_OUTPUT_MAX_BYTES} bytes`,
296+
);
296297
}
297298
if (!artifact.filename.trim() || artifact.filename.length > 255) {
298299
throw invalidArtifact("Artifact filename must be between 1 and 255 characters");
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import {
2+
listReferencedProjectGeneratedOutputs,
3+
type ReferencedProjectGeneratedOutputRecord,
4+
withUserDb,
5+
} from "@cheatcode/db";
6+
import { APIError, type createLogger } from "@cheatcode/observability";
7+
import { toProjectId, toUserId } from "@cheatcode/types";
8+
import { ProjectDeliverableRelativePathSchema } from "@cheatcode/types/api";
9+
import { GENERATED_OUTPUT_MAX_BYTES } from "@cheatcode/types/artifacts";
10+
import type { AgentRunEnv } from "./agent-run-env";
11+
import type { StartRunInput } from "./agent-run-schemas";
12+
import type { ProjectSandbox } from "./project-sandbox";
13+
14+
const DELIVERABLE_REFERENCE_PATTERN =
15+
/(?:^|\s)(\/deliverables\/[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\/[a-z0-9._-]+)(?=\s|$)/gu;
16+
const MAX_REFERENCED_DELIVERABLES = 10;
17+
18+
interface DeliverableReference {
19+
filename: string;
20+
outputId: string;
21+
}
22+
23+
interface RestoreReferencedDeliverablesOptions {
24+
env: AgentRunEnv;
25+
input: StartRunInput;
26+
logger: ReturnType<typeof createLogger>;
27+
sandbox: DurableObjectStub<ProjectSandbox>;
28+
}
29+
30+
/** Restores only explicitly referenced durable outputs into their deterministic project paths. */
31+
export async function restoreReferencedDeliverables(
32+
options: RestoreReferencedDeliverablesOptions,
33+
): Promise<void> {
34+
const references = parseDeliverableReferences(options.input.messageText);
35+
if (references.length === 0) return;
36+
const { projectId, workspaceSlug } = options.input;
37+
if (!projectId || !workspaceSlug) throw referencedDeliverableNotFound();
38+
const outputs = await loadReferencedOutputs(
39+
options.env,
40+
options.input.userId,
41+
projectId,
42+
references,
43+
);
44+
const byId = new Map(outputs.map((output) => [output.id, output]));
45+
for (const reference of references) {
46+
const output = byId.get(reference.outputId);
47+
if (!output || output.filename !== reference.filename) throw referencedDeliverableNotFound();
48+
const bytes = await readVerifiedOutput(options.env.R2_OUTPUTS, output);
49+
await options.sandbox.restoreGeneratedOutput({
50+
bytes,
51+
filename: reference.filename,
52+
outputId: reference.outputId,
53+
projectId,
54+
workspaceSlug,
55+
});
56+
}
57+
options.logger.info("project_deliverables_restored", {
58+
projectId,
59+
restoredFileCount: references.length,
60+
});
61+
}
62+
63+
function parseDeliverableReferences(message: string): DeliverableReference[] {
64+
const references = new Map<string, DeliverableReference>();
65+
for (const match of message.matchAll(DELIVERABLE_REFERENCE_PATTERN)) {
66+
const source = match[1];
67+
if (!source) continue;
68+
const parsed = ProjectDeliverableRelativePathSchema.safeParse(source.slice(1));
69+
if (!parsed.success) continue;
70+
const [, outputId, filename] = parsed.data.split("/");
71+
if (!outputId || !filename) continue;
72+
references.set(outputId, { filename, outputId });
73+
if (references.size > MAX_REFERENCED_DELIVERABLES) {
74+
throw new APIError(
75+
422,
76+
"request_body_invalid",
77+
`Reference at most ${MAX_REFERENCED_DELIVERABLES} deliverables in one message.`,
78+
{ retriable: false },
79+
);
80+
}
81+
}
82+
return [...references.values()];
83+
}
84+
85+
async function loadReferencedOutputs(
86+
env: AgentRunEnv,
87+
sourceUserId: string,
88+
sourceProjectId: string,
89+
references: readonly DeliverableReference[],
90+
): Promise<ReferencedProjectGeneratedOutputRecord[]> {
91+
const userId = toUserId(sourceUserId);
92+
return withUserDb(env, userId, async ({ transaction }) => {
93+
return transaction((tx) =>
94+
listReferencedProjectGeneratedOutputs(tx, {
95+
outputIds: references.map((reference) => reference.outputId),
96+
projectId: toProjectId(sourceProjectId),
97+
userId,
98+
}),
99+
);
100+
});
101+
}
102+
103+
async function readVerifiedOutput(
104+
bucket: R2Bucket,
105+
output: ReferencedProjectGeneratedOutputRecord,
106+
): Promise<Uint8Array<ArrayBuffer>> {
107+
const object = await bucket.get(output.r2Key);
108+
const metadata = object?.customMetadata;
109+
const checksum = object?.checksums.sha256;
110+
if (
111+
!object ||
112+
object.key !== output.r2Key ||
113+
object.size <= 0 ||
114+
object.size > GENERATED_OUTPUT_MAX_BYTES ||
115+
object.httpMetadata?.contentType !== output.mimeType ||
116+
metadata?.["filename"] !== output.filename ||
117+
metadata?.["outputId"] !== output.id ||
118+
!metadata["contentSha256"] ||
119+
!checksum ||
120+
bytesToHex(new Uint8Array(checksum)) !== metadata["contentSha256"]
121+
) {
122+
throw referencedDeliverableInvalid();
123+
}
124+
const bytes = new Uint8Array(await object.arrayBuffer()) as Uint8Array<ArrayBuffer>;
125+
if (bytes.byteLength !== object.size) throw referencedDeliverableInvalid();
126+
return bytes;
127+
}
128+
129+
function bytesToHex(bytes: Uint8Array): string {
130+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
131+
}
132+
133+
function referencedDeliverableNotFound(): APIError {
134+
return new APIError(
135+
404,
136+
"resource_output_not_found",
137+
"A referenced deliverable is unavailable.",
138+
{
139+
hint: "Choose the file again from the project file menu.",
140+
retriable: false,
141+
},
142+
);
143+
}
144+
145+
function referencedDeliverableInvalid(): APIError {
146+
return new APIError(
147+
409,
148+
"conflict_state_invalid",
149+
"A referenced deliverable could not be restored safely.",
150+
{ retriable: false },
151+
);
152+
}

apps/agent-worker/src/durable-objects/agent-run-lifecycle.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { mastra } from "@cheatcode/agent-core";
22
import { createLogger } from "@cheatcode/observability";
33
import type { UIMessageChunk } from "ai";
4+
import { restoreReferencedDeliverables } from "./agent-run-deliverables";
45
import type { AgentRunEnv } from "./agent-run-env";
56
import { toAgentRunStreamError } from "./agent-run-errors";
67
import { persistOrQueueAssistantMessage } from "./agent-run-message-persistence";
@@ -92,6 +93,12 @@ async function executeActiveRun(execution: RunExecution): Promise<void> {
9293
return;
9394
}
9495
await restoreRunProjectFiles(execution);
96+
await restoreReferencedDeliverables({
97+
env: deps.env,
98+
input,
99+
logger: execution.logger,
100+
sandbox: execution.sandbox,
101+
});
95102
const path = await deps.executeRunPath(
96103
input,
97104
execution.sandbox,

apps/agent-worker/src/durable-objects/project-sandbox-content-support.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
export const PROJECT_ARCHIVE_MAX_BYTES = 512 * 1024 * 1024;
1010
export const PROJECT_ARCHIVE_MAX_FILES = 25_000;
1111
export const WORKSPACE_DIR = "/workspace";
12-
const MANAGED_PROJECT_UPLOAD_PATH = /^\/workspace\/[^/]+\/uploads(?:\/|$)/u;
12+
const MANAGED_PROJECT_SOURCE_PATH = /^\/workspace\/[^/]+\/(?:deliverables|uploads)(?:\/|$)/u;
1313
const PROJECT_WORKSPACE_ROOT_PATH = /^\/workspace\/[^/]+\/?$/u;
1414

1515
export const PROJECT_ARCHIVE_SCRIPT = `
@@ -106,11 +106,11 @@ if archive_size > max_output_bytes:
106106
export { PROJECT_ARCHIVE_MAX_OUTPUT_BYTES };
107107

108108
export function assertMutableWorkspacePath(path: string): void {
109-
if (!MANAGED_PROJECT_UPLOAD_PATH.test(path)) {
109+
if (!MANAGED_PROJECT_SOURCE_PATH.test(path)) {
110110
return;
111111
}
112-
throw new APIError(403, "permission_access_denied", "Uploaded project files are read-only", {
113-
hint: "Read the uploaded file or copy it to another project path before editing it.",
112+
throw new APIError(403, "permission_access_denied", "Referenced project files are read-only", {
113+
hint: "Read the source file or copy it to another project path before editing it.",
114114
retriable: false,
115115
});
116116
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { SandboxWriteFileResult } from "@cheatcode/sandbox-contracts";
2+
import { projectDeliverableRelativePath } from "@cheatcode/types/api";
3+
import { dirname } from "../sandbox-support";
4+
import { WORKSPACE_DIR } from "./project-sandbox-content-support";
5+
import {
6+
type ProjectRestoreGeneratedOutputInput,
7+
ProjectRestoreGeneratedOutputInputSchema,
8+
} from "./project-sandbox-runtime";
9+
import type { SandboxRuntime } from "./project-sandbox-runtime-handle";
10+
11+
type GeneratedOutputRuntime = Pick<SandboxRuntime, "client" | "ensureSandbox">;
12+
13+
export interface GeneratedOutputOps {
14+
restoreGeneratedOutput: (
15+
input: ProjectRestoreGeneratedOutputInput,
16+
) => Promise<SandboxWriteFileResult>;
17+
}
18+
19+
export function createGeneratedOutputOps(runtime: GeneratedOutputRuntime): GeneratedOutputOps {
20+
return {
21+
restoreGeneratedOutput: (input) => restoreGeneratedOutput(runtime, input),
22+
};
23+
}
24+
25+
async function restoreGeneratedOutput(
26+
runtime: GeneratedOutputRuntime,
27+
input: ProjectRestoreGeneratedOutputInput,
28+
): Promise<SandboxWriteFileResult> {
29+
const parsed = ProjectRestoreGeneratedOutputInputSchema.parse(input);
30+
const relativePath = projectDeliverableRelativePath(parsed.outputId, parsed.filename);
31+
const path = `${WORKSPACE_DIR}/${parsed.workspaceSlug}/${relativePath}`;
32+
const id = await runtime.ensureSandbox();
33+
await runtime.client().createFolder(id, dirname(path));
34+
await runtime.client().uploadFile(id, path, parsed.bytes);
35+
return { path, success: true };
36+
}

apps/agent-worker/src/durable-objects/project-sandbox-lease-policy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const LEASE_POLICIES = {
3131
readFile: ["workspace", "path"], listUploadedFiles: ["sandbox"],
3232
uploadProjectFile: ["workspace", "workspace-slug"],
3333
restoreUploadedFiles: ["workspace", "workspace-slug"],
34+
restoreGeneratedOutput: ["workspace", "workspace-slug"],
3435
writeFile: ["workspace", "path"], listFiles: ["workspace", "path"],
3536
searchFiles: ["workspace", "path"], deleteFile: ["workspace", "path"],
3637
getSignedPreviewUrl: ["sandbox"], exposeBrowserTakeover: ["sandbox"],

apps/agent-worker/src/durable-objects/project-sandbox-project-files.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { APIError } from "@cheatcode/observability";
44
import {
55
PROJECT_FILE_MAX_CURRENT_FILES,
66
type ProjectFile,
7-
ProjectFileListSchema,
87
ProjectFileRelativePathSchema,
98
ProjectFileSchema,
109
type ProjectFileUploadResponse,
1110
ProjectFileUploadResponseSchema,
11+
ProjectUploadedFileListSchema,
1212
} from "@cheatcode/types/api";
1313
import { z } from "zod";
1414
import { sleep } from "../sandbox-support";
@@ -534,7 +534,7 @@ async function listProjectFileRecords(
534534
return parsed.success ? [parsed.data] : [];
535535
});
536536
files.sort((left, right) => left.path.localeCompare(right.path));
537-
return ProjectFileListSchema.parse({ files });
537+
return ProjectUploadedFileListSchema.parse({ files });
538538
}
539539

540540
async function deleteStoragePrefix(runtime: FileRuntime, prefix: string): Promise<void> {

apps/agent-worker/src/durable-objects/project-sandbox-runtime.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { WorkspaceFilePathSchema, WorkspacePathSchema } from "@cheatcode/agent-core/tools/code";
22
import { EnvironmentVariablesSchema } from "@cheatcode/sandbox-contracts";
33
import { toProjectId } from "@cheatcode/types";
4-
import { PROJECT_FILE_MAX_BYTES, ProjectFileRelativePathSchema } from "@cheatcode/types/api";
4+
import {
5+
PROJECT_FILE_MAX_BYTES,
6+
ProjectDeliverableFilenameSchema,
7+
ProjectFileRelativePathSchema,
8+
} from "@cheatcode/types/api";
9+
import { GENERATED_OUTPUT_MAX_BYTES, OutputIdSchema } from "@cheatcode/types/artifacts";
510
import { z } from "zod";
611
import { shellQuote } from "../sandbox-support";
712

@@ -107,6 +112,24 @@ export const ProjectRestoreUploadedFilesInputSchema = z
107112
"Workspace slug does not belong to the requested project.",
108113
);
109114

115+
export const ProjectRestoreGeneratedOutputInputSchema = z
116+
.strictObject({
117+
bytes: z
118+
.instanceof(Uint8Array)
119+
.refine(
120+
(value) => value.byteLength > 0 && value.byteLength <= GENERATED_OUTPUT_MAX_BYTES,
121+
`Generated outputs must be between 1 byte and ${GENERATED_OUTPUT_MAX_BYTES} bytes.`,
122+
),
123+
filename: ProjectDeliverableFilenameSchema,
124+
outputId: OutputIdSchema,
125+
projectId: z.string().uuid().toLowerCase().transform(toProjectId),
126+
workspaceSlug: ProjectWorkspaceSlugSchema,
127+
})
128+
.refine(
129+
(input) => input.workspaceSlug.endsWith(`-${input.projectId.toLowerCase()}`),
130+
"Workspace slug does not belong to the requested project.",
131+
);
132+
110133
export const ProjectListFilesInputSchema = z.strictObject({
111134
path: WorkspacePathSchema,
112135
includeHidden: z.boolean().default(false),
@@ -222,6 +245,9 @@ export type ProjectListUploadedFilesInput = z.input<typeof ProjectListUploadedFi
222245
export type ProjectRestoreUploadedFilesInput = z.input<
223246
typeof ProjectRestoreUploadedFilesInputSchema
224247
>;
248+
export type ProjectRestoreGeneratedOutputInput = z.input<
249+
typeof ProjectRestoreGeneratedOutputInputSchema
250+
>;
225251
export type ProjectListFilesInput = z.input<typeof ProjectListFilesInputSchema>;
226252
export type ProjectSearchFilesInput = z.input<typeof ProjectSearchFilesInputSchema>;
227253
export type ProjectDeleteFileInput = z.input<typeof ProjectDeleteFileInputSchema>;

0 commit comments

Comments
 (0)