diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml new file mode 100644 index 00000000..537d8b6c --- /dev/null +++ b/.github/workflows/migrations.yml @@ -0,0 +1,111 @@ +name: Migrations + +on: + pull_request: + branches: [main] + paths: + - 'packages/server/src/db/**' + push: + branches: [main] + paths: + - 'packages/server/src/db/**' + +concurrency: + group: migrations-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate Migrations + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/setup + with: + build: 'true' + + # Create a minimal wrangler.toml for local D1 operations + - name: Create temporary wrangler config + run: | + cat > wrangler.toml <<'EOF' + name = "relaycast-migration-check" + compatibility_date = "2024-12-01" + + [[d1_databases]] + binding = "DB" + database_name = "relaycast" + database_id = "local" + migrations_dir = "packages/server/src/db/migrations" + EOF + + - name: Apply migrations to local D1 + run: npx wrangler d1 migrations apply DB --local + + # Verify no uncommitted migration changes from schema drift + - name: Check for uncommitted migration changes + run: | + cd packages/server + npx drizzle-kit generate + cd ../.. + if [ -n "$(git status --porcelain packages/server/src/db/migrations/)" ]; then + echo "::error::Uncommitted migration changes detected. Run 'npx drizzle-kit generate' locally and commit the results." + git diff packages/server/src/db/migrations/ + exit 1 + fi + echo "No uncommitted migration changes." + + migration-check: + name: Incremental Migration Check + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + # Check out base branch and apply its migrations + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + + - uses: ./.github/actions/setup + with: + build: 'true' + + - name: Create temporary wrangler config (base) + run: | + cat > wrangler.toml <<'EOF' + name = "relaycast-migration-check" + compatibility_date = "2024-12-01" + + [[d1_databases]] + binding = "DB" + database_name = "relaycast" + database_id = "local" + migrations_dir = "packages/server/src/db/migrations" + EOF + + - name: Apply base branch migrations + run: npx wrangler d1 migrations apply DB --local + + # Check out PR branch overtop, preserving the local D1 database + - uses: actions/checkout@v4 + with: + clean: false + + - uses: ./.github/actions/setup + with: + build: 'true' + + - name: Create temporary wrangler config (PR) + run: | + cat > wrangler.toml <<'EOF' + name = "relaycast-migration-check" + compatibility_date = "2024-12-01" + + [[d1_databases]] + binding = "DB" + database_name = "relaycast" + database_id = "local" + migrations_dir = "packages/server/src/db/migrations" + EOF + + - name: Apply PR migrations incrementally + run: npx wrangler d1 migrations apply DB --local diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 72d3b75f..f23a5754 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -325,10 +325,59 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Compact trajectories after release + compact-trajectories: + name: Compact Trajectories + needs: [build, create-release] + runs-on: ubuntu-latest + if: | + github.event.inputs.dry_run != 'true' && + needs.create-release.result == 'success' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install trail + run: npm install -g agent-trajectories + + - name: Compact trajectories for release + run: | + NEW_VERSION="${{ needs.build.outputs.new_version }}" + # Get commits since previous tag + PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | sed -n '2p') + OUTPUT=".trajectories/compacted/v${NEW_VERSION}.json" + mkdir -p .trajectories/compacted + if [ -n "$PREV_TAG" ]; then + COMMITS=$(git log "${PREV_TAG}..v${NEW_VERSION}" --format=%H | paste -sd, -) + if [ -n "$COMMITS" ]; then + npx trail compact --commits "$COMMITS" --output "$OUTPUT" || true + fi + else + npx trail compact --output "$OUTPUT" || true + fi + + - name: Commit compacted trajectories + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .trajectories/compacted/ || true + git diff --cached --quiet || \ + (git commit -m "chore: compact trajectories for v${{ needs.build.outputs.new_version }}" && git push) + # Summary summary: name: Summary - needs: [build, publish-packages, publish-single, create-release] + needs: [build, publish-packages, publish-single, create-release, compact-trajectories] runs-on: ubuntu-latest if: always() @@ -349,3 +398,4 @@ jobs: echo "| Publish Packages | ${{ needs.publish-packages.result == 'success' && '✅' || (needs.publish-packages.result == 'skipped' && '⏭️' || '❌') }} ${{ needs.publish-packages.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Publish Single | ${{ needs.publish-single.result == 'success' && '✅' || (needs.publish-single.result == 'skipped' && '⏭️' || '❌') }} ${{ needs.publish-single.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Create Release | ${{ needs.create-release.result == 'success' && '✅' || (needs.create-release.result == 'skipped' && '⏭️' || '❌') }} ${{ needs.create-release.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Compact Trajectories | ${{ needs.compact-trajectories.result == 'success' && '✅' || (needs.compact-trajectories.result == 'skipped' && '⏭️' || '❌') }} ${{ needs.compact-trajectories.result }} |" >> $GITHUB_STEP_SUMMARY diff --git a/.trajectories/completed/2026-02/traj_6wned2xdggug.json b/.trajectories/completed/2026-02/traj_6wned2xdggug.json new file mode 100644 index 00000000..407df0d2 --- /dev/null +++ b/.trajectories/completed/2026-02/traj_6wned2xdggug.json @@ -0,0 +1,95 @@ +{ + "id": "traj_6wned2xdggug", + "version": 1, + "task": { + "title": "Workspace eviction: two-phase soft delete + hard delete" + }, + "status": "completed", + "startedAt": "2026-02-21T10:36:30.207Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-02-21T10:36:34.847Z" + } + ], + "chapters": [ + { + "id": "chap_h3qh4b72l29j", + "title": "Work", + "agentName": "default", + "startedAt": "2026-02-21T10:36:34.847Z", + "events": [ + { + "ts": 1771670194848, + "type": "decision", + "content": "Two-phase eviction: suspend at 30 days inactivity (soft delete), hard delete at 60 days (30 days after suspend). This replaces the single-pass hard delete at 30 days.: Two-phase eviction: suspend at 30 days inactivity (soft delete), hard delete at 60 days (30 days after suspend). This replaces the single-pass hard delete at 30 days.", + "raw": { + "question": "Two-phase eviction: suspend at 30 days inactivity (soft delete), hard delete at 60 days (30 days after suspend). This replaces the single-pass hard delete at 30 days.", + "chosen": "Two-phase eviction: suspend at 30 days inactivity (soft delete), hard delete at 60 days (30 days after suspend). This replaces the single-pass hard delete at 30 days.", + "alternatives": [], + "reasoning": "" + }, + "significance": "high" + }, + { + "ts": 1771670200789, + "type": "decision", + "content": "Added suspendedAt column to workspaces table. touchWorkspaceActivity now clears suspendedAt to revive suspended workspaces on any activity.: Added suspendedAt column to workspaces table. touchWorkspaceActivity now clears suspendedAt to revive suspended workspaces on any activity.", + "raw": { + "question": "Added suspendedAt column to workspaces table. touchWorkspaceActivity now clears suspendedAt to revive suspended workspaces on any activity.", + "chosen": "Added suspendedAt column to workspaces table. touchWorkspaceActivity now clears suspendedAt to revive suspended workspaces on any activity.", + "alternatives": [], + "reasoning": "" + }, + "significance": "high" + }, + { + "ts": 1771670204868, + "type": "decision", + "content": "Added touchWorkspaceActivity to requireWorkspaceKey and requireAgentToken middleware (debounced, fire-and-forget) to fix missing activity tracking on those auth paths.: Added touchWorkspaceActivity to requireWorkspaceKey and requireAgentToken middleware (debounced, fire-and-forget) to fix missing activity tracking on those auth paths.", + "raw": { + "question": "Added touchWorkspaceActivity to requireWorkspaceKey and requireAgentToken middleware (debounced, fire-and-forget) to fix missing activity tracking on those auth paths.", + "chosen": "Added touchWorkspaceActivity to requireWorkspaceKey and requireAgentToken middleware (debounced, fire-and-forget) to fix missing activity tracking on those auth paths.", + "alternatives": [], + "reasoning": "" + }, + "significance": "high" + }, + { + "ts": 1771670211558, + "type": "decision", + "content": "R2 cleanup before hard delete: purgeWorkspaceR2 lists objects with workspace prefix and deletes in batches. Runs per-workspace during eviction phase 2.: R2 cleanup before hard delete: purgeWorkspaceR2 lists objects with workspace prefix and deletes in batches. Runs per-workspace during eviction phase 2.", + "raw": { + "question": "R2 cleanup before hard delete: purgeWorkspaceR2 lists objects with workspace prefix and deletes in batches. Runs per-workspace during eviction phase 2.", + "chosen": "R2 cleanup before hard delete: purgeWorkspaceR2 lists objects with workspace prefix and deletes in batches. Runs per-workspace during eviction phase 2.", + "alternatives": [], + "reasoning": "" + }, + "significance": "high" + }, + { + "ts": 1771670218924, + "type": "reflection", + "content": "All changes implemented and verified. Build passes, all 349 tests pass. Files modified: schema.ts (added suspendedAt), 0002_workspace_eviction.sql (added suspended_at column + index), eviction.ts (rewritten with two-phase logic + R2 cleanup), auth.ts (added touch to requireWorkspaceKey and requireAgentToken), worker.ts (updated cron to call both phases), test-helpers.ts and auth.test.ts (added lastActivityAt/suspendedAt to mocks).", + "significance": "high" + } + ], + "endedAt": "2026-02-21T10:37:19.394Z" + } + ], + "commits": [], + "filesChanged": [], + "projectId": "/Users/khaliqgant/Projects/agent-workforce/relaycast", + "tags": [], + "_trace": { + "startRef": "e36eb38ae271ddf374dfb10309e1dd0f7524e5b9", + "endRef": "e36eb38ae271ddf374dfb10309e1dd0f7524e5b9" + }, + "completedAt": "2026-02-21T10:37:19.394Z", + "retrospective": { + "summary": "Implemented two-phase workspace eviction: soft delete (suspend) at 30 days, hard delete with R2 cleanup at 60 days. Fixed activity tracking gaps in requireWorkspaceKey and requireAgentToken middleware.", + "approach": "Schema-first: added suspendedAt column, updated migration SQL, rewrote eviction engine with suspend/evict/purge functions, patched auth middleware, updated cron handler. Fixed test mocks for new fields.", + "confidence": 0.95 + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-02/traj_6wned2xdggug.md b/.trajectories/completed/2026-02/traj_6wned2xdggug.md new file mode 100644 index 00000000..aade442e --- /dev/null +++ b/.trajectories/completed/2026-02/traj_6wned2xdggug.md @@ -0,0 +1,27 @@ +# Trajectory: Workspace eviction: two-phase soft delete + hard delete + +> **Status:** ✅ Completed +> **Confidence:** 95% +> **Started:** February 21, 2026 at 11:36 AM +> **Completed:** February 21, 2026 at 11:37 AM + +--- + +## Summary + +Implemented two-phase workspace eviction: soft delete (suspend) at 30 days, hard delete with R2 cleanup at 60 days. Fixed activity tracking gaps in requireWorkspaceKey and requireAgentToken middleware. + +**Approach:** Schema-first: added suspendedAt column, updated migration SQL, rewrote eviction engine with suspend/evict/purge functions, patched auth middleware, updated cron handler. Fixed test mocks for new fields. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Two-phase eviction: suspend at 30 days inactivity (soft delete), hard delete at 60 days (30 days after suspend). This replaces the single-pass hard delete at 30 days.: Two-phase eviction: suspend at 30 days inactivity (soft delete), hard delete at 60 days (30 days after suspend). This replaces the single-pass hard delete at 30 days. +- Added suspendedAt column to workspaces table. touchWorkspaceActivity now clears suspendedAt to revive suspended workspaces on any activity.: Added suspendedAt column to workspaces table. touchWorkspaceActivity now clears suspendedAt to revive suspended workspaces on any activity. +- Added touchWorkspaceActivity to requireWorkspaceKey and requireAgentToken middleware (debounced, fire-and-forget) to fix missing activity tracking on those auth paths.: Added touchWorkspaceActivity to requireWorkspaceKey and requireAgentToken middleware (debounced, fire-and-forget) to fix missing activity tracking on those auth paths. +- R2 cleanup before hard delete: purgeWorkspaceR2 lists objects with workspace prefix and deletes in batches. Runs per-workspace during eviction phase 2.: R2 cleanup before hard delete: purgeWorkspaceR2 lists objects with workspace prefix and deletes in batches. Runs per-workspace during eviction phase 2. +- All changes implemented and verified. Build passes, all 349 tests pass. Files modified: schema.ts (added suspendedAt), 0002_workspace_eviction.sql (added suspended_at column + index), eviction.ts (rewritten with two-phase logic + R2 cleanup), auth.ts (added touch to requireWorkspaceKey and requireAgentToken), worker.ts (updated cron to call both phases), test-helpers.ts and auth.test.ts (added lastActivityAt/suspendedAt to mocks). diff --git a/.trajectories/index.json b/.trajectories/index.json index 6d2e8038..4b42dfd2 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-02-21T00:57:30.181Z", + "lastUpdated": "2026-02-21T10:37:19.509Z", "trajectories": { "traj_o8g8ahqth5fx": { "title": "Wave 0: add repo infrastructure files (docker, turbo, tsconfig, env)", @@ -966,6 +966,13 @@ "startedAt": "2026-02-21T00:55:51.809Z", "completedAt": "2026-02-21T00:57:30.112Z", "path": "/Users/will/Projects/relaycast/.trajectories/completed/2026-02/traj_bic0crrp84gw.json" + }, + "traj_6wned2xdggug": { + "title": "Workspace eviction: two-phase soft delete + hard delete", + "status": "completed", + "startedAt": "2026-02-21T10:36:30.207Z", + "completedAt": "2026-02-21T10:37:19.394Z", + "path": "/Users/khaliqgant/Projects/agent-workforce/relaycast/.trajectories/completed/2026-02/traj_6wned2xdggug.json" } } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0afd2cb2..b685e429 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "packages/*" ], "devDependencies": { + "agent-trajectories": "^0.5.2", "drizzle-kit": "^0.31.9", "drizzle-orm": "^0.45.1", "eslint": "^9.39.2", @@ -1068,6 +1069,45 @@ "node": ">=6.9.0" } }, + "node_modules/@clack/core": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.5.tgz", + "integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz", + "integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==", + "bundleDependencies": [ + "is-unicode-supported" + ], + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "^0.3.3", + "is-unicode-supported": "*", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts/node_modules/is-unicode-supported": { + "version": "1.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", @@ -5349,6 +5389,34 @@ "node": ">= 14" } }, + "node_modules/agent-trajectories": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.2.tgz", + "integrity": "sha512-oOJ7pu86p0m62FD72sOz/WJwRXkXxe2RuIil6C5pImQLYGYRM67tReiarxddk2FmBH+2l3VKiD1wrGGnIJnUBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.7.0", + "commander": "^12.0.0", + "zod": "^3.23.0" + }, + "bin": { + "trail": "dist/cli/index.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/agent-trajectories/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -5712,6 +5780,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -7319,6 +7397,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7340,6 +7419,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7361,6 +7441,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7382,6 +7463,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7403,6 +7485,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7424,6 +7507,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7445,6 +7529,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7466,6 +7551,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7487,6 +7573,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7508,6 +7595,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7529,6 +7617,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8654,6 +8743,13 @@ "dev": true, "license": "ISC" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9041,6 +9137,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -9058,6 +9155,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -9075,6 +9173,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -9092,6 +9191,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -9109,6 +9209,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -9126,6 +9227,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -9143,6 +9245,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -9160,6 +9263,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -9177,6 +9281,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -9194,6 +9299,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -9211,6 +9317,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -9228,6 +9335,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -9245,6 +9353,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -9262,6 +9371,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -9279,6 +9389,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -9296,6 +9407,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -9313,6 +9425,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -9330,6 +9443,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -9347,6 +9461,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -9364,6 +9479,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -9381,6 +9497,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -9398,6 +9515,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -9415,6 +9533,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -9432,6 +9551,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -9449,6 +9569,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -9466,6 +9587,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } diff --git a/package.json b/package.json index 56a7e320..d5023cdc 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "e2e": "npx tsx scripts/e2e.ts" }, "devDependencies": { + "agent-trajectories": "^0.5.2", "drizzle-kit": "^0.31.9", "drizzle-orm": "^0.45.1", "eslint": "^9.39.2", diff --git a/packages/server/src/__tests__/test-helpers.ts b/packages/server/src/__tests__/test-helpers.ts index 6bdf68d9..b2ed580f 100644 --- a/packages/server/src/__tests__/test-helpers.ts +++ b/packages/server/src/__tests__/test-helpers.ts @@ -38,6 +38,8 @@ export const FAKE_WORKSPACE = { systemPrompt: null, plan: 'free' as const, createdAt: new Date(), + lastActivityAt: new Date(), + suspendedAt: null, metadata: {}, }; diff --git a/packages/server/src/db/migrations/0002_workspace_eviction.sql b/packages/server/src/db/migrations/0002_workspace_eviction.sql new file mode 100644 index 00000000..ee445540 --- /dev/null +++ b/packages/server/src/db/migrations/0002_workspace_eviction.sql @@ -0,0 +1,7 @@ +ALTER TABLE workspaces ADD COLUMN last_activity_at INTEGER NOT NULL DEFAULT (unixepoch()); +--> statement-breakpoint +ALTER TABLE workspaces ADD COLUMN suspended_at INTEGER; +--> statement-breakpoint +CREATE INDEX idx_workspaces_last_activity ON workspaces(last_activity_at); +--> statement-breakpoint +CREATE INDEX idx_workspaces_suspended_at ON workspaces(suspended_at); diff --git a/packages/server/src/db/migrations/meta/_journal.json b/packages/server/src/db/migrations/meta/_journal.json index f87b7c06..fc4f1745 100644 --- a/packages/server/src/db/migrations/meta/_journal.json +++ b/packages/server/src/db/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1771360048665, "tag": "0000_broad_dagger", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1771360048666, + "tag": "0001_fts5_search", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1771360048667, + "tag": "0002_workspace_eviction", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index c2f27f4d..c1678f6d 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -19,6 +19,8 @@ export const workspaces = sqliteTable('workspaces', { systemPrompt: text('system_prompt'), plan: text('plan').notNull().default('free'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), + lastActivityAt: integer('last_activity_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), + suspendedAt: integer('suspended_at', { mode: 'timestamp' }), metadata: text('metadata', { mode: 'json' }).default('{}'), }); diff --git a/packages/server/src/engine/eviction.ts b/packages/server/src/engine/eviction.ts new file mode 100644 index 00000000..f9cb8be8 --- /dev/null +++ b/packages/server/src/engine/eviction.ts @@ -0,0 +1,103 @@ +import { sql, eq, lte, isNull, and } from 'drizzle-orm'; +import type { getDb } from '../db/index.js'; +import { workspaces } from '../db/schema.js'; + +type Db = ReturnType; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +// ── Phase 1: Suspend ──────────────────────────────────────────────── + +/** + * Suspend workspaces that have had no activity for 30+ days. + * Sets `suspendedAt` without deleting any data. + * Returns the list of suspended workspace IDs. + */ +export async function suspendInactiveWorkspaces( + db: Db, + maxInactiveMs: number = THIRTY_DAYS_MS, +): Promise { + const cutoff = new Date(Date.now() - maxInactiveMs); + + const rows = await db + .update(workspaces) + .set({ suspendedAt: sql`(unixepoch())` }) + .where( + and( + lte(workspaces.lastActivityAt, cutoff), + isNull(workspaces.suspendedAt), + ), + ) + .returning({ id: workspaces.id }); + + return rows.map((r) => r.id); +} + +// ── Phase 2: Hard delete ──────────────────────────────────────────── + +/** + * Hard-delete workspaces that have been suspended for 30+ days + * (i.e. 60+ days total inactivity). Cleans up R2 files first. + * Returns the list of evicted workspace IDs. + */ +export async function evictSuspendedWorkspaces( + db: Db, + env: { FILES_BUCKET: R2Bucket }, + maxSuspendedMs: number = THIRTY_DAYS_MS, +): Promise { + const cutoff = new Date(Date.now() - maxSuspendedMs); + + const candidates = await db + .select({ id: workspaces.id }) + .from(workspaces) + .where(lte(workspaces.suspendedAt, cutoff)) + .limit(50); + + const evicted: string[] = []; + for (const { id } of candidates) { + await purgeWorkspaceR2(env.FILES_BUCKET, id); + await db.delete(workspaces).where(eq(workspaces.id, id)); + evicted.push(id); + } + + return evicted; +} + +// ── R2 cleanup ────────────────────────────────────────────────────── + +/** + * Delete all R2 objects under `{workspaceId}/`. + * R2 `delete()` accepts up to 1000 keys per call. + */ +async function purgeWorkspaceR2( + bucket: R2Bucket, + workspaceId: string, +): Promise { + let cursor: string | undefined; + do { + const listed = await bucket.list({ prefix: `${workspaceId}/`, cursor }); + if (listed.objects.length > 0) { + await bucket.delete(listed.objects.map((o) => o.key)); + } + cursor = listed.truncated ? listed.cursor : undefined; + } while (cursor); +} + +// ── Activity touch ────────────────────────────────────────────────── + +/** + * Touch `lastActivityAt` for a workspace and clear `suspendedAt` + * if it was set (reviving a suspended workspace). + */ +export async function touchWorkspaceActivity( + db: Db, + workspaceId: string, +): Promise { + await db + .update(workspaces) + .set({ + lastActivityAt: sql`(unixepoch())`, + suspendedAt: null, + }) + .where(eq(workspaces.id, workspaceId)); +} diff --git a/packages/server/src/middleware/__tests__/auth.test.ts b/packages/server/src/middleware/__tests__/auth.test.ts index 19d63b13..73923a54 100644 --- a/packages/server/src/middleware/__tests__/auth.test.ts +++ b/packages/server/src/middleware/__tests__/auth.test.ts @@ -9,10 +9,13 @@ import { hashToken, } from '../auth.js'; -// Mock touchLastSeen to avoid DB calls +// Mock touchLastSeen and touchWorkspaceActivity to avoid DB calls vi.mock('../../engine/agent.js', () => ({ touchLastSeen: vi.fn().mockResolvedValue(undefined), })); +vi.mock('../../engine/eviction.js', () => ({ + touchWorkspaceActivity: vi.fn().mockResolvedValue(undefined), +})); const fakeWorkspace = { id: 'ws_123', @@ -21,6 +24,8 @@ const fakeWorkspace = { systemPrompt: null, plan: 'free' as const, createdAt: new Date(), + lastActivityAt: new Date(), + suspendedAt: null, metadata: {}, }; @@ -62,6 +67,11 @@ function mockDbReturning(...results: any[]) { }), }), }), + update: () => ({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), } as any; } diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 4887f966..bd03afb5 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -3,9 +3,11 @@ import { createMiddleware } from 'hono/factory'; import { eq } from 'drizzle-orm'; import { workspaces, agents } from '../db/schema.js'; import { touchLastSeen } from '../engine/agent.js'; +import { touchWorkspaceActivity } from '../engine/eviction.js'; import type { AppEnv } from '../env.js'; const LAST_SEEN_DEBOUNCE_MS = 30_000; // 30 seconds +const ACTIVITY_DEBOUNCE_MS = 5 * 60_000; // 5 minutes export function hashToken(token: string): string { return crypto.createHash('sha256').update(token).digest('hex'); @@ -44,6 +46,12 @@ export const requireWorkspaceKey = createMiddleware(async (c, next) => { } c.set('workspace', workspace); + + // Touch workspace activity (debounced, fire-and-forget) + if (Date.now() - workspace.lastActivityAt.getTime() > ACTIVITY_DEBOUNCE_MS) { + touchWorkspaceActivity(db, workspace.id).catch(() => {}); + } + await next(); }); @@ -104,6 +112,12 @@ export const requireAuth = createMiddleware(async (c, next) => { ); } + // Touch workspace activity (debounced, fire-and-forget) + const ws = c.get('workspace'); + if (ws && Date.now() - ws.lastActivityAt.getTime() > ACTIVITY_DEBOUNCE_MS) { + touchWorkspaceActivity(db, ws.id).catch(() => {}); + } + await next(); }); @@ -152,5 +166,11 @@ export const requireAgentToken = createMiddleware(async (c, next) => { ); } c.set('workspace', workspace); + + // Touch workspace activity (debounced, fire-and-forget) + if (Date.now() - workspace.lastActivityAt.getTime() > ACTIVITY_DEBOUNCE_MS) { + touchWorkspaceActivity(db, workspace.id).catch(() => {}); + } + await next(); }); diff --git a/packages/server/src/worker.ts b/packages/server/src/worker.ts index 05db4491..e4eec957 100644 --- a/packages/server/src/worker.ts +++ b/packages/server/src/worker.ts @@ -322,7 +322,35 @@ async function handleQueue(batch: MessageBatch, env: AppEnv['Bindings']) { await logger.flush(); } +// Cron handler for workspace eviction (two-phase: suspend → hard delete) +async function handleScheduled(event: ScheduledEvent, env: AppEnv['Bindings'], ctx: ExecutionContext) { + const { getDb } = await import('./db/index.js'); + const { suspendInactiveWorkspaces, evictSuspendedWorkspaces } = await import('./engine/eviction.js'); + const logger = createLogger(env, { source: 'worker.cron' }); + + try { + const db = getDb(env.DB); + + // Phase 1: Suspend workspaces inactive for 30+ days + const suspended = await suspendInactiveWorkspaces(db); + if (suspended.length > 0) { + logger.info('Suspended inactive workspaces', { count: suspended.length, ids: suspended }); + } + + // Phase 2: Hard-delete workspaces suspended for 30+ days (60 days total) + const evicted = await evictSuspendedWorkspaces(db, env); + if (evicted.length > 0) { + logger.info('Evicted suspended workspaces', { count: evicted.length, ids: evicted }); + } + } catch (error) { + logger.error('Workspace eviction failed', { ...toErrorDetails(error) }); + } + + ctx.waitUntil(logger.flush()); +} + export default { fetch: app.fetch, queue: handleQueue, + scheduled: handleScheduled, }; diff --git a/wrangler.toml b/wrangler.toml index 808aa06f..b555840f 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -75,7 +75,7 @@ id = "PLACEHOLDER_KV_ID" # Cron triggers [triggers] -crons = [] +crons = ["0 2 * * *"] # Staging environment overrides [env.staging] @@ -129,7 +129,7 @@ binding = "KV" id = "PLACEHOLDER_STAGING_KV_ID" [env.staging.triggers] -crons = [] +crons = ["0 2 * * *"] # Preview environment overrides [env.preview] @@ -171,7 +171,7 @@ binding = "KV" id = "PLACEHOLDER_PREVIEW_KV_ID" [env.preview.triggers] -crons = [] +crons = ["0 2 * * *"] # Secrets (set via `wrangler secret put`): # R2_ACCESS_KEY_ID