Skip to content

Commit f5cb33f

Browse files
committed
sync
1 parent 58916c7 commit f5cb33f

9 files changed

Lines changed: 170 additions & 3 deletions

File tree

apps/agents-web/app/api/v1/agents/deploy/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ type ProgressReporter = (message: string) => void
117117

118118
const NDJSON_CONTENT_TYPE = "application/x-ndjson"
119119

120-
export const maxDuration = 900
120+
// Vercel Pro caps serverless maxDuration at 800s — the previous value of 900
121+
// made every production deploy of agents-web fail at the patchBuild step.
122+
export const maxDuration = 800
121123

122124
function encodeEvent(event: DeployEvent): Uint8Array {
123125
return new TextEncoder().encode(`${JSON.stringify(event)}\n`)

apps/agents-web/infra/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Terraform for the current `agents-web` stack with an `EKS`-based sandbox plane.
1313
- one self-managed sandbox node autoscaling group
1414
- one encrypted `EFS` filesystem for persistent sandbox volumes
1515
- one private `ECR` repository for the OpenSandbox runtime image
16+
- one private `S3` bucket for workflow skill artifacts, readable through the stack S3 VPC endpoint
1617
- one private PostgreSQL `RDS` instance
1718
- one private Redis `ElastiCache` instance
1819
- one Elastic IP for the app VM
@@ -55,6 +56,7 @@ Terraform now prepares the AWS base for:
5556
- `opensandbox-image-builder` RBAC for deploy-time derived runtime image builds
5657
- `opensandbox-server-internal` service
5758
- ECR push permissions for sandbox worker nodes to publish derived OpenSandbox runtime images
59+
- a private workflow skill artifact bucket plus an S3 Gateway VPC Endpoint on the public route table used by current sandbox nodes
5860
- outputs for the future OpenSandbox server config
5961

6062
Terraform does **not** yet:
@@ -95,6 +97,12 @@ the runtime repository and the cache repository. Without this, final derived
9597
image pushes still work, but cache layer pushes fail and every build re-runs
9698
expensive `apt` / `mise install` layers.
9799

100+
Workflow skill archive URLs are normal S3 object URLs under
101+
`terraform output workflow_skill_artifacts_url_base`, but the bucket policy only
102+
allows `GetObject` through this stack's S3 Gateway VPC Endpoint. They are meant
103+
for OpenSandbox/EKS image builders inside this AWS contour, not for public
104+
internet downloads.
105+
98106
## Current OpenSandbox Runtime Base Image
99107

100108
The base runtime image is built from `packages/agent-runtime/Dockerfile` and published to the private OpenSandbox runtime `ECR` repository. `relay` uses this image through `OPENSANDBOX_RUNTIME_IMAGE`; `agents-web` uses the same image as `OPENSANDBOX_BUILD_BASE_IMAGE` for derived sandbox images.

apps/agents-web/infra/main.tf

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,29 @@ data "aws_iam_policy_document" "app_eks_access" {
3535
}
3636
}
3737

