Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3f4e321
fix(machines): orphan-teardown reconciler + hard-purge guard
2witstudios Jul 14, 2026
7a02db6
fix(machines): stamp branch teardown instead of deleting the row; gua…
2witstudios Jul 14, 2026
160caf7
test(machines): real-Postgres coverage for the reconcile CAS writes
2witstudios Jul 14, 2026
4589bda
docs(cron): describe the stamp + CAS semantics in the crontab entry
2witstudios Jul 14, 2026
c05bac2
fix(machines): skip already-reclaimed branches on teardown; pin the s…
2witstudios Jul 14, 2026
18df3d9
fix(machines): require teardown INTENT before reclaiming; strict kill…
2witstudios Jul 14, 2026
21a5d81
perf(machines): cap the reconcile batch; report a partial sweep
2witstudios Jul 14, 2026
b736080
fix(machines): rescue the sprite pointer in the DB, instead of guardi…
2witstudios Jul 14, 2026
76d8d2f
fix(machines): key teardown on the Sprite INSTANCE, not the reused name
2witstudios Jul 14, 2026
0f78e79
fix(machines): harden the reclaim triggers; pin the destroy-path clas…
2witstudios Jul 14, 2026
f439cff
fix(machines): CAS the teardown plan's session removal too
2witstudios Jul 14, 2026
4121552
fix(machines): honest capped reporting; a failed bookkeeping write co…
2witstudios Jul 14, 2026
89c161c
Merge master into pu/sprites-orphan-reconciler
2witstudios Jul 14, 2026
8f9b2fe
chore(db): fold the trigger migrations into one
2witstudios Jul 14, 2026
78194b9
fix(machines): the instance id must actually REACH the session row
2witstudios Jul 14, 2026
3ccb8aa
Merge master into pu/sprites-orphan-reconciler
2witstudios Jul 14, 2026
14196e2
fix(machines): a REPLACED sprite is a successful reclaim, not a forev…
2witstudios Jul 14, 2026
33fcfaa
refactor(machines): dedup the instance-CAS idiom; fold the two best-e…
2witstudios Jul 14, 2026
84a41d9
Merge remote-tracking branch 'origin/master' into pu/sprites-orphan-r…
2witstudios Jul 14, 2026
c4166f6
Merge remote-tracking branch 'origin/master' into pu/sprites-orphan-r…
2witstudios Jul 14, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Contract tests for /api/cron/reconcile-orphaned-sprites
* Verifies HMAC gating, audit logging, and that the reconcile result surfaces in
* the response for the orphaned-Sprite teardown cron.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';

const { mockReconcile, mockAudit } = vi.hoisted(() => ({
mockReconcile: vi.fn(),
mockAudit: vi.fn(),
}));

vi.mock('@/lib/auth/cron-auth', () => ({
validateSignedCronRequest: vi.fn(),
}));

vi.mock('@pagespace/lib/services/machines/machine-orphan-reconcile', () => ({
reconcileOrphanSprites: mockReconcile,
}));

vi.mock('@/lib/machines/machine-orphan-reconcile-runtime', () => ({
defaultReconcileOrphanSpritesDeps: {},
}));

vi.mock('@pagespace/lib/audit/audit-log', () => ({
audit: mockAudit,
}));

vi.mock('next/server', () => ({
NextResponse: {
json: (body: unknown, init?: ResponseInit) =>
new Response(JSON.stringify(body), {
status: init?.status ?? 200,
headers: { 'content-type': 'application/json' },
}),
},
}));

import { GET, POST } from '../route';
import { validateSignedCronRequest } from '@/lib/auth/cron-auth';

function makeRequest(): Request {
return new Request('http://localhost:3000/api/cron/reconcile-orphaned-sprites');
}

describe('/api/cron/reconcile-orphaned-sprites', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateSignedCronRequest).mockReturnValue(null);
mockReconcile.mockResolvedValue({ processed: 0, torndown: 0, skipped: 0, failed: 0 });
});

it('given valid HMAC, should run the reconcile and surface its counts', async () => {
mockReconcile.mockResolvedValue({ processed: 3, torndown: 2, skipped: 0, failed: 1 });

const response = await GET(makeRequest());
const body = await response.json();

expect(response.status).toBe(200);
expect(body).toMatchObject({ success: true, processed: 3, torndown: 2, skipped: 0, failed: 1 });
expect(body.timestamp).toBeDefined();
});

it('given invalid HMAC, should return the auth error and never touch a Sprite', async () => {
const authResponse = new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 });
vi.mocked(validateSignedCronRequest).mockReturnValue(authResponse as never);

const response = await GET(makeRequest());

expect(response.status).toBe(403);
expect(mockReconcile).not.toHaveBeenCalled();
});

it('should write an audit entry recording what was reclaimed', async () => {
mockReconcile.mockResolvedValue({ processed: 2, torndown: 2, skipped: 0, failed: 0 });

await GET(makeRequest());

expect(mockAudit).toHaveBeenCalledWith(
expect.objectContaining({
eventType: 'data.write',
resourceType: 'cron_job',
resourceId: 'reconcile_orphaned_sprites',
details: { processed: 2, torndown: 2, skipped: 0, failed: 0 },
}),
);
});

