-
Notifications
You must be signed in to change notification settings - Fork 0
Fix repeated Studio builds and Sandbox cleanup #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
c215f5f
3427fc7
1416f4f
c26491b
512124e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ | |
| ProjectRuntimeAdapter, | ||
| RuntimeAuditSink, | ||
| } from "../../../../lib/project-runtime-adapter.ts"; | ||
| import type { ProjectV2 } from "../../../../lib/project-v2-types.ts"; | ||
| import { | ||
| ServerBuilderAuditSink, | ||
| SnapshotBuilderProjectRepository, | ||
|
|
@@ -57,13 +58,111 @@ | |
| projectId: z.string().regex(/^[a-z0-9][a-z0-9:._-]{0,127}$/i), | ||
| prompt: z.string().min(1).max(20_000), | ||
| mode: z.enum(["build", "edit", "repair"]), | ||
| buildRequestId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{7,95}$/).optional(), | ||
| provider: z.object({ | ||
| provider: z.enum(["free", "gateway", "openai", "anthropic", "openrouter", "kimi", "custom"]), | ||
| model: z.string().min(1).max(192).optional(), | ||
| baseUrl: z.string().url().max(2_000).optional(), | ||
| }).strict(), | ||
| }).strict(); | ||
|
|
||
| function autoBuildRunId(requestId: string): string { | ||
| return `auto:${requestId}`; | ||
| } | ||
|
|
||
| function withAutoBuildRun( | ||
| project: ProjectV2, | ||
| requestId: string, | ||
| status: "running" | "succeeded" | "failed" | "stopped", | ||
| ): ProjectV2 { | ||
| const id = autoBuildRunId(requestId); | ||
| const prior = project.runs.find((run) => run.id === id); | ||
| const now = new Date().toISOString(); | ||
| const runs = [ | ||
| ...project.runs.filter((run) => run.id !== id), | ||
| { | ||
| id, | ||
| taskId: "build", | ||
| projectRevision: project.revision, | ||
| status, | ||
| runtime: "vercel-sandbox" as const, | ||
| startedAt: status === "running" ? now : prior?.startedAt ?? now, | ||
| ...(status === "running" | ||
| ? {} | ||
| : { finishedAt: now, exitCode: status === "succeeded" ? 0 : null }), | ||
| logIds: prior?.logIds ?? [], | ||
| auditEventIds: prior?.auditEventIds ?? [], | ||
| }, | ||
| ].slice(-256); | ||
| const retainedRunIds = new Set(runs.map((run) => run.id)); | ||
| return { | ||
| ...project, | ||
| runs, | ||
| logs: project.logs.filter((log) => retainedRunIds.has(log.runId)), | ||
| updatedAt: now, | ||
| }; | ||
| } | ||
|
|
||
| async function claimAutoBuildRequest(input: { | ||
| actorId: string; | ||
| project: ProjectV2; | ||
| repository: BuilderProjectRepository; | ||
| requestId: string; | ||
| }): Promise<{ status: "claimed" | "running"; project: ProjectV2 }> { | ||
| let project = input.project; | ||
| for (let attempt = 0; attempt < 2; attempt += 1) { | ||
| const existing = project.runs.find( | ||
| (run) => run.id === autoBuildRunId(input.requestId), | ||
| ); | ||
| if (existing?.status === "running" || existing?.status === "queued") { | ||
| return { status: "running", project }; | ||
| } | ||
| try { | ||
| project = await input.repository.saveAuthorized( | ||
| input.actorId, | ||
| withAutoBuildRun(project, input.requestId, "running"), | ||
| project.revision, | ||
| ); | ||
| return { status: "claimed", project }; | ||
| } catch (error) { | ||
| const current = await input.repository.loadAuthorized( | ||
| input.actorId, | ||
| project.id, | ||
| ); | ||
| if (!current) throw error; | ||
| const claimed = current.runs.find( | ||
| (run) => run.id === autoBuildRunId(input.requestId), | ||
| ); | ||
| if (claimed?.status === "running" || claimed?.status === "queued") { | ||
| return { status: "running", project: current }; | ||
| } | ||
| if (attempt === 1) throw error; | ||
| project = current; | ||
| } | ||
| } | ||
| return { status: "running", project }; | ||
| } | ||
|
|
||
| async function settleAutoBuildRequest(input: { | ||
| actorId: string; | ||
| projectId: string; | ||
| repository: BuilderProjectRepository; | ||
| requestId: string; | ||
| status: "succeeded" | "failed" | "stopped"; | ||
| }): Promise<ProjectV2 | null> { | ||
| const current = await input.repository.loadAuthorized(input.actorId, input.projectId); | ||
| if (!current) return null; | ||
| const run = current.runs.find( | ||
| (item) => item.id === autoBuildRunId(input.requestId), | ||
| ); | ||
| if (!run || run.status !== "running") return current; | ||
| return input.repository.saveAuthorized( | ||
| input.actorId, | ||
| withAutoBuildRun(current, input.requestId, input.status), | ||
| current.revision, | ||
| ); | ||
| } | ||
|
|
||
| const ALL_AGENT_PERMISSIONS = new Set([ | ||
| "files:read", | ||
| "files:write", | ||
|
|
@@ -240,14 +339,42 @@ | |
| 400, | ||
| ); | ||
| } | ||
| if (parsed.data.buildRequestId && parsed.data.mode !== "build") { | ||
| return builderJson( | ||
| { | ||
| code: "BUILDER_INVALID_REQUEST", | ||
| error: "Automatic build request IDs are valid only for initial builds.", | ||
| }, | ||
| 400, | ||
| ); | ||
| } | ||
| const repository = dependencies.repository ?? new SnapshotBuilderProjectRepository(); | ||
| const project = await repository.loadAuthorized(actorId, parsed.data.projectId); | ||
| let project = await repository.loadAuthorized(actorId, parsed.data.projectId); | ||
| if (!project) { | ||
| return builderJson( | ||
| { code: "BUILDER_PROJECT_NOT_FOUND", error: "Project V2 was not found." }, | ||
| 404, | ||
| ); | ||
| } | ||
| if (parsed.data.buildRequestId) { | ||
| const claim = await claimAutoBuildRequest({ | ||
| actorId, | ||
| project, | ||
| repository, | ||
| requestId: parsed.data.buildRequestId, | ||
| }); | ||
| project = claim.project; | ||
| if (claim.status === "running") { | ||
| return builderJson( | ||
| { | ||
| code: "BUILDER_REQUEST_IN_PROGRESS", | ||
| status: "running", | ||
| project, | ||
| }, | ||
| 202, | ||
| ); | ||
| } | ||
| } | ||
| const audit = dependencies.audit ?? new ServerBuilderAuditSink(); | ||
| const sandboxRuntime = | ||
| dependencies.runtime ?? new VercelSandboxRuntimeAdapter({ audit }); | ||
|
|
@@ -264,7 +391,7 @@ | |
| try { | ||
| const session = new BuilderAgentSession({ | ||
| actorId, | ||
| requestId: randomUUID(), | ||
| requestId: parsed.data.buildRequestId ?? randomUUID(), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Inspect how BuilderAgentSession stores and consumes requestId.
set -euo pipefail
fd -t f 'session.ts' lib/builder-agent
echo '--- requestId usages in builder-agent ---'
rg -nP -C5 '\brequestId\b' lib/builder-agent --type=ts | head -150
echo '--- audit record shapes ---'
rg -nP -C5 'record\s*\(' lib/builder-agent --type=ts | head -80Repository: svg8bit/drops-studio Length of output: 13683 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- session construction and requestId propagation ---'
rg -n -C8 'new BuilderAgentSession|buildRequestId|requestId:|runBuilderAgent|BuilderAgentSession' app lib --glob '*.{ts,tsx,js,jsx}' | head -300
echo '--- all audit sink implementations and requestId consumers ---'
rg -n -C8 'BuilderAgentAuditSink|record\(event|actorHash|requestId|rate.?limit|audit' app lib --glob '*.{ts,tsx,js,jsx}' | head -500
echo '--- candidate files ---'
fd -t f . app lib | rg '(builder|audit|session|rate|runtime|workspace)' | head -200Repository: svg8bit/drops-studio Length of output: 50376 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- exact builder-agent audit sink references ---'
rg -n 'BuilderAgentAuditSink|MemoryBuilderAgentAuditSink|audit:\s*|audit\.record|new BuilderAgentSession|buildRequestId' app lib tests __tests__ 2>/dev/null || true
echo '--- route request construction ---'
sed -n '330,430p' app/api/builder/agent/route.ts
echo '--- complete builder-agent source file list ---'
fd -t f . lib/builder-agent app/api/builder/agent
echo '--- requestId field consumers outside declarations and event construction ---'
rg -n 'requestId' app lib --glob '*.{ts,tsx,js,jsx}' \
| rg -v 'interface |type |requestId: context\.requestId|requestId: input\.requestId|requestId: dependencies\.services\.requestId|readonly requestId|requestId: string|requestId\?:|providerRequestId|STUDIO_BUILD_REQUEST_PARAM' \
| head -250Repository: svg8bit/drops-studio Length of output: 22889 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- auto-build request claim and run storage ---'
sed -n '45,175p' app/api/builder/agent/route.ts
echo '--- server builder audit sink ---'
sed -n '1,150p' app/api/builder/shared.ts
echo '--- build route completion and response paths ---'
sed -n '470,555p' app/api/builder/agent/route.ts
echo '--- runtime route session construction ---'
sed -n '180,225p' app/api/builder/runtime/route.ts
echo '--- runtime and builder audit sink types/implementations ---'
rg -n -C12 'interface RuntimeAuditSink|class .*AuditSink|ServerBuilderAuditSink|record\(event' lib app/api/builder --glob '*.{ts,tsx}'Repository: svg8bit/drops-studio Length of output: 28610 🏁 Script executed: python3 - <<'PY'
from pathlib import Path
files = {
"route": Path("app/api/builder/agent/route.ts").read_text(),
"shared": Path("app/api/builder/shared.ts").read_text(),
"policy": Path("lib/builder-agent/policy.ts").read_text(),
"runtime": Path("lib/project-runtime-adapter.ts").read_text(),
}
checks = {
"auto-build storage calls are actor-authorized": (
"saveAuthorized(\n input.actorId," in files["route"]
and "loadAuthorized(\n input.actorId," in files["route"]
),
"builder audit event retains requestId and actorHash separately": (
"requestId: input.requestId" in files["policy"]
and "actorHash: runtimeActorHash(input.actorId)" in files["policy"]
),
"runtime audit event retains requestId and actorHash separately": (
"requestId: context.requestId" in files["runtime"]
and "actorHash: runtimeActorHash(context.actorId)" in files["runtime"]
),
"server sink writes event without a composite correlation key": (
'JSON.stringify({ source: "drops-studio-builder", ...event })' in files["shared"]
),
"memory audit sink does not key records by requestId": (
"this.events.push(structuredClone(event))" in files["policy"]
),
}
for name, result in checks.items():
print(f"{name}: {'yes' if result else 'no'}")
PYRepository: svg8bit/drops-studio Length of output: 468 Scope audit correlation by actor. Auto-build storage is actor-scoped, but audit events emit the client-controlled 🤖 Prompt for AI Agents |
||
| project, | ||
| repository, | ||
| runtime: sandboxRuntime, | ||
|
|
@@ -282,8 +409,9 @@ | |
| request, | ||
| parsed.data.provider, | ||
| ); | ||
| const { buildRequestId: _buildRequestId, ...builderInput } = parsed.data; | ||
| const agentRequest = { | ||
| ...parsed.data, | ||
| ...builderInput, | ||
| provider: remembered.selection, | ||
| approvedTools: [...approvedTools], | ||
| }; | ||
|
|
@@ -372,9 +500,21 @@ | |
| ); | ||
| } | ||
| } | ||
| const settledProject = parsed.data.buildRequestId | ||
| ? await settleAutoBuildRequest({ | ||
| actorId, | ||
| projectId: result.project.id, | ||
| repository, | ||
| requestId: parsed.data.buildRequestId, | ||
| status: result.releaseGate.ok ? "succeeded" : "failed", | ||
| }) | ||
| : null; | ||
| const settledResult = settledProject | ||
| ? { ...result, project: settledProject } | ||
| : result; | ||
| return builderJson( | ||
| { | ||
| result, | ||
| result: settledResult, | ||
| ...(intelligence | ||
| ? { | ||
| intelligence: { | ||
|
|
@@ -386,8 +526,19 @@ | |
| } | ||
| : {}), | ||
| }, | ||
| result.status === "blocked" ? 422 : 200, | ||
| settledResult.status === "blocked" ? 422 : 200, | ||
| ); | ||
| } catch (error) { | ||
| if (parsed.data.buildRequestId) { | ||
| await settleAutoBuildRequest({ | ||
| actorId, | ||
| projectId: project.id, | ||
| repository, | ||
| requestId: parsed.data.buildRequestId, | ||
| status: request.signal.aborted ? "stopped" : "failed", | ||
| }).catch(() => undefined); | ||
| } | ||
| throw error; | ||
| } finally { | ||
| request.signal.removeEventListener("abort", abortForDisconnectedClient); | ||
| } | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: svg8bit/drops-studio
Length of output: 158
🏁 Script executed:
Repository: svg8bit/drops-studio
Length of output: 28010
🏁 Script executed:
Repository: svg8bit/drops-studio
Length of output: 48773
🏁 Script executed:
Repository: svg8bit/drops-studio
Length of output: 50376
🏁 Script executed:
Repository: svg8bit/drops-studio
Length of output: 242
Preserve logs for active runs during retention.
.slice(-256)can evict arunningrun, and the following filter then deletes its persisted logs during automatic-build claim or settlement. Retain active runs before pruning. The validator already rejects logs whoserunIddoes not reference a project run.🤖 Prompt for AI Agents