38+
data "aws_iam_policy_document" "workflow_skill_artifacts_bucket" {
39+
statement {
40+
sid = "AllowClusterReadThroughS3Endpoint"
41+
42+
principals {
43+
type = "*"
44+
identifiers = ["*"]
45+
}
46+
47+
actions = ["s3:GetObject"]
48+
49+
resources = [
50+
"${aws_s3_bucket.workflow_skill_artifacts.arn}/${var.workflow_skills_s3_prefix}/*",
51+
]
52+
53+
condition {
54+
test = "StringEquals"
55+
variable = "aws:SourceVpce"
56+
values = [aws_vpc_endpoint.s3.id]
57+
}
58+
}
59+
}
60+
3861
locals {
3962
azs = slice(data.aws_availability_zones.available.names, 0, 2)
4063

@@ -363,6 +386,49 @@ resource "aws_route_table_association" "private" {
363386
route_table_id = aws_route_table.private.id
364387
}
365388

389+
resource "aws_vpc_endpoint" "s3" {
390+
vpc_id = aws_vpc.this.id
391+
service_name = "com.amazonaws.${var.aws_region}.s3"
392+
vpc_endpoint_type = "Gateway"
393+
route_table_ids = [aws_route_table.public.id]
394+
395+
tags = merge(local.tags, {
396+
Name = "${var.name_prefix}-s3"
397+
})
398+
}
399+
400+
resource "aws_s3_bucket" "workflow_skill_artifacts" {
401+
bucket = "${var.name_prefix}-workflow-skill-artifacts"
402+
403+
tags = merge(local.tags, {
404+
Name = "${var.name_prefix}-workflow-skill-artifacts"
405+
})
406+
}
407+
408+
resource "aws_s3_bucket_public_access_block" "workflow_skill_artifacts" {
409+
bucket = aws_s3_bucket.workflow_skill_artifacts.id
410+
411+
block_public_acls = true
412+
block_public_policy = true
413+
ignore_public_acls = true
414+
restrict_public_buckets = true
415+
}
416+
417+
resource "aws_s3_bucket_server_side_encryption_configuration" "workflow_skill_artifacts" {
418+
bucket = aws_s3_bucket.workflow_skill_artifacts.id
419+
420+
rule {
421+
apply_server_side_encryption_by_default {
422+
sse_algorithm = "AES256"
423+
}
424+
}
425+
}
426+
427+
resource "aws_s3_bucket_policy" "workflow_skill_artifacts" {
428+
bucket = aws_s3_bucket.workflow_skill_artifacts.id
429+
policy = data.aws_iam_policy_document.workflow_skill_artifacts_bucket.json
430+
}
431+
366432
resource "aws_security_group" "app" {
367433
name = "${var.name_prefix}-app"
368434
description = "Public access for bare metal services"

apps/agents-web/infra/outputs.tf

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ output "opensandbox_runtime_cache_ecr_repository_name" {
3535
value = aws_ecr_repository.opensandbox_runtime_cache.name
3636
}
3737

38+
output "workflow_skill_artifacts_bucket" {
39+
value = aws_s3_bucket.workflow_skill_artifacts.bucket
40+
}
41+
42+
output "workflow_skill_artifacts_url_base" {
43+
value = "https://${aws_s3_bucket.workflow_skill_artifacts.bucket}.s3.${var.aws_region}.amazonaws.com"
44+
}
45+
3846
output "opensandbox_runtime_image_example" {
3947
value = "${aws_ecr_repository.opensandbox_runtime.repository_url}:dev"
4048
}

apps/agents-web/infra/variables.tf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ variable "opensandbox_runtime_ecr_repository_name" {
118118
default = "21st-agent-runtime-opensandbox"
119119
}
120120

121+
variable "workflow_skills_s3_prefix" {
122+
type = string
123+
default = "workflow-skills"
124+
}
125+
121126
variable "sandbox_runtime_prepull_enabled" {
122127
type = bool
123128
default = true

apps/agents-web/lib/server/agents/build-opensandbox-image.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ type KubernetesToleration = {
4646
tolerationSeconds?: number
4747
}
4848

49+
type KubernetesResourceRequirements = {
50+
requests?: Record<string, string>
51+
limits?: Record<string, string>
52+
}
53+
4954
type ExternalTokenCache = {
5055
token: string
5156
expiresAtMs: number
@@ -68,7 +73,7 @@ const SERVICE_ACCOUNT_CA_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/c
6873
const DEFAULT_NAMESPACE = "opensandbox"
6974
const DEFAULT_BUILDER_IMAGE = "gcr.io/kaniko-project/executor:v1.24.0"
7075
const DEFAULT_SERVICE_ACCOUNT = "opensandbox-image-builder"
71-
const DEFAULT_BUILD_TIMEOUT_MS = 15 * 60 * 1000
76+
const DEFAULT_BUILD_TIMEOUT_MS = 30 * 60 * 1000
7277
const DEFAULT_BUILD_NODE_SELECTOR: Record<string, string> = {
7378
workload: "sandbox",
7479
}
@@ -80,6 +85,16 @@ const DEFAULT_BUILD_TOLERATIONS: KubernetesToleration[] = [
8085
effect: "NoSchedule",
8186
},
8287
]
88+
const DEFAULT_BUILD_RESOURCES: KubernetesResourceRequirements = {
89+
requests: {
90+
cpu: "500m",
91+
memory: "1Gi",
92+
},
93+
limits: {
94+
cpu: "2",
95+
memory: "4Gi",
96+
},
97+
}
8398

8499
let cachedExternalToken: ExternalTokenCache = null
85100

@@ -127,6 +142,13 @@ function getBuilderTolerations() {
127142
)
128143
}
129144

145+
function getBuilderResources() {
146+
return parseJsonEnv<KubernetesResourceRequirements>(
147+
"OPENSANDBOX_BUILD_RESOURCES_JSON",
148+
DEFAULT_BUILD_RESOURCES,
149+
)
150+
}
151+
130152
function getBaseImage() {
131153
const image =
132154
process.env.OPENSANDBOX_BUILD_BASE_IMAGE?.trim()
@@ -464,6 +486,7 @@ function buildJob(params: {
464486
mountPath: "/workspace",
465487
},
466488
],
489+
resources: getBuilderResources(),
467490
},
468491
],
469492
volumes: [

apps/agents-web/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,7 @@ model UserFollow {
958958
model Team {
959959
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
960960
name String
961+
slug String? @unique
961962
description String?
962963
image_url String?
963964
website_url String?

apps/agents-web/server/api/routers/teams/router.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
requireTeamOwnership,
1919
checkTeamAccess,
2020
getOrCreateInviteLink,
21+
generateUniqueTeamSlug,
2122
} from "./utils"
2223

2324
export const teamsRouter = createTRPCRouter({
@@ -159,9 +160,24 @@ export const teamsRouter = createTRPCRouter({
159160
})
160161
}
161162

163+
// Default personal teams are also created lazily through createTeam —
164+
// keep the `<username>-team` handle convention for them, same as the
165+
// apps/web Clerk webhook. Plain `slugify(name)` would collide into
166+
// the ugly `personal-projects-NNN` space.
167+
let slugSource = input.name
168+
if (input.name === "Personal Projects") {
169+
const owner = await prisma.user.findUnique({
170+
where: { id: ctx.auth.userId },
171+
select: { username: true },
172+
})
173+
if (owner?.username) slugSource = `${owner.username}-team`
174+
}
175+
const slug = await generateUniqueTeamSlug(slugSource)
176+
162177
const team = await prisma.team.create({
163178
data: {
164179
name: input.name,
180+
slug,
165181
description: input.description,
166182
image_url: input.image_url,
167183
website_url: input.website_url,

apps/agents-web/server/api/routers/teams/utils.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,45 @@
11
import { prisma } from "@/lib/prisma"
22
import { GitHubIntegration } from "@/lib/github"
33
import { TRPCError } from "@trpc/server"
4-
import { nanoid } from "nanoid"
4+
import { randomUUID } from "node:crypto"
5+
import { customAlphabet, nanoid } from "nanoid"
6+
7+
/** Fixed-length lowercase tail for slug collision fallback. */
8+
const slugTail = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 6)
9+
10+
// Same slugify pattern the other routers keep file-local.
11+
function slugifyTeamHandle(text: string): string {
12+
return text
13+
.toLowerCase()
14+
.replace(/[^a-z0-9]+/g, "-")
15+
.replace(/^-|-$/g, "")
16+
}
17+
18+
/**
19+
* Picks a globally unique team slug: tries the slugified base, then
20+
* `base-NNN` with random 3-digit suffixes, then random 6-char tails
21+
* (degenerate bases like "personal-projects" have most numeric suffixes
22+
* taken by the 2026-04-30 backfill). The partial unique index
23+
* `teams_slug_unique` is the backstop for the tiny check-to-insert race.
24+
*/
25+
export async function generateUniqueTeamSlug(text: string): Promise<string> {
26+
const base = slugifyTeamHandle(text) || "team"
27+
let candidate = base
28+
for (let attempt = 0; attempt < 6; attempt++) {
29+
const taken = await prisma.team.findFirst({
30+
where: { slug: candidate },
31+
select: { id: true },
32+
})
33+
if (!taken) return candidate
34+
candidate =
35+
attempt < 3
36+
? `${base}-${Math.floor(100 + Math.random() * 900)}`
37+
: `${base}-${slugTail()}`
38+
}
39+
// All checked candidates taken (astronomically unlikely) — fall back to a
40+
// UUID tail: an ugly slug beats a failed team creation.
41+
return `${base}-${randomUUID()}`
42+
}
543

644
/**
745
* Verifies team exists and user is the owner. Throws if not found or unauthorized.

0 commit comments

Comments
 (0)