it('given a reconcile failure, should return 500 and not log audit', async () => {
mockReconcile.mockRejectedValue(new Error('host unreachable'));

const response = await GET(makeRequest());
const body = await response.json();

expect(response.status).toBe(500);
expect(body).toMatchObject({ success: false, error: 'host unreachable' });
expect(mockAudit).not.toHaveBeenCalled();
});

it('POST should delegate to GET', async () => {
mockReconcile.mockResolvedValue({ processed: 1, torndown: 1, skipped: 0, failed: 0 });

const body = await (await POST(makeRequest())).json();

expect(body).toMatchObject({ success: true, torndown: 1 });
});
});
89 changes: 89 additions & 0 deletions apps/web/src/app/api/cron/reconcile-orphaned-sprites/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { reconcileOrphanSprites } from '@pagespace/lib/services/machines/machine-orphan-reconcile';
import { audit } from '@pagespace/lib/audit/audit-log';
import { NextResponse } from 'next/server';
import { defaultReconcileOrphanSpritesDeps } from '@/lib/machines/machine-orphan-reconcile-runtime';
import { validateSignedCronRequest } from '@/lib/auth/cron-auth';

/**
* Cron endpoint that reclaims ORPHANED Sprites — microVMs whose Machine page was
* deleted but whose teardown never confirmed, so they keep billing RAM with no
* owner reachable from inside the app (Sprites Idle-Cost Remediation).
*
* Two sources (see `machine-orphan-reconcile.ts` in @pagespace/lib):
*
* (A) the RECLAIM OUTBOX — `sandboxId`s rescued by AFTER DELETE triggers as
* their tracking row was cascaded away by a page/drive/user hard delete:
* the 30-day purge, "delete permanently" from the trash, GDPR account
* erasure. Those Sprites have no pointer left anywhere else, so they bill
* forever unless this cron kills them. The trigger runs inside the deleting
* transaction, so no delete path can strand a VM — and none of them needs a
* guard, which matters because Art. 17 erasure must never be blocked by a
* Sprite we failed to kill.
*
* (B) tracking rows under a TRASHED page whose teardown was REQUESTED but never
* confirmed — a `deleteMachine` whose kill failed (the "recoverable state a
* background reconciler can reclaim" its doc always promised). A Machine
* merely dragged to the trash is deliberately left alone: its Sprite
* hibernates and a restore is expected to hand back the disk — a kill is
* irreversible, a trash is not.
*
* A page restored mid-run is re-checked and skipped, and every release write is a
* CAS, so a live Sprite is never recorded as dead.
*
* No advisory lock (unlike reconcile-machine-storage, whose charge is a
* non-idempotent money movement): the kill is idempotent and every row write is
* concurrency-safe, so overlapping runs converge. The crontab's flock is enough.
*
* Authentication: HMAC-signed request with X-Cron-Timestamp, X-Cron-Nonce,
* X-Cron-Signature headers.
*/
export async function GET(request: Request) {
const authError = validateSignedCronRequest(request);
if (authError) {
return authError;
}

try {
const run = await reconcileOrphanSprites(defaultReconcileOrphanSpritesDeps);

console.log(
`[Cron] Orphan sprite reconcile: processed ${run.processed}, torndown ${run.torndown}, skipped ${run.skipped}, failed ${run.failed}${run.capped ? ' (CAPPED — backlog remains, draining next tick)' : ''}`,
);

audit({
eventType: 'data.write',
resourceType: 'cron_job',
resourceId: 'reconcile_orphaned_sprites',
details: {
processed: run.processed,
torndown: run.torndown,
skipped: run.skipped,
failed: run.failed,
capped: run.capped,
},
});

return NextResponse.json({
success: true,
processed: run.processed,
torndown: run.torndown,
skipped: run.skipped,
failed: run.failed,
capped: run.capped,
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('[Cron] Error reconciling orphaned sprites:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}

export async function POST(request: Request) {
return GET(request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function makeDeps(token: string | null = 'ghp_test'): GitSandboxToolsDeps {
acquireSandbox: vi.fn().mockResolvedValue({ ok: true, sandboxId: 'sbx-1', resumed: false }),
reconnect: vi.fn().mockResolvedValue({
sandboxId: 'sbx-1',
spriteInstanceId: null,
runCommand: vi.fn().mockImplementation(async (opts) => {
runCommandCalls.push(opts);
mockRun(opts);
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lib/ai/tools/__tests__/sandbox-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function fakeRunDeps(): SandboxRunDeps {
acquireSandbox: async () => ({ ok: true, sandboxId: 'sbx', resumed: false }),
reconnect: async () => ({
sandboxId: 'sbx',
spriteInstanceId: null,
runCommand: async () => ({ exitCode: 0, stdout: 'hi', stderr: '' }),
writeFiles: async () => {},
readFileToBuffer: async () => Buffer.from('data'),
Expand Down Expand Up @@ -238,6 +239,7 @@ describe('createSandboxTools', () => {
const runDeps = fakeRunDeps();
runDeps.reconnect = async () => ({
sandboxId: 'sbx',
spriteInstanceId: null,
runCommand: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
writeFiles: async () => {
wrote = true;
Expand Down Expand Up @@ -320,6 +322,7 @@ describe('createSandboxTools', () => {
const runDeps = fakeRunDeps();
runDeps.reconnect = async () => ({
sandboxId: 'sbx',
spriteInstanceId: null,
runCommand: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
writeFiles: async () => {
wrote = true;
Expand Down
Loading