Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/workflows/clearLifecycleRunIdIfOwned.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { selectSessions } from "@/lib/supabase/sessions/selectSessions";
import { updateSession } from "@/lib/supabase/sessions/updateSession";

/**
* Workflow step that clears `lifecycle_run_id` only if it still
* matches the supplied `runId`. Used at the end of a workflow run to
* release the lease without clobbering one that's been reclaimed by
* a kick.
*
* @param sessionId - The session id.
* @param runId - The lease this workflow owned; only clear if it
* still matches.
*/
export async function clearLifecycleRunIdIfOwned(sessionId: string, runId: string): Promise<void> {
"use step";

const rows = await selectSessions({ id: sessionId });
const session = rows[0];
if (!session || session.lifecycle_run_id !== runId) return;

await updateSession(sessionId, { lifecycle_run_id: null });
Comment on lines +17 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Use an atomic conditional update to avoid lease clobbering.

Lines 17–21 have a TOCTOU race: a new workflow can claim lifecycle_run_id after the read and before the write, then this step clears the new owner’s lease. This should be a single DB update with WHERE id = sessionId AND lifecycle_run_id = runId.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/workflows/clearLifecycleRunIdIfOwned.ts` around lines 17 - 21, The
current TOCTOU occurs because selectSessions(...) reads lifecycle_run_id then
updateSession(...) clears it unconditionally; replace this two-step flow with a
single atomic conditional update so the row is only cleared if it still has the
expected owner: remove the selectSessions(...) read and call/update
updateSession (or add a new updateSessionIfMatch) to execute an UPDATE ... SET
lifecycle_run_id = NULL WHERE id = sessionId AND lifecycle_run_id = runId and
check affected-rows to decide success; ensure the code references the existing
selectSessions and updateSession symbols (or implement updateSessionIfMatch) so
the DB change is atomic and avoids clobbering a newly acquired lease.

}
47 changes: 47 additions & 0 deletions app/workflows/computeLifecycleWakeDecision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { getLifecycleDueAtMs } from "@/lib/sandbox/getLifecycleDueAtMs";
import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState";
import { selectSessions } from "@/lib/supabase/sessions/selectSessions";

interface LifecycleWakeDecision {
shouldContinue: boolean;
wakeAtMs?: number;
reason?: string;
}

/**
* Workflow step run at the top of each `sandboxLifecycleWorkflow`
* iteration. Reads the session, decides whether to continue looping,
* and (when continuing) returns the next wake time. Bails when a
* concurrent kick has overwritten `lifecycle_run_id` with a different
* value — that newer run is now responsible for the session.
*
* @param sessionId - The session id the workflow is tracking.
* @param runId - The lease this workflow run owns.
* @returns A decision object with continuation flag, wake time, and
* skip reason (when terminating).
*/
export async function computeLifecycleWakeDecision(
sessionId: string,
runId: string,
): Promise<LifecycleWakeDecision> {
"use step";

const rows = await selectSessions({ id: sessionId });
const session = rows[0];
if (!session) return { shouldContinue: false, reason: "session-not-found" };
if (session.status === "archived" || session.lifecycle_state === "archived") {
return { shouldContinue: false, reason: "session-archived" };
}
if (
!hasRuntimeSandboxState(session.sandbox_state) ||
(session.sandbox_state as { type?: unknown } | null)?.type !== "vercel"
) {
return { shouldContinue: false, reason: "sandbox-not-operable" };
}

if (session.lifecycle_run_id !== null && session.lifecycle_run_id !== runId) {
return { shouldContinue: false, reason: "run-replaced" };
}

return { shouldContinue: true, wakeAtMs: getLifecycleDueAtMs(session) };
}
23 changes: 23 additions & 0 deletions app/workflows/runLifecycleEvaluation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { evaluateSandboxLifecycle } from "@/lib/sandbox/evaluateSandboxLifecycle";
import type {
SandboxLifecycleEvaluationResult,
SandboxLifecycleReason,
} from "@/lib/sandbox/sandboxLifecycleTypes";

/**
* Workflow step that runs a single lifecycle evaluation pass. Thin
* wrapper around `evaluateSandboxLifecycle` so the workflow
* orchestrator gets a step boundary (with `"use step"` durability
* semantics) on each evaluation.
*
* @param sessionId - The session whose sandbox to evaluate.
* @param reason - Why the workflow was triggered (for logging only).
* @returns The result of this single evaluation pass.
*/
export async function runLifecycleEvaluation(
sessionId: string,
reason: SandboxLifecycleReason,
): Promise<SandboxLifecycleEvaluationResult> {
"use step";
return evaluateSandboxLifecycle(sessionId, reason);
}
45 changes: 45 additions & 0 deletions app/workflows/sandboxLifecycleWorkflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { sleep } from "workflow";
import { clearLifecycleRunIdIfOwned } from "@/app/workflows/clearLifecycleRunIdIfOwned";
import { computeLifecycleWakeDecision } from "@/app/workflows/computeLifecycleWakeDecision";
import { runLifecycleEvaluation } from "@/app/workflows/runLifecycleEvaluation";
import { SANDBOX_LIFECYCLE_MIN_SLEEP_MS } from "@/lib/sandbox/sandboxLifecycleConfig";
import type { SandboxLifecycleReason } from "@/lib/sandbox/sandboxLifecycleTypes";

/**
* Vercel Workflow that pauses idle sandboxes. Runs as a `while(true)`
* loop: compute next wake time → `sleep(date)` → evaluate → either
* loop (when not-due-yet or active-stream defers) or terminate
* (hibernated / failed / sandbox gone). Holds the
* `lifecycle_run_id` lease throughout so concurrent kicks can't
* spawn duplicate workflows.
*/
export async function sandboxLifecycleWorkflow(
sessionId: string,
reason: SandboxLifecycleReason,
runId: string,
) {
"use workflow";

while (true) {
const decision = await computeLifecycleWakeDecision(sessionId, runId);
if (!decision.shouldContinue || decision.wakeAtMs === undefined) {
await clearLifecycleRunIdIfOwned(sessionId, runId);
return { skipped: true, reason: decision.reason ?? "no-decision" };
}

const wakeAtMs = Math.max(decision.wakeAtMs, Date.now() + SANDBOX_LIFECYCLE_MIN_SLEEP_MS);
await sleep(new Date(wakeAtMs));

const evaluation = await runLifecycleEvaluation(sessionId, reason);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Revalidate lifecycle lease after sleep() before evaluating, otherwise a replaced run can still execute one side-effecting evaluation pass.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/workflows/sandboxLifecycleWorkflow.ts, line 83:

<comment>Revalidate lifecycle lease after `sleep()` before evaluating, otherwise a replaced run can still execute one side-effecting evaluation pass.</comment>

<file context>
@@ -0,0 +1,95 @@
+    const wakeAtMs = Math.max(decision.wakeAtMs, Date.now() + SANDBOX_LIFECYCLE_MIN_SLEEP_MS);
+    await sleep(new Date(wakeAtMs));
+
+    const evaluation = await runLifecycleEvaluation(sessionId, reason);
+
+    if (
</file context>


if (
evaluation.action === "skipped" &&
(evaluation.reason === "not-due-yet" || evaluation.reason === "active-workflow")
) {
continue;
}

await clearLifecycleRunIdIfOwned(sessionId, runId);
return { skipped: false, evaluation };
}
}
27 changes: 27 additions & 0 deletions lib/sandbox/__tests__/createSandboxHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { updateSession } from "@/lib/supabase/sessions/updateSession";
import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills";
import { findOrgSnapshot } from "@/lib/sandbox/findOrgSnapshot";
import { kickBuildOrgSnapshotWorkflow } from "@/lib/sandbox/kickBuildOrgSnapshotWorkflow";
import { kickSandboxLifecycleWorkflow } from "@/lib/sandbox/kickSandboxLifecycleWorkflow";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
Expand Down Expand Up @@ -37,6 +38,9 @@ vi.mock("@/lib/sandbox/findOrgSnapshot", () => ({
vi.mock("@/lib/sandbox/kickBuildOrgSnapshotWorkflow", () => ({
kickBuildOrgSnapshotWorkflow: vi.fn(),
}));
vi.mock("@/lib/sandbox/kickSandboxLifecycleWorkflow", () => ({
kickSandboxLifecycleWorkflow: vi.fn(),
}));

const ACCOUNT_ID = "acc-1";

Expand Down Expand Up @@ -281,6 +285,29 @@ describe("createSandboxHandler", () => {
expect(kickBuildOrgSnapshotWorkflow).not.toHaveBeenCalled();
});

it("kicks the sandbox lifecycle workflow with reason='sandbox-created' when sessionId is provided", async () => {
await createSandboxHandler(makeReq());

expect(kickSandboxLifecycleWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "sess-1",
reason: "sandbox-created",
scheduleBackgroundWork: expect.any(Function),
}),
);
});

it("does not kick the lifecycle workflow when no sessionId is provided", async () => {
vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({
body: { repoUrl: "https://github.com/o/r" },
auth: { accountId: ACCOUNT_ID, orgId: null, authToken: "k" },
});

await createSandboxHandler(makeReq());

expect(kickSandboxLifecycleWorkflow).not.toHaveBeenCalled();
});

it("does not attempt skill installation when no sessionId is provided", async () => {
vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({
body: { repoUrl: "https://github.com/o/r" },
Expand Down
3 changes: 3 additions & 0 deletions lib/sandbox/__tests__/getSandboxStatusHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({
vi.mock("@/lib/supabase/sessions/selectSessions", () => ({
selectSessions: vi.fn(),
}));
vi.mock("@/lib/sandbox/kickSandboxLifecycleWorkflow", () => ({
kickSandboxLifecycleWorkflow: vi.fn(),
}));

const ACCOUNT_ID = "acc-1";
const FAR_FUTURE = "2099-01-01T00:00:00.000Z";
Expand Down
27 changes: 27 additions & 0 deletions lib/sandbox/buildActiveLifecycleUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { buildLifecycleActivityUpdate } from "@/lib/sandbox/buildLifecycleActivityUpdate";
import { getSandboxExpiresAtDate } from "@/lib/sandbox/getSandboxExpiresAtDate";
import type { TablesUpdate } from "@/types/database.types";

/**
* Builds the lifecycle-related fields to write when transitioning a
* session into the `active` state right after a sandbox has been
* provisioned or resumed. Combines `buildLifecycleActivityUpdate`
* with the sandbox's own `expiresAt` so the row's
* `sandbox_expires_at` matches the freshly-probed runtime expiry.
*
* @param sandboxState - The `sandbox_state` JSON value, typically
* from `sandbox.getState()`.
* @param options.activityAt - Optional override for "now".
* @param options.lifecycleState - Defaults to `"active"`; pass
* `"restoring"` for the snapshot-resume path.
* @returns A partial Supabase update object.
*/
export function buildActiveLifecycleUpdate(
sandboxState: unknown,
options?: { activityAt?: Date; lifecycleState?: "active" | "restoring" },
): TablesUpdate<"sessions"> {
return {
...buildLifecycleActivityUpdate(options?.activityAt, options?.lifecycleState ?? "active"),
sandbox_expires_at: getSandboxExpiresAtDate(sandboxState),
};
}
20 changes: 20 additions & 0 deletions lib/sandbox/buildHibernatedLifecycleUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { TablesUpdate } from "@/types/database.types";

/**
* Builds the lifecycle-related fields to write when the workflow
* pauses a sandbox. Clears `sandbox_expires_at`, `hibernate_after`,
* and the `lifecycle_run_id` lease so a future kick can claim it.
* Note the caller is responsible for separately clearing
* `sandbox_state` runtime metadata via `clearSandboxState`.
*
* @returns A partial Supabase update object.
*/
export function buildHibernatedLifecycleUpdate(): TablesUpdate<"sessions"> {
return {
lifecycle_state: "hibernated",
sandbox_expires_at: null,
hibernate_after: null,
lifecycle_run_id: null,
lifecycle_error: null,
};
}
29 changes: 29 additions & 0 deletions lib/sandbox/buildLifecycleActivityUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { SANDBOX_INACTIVITY_TIMEOUT_MS } from "@/lib/sandbox/sandboxLifecycleConfig";
import type { TablesUpdate } from "@/types/database.types";

/**
* Builds the lifecycle-related fields to write when refreshing a
* sandbox's "last activity" — used by `/api/sandbox/activity` and
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Flag AI Slop and Fabricated Changes

Doc comment references a non-existent /api/sandbox/activity route, making the documentation misleading.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/sandbox/buildLifecycleActivityUpdate.ts, line 6:

<comment>Doc comment references a non-existent `/api/sandbox/activity` route, making the documentation misleading.</comment>

<file context>
@@ -0,0 +1,29 @@
+
+/**
+ * Builds the lifecycle-related fields to write when refreshing a
+ * sandbox's "last activity" — used by `/api/sandbox/activity` and
+ * by `buildActiveLifecycleUpdate`. Sets `last_activity_at` to now,
+ * pushes `hibernate_after` out by SANDBOX_INACTIVITY_TIMEOUT_MS, and
</file context>

* by `buildActiveLifecycleUpdate`. Sets `last_activity_at` to now,
* pushes `hibernate_after` out by SANDBOX_INACTIVITY_TIMEOUT_MS, and
* clears any stale `lifecycle_error`. Defaults `lifecycle_state` to
* `"active"` but accepts `"restoring"` for the snapshot-resume path.
*
* @param activityAt - Optional override for "now"; defaults to current time.
* @param lifecycleState - Defaults to `"active"`.
* @returns A partial Supabase update object.
*/
export function buildLifecycleActivityUpdate(
activityAt: Date = new Date(),
lifecycleState: "active" | "restoring" = "active",
): Pick<
TablesUpdate<"sessions">,
"lifecycle_state" | "lifecycle_error" | "last_activity_at" | "hibernate_after"
> {
return {
lifecycle_state: lifecycleState,
lifecycle_error: null,
last_activity_at: activityAt.toISOString(),
hibernate_after: new Date(activityAt.getTime() + SANDBOX_INACTIVITY_TIMEOUT_MS).toISOString(),
};
}
23 changes: 23 additions & 0 deletions lib/sandbox/clearSandboxState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getPersistentSandboxName } from "@/lib/sandbox/getPersistentSandboxName";

/**
* Strips runtime metadata (expiresAt, etc.) from a sandbox state while
* preserving the durable resume handle (sandboxName) so a future
* `connectSandbox` can pick it back up. Used by the lifecycle
* workflow when transitioning to `hibernated`.
*
* @param state - The current `sandbox_state` JSON value.
* @returns A trimmed state with only `type` + `sandboxName`, or null
* when the input is null.
*/
export function clearSandboxState(state: unknown): { type: string; sandboxName?: string } | null {
if (!state || typeof state !== "object") return null;

const sandboxName = getPersistentSandboxName(state);
const type = (state as { type?: unknown }).type;

return {
type: typeof type === "string" ? type : "vercel",
...(sandboxName ? { sandboxName } : {}),
};
}
20 changes: 20 additions & 0 deletions lib/sandbox/computeExpiryDueAtMs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { isoToEpochMs } from "@/lib/sandbox/isoToEpochMs";
import { SANDBOX_EXPIRES_BUFFER_MS } from "@/lib/sandbox/sandboxLifecycleConfig";
import type { Tables } from "@/types/database.types";

/**
* Computes when a session's sandbox should hibernate due to expiry —
* `sandbox_expires_at` minus a small buffer so we pause before
* Vercel's hard timeout. Returns null when the row has no expiry set
* (paused sandbox, type stub).
*
* @param row - The `sessions` row.
* @returns Epoch ms of the expiry due time, or null when not applicable.
*/
export function computeExpiryDueAtMs(
row: Pick<Tables<"sessions">, "sandbox_expires_at">,
): number | null {
const expiresAt = isoToEpochMs(row.sandbox_expires_at);
if (expiresAt === null) return null;
return expiresAt - SANDBOX_EXPIRES_BUFFER_MS;
}
21 changes: 21 additions & 0 deletions lib/sandbox/computeInactivityDueAtMs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { isoToEpochMs } from "@/lib/sandbox/isoToEpochMs";
import { SANDBOX_INACTIVITY_TIMEOUT_MS } from "@/lib/sandbox/sandboxLifecycleConfig";
import type { Tables } from "@/types/database.types";

/**
* Computes when a session's sandbox should hibernate due to inactivity.
* Prefers `hibernate_after` if set; otherwise computes from the most
* recent activity timestamp + SANDBOX_INACTIVITY_TIMEOUT_MS.
*
* @param row - The `sessions` row.
* @returns Epoch ms of the inactivity due time.
*/
export function computeInactivityDueAtMs(
row: Pick<Tables<"sessions">, "hibernate_after" | "last_activity_at" | "updated_at">,
): number {
const hibernateAfter = isoToEpochMs(row.hibernate_after);
if (hibernateAfter !== null) return hibernateAfter;
const lastActivity = isoToEpochMs(row.last_activity_at);
const fallback = lastActivity ?? isoToEpochMs(row.updated_at) ?? Date.now();
return fallback + SANDBOX_INACTIVITY_TIMEOUT_MS;
}
11 changes: 11 additions & 0 deletions lib/sandbox/createLifecycleRunId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Generates a unique identifier for a sandbox-lifecycle workflow run.
* Format: `lifecycle:<timestamp-ms>:<uuid-v4>` — the timestamp gives
* humans a quick sanity check of when the run started, the UUID
* guarantees uniqueness across concurrent kicks.
*
* @returns A new run id string.
*/
export function createLifecycleRunId(): string {
return `lifecycle:${Date.now()}:${crypto.randomUUID()}`;
}
16 changes: 15 additions & 1 deletion lib/sandbox/createSandboxHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ms from "ms";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Enforce Clear Code Style and Maintainability Practices

File exceeds the rule’s 100-line limit and mixes several concerns, including the newly added background scheduling logic.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/sandbox/createSandboxHandler.ts, line 154:

<comment>File exceeds the rule’s 100-line limit and mixes several concerns, including the newly added background scheduling logic.</comment>

<file context>
@@ -145,11 +145,17 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe
+    // function alive past the response until the chain completes —
+    // without that, the chain dies on function teardown and the
+    // workflow never starts. Failures are logged and never surfaced.
+    kickSandboxLifecycleWorkflow({
+      sessionId: sessionRow.id,
+      reason: "sandbox-created",
</file context>

import { NextRequest, NextResponse } from "next/server";
import { NextRequest, NextResponse, after } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateCreateSandboxBody } from "@/lib/sandbox/validateCreateSandboxBody";
import { selectSessions } from "@/lib/supabase/sessions/selectSessions";
Expand All @@ -8,6 +8,7 @@ import { findOrgSnapshot } from "@/lib/sandbox/findOrgSnapshot";
import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName";
import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills";
import { kickBuildOrgSnapshotWorkflow } from "@/lib/sandbox/kickBuildOrgSnapshotWorkflow";
import { kickSandboxLifecycleWorkflow } from "@/lib/sandbox/kickSandboxLifecycleWorkflow";
import { extractOrgRepoName } from "@/lib/recoupable/extractOrgRepoName";
import { updateSession } from "@/lib/supabase/sessions/updateSession";
import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken";
Expand Down Expand Up @@ -142,6 +143,19 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe
error,
);
}

// Register the new sandbox with the lifecycle workflow so it gets
// auto-paused after SANDBOX_INACTIVITY_TIMEOUT_MS of idle. The
// kick chain (selectSessions → claim lease → start workflow) is
// registered with `after()` so the serverless platform keeps the
// function alive past the response until the chain completes —
// without that, the chain dies on function teardown and the
// workflow never starts. Failures are logged and never surfaced.
kickSandboxLifecycleWorkflow({
sessionId: sessionRow.id,
reason: "sandbox-created",
scheduleBackgroundWork: task => after(() => task),
});
}

return NextResponse.json(
Expand Down
Loading
Loading