diff --git a/.env.example b/.env.example index 195942c7..5ea6b9b2 100644 --- a/.env.example +++ b/.env.example @@ -46,7 +46,24 @@ APP_NAME=snapotter # Postgres connection (required; this default matches docker-compose.dev.yml) # Start the dev stack first: docker compose -f docker-compose.dev.yml up -d DATABASE_URL=postgres://snapotter:snapotter@localhost:5432/snapotter -# Redis connection (used from phase 2 onward) -# REDIS_URL=redis://localhost:6379 +# Redis connection (required; this default matches docker-compose.dev.yml) +REDIS_URL=redis://localhost:6379 + +# Job spine tuning +SYNC_WAIT_MS=8000 # sync-response window (ms) before returning 202 +JOBS_RETENTION_DAYS=30 # completed-job metadata TTL (days) +AUDIT_RETENTION_DAYS=0 # audit-log TTL (0 = keep forever) +LOG_DIR=./data/logs # rotating log ring for support bundles +# JOB_TIMEOUT_FAST_S=120 # timeout for fast pools (image), seconds +# JOB_TIMEOUT_LONG_S=7200 # timeout for long pools (ai, media), seconds +# SCRATCH_PATH= # worker scratch dir (default: OS tmpdir/snapotter-scratch) + +# Prometheus metrics: GET /api/v1/metrics requires an authenticated admin +# (scrapers need a session cookie or API key with system:health permission). + +# Analytics: defaults OFF. Enable only with your own PostHog/Sentry keys. +# ANALYTICS_ENABLED=false +# POSTHOG_API_KEY= +# SENTRY_DSN= # One-time SQLite import on first boot (1.x upgrade path). Leave unset normally. # SQLITE_MIGRATE_PATH=/data/snapotter.db diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bdf78fd..319bf975 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,15 @@ jobs: --health-interval 5s --health-timeout 3s --health-retries 10 + redis: + image: redis:8-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 24b572fc..38e04efa 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -30,6 +30,15 @@ jobs: --health-interval 5s --health-timeout 3s --health-retries 10 + redis: + image: redis:8-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 strategy: fail-fast: false matrix: @@ -79,6 +88,15 @@ jobs: --health-interval 5s --health-timeout 3s --health-retries 10 + redis: + image: redis:8-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install system dependencies @@ -122,6 +140,15 @@ jobs: --health-interval 5s --health-timeout 3s --health-retries 10 + redis: + image: redis:8-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup @@ -197,6 +224,15 @@ jobs: --health-interval 5s --health-timeout 3s --health-retries 10 + redis: + image: redis:8-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install system dependencies @@ -212,6 +248,7 @@ jobs: mkdir -p /tmp/st-data AUTH_ENABLED=false ANALYTICS_ENABLED=false \ DATABASE_URL=postgres://snapotter:snapotter@localhost:5432/snapotter \ + REDIS_URL=redis://localhost:6379 \ WORKSPACE_PATH=/tmp/st-data/workspace DATA_DIR=/tmp/st-data \ pnpm --filter @snapotter/api dev & for i in $(seq 1 60); do diff --git a/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json index 6bf86838..88f526a9 100644 --- a/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json +++ b/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json @@ -1,54 +1,67 @@ { - "version": "6", - "dialect": "sqlite", - "id": "91a14a95-bbcb-46ef-abe3-6d2f6fbc8458", - "prevId": "c7909605-aabf-4832-8ef8-9390c0f7c99a", + "id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90", + "prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335", + "version": "7", + "dialect": "postgresql", "tables": { - "api_keys": { + "public.api_keys": { "name": "api_keys", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "key_hash": { "name": "key_hash", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'Default API Key'" }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "last_used_at": { "name": "last_used_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, "indexes": {}, @@ -65,165 +78,423 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "jobs": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_username": { + "name": "actor_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { "name": "jobs", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'queued'" - }, - "progress": { - "name": "progress", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "input_files": { - "name": "input_files", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "output_path": { - "name": "output_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "settings": { - "name": "settings", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "completed_at": { - "name": "completed_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "pipelines": { - "name": "pipelines", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "steps": { - "name": "steps", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "sessions": { - "name": "sessions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": false }, - "expires_at": { - "name": "expires_at", + "tool_id": { + "name": "tool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pool": { + "name": "pool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", "type": "integer", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": 0 + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_refs": { + "name": "input_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_refs": { + "name": "output_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "bytes_in": { + "name": "bytes_in", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "jobs_created_at_idx": { + "name": "jobs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_status_idx": { + "name": "jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_user_id_users_id_fk": { + "name": "jobs_user_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipelines": { + "name": "pipelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "pipelines_user_id_users_id_fk": { + "name": "pipelines_user_id_users_id_fk", + "tableFrom": "pipelines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "roles_created_by_users_id_fk": { + "name": "roles_created_by_users_id_fk", + "tableFrom": "roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true } }, "indexes": {}, @@ -240,115 +511,297 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "settings": { + "public.settings": { "name": "settings", + "schema": "", "columns": { "key": { "name": "key", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "value": { "name": "value", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "updated_at": { "name": "updated_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "users": { - "name": "users", + "public.teams": { + "name": "teams", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_files": { + "name": "user_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, "notNull": true, - "autoincrement": false + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_chain": { + "name": "tool_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_files_user_id_users_id_fk": { + "name": "user_files_user_id_users_id_fk", + "tableFrom": "user_files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true }, "username": { "name": "username", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "password_hash": { "name": "password_hash", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": false }, "role": { "name": "role", "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'user'" }, + "team": { + "name": "team", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, "must_change_password": { "name": "must_change_password", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": true }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "updated_at": { "name": "updated_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "users_username_unique": { - "name": "users_username_unique", - "columns": ["username"], - "isUnique": true + "notNull": true + }, + "analytics_enabled": { + "name": "analytics_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_shown_at": { + "name": "analytics_consent_shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_remind_at": { + "name": "analytics_consent_remind_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, + "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} + "enums": { + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": ["queued", "processing", "completed", "failed", "canceled"] + } }, - "internal": { - "indexes": {} + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} } } diff --git a/apps/api/drizzle/0001_jobs_spine.sql b/apps/api/drizzle/0001_jobs_spine.sql new file mode 100644 index 00000000..01b660e2 --- /dev/null +++ b/apps/api/drizzle/0001_jobs_spine.sql @@ -0,0 +1,25 @@ +ALTER TYPE "public"."job_status" ADD VALUE 'canceled';--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "user_id" text;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "tool_id" text;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "pool" text;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "input_refs" jsonb;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "output_refs" jsonb;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "bytes_in" bigint;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "bytes_out" bigint;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "duration_ms" integer;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "started_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "error_jsonb" jsonb;--> statement-breakpoint +UPDATE "jobs" SET "error_jsonb" = jsonb_build_object('message', "error") WHERE "error" IS NOT NULL;--> statement-breakpoint +ALTER TABLE "jobs" DROP COLUMN "error";--> statement-breakpoint +ALTER TABLE "jobs" RENAME COLUMN "error_jsonb" TO "error";--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "progress_jsonb" jsonb;--> statement-breakpoint +UPDATE "jobs" SET "progress_jsonb" = jsonb_build_object('percent', round("progress" * 100)) WHERE "progress" IS NOT NULL;--> statement-breakpoint +ALTER TABLE "jobs" DROP COLUMN "progress";--> statement-breakpoint +ALTER TABLE "jobs" RENAME COLUMN "progress_jsonb" TO "progress";--> statement-breakpoint +UPDATE "jobs" SET "input_refs" = '[]'::jsonb;--> statement-breakpoint +ALTER TABLE "jobs" DROP COLUMN "input_files";--> statement-breakpoint +ALTER TABLE "jobs" DROP COLUMN "output_path";--> statement-breakpoint +ALTER TABLE "jobs" ADD CONSTRAINT "jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "jobs_created_at_idx" ON "jobs" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "jobs_status_idx" ON "jobs" USING btree ("status"); diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 35c66525..9ba13992 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1781103398348, "tag": "0000_postgres_baseline", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1781189798348, + "tag": "0001_jobs_spine", + "breakpoints": true } ] } diff --git a/apps/api/package.json b/apps/api/package.json index 1cc53332..e793da30 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,11 +27,13 @@ "@snapotter/shared": "workspace:*", "archiver": "^7.0.1", "better-sqlite3": "^11.7.0", + "bullmq": "^5.78.0", "dotenv": "^16.4.0", "drizzle-orm": "^0.45.2", "exif-reader": "^2.0.3", "fastify": "^5.8.5", "fflate": "^0.8.3", + "ioredis": "^5.10.1", "js-yaml": "^4.2.0", "mupdf": "^1.27.0", "openid-client": "^6.8.4", @@ -39,12 +41,15 @@ "p-queue": "^9.3.0", "pdfkit": "^0.18.0", "pg": "^8.21.0", + "pino-roll": "^4.0.0", "piscina": "^5.1.4", "playwright": "^1.60.0", "posthog-node": "^5.35.9", "potrace": "^2.1.8", + "prom-client": "^15.1.3", "qrcode": "^1.5.4", "sharp": "^0.34.5", + "tar": "^7.5.16", "tsx": "^4.22.4", "zod": "^3.24.0", "zxing-wasm": "^3.1.0" @@ -59,6 +64,7 @@ "@types/pg": "^8.20.0", "@types/potrace": "^2.1.5", "@types/qrcode": "^1.5.6", + "@types/tar": "^7.0.87", "drizzle-kit": "^0.31.0", "typescript": "^5.7.0" }, diff --git a/apps/api/src/db/migrate-from-sqlite.ts b/apps/api/src/db/migrate-from-sqlite.ts index 229787de..cdd5f643 100644 --- a/apps/api/src/db/migrate-from-sqlite.ts +++ b/apps/api/src/db/migrate-from-sqlite.ts @@ -19,9 +19,9 @@ const TS = new Set([ ]); // columns storing 0/1 booleans in 1.x const BOOL = new Set(["must_change_password", "analytics_enabled", "is_builtin"]); -// per-table columns whose text-JSON becomes jsonb +// per-table columns whose values must be cast to jsonb in the INSERT const JSONB: Record> = { - jobs: new Set(["input_files", "settings"]), + jobs: new Set(["settings", "input_refs", "output_refs", "progress", "error"]), pipelines: new Set(["steps"]), api_keys: new Set(["permissions"]), roles: new Set(["permissions"]), @@ -45,6 +45,31 @@ const TABLE_ORDER = [ function convertRow(table: string, row: SqliteRow): SqliteRow { const out: SqliteRow = {}; for (const [col, raw] of Object.entries(row)) { + // Jobs table: remap removed 1.x columns to new spine columns + if (table === "jobs") { + if (col === "input_files") { + // 1.x refs are dead workspace paths; discard content, store empty array + out.input_refs = []; + continue; + } + if (col === "output_path") { + // Replaced by output_refs; 1.x paths are dead + out.output_refs = []; + continue; + } + if (col === "progress") { + // real 0-1 becomes jsonb {percent} + const p = typeof raw === "number" ? raw : 0; + out.progress = { percent: Math.round(p * 100) }; + continue; + } + if (col === "error") { + // text becomes jsonb {message} + out.error = raw ? { message: String(raw) } : null; + continue; + } + } + if (raw === null || raw === undefined) { out[col] = null; } else if (TS.has(col)) { diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 8a87edaa..f3ac03e2 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,15 +1,22 @@ import { + bigint, boolean, + index, integer, jsonb, pgEnum, pgTable, - real, text, timestamp, } from "drizzle-orm/pg-core"; -export const jobStatus = pgEnum("job_status", ["queued", "processing", "completed", "failed"]); +export const jobStatus = pgEnum("job_status", [ + "queued", + "processing", + "completed", + "failed", + "canceled", +]); export const users = pgTable("users", { id: text("id").primaryKey(), @@ -60,20 +67,35 @@ export const settings = pgTable("settings", { .$defaultFn(() => new Date()), }); -export const jobs = pgTable("jobs", { - id: text("id").primaryKey(), - type: text("type").notNull(), - status: jobStatus("status").notNull().default("queued"), - progress: real("progress").notNull().default(0), - inputFiles: jsonb("input_files").$type<{ totalFiles: number } | unknown[]>().notNull(), - outputPath: text("output_path"), - settings: jsonb("settings").$type>(), - error: text("error"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .$defaultFn(() => new Date()), - completedAt: timestamp("completed_at", { withTimezone: true }), -}); +export const jobs = pgTable( + "jobs", + { + id: text("id").primaryKey(), + userId: text("user_id").references(() => users.id, { onDelete: "set null" }), + toolId: text("tool_id"), + pool: text("pool"), + type: text("type").notNull(), + status: jobStatus("status").notNull().default("queued"), + attempts: integer("attempts").notNull().default(0), + progress: jsonb("progress").$type<{ percent: number; stage?: string }>(), + inputRefs: jsonb("input_refs").$type(), + outputRefs: jsonb("output_refs").$type(), + settings: jsonb("settings").$type>(), + error: jsonb("error").$type<{ message: string; details?: unknown }>(), + bytesIn: bigint("bytes_in", { mode: "number" }), + bytesOut: bigint("bytes_out", { mode: "number" }), + durationMs: integer("duration_ms"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .$defaultFn(() => new Date()), + startedAt: timestamp("started_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + }, + (table) => [ + index("jobs_created_at_idx").on(table.createdAt), + index("jobs_status_idx").on(table.status), + ], +); export const apiKeys = pgTable("api_keys", { id: text("id").primaryKey(), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 402b4e98..5a37e322 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { join } from "node:path"; import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; @@ -9,8 +10,14 @@ import Fastify from "fastify"; import { env } from "./config.js"; import { closeDb, db, schema } from "./db/index.js"; import { runMigrations } from "./db/migrate.js"; +import { startCancelListener, stopCancelListener } from "./jobs/cancel.js"; +import { closeRedis, pingRedis } from "./jobs/connection.js"; +import { closeFlowProducer, closeQueueEvents } from "./jobs/enqueue.js"; +import { closeQueues, queueCounts } from "./jobs/queues.js"; +import { enqueueSystemJob, SYSTEM_JOBS, scheduleSystemJobs } from "./jobs/system-jobs.js"; +import { closeWorkers, startWorkers } from "./jobs/worker.js"; import { captureException, initAnalytics, shutdownAnalytics } from "./lib/analytics.js"; -import { startCleanupCron } from "./lib/cleanup.js"; +import { shouldRunStartupCleanup } from "./lib/cleanup.js"; import { buildCsp } from "./lib/csp.js"; import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js"; import { shutdownWorkerPool } from "./lib/worker-pool.js"; @@ -25,6 +32,7 @@ import { import { oidcRoutes } from "./plugins/oidc.js"; import { registerStatic } from "./plugins/static.js"; import { registerUpload } from "./plugins/upload.js"; +import { adminOpsRoutes } from "./routes/admin-ops.js"; import { analyticsRoutes } from "./routes/analytics.js"; import { apiKeyRoutes } from "./routes/api-keys.js"; import { auditLogRoutes } from "./routes/audit-log.js"; @@ -36,7 +44,7 @@ import { registerFetchUrlsRoute } from "./routes/fetch-urls.js"; import { fileRoutes } from "./routes/files.js"; import { registerMemeTemplates } from "./routes/meme-templates.js"; import { registerPipelineRoutes } from "./routes/pipeline.js"; -import { recoverStaleJobs, registerProgressRoutes } from "./routes/progress.js"; +import { registerProgressRoutes } from "./routes/progress.js"; import { rolesRoutes } from "./routes/roles.js"; import { settingsRoutes } from "./routes/settings.js"; import { teamsRoutes } from "./routes/teams.js"; @@ -56,6 +64,19 @@ try { } console.log("Database initialized"); +// Verify Redis is reachable (required for BullMQ job queues) +try { + await pingRedis(); +} catch (err) { + const safeUrl = env.REDIS_URL.replace(/:\/\/[^@]*@/, "://***@"); + console.error( + `FATAL: Cannot connect to Redis at ${safeUrl}. Is Redis running? (docker compose up, or set REDIS_URL)`, + ); + console.error(err); + process.exit(1); +} +console.log("Redis connected"); + // Auto-import 1.x SQLite database on first boot (before default user creation) if (env.SQLITE_MIGRATE_PATH) { const { rows } = await db.execute(sql`SELECT count(*)::int AS n FROM users`); @@ -144,15 +165,34 @@ try { // Enterprise package not available } -// Mark any jobs left in processing/queued from a previous unclean shutdown -await recoverStaleJobs(); +// Start the cooperative cancellation listener (Redis pub/sub) +await startCancelListener(); // Set up AI feature directories and recover from interrupted installs ensureAiDirs(); recoverInterruptedInstalls(); const app = Fastify({ - logger: { level: env.LOG_LEVEL }, + logger: { + level: env.LOG_LEVEL, + transport: { + targets: [ + { target: "pino/file", options: { destination: 1 } }, + { + // Rotate at 10 MB, keep 5 files + target: "pino-roll", + options: { + file: join(env.LOG_DIR, "snapotter"), + extension: ".log", + size: "10m", + limit: { count: 5 }, + mkdir: true, + }, + }, + ], + }, + redact: ["req.headers.authorization", "req.headers.cookie"], + }, bodyLimit: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : 1073741824, trustProxy: env.TRUST_PROXY, routerOptions: { maxParamLength: 500 }, @@ -296,6 +336,9 @@ await auditLogRoutes(app); // Roles management routes await rolesRoutes(app); +// Admin ops routes (runtime log level, Prometheus metrics) +await adminOpsRoutes(app); + // API docs (Scalar) await docsRoutes(app); @@ -329,13 +372,20 @@ app.get("/api/v1/admin/health", async (request, reply) => { } catch { /* db unreachable */ } + let queueStats = { active: 0, pending: 0 }; + try { + const counts = await queueCounts(); + queueStats = { active: counts.active, pending: counts.waiting }; + } catch { + /* redis unreachable */ + } return { status: dbOk ? "healthy" : "degraded", version: APP_VERSION, uptime: `${process.uptime().toFixed(0)}s`, storage: { mode: env.STORAGE_MODE, available: "N/A" }, database: dbOk ? "ok" : "error", - queue: { active: 0, pending: 0 }, + queue: queueStats, ai: { gpu: isGpuAvailable(), dispatcher: getDispatcherStatus() }, enterprise: enterpriseLicense ? { active: true, org: enterpriseLicense.org, plan: enterpriseLicense.plan } @@ -356,13 +406,56 @@ app.get("/api/v1/config/auth", async () => { return config; }); +// Readiness probe (no auth -- used by load balancers / k8s) +app.get("/api/v1/readyz", async (_request, reply) => { + let postgres = false; + let redis = false; + try { + await db.select().from(schema.settings).limit(1); + postgres = true; + } catch { + /* db unreachable */ + } + try { + redis = await pingRedis(); + } catch { + /* redis unreachable */ + } + const ok = postgres && redis; + return reply.code(ok ? 200 : 503).send({ ok, postgres, redis }); +}); + +// Cancel a job (authenticated) +app.post( + "/api/v1/jobs/:jobId/cancel", + async ( + request: import("fastify").FastifyRequest<{ Params: { jobId: string } }>, + reply: import("fastify").FastifyReply, + ) => { + const { requireAuth } = await import("./plugins/auth.js"); + const user = requireAuth(request, reply); + if (!user) return; + + const { requestCancel } = await import("./jobs/cancel.js"); + const { jobId } = request.params; + const canceled = await requestCancel(jobId); + return reply.send({ canceled }); + }, +); + // Serve SPA in production if (process.env.NODE_ENV === "production") { await registerStatic(app); } -// Start workspace cleanup cron -const cleanupCron = await startCleanupCron(); +// Schedule repeatable system jobs (storage TTL, session purge, retention) +await scheduleSystemJobs(); +if (await shouldRunStartupCleanup()) { + await enqueueSystemJob(SYSTEM_JOBS.storageTtl); +} + +// Start BullMQ worker pools (after route registration so the tool registry is full) +startWorkers(); // Start try { @@ -406,8 +499,6 @@ async function shutdown(signal: string) { }, SHUTDOWN_TIMEOUT_MS); forceExit.unref(); - cleanupCron.stop(); - try { await app.close(); console.log("HTTP server closed"); @@ -445,6 +536,19 @@ async function shutdown(signal: string) { // analytics shutdown is best-effort } + // Close BullMQ resources before database (workers first so no new jobs start) + try { + await closeWorkers(); + await closeFlowProducer(); + await closeQueueEvents(); + await closeQueues(); + await stopCancelListener(); + await closeRedis(); + console.log("Redis connections closed"); + } catch (err) { + console.error("Error closing Redis connections:", err); + } + try { await closeDb(); console.log("Database connection closed"); diff --git a/apps/api/src/jobs/ai-handlers.ts b/apps/api/src/jobs/ai-handlers.ts new file mode 100644 index 00000000..c41b252b --- /dev/null +++ b/apps/api/src/jobs/ai-handlers.ts @@ -0,0 +1,46 @@ +/** + * AI tool handler registry (stub). + * + * AI tools register their async processing functions here. The worker + * runtime checks hasAiJobHandler() to decide between the standard + * tool-registry process path and the AI-specific path. + * + * Handlers are populated by the AI tool modules during route registration + * (Task 8 wires them up). + */ +import type { ToolProcessCtx } from "../routes/tool-factory.js"; +import type { ToolJobData } from "./types.js"; + +export interface AiJobOutput { + buffer: Buffer; + filename: string; + contentType: string; + resultPayload?: Record; + extraOutputs?: Array<{ name: string; buffer: Buffer; contentType: string }>; +} + +export type AiJobHandler = ( + input: Buffer, + data: ToolJobData, + ctx: ToolProcessCtx, +) => Promise; + +const handlers = new Map(); + +export function registerAiJobHandler(toolId: string, handler: AiJobHandler): void { + handlers.set(toolId, handler); +} + +export function hasAiJobHandler(toolId: string): boolean { + return handlers.has(toolId); +} + +export async function runAiToolJob( + data: ToolJobData, + input: Buffer, + ctx: ToolProcessCtx, +): Promise { + const h = handlers.get(data.toolId); + if (!h) throw new Error(`No AI job handler for ${data.toolId}`); + return h(input, data, ctx); +} diff --git a/apps/api/src/jobs/batch-progress.ts b/apps/api/src/jobs/batch-progress.ts new file mode 100644 index 00000000..ee1fc8d5 --- /dev/null +++ b/apps/api/src/jobs/batch-progress.ts @@ -0,0 +1,67 @@ +/** + * Batch child outcome tracking via Redis counters. + * + * Children record their outcomes (success/failure) in Redis counters + * keyed by the parent batch job ID. This drives the batch-type SSE + * progress events that match the legacy (1.x p-queue) wire format: + * + * completedFiles = total finished (successes + failures) + * failedFiles = failures only (a subset of completedFiles) + * finished = completedFiles >= totalFiles + * terminal status: "completed" when at least one success (done > 0), + * "failed" only when every file failed + */ + +import { updateJobProgress } from "../routes/progress.js"; +import { sharedRedis } from "./connection.js"; +import { bullPrefix } from "./types.js"; + +/** + * Record a batch child outcome and emit a batch progress event. + * + * Called by batch-child workers (success and failure paths) and by + * pipeline-finalize workers when they are part of a pipeline-batch. + * + * Wire format matches the legacy 1.x batch SSE frames: + * completedFiles = done + failed (total finished) + * failedFiles = failed count only + * terminal status: "completed" when done > 0, else "failed" + */ +export async function recordChildOutcome( + parentId: string, + totalFiles: number, + filename: string, + error?: string, +): Promise { + const r = sharedRedis(); + const base = `${bullPrefix()}:batch:${parentId}`; + const done = await r.incr(`${base}:${error ? "failed" : "done"}`); + const other = Number((await r.get(`${base}:${error ? "done" : "failed"}`)) ?? 0); + if (error) await r.rpush(`${base}:errors`, JSON.stringify({ filename, error })); + await r.expire(`${base}:done`, 3600); + await r.expire(`${base}:failed`, 3600); + await r.expire(`${base}:errors`, 3600); + + // Resolve per-counter values regardless of which counter was just bumped + const doneCount = error ? other : done; + const failedCount = error ? done : other; + + // Legacy semantics: completedFiles = total finished (successes + failures) + const completedFiles = doneCount + failedCount; + const failedFiles = failedCount; + + // SSE errors list is capped at 100 entries to bound frame size; failedFiles counter stays accurate + const errors: Array<{ filename: string; error: string }> = ( + await r.lrange(`${base}:errors`, 0, 99) + ).map((e) => JSON.parse(e)); + const finished = completedFiles >= totalFiles; + updateJobProgress({ + jobId: parentId, + status: finished ? (doneCount > 0 ? "completed" : "failed") : "processing", + totalFiles, + completedFiles, + failedFiles, + errors, + currentFile: filename, + }); +} diff --git a/apps/api/src/jobs/cancel.ts b/apps/api/src/jobs/cancel.ts new file mode 100644 index 00000000..05b8c60c --- /dev/null +++ b/apps/api/src/jobs/cancel.ts @@ -0,0 +1,103 @@ +/** + * Cooperative job cancellation via Redis pub/sub. + * + * - Workers call registerCancelable(jobId) on start and unregister on finish. + * - requestCancel(jobId) handles three states: + * 1. Waiting/delayed: remove from queue, mark DB row canceled. + * 2. Active (in a worker): publish the jobId on the cancel channel; + * the worker's AbortSignal fires and it cleans up. + * 3. Terminal or absent: no-op, returns false. + * - startCancelListener() subscribes to the cancel channel and fires + * registered AbortControllers. + */ + +import { eq } from "drizzle-orm"; +import type Redis from "ioredis"; +import { db, schema } from "../db/index.js"; +import { createRedisConnection, sharedRedis } from "./connection.js"; +import { getQueue } from "./queues.js"; +import { bullPrefix, POOLS, type Pool } from "./types.js"; + +// ── Per-worker cancel registry ────────────────────────────────── + +const cancelables = new Map(); + +export function registerCancelable(jobId: string): AbortController { + const ac = new AbortController(); + cancelables.set(jobId, ac); + return ac; +} + +export function unregisterCancelable(jobId: string): void { + cancelables.delete(jobId); +} + +// ── Pub/sub listener ──────────────────────────────────────────── + +const CANCEL_CHANNEL = () => `${bullPrefix()}:cancel`; + +let subscriber: Redis | null = null; + +export async function startCancelListener(): Promise { + subscriber = createRedisConnection(); + subscriber.on("error", (err) => { + console.error("Cancel listener subscriber error", err); + }); + await subscriber.subscribe(CANCEL_CHANNEL()); + subscriber.on("message", (_channel: string, message: string) => { + const ac = cancelables.get(message); + if (ac) { + ac.abort(); + cancelables.delete(message); + } + }); +} + +export async function stopCancelListener(): Promise { + if (subscriber) { + await subscriber.unsubscribe(CANCEL_CHANNEL()); + await subscriber.quit(); + subscriber = null; + } +} + +// ── Cancel request ────────────────────────────────────────────── + +/** + * Attempt to cancel a job. + * + * Returns true if the job was removed (waiting/delayed) or a cancel + * signal was published (active). Returns false if the job is already + * terminal or not found in any queue. + */ +export async function requestCancel(jobId: string): Promise { + for (const pool of POOLS) { + const queue = getQueue(pool); + const job = await queue.getJob(jobId); + if (!job) continue; + + const state = await job.getState(); + + // Waiting or delayed: remove from queue and mark DB row + if (state === "waiting" || state === "delayed") { + await job.remove(); + await db + .update(schema.jobs) + .set({ status: "canceled", completedAt: new Date() }) + .where(eq(schema.jobs.id, jobId)); + return true; + } + + // Active: publish cancel signal for the worker + if (state === "active") { + await sharedRedis().publish(CANCEL_CHANNEL(), jobId); + return true; + } + + // Terminal (completed, failed): no-op + return false; + } + + // Not found in any pool + return false; +} diff --git a/apps/api/src/jobs/connection.ts b/apps/api/src/jobs/connection.ts new file mode 100644 index 00000000..fd38e38e --- /dev/null +++ b/apps/api/src/jobs/connection.ts @@ -0,0 +1,48 @@ +/** + * Redis connection factory for BullMQ queues and pub/sub. + * + * Uses ioredis with settings compatible with BullMQ's requirements + * (maxRetriesPerRequest: null for blocking commands). + */ +import Redis from "ioredis"; +import { env } from "../config.js"; + +/** + * Create a new ioredis connection from REDIS_URL. + * Each caller gets an independent connection (BullMQ requires separate + * connections for Queue, Worker, and QueueEvents). + */ +export function createRedisConnection(): Redis { + return new Redis(env.REDIS_URL, { + maxRetriesPerRequest: null, + enableReadyCheck: true, + }); +} + +let _shared: Redis | null = null; + +/** + * Module-level singleton connection for lightweight commands + * (publish, setex, get). NOT suitable for BullMQ Queue/Worker + * constructors which need their own connections. + */ +export function sharedRedis(): Redis { + if (!_shared) { + _shared = createRedisConnection(); + } + return _shared; +} + +/** Verify Redis is reachable. Resolves true or throws. */ +export async function pingRedis(): Promise { + const result = await sharedRedis().ping(); + return result === "PONG"; +} + +/** Gracefully close the shared connection. */ +export async function closeRedis(): Promise { + if (_shared) { + await _shared.quit(); + _shared = null; + } +} diff --git a/apps/api/src/jobs/enqueue.ts b/apps/api/src/jobs/enqueue.ts new file mode 100644 index 00000000..a60c8f1b --- /dev/null +++ b/apps/api/src/jobs/enqueue.ts @@ -0,0 +1,107 @@ +/** + * Job enqueueing and synchronous-wait helpers. + * + * enqueueToolJob() inserts the durable DB row then adds the job to + * the appropriate BullMQ queue. waitForJob() blocks the HTTP request + * until the worker produces a result or the sync-wait window expires. + */ +import { FlowProducer, type Job, QueueEvents } from "bullmq"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { createRedisConnection } from "./connection.js"; +import { getQueue } from "./queues.js"; +import { POOLS, type Pool, queueName, type ToolJobData, type ToolJobResult } from "./types.js"; + +// ── QueueEvents (one per pool, lazy) ──────────────────────────── + +const queueEventsMap = new Map(); + +function getQueueEvents(pool: Pool): QueueEvents { + let qe = queueEventsMap.get(pool); + if (!qe) { + qe = new QueueEvents(queueName(pool), { + connection: createRedisConnection(), + }); + queueEventsMap.set(pool, qe); + } + return qe; +} + +export async function closeQueueEvents(): Promise { + const promises = [...queueEventsMap.values()].map((qe) => qe.close()); + await Promise.all(promises); + queueEventsMap.clear(); +} + +// ── FlowProducer (lazy singleton, used by Task 9) ─────────────── + +let _flowProducer: FlowProducer | null = null; + +export function getFlowProducer(): FlowProducer { + if (!_flowProducer) { + _flowProducer = new FlowProducer({ + connection: createRedisConnection(), + }); + } + return _flowProducer; +} + +export async function closeFlowProducer(): Promise { + if (_flowProducer) { + await _flowProducer.close(); + _flowProducer = null; + } +} + +// ── Enqueue + wait ────────────────────────────────────────────── + +/** + * Insert a durable job row and enqueue the job in BullMQ. + * Returns the BullMQ Job instance. + */ +export async function enqueueToolJob(data: ToolJobData): Promise> { + // Insert the durable DB row first (crash-safe: row exists even if + // Redis add fails and the job is retried on next boot). + await db.insert(schema.jobs).values({ + id: data.jobId, + userId: data.userId, + toolId: data.toolId, + pool: data.pool, + type: data.kind, + status: "queued", + inputRefs: data.inputRefs, + settings: data.settings as Record, + }); + + const queue = getQueue(data.pool); + const job = await queue.add(data.toolId, { ...data, jobId: data.jobId }, { jobId: data.jobId }); + return job; +} + +/** + * Block until a job finishes or the sync-wait window expires. + * + * Returns the ToolJobResult on success, null if the window expires + * (caller should fall back to SSE polling), or throws on real failure. + */ +export async function waitForJob( + pool: Pool, + jobId: string, + windowMs: number = env.SYNC_WAIT_MS, +): Promise { + const queueEvents = getQueueEvents(pool); + const queue = getQueue(pool); + const job = (await queue.getJob(jobId)) as Job | undefined; + if (!job) return null; + + try { + const result = await job.waitUntilFinished(queueEvents, windowMs); + return result; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (/timed out before finishing/i.test(msg)) { + return null; // sync-wait window expired; fall back to SSE + } + throw err; // real failure + } +} diff --git a/apps/api/src/jobs/postprocess.ts b/apps/api/src/jobs/postprocess.ts new file mode 100644 index 00000000..d8240b7c --- /dev/null +++ b/apps/api/src/jobs/postprocess.ts @@ -0,0 +1,197 @@ +/** + * Post-processing helpers shared by the inline tool factory and the BullMQ + * worker runtime. Extracted from tool-factory.ts so both code paths use + * the same logic without duplication. + * + * generatePreview writes to object storage (for the worker). The factory + * keeps its own workspace-based preview write until Task 8 converts it. + */ +import { randomUUID } from "node:crypto"; +import { extname } from "node:path"; +import { eq } from "drizzle-orm"; +import sharp from "sharp"; +import { db, schema } from "../db/index.js"; +import { putObject } from "../lib/object-storage.js"; + +// ── Content-type to extension map ────────────────────────────── + +export const CONTENT_TYPE_TO_EXT: Record = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + "image/tiff": ".tiff", + "image/avif": ".avif", + "image/svg+xml": ".svg", + "image/bmp": ".bmp", + "image/heic": ".heic", + "image/heif": ".heif", + "image/jxl": ".jxl", + "image/x-icon": ".ico", + "image/vnd.adobe.photoshop": ".psd", + "image/x-exr": ".exr", + "image/vnd.radiance": ".hdr", + "image/x-targa": ".tga", + "image/jp2": ".jp2", + "image/qoi": ".qoi", + "application/postscript": ".eps", + "image/vnd.ms-dds": ".dds", + "image/x-dpx": ".dpx", + "image/fits": ".fits", +}; + +// ── Build output filename ────────────────────────────────────── + +/** + * Build the output filename: add a tool-specific `_toolId` suffix when + * the tool did not rename the file, then fix the extension when the + * output content-type differs from the original extension. + */ +export function buildOutputName( + resultFilename: string, + originalFilename: string, + toolId: string, + contentType: string, +): string { + let out = resultFilename; + + // Add tool suffix only when the tool did not change the filename + if (out === originalFilename) { + const ext = extname(originalFilename); + const base = ext ? originalFilename.slice(0, -ext.length) : originalFilename; + out = `${base}_${toolId}${ext}`; + } + + // Fix extension mismatch (e.g. SVG input -> PNG output) + const expectedExt = CONTENT_TYPE_TO_EXT[contentType]; + if (expectedExt) { + const currentExt = extname(out).toLowerCase(); + if (currentExt && currentExt !== expectedExt) { + out = out.slice(0, -currentExt.length) + expectedExt; + } + } + + return out; +} + +// ── Generate preview (object-storage backed) ─────────────────── + +const BROWSER_PREVIEWABLE = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", + "image/bmp", + "image/avif", +]); + +/** + * Generate a browser-previewable WebP thumbnail for formats that browsers + * cannot render in tags. Writes to object storage under + * `outputs//preview.webp`. + * + * Returns the object key on success, undefined when the format is already + * previewable or when generation fails (non-fatal). + */ +export async function generatePreview( + buffer: Buffer, + contentType: string, + jobId: string, + fallbackInput?: Buffer, +): Promise { + if (BROWSER_PREVIEWABLE.has(contentType)) return undefined; + + const key = `outputs/${jobId}/preview.webp`; + + try { + let previewInput = buffer; + // Sharp cannot decode HEIC natively; use system decoder first + if (contentType === "image/heic" || contentType === "image/heif") { + const { decodeHeic } = await import("../lib/heic-converter.js"); + previewInput = await decodeHeic(buffer); + } + const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer(); + await putObject(key, previewBuffer); + return key; + } catch { + // Retry with the original input buffer (pre-processing) which was + // already validated and decoded during the intake phase. + if (fallbackInput) { + try { + const fallbackBuffer = await sharp(fallbackInput).webp({ quality: 80 }).toBuffer(); + await putObject(key, fallbackBuffer); + return key; + } catch { + // Both attempts failed; frontend will use the upload preview + } + } + } + return undefined; +} + +// ── Auto-save to user file library ───────────────────────────── + +export interface AutoSaveOpts { + fileId?: string; + userId: string | null; + buffer: Buffer; + outName: string; + contentType: string; + toolId: string; +} + +/** + * Auto-save a processed output to the persistent user file library when a + * fileId is provided. Creates a new version linked to the parent file with + * the tool appended to the toolChain. + * + * Returns the new file ID on success, undefined when no fileId or on error. + */ +export async function autoSaveToLibrary(opts: AutoSaveOpts): Promise { + if (!opts.fileId) return undefined; + + try { + const { saveFile } = await import("../lib/file-storage.js"); + const [parent] = await db + .select() + .from(schema.userFiles) + .where(eq(schema.userFiles.id, opts.fileId)); + if (!parent) return undefined; + + const newVersion = parent.version + 1; + const parentChain: string[] = parent.toolChain ?? []; + const newToolChain = [...parentChain, opts.toolId]; + const storedName = await saveFile(opts.buffer, opts.outName); + + // Get image dimensions from the processed output + let width: number | null = null; + let height: number | null = null; + try { + const meta = await sharp(opts.buffer).metadata(); + width = meta.width ?? null; + height = meta.height ?? null; + } catch { + // dimensions are non-critical + } + + const newId = randomUUID(); + await db.insert(schema.userFiles).values({ + id: newId, + userId: parent.userId, + originalName: opts.outName, + storedName, + mimeType: opts.contentType, + size: opts.buffer.length, + width, + height, + version: newVersion, + parentId: opts.fileId, + toolChain: newToolChain, + }); + return newId; + } catch { + // Non-fatal: tool processing already succeeded + return undefined; + } +} diff --git a/apps/api/src/jobs/queues.ts b/apps/api/src/jobs/queues.ts new file mode 100644 index 00000000..5bd65c47 --- /dev/null +++ b/apps/api/src/jobs/queues.ts @@ -0,0 +1,71 @@ +/** + * BullMQ queue instances, one per processing pool. + * + * Queues are created lazily on first access and share the pool's + * default job options (retry policy, TTL-based cleanup). + */ +import { Queue } from "bullmq"; +import { createRedisConnection } from "./connection.js"; +import { POOLS, type Pool, queueName, type ToolJobData, type ToolJobResult } from "./types.js"; + +const queues = new Map>(); + +/** Get (or lazily create) the BullMQ Queue for a pool. */ +export function getQueue(pool: Pool): Queue { + let q = queues.get(pool); + if (!q) { + q = new Queue(queueName(pool), { + connection: createRedisConnection(), + defaultJobOptions: { + attempts: pool === "ai" ? 1 : 2, + backoff: { type: "exponential", delay: 1000 }, + removeOnComplete: { age: 3600, count: 5000 }, + removeOnFail: { age: 24 * 3600, count: 5000 }, + }, + }); + queues.set(pool, q); + } + return q; +} + +/** Close all queue connections. */ +export async function closeQueues(): Promise { + const promises = [...queues.values()].map((q) => q.close()); + await Promise.all(promises); + queues.clear(); +} + +/** Aggregate counts across all pools. */ +export async function queueCounts(): Promise<{ + active: number; + waiting: number; + delayed: number; +}> { + let active = 0; + let waiting = 0; + let delayed = 0; + for (const pool of POOLS) { + const q = queues.get(pool); + if (!q) continue; + const counts = await q.getJobCounts("active", "waiting", "delayed"); + active += counts.active ?? 0; + waiting += counts.waiting ?? 0; + delayed += counts.delayed ?? 0; + } + return { active, waiting, delayed }; +} + +/** Per-pool job counts (for Prometheus metrics). */ +export async function perPoolCounts(): Promise> { + const result: Record = {}; + for (const pool of POOLS) { + const q = queues.get(pool); + if (!q) { + result[pool] = { active: 0, waiting: 0 }; + continue; + } + const counts = await q.getJobCounts("active", "waiting"); + result[pool] = { active: counts.active ?? 0, waiting: counts.waiting ?? 0 }; + } + return result; +} diff --git a/apps/api/src/jobs/system-jobs.ts b/apps/api/src/jobs/system-jobs.ts new file mode 100644 index 00000000..fa093197 --- /dev/null +++ b/apps/api/src/jobs/system-jobs.ts @@ -0,0 +1,165 @@ +/** + * System job dispatcher and schedulers. + * + * Three repeatable system jobs replace the old setInterval crons: + * - storageTtl: sweeps uploads/ and outputs/ dirs past FILE_MAX_AGE_HOURS + * - sessionPurge: removes expired sessions (hourly) + * - retention: prunes old jobs and audit_log rows per retention env vars (6-hourly) + * + * The system pool also handles batch-finalize (routed by the worker before + * calling runSystemJob); anything else is a bug. + */ +import type { Job } from "bullmq"; +import { inArray, sql } from "drizzle-orm"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { getMaxAgeMs } from "../lib/cleanup.js"; +import { deletePrefix, listJobDirs, type ObjectInfo } from "../lib/object-storage.js"; +import { getQueue } from "./queues.js"; + +export const SYSTEM_JOBS = { + storageTtl: "system:storage-ttl", + sessionPurge: "system:session-purge", + retention: "system:retention", +} as const; + +// -- Scheduling --------------------------------------------------------------- + +export async function scheduleSystemJobs(): Promise { + const q = getQueue("system"); + // 0 = disabled; skip storage TTL scheduler + if (env.CLEANUP_INTERVAL_MINUTES > 0) { + await q.upsertJobScheduler(SYSTEM_JOBS.storageTtl, { + every: env.CLEANUP_INTERVAL_MINUTES * 60_000, + }); + } else { + // A scheduler registered by a previous boot survives in Redis; remove it + // so setting CLEANUP_INTERVAL_MINUTES=0 actually disables the sweep. + await q.removeJobScheduler(SYSTEM_JOBS.storageTtl).catch(() => {}); + } + await q.upsertJobScheduler(SYSTEM_JOBS.sessionPurge, { every: 60 * 60_000 }); + await q.upsertJobScheduler(SYSTEM_JOBS.retention, { every: 6 * 60 * 60_000 }); +} + +/** Enqueue a one-shot system job (e.g. startup cleanup trigger). */ +export async function enqueueSystemJob(name: string): Promise { + const q = getQueue("system"); + // System cron jobs carry no tool payload; processor routes on job.name + await q.add(name, {} as never); +} + +// -- Dispatcher --------------------------------------------------------------- + +export async function runSystemJob(job: Job): Promise { + switch (job.name) { + case SYSTEM_JOBS.storageTtl: + return storageTtlSweep(); + case SYSTEM_JOBS.sessionPurge: + return db.execute(sql`DELETE FROM sessions WHERE expires_at < now()`); + case SYSTEM_JOBS.retention: + return retentionSweep(); + default: + // batch-finalize runs on the system pool too but is routed by the + // worker before calling runSystemJob. Anything else is a bug. + throw new Error(`Unknown system job: ${job.name}`); + } +} + +// -- Storage TTL sweep -------------------------------------------------------- + +export type DirExpiryDecision = "expired" | "keep" | "skip"; + +/** + * Pure decision function for whether a storage dir should be expired. + * + * Three branches: + * - mtimeMs > 0 (local backend): expire when mtimeMs < cutoffMs + * - mtimeMs === 0 with a jobs row: use completedAt ?? createdAt as age basis + * - mtimeMs === 0 without a row: skip (data-safe) + */ +export function decideExpiry( + dir: ObjectInfo, + cutoffMs: number, + rowsById: Map, +): DirExpiryDecision { + if (dir.mtimeMs > 0) { + return dir.mtimeMs < cutoffMs ? "expired" : "keep"; + } + // mtimeMs === 0: S3 backend cannot provide directory mtimes. + const jobId = dir.key.split("/")[1]; + const row = rowsById.get(jobId); + if (!row) { + // Rowless S3 orphan: may be an in-flight upload whose jobs row has + // not been inserted yet. Accepted as a known leak until a dedicated + // orphan-reaper task is added. + return "skip"; + } + const ageMs = (row.completedAt ?? row.createdAt).getTime(); + return ageMs < cutoffMs ? "expired" : "keep"; +} + +async function storageTtlSweep(): Promise<{ removed: number; failed: number }> { + const maxAgeMs = await getMaxAgeMs(); + if (maxAgeMs <= 0) return { removed: 0, failed: 0 }; + + const cutoffMs = Date.now() - maxAgeMs; + const uploadDirs = await listJobDirs("uploads"); + const outputDirs = await listJobDirs("outputs"); + const allDirs = [...uploadDirs, ...outputDirs]; + if (allDirs.length === 0) return { removed: 0, failed: 0 }; + + // Batch-lookup job rows for dirs with unknown mtime (S3 backend) + const unknownIds = [ + ...new Set(allDirs.filter((d) => d.mtimeMs === 0).map((d) => d.key.split("/")[1])), + ]; + const rowsById = new Map(); + if (unknownIds.length > 0) { + const rows = await db + .select({ + id: schema.jobs.id, + createdAt: schema.jobs.createdAt, + completedAt: schema.jobs.completedAt, + }) + .from(schema.jobs) + .where(inArray(schema.jobs.id, unknownIds)); + for (const r of rows) { + rowsById.set(r.id, { createdAt: r.createdAt, completedAt: r.completedAt }); + } + } + + let removed = 0; + const errors: string[] = []; + for (const dir of allDirs) { + if (decideExpiry(dir, cutoffMs, rowsById) === "expired") { + try { + await deletePrefix(dir.key); + removed++; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`${dir.key}: ${message}`); + } + } + } + if (errors.length > 0) { + console.error(`Storage TTL: ${errors.length} dir(s) failed to delete:\n${errors.join("\n")}`); + } + if (removed > 0) { + console.log(`Storage TTL: removed ${removed} expired job dirs`); + } + return { removed, failed: errors.length }; +} + +// -- Retention sweep ---------------------------------------------------------- + +async function retentionSweep(): Promise { + if (env.JOBS_RETENTION_DAYS > 0) { + await db.execute( + sql`DELETE FROM jobs WHERE created_at < now() - ${env.JOBS_RETENTION_DAYS} * interval '1 day' AND status IN ('completed', 'failed', 'canceled')`, + ); + } + if (env.AUDIT_RETENTION_DAYS > 0) { + await db.execute( + sql`DELETE FROM audit_log WHERE created_at < now() - ${env.AUDIT_RETENTION_DAYS} * interval '1 day'`, + ); + } +} diff --git a/apps/api/src/jobs/types.ts b/apps/api/src/jobs/types.ts new file mode 100644 index 00000000..aa41f12f --- /dev/null +++ b/apps/api/src/jobs/types.ts @@ -0,0 +1,55 @@ +/** + * Shared types and naming helpers for the BullMQ job system. + */ + +/** The five processing pools that partition work by resource profile. */ +export const POOLS = ["image", "media", "ai", "docs", "system"] as const; +export type Pool = (typeof POOLS)[number]; + +/** Redis key prefix for all BullMQ data. */ +export function bullPrefix(): string { + return process.env.BULLMQ_PREFIX ?? "snapotter"; +} + +/** Canonical BullMQ queue name for a pool. */ +export function queueName(pool: Pool): string { + return `${bullPrefix()}-${pool}`; +} + +/** Payload stored in each BullMQ job. */ +export interface ToolJobData { + jobId: string; + toolId: string; + userId: string | null; + pool: Pool; + inputRefs: string[]; + filename: string; + settings: unknown; + fileId?: string; + clientJobId?: string; + kind: + | "tool" + | "ai-tool" + | "pipeline-step" + | "pipeline-finalize" + | "batch-child" + | "batch-finalize"; + stepIndex?: number; + totalSteps?: number; + prevJobId?: string; + parentId?: string; + totalFiles?: number; + fileIndex?: number; +} + +/** Result returned by a completed BullMQ job. */ +export interface ToolJobResult { + outputRefs: string[]; + filename: string; + contentType: string; + originalSize: number; + processedSize: number; + previewRef?: string; + savedFileId?: string; + resultPayload?: Record; +} diff --git a/apps/api/src/jobs/worker.ts b/apps/api/src/jobs/worker.ts new file mode 100644 index 00000000..a54e96bd --- /dev/null +++ b/apps/api/src/jobs/worker.ts @@ -0,0 +1,681 @@ +/** + * In-process BullMQ worker pools. + * + * One Worker per processing pool (image, media, ai, docs, system). + * Tool jobs are dispatched to the tool registry or AI handler registry; + * system jobs are routed to the system-jobs module. + * + * Each tool job gets: + * - A per-job scratch directory (cleaned up in finally) + * - An AbortController registered for cooperative cancellation + * - A timeout guard that aborts the signal with reason "timeout" + * - Durable DB row updates at each lifecycle stage + * - Progress events via Redis pub/sub (updateSingleFileProgress) + * + * Timeout vs cancel: the timeout guard calls ac.abort("timeout") so + * signal.reason === "timeout" distinguishes it from a user cancel + * (which calls ac.abort() with no args, yielding an AbortError + * DOMException reason). Timed-out jobs get status "failed" and are + * retried per the queue's attempts policy; canceled jobs get status + * "canceled" and are never retried. Terminal DB writes and SSE frames + * are deferred until the final attempt so intermediate retries stay + * invisible to the client. + */ +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type Job, UnrecoverableError, Worker } from "bullmq"; +import { eq } from "drizzle-orm"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { resolveConcurrency } from "../lib/env.js"; +import { jobDuration, jobsTotal } from "../lib/metrics.js"; +import { getObjectBuffer, putObject } from "../lib/object-storage.js"; +import { publishEphemeral, updateSingleFileProgress } from "../routes/progress.js"; +import { getToolConfig, type ToolProcessCtx } from "../routes/tool-factory.js"; +import { hasAiJobHandler, runAiToolJob } from "./ai-handlers.js"; +import { recordChildOutcome } from "./batch-progress.js"; +import { registerCancelable, unregisterCancelable } from "./cancel.js"; +import { createRedisConnection } from "./connection.js"; +import { autoSaveToLibrary, buildOutputName, generatePreview } from "./postprocess.js"; +import { runSystemJob } from "./system-jobs.js"; +import { POOLS, type Pool, queueName, type ToolJobData, type ToolJobResult } from "./types.js"; + +// ── Helpers ──────────────────────────────────────────────────── + +/** SCRATCH_PATH defaults to "" in the env schema; the empty string + * intentionally falls through to the OS tmpdir. */ +function scratchRoot(): string { + return env.SCRATCH_PATH || join(tmpdir(), "snapotter-scratch"); +} + +function timeoutMsFor(pool: Pool): number { + if (pool === "ai" || pool === "media") { + return env.JOB_TIMEOUT_LONG_S * 1000; + } + return env.JOB_TIMEOUT_FAST_S * 1000; +} + +// ── Legacy result payload ────────────────────────────────────── + +export interface LegacyResultPayload { + jobId: string; + downloadUrl: string; + previewUrl?: string; + originalSize: number; + processedSize: number; + savedFileId?: string; + [key: string]: unknown; +} + +export function buildLegacyResultPayload( + jobResult: ToolJobResult, + jobId: string, +): LegacyResultPayload { + const outName = jobResult.filename; + const payload: LegacyResultPayload = { + jobId, + downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + originalSize: jobResult.originalSize, + processedSize: jobResult.processedSize, + }; + if (jobResult.previewRef) { + payload.previewUrl = `/api/v1/download/${jobId}/preview.webp`; + } + if (jobResult.savedFileId) { + payload.savedFileId = jobResult.savedFileId; + } + if (jobResult.resultPayload) { + Object.assign(payload, jobResult.resultPayload); + } + return payload; +} + +// ── Tool job processor ───────────────────────────────────────── + +async function processToolJob(job: Job): Promise { + const data = job.data; + const { jobId } = data; + const startTime = Date.now(); + + // Register for cooperative cancellation + const ac = registerCancelable(jobId); + const signal = ac.signal; + + // Timeout guard (0 means unlimited; only arm when positive) + const timeoutMs = timeoutMsFor(data.pool); + const timeoutHandle = + timeoutMs > 0 ? setTimeout(() => ac.abort("timeout"), timeoutMs) : undefined; + + // Per-job scratch directory + const scratchDir = join(scratchRoot(), jobId); + + try { + await mkdir(scratchDir, { recursive: true }); + + // Mark job as processing in the durable row + await db + .update(schema.jobs) + .set({ + status: "processing", + startedAt: new Date(), + attempts: job.attemptsMade + 1, + }) + .where(eq(schema.jobs.id, jobId)); + + // Load input from object storage + const inputBuffer = await getObjectBuffer(data.inputRefs[0]); + + // Progress reporter: emits both Redis pub/sub and BullMQ job progress + const progressJobId = data.clientJobId ?? jobId; + const report = (percent: number, stage?: string) => { + updateSingleFileProgress({ + jobId: progressJobId, + phase: "processing", + percent, + stage, + }); + void job.updateProgress({ percent, stage }); + }; + + // Check for cancellation before dispatching + if (signal.aborted) throw new Error("Canceled"); + + // Build the process context + const ctx: ToolProcessCtx = { signal, scratchDir, report }; + + // Dispatch: AI handler or standard tool registry + let resultBuffer: Buffer; + let resultFilename: string; + let resultContentType: string; + let resultPayload: Record | undefined; + let extraOutputs: Array<{ name: string; buffer: Buffer; contentType: string }> | undefined; + + if (hasAiJobHandler(data.toolId)) { + const aiResult = await runAiToolJob(data, inputBuffer, ctx); + resultBuffer = aiResult.buffer; + resultFilename = aiResult.filename; + resultContentType = aiResult.contentType; + resultPayload = aiResult.resultPayload; + extraOutputs = aiResult.extraOutputs; + } else { + const config = getToolConfig(data.toolId); + if (!config) throw new Error(`No tool config for ${data.toolId}`); + const result = await config.process(inputBuffer, data.settings, data.filename, ctx); + resultBuffer = result.buffer; + resultFilename = result.filename; + resultContentType = result.contentType; + } + + // Build output name with tool suffix and extension fixup + const outName = buildOutputName(resultFilename, data.filename, data.toolId, resultContentType); + + // Write primary output to object storage + const primaryKey = `outputs/${jobId}/${outName}`; + await putObject(primaryKey, resultBuffer); + const outputRefs: string[] = [primaryKey]; + + // Write extra outputs (AI tools may produce multiple files) + if (extraOutputs) { + for (const extra of extraOutputs) { + const extraKey = `outputs/${jobId}/${extra.name}`; + await putObject(extraKey, extra.buffer); + outputRefs.push(extraKey); + } + } + + // Generate preview for non-browser-previewable formats + const previewRef = await generatePreview(resultBuffer, resultContentType, jobId, inputBuffer); + + // Auto-save to user file library + const savedFileId = await autoSaveToLibrary({ + fileId: data.fileId, + userId: data.userId, + buffer: resultBuffer, + outName, + contentType: resultContentType, + toolId: data.toolId, + }); + + const durationMs = Date.now() - startTime; + + // Build the result + const jobResult: ToolJobResult = { + outputRefs, + filename: outName, + contentType: resultContentType, + originalSize: inputBuffer.length, + processedSize: resultBuffer.length, + previewRef, + savedFileId, + resultPayload, + }; + + // Update durable row to completed + await db + .update(schema.jobs) + .set({ + status: "completed", + completedAt: new Date(), + durationMs, + bytesIn: inputBuffer.length, + bytesOut: resultBuffer.length, + outputRefs, + progress: { percent: 100, stage: "complete" }, + }) + .where(eq(schema.jobs.id, jobId)); + + // Record Prometheus metrics + jobsTotal.inc({ pool: data.pool, status: "completed" }); + jobDuration.observe({ pool: data.pool }, durationMs / 1000); + + // Emit terminal progress event with legacy result payload + const legacyResult = buildLegacyResultPayload(jobResult, jobId); + updateSingleFileProgress({ + jobId: progressJobId, + phase: "complete", + percent: 100, + stage: "complete", + result: legacyResult, + }); + + return jobResult; + } catch (err) { + const durationMs = Date.now() - startTime; + const isTimeout = signal.aborted && signal.reason === "timeout"; + const isCanceled = signal.aborted && !isTimeout; + const errorMessage = err instanceof Error ? err.message : String(err); + const finalError = isCanceled + ? "Canceled" + : isTimeout + ? `Timed out after ${Math.round(timeoutMs / 1000)}s` + : errorMessage; + + const maxAttempts = job.opts.attempts ?? 1; + const willRetry = !isCanceled && job.attemptsMade + 1 < maxAttempts; + + const progressJobId = data.clientJobId ?? jobId; + + // When the job will be retried, do NOT write a terminal DB row or + // emit a terminal SSE frame. The row stays "processing" and the + // next attempt overwrites startedAt/attempts as usual. + if (!willRetry) { + // Record Prometheus metrics on final attempt only + jobsTotal.inc({ pool: data.pool, status: isCanceled ? "canceled" : "failed" }); + jobDuration.observe({ pool: data.pool }, durationMs / 1000); + + await db + .update(schema.jobs) + .set({ + status: isCanceled ? "canceled" : "failed", + completedAt: new Date(), + durationMs, + error: { message: finalError }, + }) + .where(eq(schema.jobs.id, jobId)) + .catch(() => {}); + + if (isCanceled) { + // Ephemeral terminal event for live SSE clients. Uses + // publishEphemeral so the replay key is set without + // overwriting the DB row (which stays "canceled"). + publishEphemeral({ + jobId: progressJobId, + type: "single", + phase: "failed", + percent: 0, + error: "Canceled", + }); + } else { + updateSingleFileProgress({ + jobId: progressJobId, + phase: "failed", + percent: 0, + error: finalError, + }); + } + } + + if (isCanceled) throw new UnrecoverableError("Canceled"); + if (isTimeout) throw new Error(finalError); + throw err; + } finally { + clearTimeout(timeoutHandle); + unregisterCancelable(jobId); + // Clean up scratch directory + await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); + } +} + +// ── Pipeline step handler ───────────────────────────────────── + +/** + * Process a single pipeline step. Resolves inputRefs at run time + * (step 0 uses the upload key; later steps read the previous step's + * output_refs from the DB), reports pipeline-level progress, then + * falls through to processToolJob for the actual tool work. + * + * Errors are caught and returned as a failure marker instead of + * throwing so that subsequent steps and the finalize parent still + * run (BullMQ parents do not run when children fail hard). + */ +async function processPipelineStep(job: Job): Promise { + const data = job.data; + + // Resolve inputRefs at run time: step 0 already has them from the + // route; later steps read the previous step's output from the DB. + if (data.stepIndex !== undefined && data.stepIndex > 0 && data.prevJobId) { + const [prevRow] = await db + .select({ + outputRefs: schema.jobs.outputRefs, + status: schema.jobs.status, + error: schema.jobs.error, + }) + .from(schema.jobs) + .where(eq(schema.jobs.id, data.prevJobId)); + + if (!prevRow || prevRow.status === "failed" || !prevRow.outputRefs?.[0]) { + // Previous step failed -- propagate the error without processing. + const prevError = + prevRow?.status === "failed" + ? ((prevRow.error as { message?: string } | null)?.message ?? "Processing failed") + : "Previous step has no output"; + await db + .update(schema.jobs) + .set({ status: "failed", completedAt: new Date(), error: { message: prevError } }) + .where(eq(schema.jobs.id, data.jobId)); + return { + outputRefs: [], + filename: data.filename, + contentType: "", + originalSize: 0, + processedSize: 0, + resultPayload: { failed: true, error: prevError }, + }; + } + data.inputRefs = [prevRow.outputRefs[0]]; + } + + // Report pipeline-level progress to the pipeline's SSE channel. + const pipelineProgressId = data.clientJobId; + if (pipelineProgressId) { + const percent = Math.round(((data.stepIndex ?? 0) / (data.totalSteps ?? 1)) * 90); + const stage = `Step ${(data.stepIndex ?? 0) + 1}/${data.totalSteps}: ${data.toolId}`; + updateSingleFileProgress({ jobId: pipelineProgressId, phase: "processing", percent, stage }); + } + + // Clear clientJobId so processToolJob's terminal SSE event goes to the + // step's own jobId (nobody listens) instead of prematurely ending the + // pipeline's SSE stream. + data.clientJobId = undefined; + + try { + return await processToolJob(job); + } catch (err) { + // Step failed -- return failure marker. processToolJob already + // updated the DB row to "failed" and emitted a terminal event + // on the step's own progress channel. + const errorMsg = err instanceof Error ? err.message : String(err); + return { + outputRefs: [], + filename: data.filename, + contentType: "", + originalSize: 0, + processedSize: 0, + resultPayload: { failed: true, error: errorMsg }, + }; + } +} + +// ── Pipeline finalize handler ───────────────────────────────── + +/** + * Assemble the pipeline result after all steps have completed. + * + * Reads all step DB rows, copies the last step's output to + * `outputs//` so the legacy download URL + * works, and returns the pipeline envelope payload. + * + * When part of a pipeline-batch (parentId is set), also records the + * child outcome for batch progress tracking. + */ +async function processPipelineFinalize(job: Job): Promise { + const data = job.data; + const totalSteps = data.totalSteps ?? 0; + + const steps: Array<{ step: number; toolId: string; size: number }> = []; + let firstBytesIn = 0; + let lastOutputRef = ""; + let lastBytesOut = 0; + let failedAtStep: number | null = null; + let failError = ""; + + for (let i = 0; i < totalSteps; i++) { + const stepId = `${data.jobId}-s${i}`; + const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, stepId)); + + if (!row) { + failedAtStep = i; + failError = `Step ${i + 1} row not found`; + break; + } + + if (row.status !== "completed") { + failedAtStep = i; + failError = (row.error as { message?: string } | null)?.message ?? `Step ${i + 1} failed`; + break; + } + + steps.push({ + step: i + 1, + toolId: row.toolId ?? "unknown", + size: Number(row.bytesOut ?? 0), + }); + + if (i === 0) firstBytesIn = Number(row.bytesIn ?? 0); + if (i === totalSteps - 1) { + lastOutputRef = row.outputRefs?.[0] ?? ""; + lastBytesOut = Number(row.bytesOut ?? 0); + } + } + + const progressJobId = data.clientJobId ?? data.jobId; + + // ── Failure path ──────────────────────────────────────────── + if (failedAtStep !== null) { + const errorMsg = `Step ${failedAtStep + 1}: ${failError}`; + + await db + .update(schema.jobs) + .set({ status: "failed", completedAt: new Date(), error: { message: errorMsg } }) + .where(eq(schema.jobs.id, data.jobId)); + + updateSingleFileProgress({ + jobId: progressJobId, + phase: "failed", + percent: 0, + error: errorMsg, + }); + + // Batch progress (pipeline-batch only) + if (data.parentId && data.totalFiles !== undefined) { + await recordChildOutcome(data.parentId, data.totalFiles, data.filename, errorMsg); + } + + return { + outputRefs: [], + filename: data.filename, + contentType: "", + originalSize: firstBytesIn, + processedSize: 0, + resultPayload: { + error: errorMsg, + stepsCompleted: steps.length, + steps, + }, + }; + } + + // ── Success path ──────────────────────────────────────────── + if (!lastOutputRef) throw new Error("Last step has no output"); + + // Copy last step's output to outputs// so + // the legacy download URL /api/v1/download//... works. + const lastOutputBuffer = await getObjectBuffer(lastOutputRef); + const outFilename = lastOutputRef.split("/").pop()!; + const parentKey = `outputs/${data.jobId}/${outFilename}`; + await putObject(parentKey, lastOutputBuffer); + + await db + .update(schema.jobs) + .set({ + status: "completed", + completedAt: new Date(), + outputRefs: [parentKey], + bytesIn: firstBytesIn, + bytesOut: lastBytesOut, + }) + .where(eq(schema.jobs.id, data.jobId)); + + updateSingleFileProgress({ + jobId: progressJobId, + phase: "complete", + percent: 100, + stage: "complete", + }); + + // Batch progress (pipeline-batch only) + if (data.parentId && data.totalFiles !== undefined) { + await recordChildOutcome(data.parentId, data.totalFiles, outFilename); + } + + return { + outputRefs: [parentKey], + filename: outFilename, + contentType: "application/octet-stream", + originalSize: firstBytesIn, + processedSize: lastBytesOut, + resultPayload: { + stepsCompleted: totalSteps, + steps, + }, + }; +} + +// ── Batch child handler ─────────────────────────────────────── + +/** + * Wraps processToolJob for batch-child jobs. On success, records the + * outcome in the batch progress counters. On failure, catches the + * error and returns a failure marker *instead of throwing* so the + * parent batch-finalize job still runs. A hard throw would prevent + * BullMQ from advancing the parent. + * + * Each child records exactly once: the success path calls + * recordChildOutcome after processToolJob returns; the failure path + * calls it in the catch block. Flow children are enqueued with + * attempts: 1 (set in batch.ts / pipeline.ts), so every failure is + * final and processToolJob always writes the terminal DB row before + * rethrowing. If attempts were ever raised above 1, non-final + * failures would skip the DB write and leave the row "processing". + */ +async function processBatchChild(job: Job): Promise { + try { + const result = await processToolJob(job); + await recordChildOutcome(job.data.parentId!, job.data.totalFiles!, job.data.filename); + return result; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + await recordChildOutcome(job.data.parentId!, job.data.totalFiles!, job.data.filename, error); + // Return a completed job with a failure marker so the parent runs. + return { + outputRefs: [], + filename: job.data.filename, + contentType: "", + originalSize: 0, + processedSize: 0, + resultPayload: { failed: true, error }, + }; + } +} + +// ── Batch finalize handler ──────────────────────────────────── + +/** + * Assembles the ordered manifest from child DB rows after all batch + * children have completed. Runs on the system pool (concurrency 1) + * and does only lightweight DB reads -- no heavy processing. + * + * The manifest `[{index, filename, outputRef?, error?}]` is returned + * as the job result so the HTTP route can stream the ZIP. + */ +async function processBatchFinalize(job: Job): Promise { + const data = job.data; + const flowChildCount = + (data.settings as { flowChildCount?: number } | null)?.flowChildCount ?? data.totalFiles ?? 0; + + const manifest: Array<{ + index: number; + filename: string; + outputRef?: string; + error?: string; + }> = []; + + for (let i = 0; i < flowChildCount; i++) { + const childId = `${data.jobId}-f${i}`; + const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, childId)); + + if (!row) { + manifest.push({ index: i, filename: `file-${i}`, error: "Child job row not found" }); + continue; + } + + if (row.status === "completed" && row.outputRefs?.[0]) { + const outFilename = row.outputRefs[0].split("/").pop()!; + manifest.push({ index: i, filename: outFilename, outputRef: row.outputRefs[0] }); + } else { + const errorMsg = (row.error as { message?: string } | null)?.message ?? "Processing failed"; + const inputFilename = row.inputRefs?.[0]?.split("/").pop() ?? `file-${i}`; + manifest.push({ index: i, filename: inputFilename, error: errorMsg }); + } + } + + // Update parent row + await db + .update(schema.jobs) + .set({ status: "completed", completedAt: new Date() }) + .where(eq(schema.jobs.id, data.jobId)); + + return { + outputRefs: [], + filename: "", + contentType: "application/json", + originalSize: 0, + processedSize: 0, + resultPayload: { manifest }, + }; +} + +// ── Worker pool management ───────────────────────────────────── + +const workers: Worker[] = []; + +export function startWorkers(): void { + const concurrency = Math.max(1, Math.floor(resolveConcurrency(env) / 2)); + + for (const pool of POOLS) { + const workerConcurrency = pool === "system" || pool === "ai" ? 1 : concurrency; + + if (pool === "system") { + // System pool returns heterogeneous results: batch-finalize yields + // ToolJobResult; cron system jobs yield domain-specific values. + // Result generic is unknown to avoid casting lies. + const systemProcessor = async (job: Job): Promise => { + if (job.data?.kind === "batch-finalize") return processBatchFinalize(job); + return runSystemJob(job); + }; + + const worker = new Worker(queueName(pool), systemProcessor, { + connection: createRedisConnection(), + concurrency: workerConcurrency, + stalledInterval: 30_000, + }); + + worker.on("error", (err) => { + console.error(`Worker error [${pool}]:`, err); + }); + + workers.push(worker); + continue; + } + + const processor = async (job: Job): Promise => { + const kind = job.data.kind; + if (kind === "pipeline-step") return processPipelineStep(job); + if (kind === "pipeline-finalize") return processPipelineFinalize(job); + if (kind === "batch-child") return processBatchChild(job); + return processToolJob(job); + }; + + const worker = new Worker(queueName(pool), processor, { + connection: createRedisConnection(), + concurrency: workerConcurrency, + stalledInterval: 30_000, + }); + + worker.on("error", (err) => { + console.error(`Worker error [${pool}]:`, err); + }); + + workers.push(worker); + } + + console.log( + `Workers started: ${POOLS.map((p) => `${p}(${p === "system" || p === "ai" ? 1 : concurrency})`).join(", ")}`, + ); +} + +export async function closeWorkers(): Promise { + await Promise.all(workers.map((w) => w.close())); + workers.length = 0; +} diff --git a/apps/api/src/lib/cleanup.ts b/apps/api/src/lib/cleanup.ts index a8a38082..0ef9e41b 100644 --- a/apps/api/src/lib/cleanup.ts +++ b/apps/api/src/lib/cleanup.ts @@ -1,7 +1,4 @@ -import { mkdirSync } from "node:fs"; -import { readdir, rm, stat } from "node:fs/promises"; -import { join } from "node:path"; -import { eq, lt } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; @@ -40,80 +37,3 @@ export async function shouldRunStartupCleanup(): Promise { return true; } } - -export async function startCleanupCron(): Promise<{ stop: () => void }> { - try { - mkdirSync(env.WORKSPACE_PATH, { recursive: true }); - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "EACCES") { - console.error( - `WARNING: Cannot create workspace directory "${env.WORKSPACE_PATH}". Check volume permissions (PUID/PGID).`, - ); - } - throw err; - } - - const intervalMs = env.CLEANUP_INTERVAL_MINUTES * 60 * 1000; - - const cleanup = async () => { - const maxAgeMs = await getMaxAgeMs(); - try { - const entries = await readdir(env.WORKSPACE_PATH, { withFileTypes: true }).catch(() => []); - const now = Date.now(); - let cleaned = 0; - - for (const entry of entries) { - const fullPath = join(env.WORKSPACE_PATH, entry.name); - try { - const stats = await stat(fullPath); - if (now - stats.mtimeMs > maxAgeMs) { - await rm(fullPath, { recursive: true }); - cleaned++; - } - } catch { - // Skip files that can't be stat'd - } - } - - if (cleaned > 0) { - console.log(`Cleanup: removed ${cleaned} expired workspace entries`); - } - } catch (err) { - console.error("Cleanup error:", err); - } - }; - - // Purge expired sessions from the database - const purgeExpiredSessions = async () => { - try { - const now = new Date(); - const result = await db.delete(schema.sessions).where(lt(schema.sessions.expiresAt, now)); - if (result.rowCount && result.rowCount > 0) { - console.log(`Cleanup: purged ${result.rowCount} expired sessions`); - } - } catch (err) { - console.error("Session cleanup error:", err); - } - }; - - // Run on startup only if setting allows it - if (await shouldRunStartupCleanup()) { - cleanup(); - purgeExpiredSessions(); - } - - // Schedule recurring cleanup - const cleanupTimer = setInterval(cleanup, intervalMs); - const sessionTimer = setInterval(purgeExpiredSessions, 60 * 60 * 1000); // Hourly - console.log( - `Cleanup scheduled: every ${env.CLEANUP_INTERVAL_MINUTES}m, max age configurable (env default: ${env.FILE_MAX_AGE_HOURS}h)`, - ); - - return { - stop: () => { - clearInterval(cleanupTimer); - clearInterval(sessionTimer); - }, - }; -} diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index f0a93176..aadb1838 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -80,18 +80,22 @@ const envSchema = z OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), EXTERNAL_URL: z.string().default(""), COOKIE_SECRET: z.string().default(""), + REDIS_URL: z.string().default("redis://localhost:6379"), + SYNC_WAIT_MS: z.coerce.number().default(8000), + JOB_TIMEOUT_FAST_S: z.coerce.number().default(120), + JOB_TIMEOUT_LONG_S: z.coerce.number().default(7200), + JOBS_RETENTION_DAYS: z.coerce.number().default(30), + AUDIT_RETENTION_DAYS: z.coerce.number().default(0), + LOG_DIR: z.string().default("./data/logs"), + SCRATCH_PATH: z.string().default(""), ANALYTICS_ENABLED: z .enum(["true", "false"]) - .default("true") + .default("false") .transform((v) => v === "true"), ANALYTICS_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1.0), - POSTHOG_API_KEY: z.string().default("phc_CVHjGivwWVzh76M5EjijTwP5LpiqWie3EbCzXU7w2Smy"), + POSTHOG_API_KEY: z.string().default(""), POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), - SENTRY_DSN: z - .string() - .default( - "https://2fd53fc3b3fdc59d02cac044a4f90b71@o4511263372738560.ingest.us.sentry.io/4511264620085248", - ), + SENTRY_DSN: z.string().default(""), }) .superRefine((data, ctx) => { if (data.STORAGE_MODE === "s3") { diff --git a/apps/api/src/lib/feature-status.ts b/apps/api/src/lib/feature-status.ts index 4d7cfac9..912cc369 100644 --- a/apps/api/src/lib/feature-status.ts +++ b/apps/api/src/lib/feature-status.ts @@ -1,5 +1,7 @@ +import { randomUUID } from "node:crypto"; import { constants, + copyFileSync, existsSync, mkdirSync, openSync, @@ -12,9 +14,11 @@ import { writeFileSync, } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import type { Readable } from "node:stream"; import { fileURLToPath } from "node:url"; import type { FeatureBundleState, FeatureStatus } from "@snapotter/shared"; import { FEATURE_BUNDLES, TOOL_BUNDLE_MAP } from "@snapotter/shared"; +import * as tar from "tar"; // ── Paths ─────────────────────────────────────────────────────────────── @@ -464,3 +468,194 @@ export function getFeatureStates(): FeatureBundleState[] { }; }); } + +// ── Offline bundle import ────────────────────────────────────────────── +// +// Archive format (v1): +// Gzipped tar containing: +// bundle.json - { bundleId, version, models: string[] } +// models/... - files mirroring MODELS_DIR layout +// +// v1 validates bundleId against the manifest but does NOT verify per-file +// checksums. Transport integrity is the operator's responsibility; a +// checksum manifest is a phase-3 candidate. +// +// Security: symlink/hardlink and other non-file entry types are rejected +// during extraction. Only "File" and "Directory" entries are permitted. + +interface BundleDescriptor { + bundleId: string; + version: string; + models: string[]; +} + +const IMPORT_MAX_BYTES = 20 * 1024 * 1024 * 1024; // 20 GB cumulative +const IMPORT_MAX_ENTRIES = 10_000; + +export async function importBundleArchive( + stream: Readable, +): Promise<{ bundleId: string; version: string; models: string[] }> { + const stagingId = `import-${randomUUID()}`; + const stagingDir = join(AI_DIR, stagingId); + + if (!acquireInstallLock("__import__")) { + throw new ImportLockError("Another install or import is already in progress"); + } + + try { + mkdirSync(stagingDir, { recursive: true }); + + // Extract with safety guards + let cumulativeBytes = 0; + let entryCount = 0; + + await new Promise((res, rej) => { + const extractor = tar.extract({ + cwd: stagingDir, + strip: 0, + filter: (entryPath, entry) => { + // Reject non-file entry types (symlinks, hardlinks, devices, FIFOs, etc.) + if ("type" in entry && entry.type !== "File" && entry.type !== "Directory") { + rej(new ImportValidationError(`Unsupported entry type ${entry.type}: ${entryPath}`)); + return false; + } + // Reject absolute paths and path traversal + if (entryPath.startsWith("/") || entryPath.split("/").includes("..")) { + rej(new ImportValidationError(`Blocked unsafe archive entry: ${entryPath}`)); + return false; + } + entryCount++; + if (entryCount > IMPORT_MAX_ENTRIES) { + rej(new ImportValidationError(`Archive exceeds ${IMPORT_MAX_ENTRIES} entry limit`)); + return false; + } + // Track cumulative size from tar headers (avoids consuming + // entry data which would prevent extraction to disk) + const entrySize = "size" in entry ? (entry.size as number) : 0; + cumulativeBytes += entrySize; + if (cumulativeBytes > IMPORT_MAX_BYTES) { + rej(new ImportValidationError("Archive exceeds 20 GB cumulative size limit")); + return false; + } + return true; + }, + }); + + stream.pipe(extractor); + extractor.on("finish", () => res()); + extractor.on("error", rej); + stream.on("error", rej); + }); + + // Read and validate bundle.json + const bundlePath = join(stagingDir, "bundle.json"); + if (!existsSync(bundlePath)) { + throw new ImportValidationError("Archive is missing bundle.json at root"); + } + + let descriptor: BundleDescriptor; + try { + descriptor = JSON.parse(readFileSync(bundlePath, "utf-8")) as BundleDescriptor; + } catch { + throw new ImportValidationError("bundle.json is not valid JSON"); + } + + if (!descriptor.bundleId || typeof descriptor.bundleId !== "string") { + throw new ImportValidationError("bundle.json: bundleId must be a non-empty string"); + } + if (!descriptor.version || typeof descriptor.version !== "string") { + throw new ImportValidationError("bundle.json: version must be a non-empty string"); + } + if ( + !Array.isArray(descriptor.models) || + !descriptor.models.every((m) => typeof m === "string") + ) { + throw new ImportValidationError("bundle.json: models must be an array of strings"); + } + + // Validate individual model paths against traversal + for (const model of descriptor.models) { + if (!model || model.includes("..") || model.startsWith("/") || model.includes("\\")) { + throw new ImportValidationError(`bundle.json: invalid model path "${model}"`); + } + } + + // Validate bundleId against the manifest + const manifest = readManifest(); + if (!manifest) { + throw new ImportValidationError("Feature manifest not found; cannot validate bundle"); + } + if (!manifest.bundles[descriptor.bundleId]) { + throw new ImportValidationError( + `Unknown bundleId "${descriptor.bundleId}"; not in feature manifest`, + ); + } + + // Move models/* into MODELS_DIR + const stagingModels = join(stagingDir, "models"); + if (existsSync(stagingModels)) { + mkdirSync(MODELS_DIR, { recursive: true }); + moveTreeRecursive(stagingModels, MODELS_DIR); + } + + markInstalled(descriptor.bundleId, descriptor.version, descriptor.models); + + return { + bundleId: descriptor.bundleId, + version: descriptor.version, + models: descriptor.models, + }; + } finally { + // Clean up staging dir (best-effort) + try { + rmSync(stagingDir, { recursive: true, force: true }); + } catch { + // staging cleanup is best-effort + } + releaseInstallLock(); + } +} + +/** Recursively move entries from src into dest, merging directories. */ +function moveTreeRecursive(src: string, dest: string): void { + const entries = readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = join(src, entry.name); + const destPath = join(dest, entry.name); + if (entry.isDirectory()) { + mkdirSync(destPath, { recursive: true }); + moveTreeRecursive(srcPath, destPath); + } else { + mkdirSync(dirname(destPath), { recursive: true }); + try { + renameSync(srcPath, destPath); + } catch (err: unknown) { + // EXDEV: cross-device link (staging on different fs than MODELS_DIR). + // Staging is under AI_DIR so same-fs is expected, but fall back to + // copy+unlink for robustness (e.g. /tmp overlay mounts). + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + copyFileSync(srcPath, destPath); + unlinkSync(srcPath); + } else { + throw err; + } + } + } + } +} + +// ── Import-specific error types ──────────────────────────────────────── + +export class ImportValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ImportValidationError"; + } +} + +export class ImportLockError extends Error { + constructor(message: string) { + super(message); + this.name = "ImportLockError"; + } +} diff --git a/apps/api/src/lib/metrics.ts b/apps/api/src/lib/metrics.ts new file mode 100644 index 00000000..7aeb0974 --- /dev/null +++ b/apps/api/src/lib/metrics.ts @@ -0,0 +1,40 @@ +/** + * Prometheus metrics registry and helpers. + * + * Exposes counters for finished jobs, a histogram for job duration, + * and a metricsText() function that appends live queue-depth gauges + * from BullMQ before returning the scrape payload. + */ +import { Counter, collectDefaultMetrics, Histogram, Registry } from "prom-client"; +import { perPoolCounts } from "../jobs/queues.js"; + +export const registry = new Registry(); +collectDefaultMetrics({ register: registry }); + +export const jobsTotal = new Counter({ + name: "snapotter_jobs_total", + help: "Jobs finished by pool and status", + labelNames: ["pool", "status"] as const, + registers: [registry], +}); + +export const jobDuration = new Histogram({ + name: "snapotter_job_duration_seconds", + help: "Job processing duration", + labelNames: ["pool"] as const, + buckets: [0.1, 0.5, 1, 2, 5, 15, 60, 300, 1800], + registers: [registry], +}); + +export async function metricsText(): Promise { + const counts = await perPoolCounts(); + const lines: string[] = [ + "# HELP snapotter_queue_jobs Current queue depth by pool and state", + "# TYPE snapotter_queue_jobs gauge", + ]; + for (const [pool, c] of Object.entries(counts)) { + lines.push(`snapotter_queue_jobs{pool="${pool}",state="active"} ${c.active}`); + lines.push(`snapotter_queue_jobs{pool="${pool}",state="waiting"} ${c.waiting}`); + } + return `${await registry.metrics()}\n${lines.join("\n")}\n`; +} diff --git a/apps/api/src/lib/object-storage.ts b/apps/api/src/lib/object-storage.ts new file mode 100644 index 00000000..0e1f5064 --- /dev/null +++ b/apps/api/src/lib/object-storage.ts @@ -0,0 +1,251 @@ +import { createReadStream, createWriteStream, existsSync } from "node:fs"; +import { mkdir, readdir, rm, stat, statfs, unlink, writeFile } from "node:fs/promises"; +import { dirname, join, normalize, sep } from "node:path"; +import type { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { env } from "../config.js"; + +// Processing object store: keys are "//" under the +// prefixes uploads/ and outputs/. Local backend roots at WORKSPACE_PATH +// (operators keep their existing volume); S3 backend (enterprise, lazy) maps +// keys 1:1. This module replaces lib/workspace.ts. + +export interface ObjectInfo { + key: string; + size: number; + /** + * Last-modified time in epoch milliseconds. 0 means UNKNOWN (the S3 backend + * cannot cheaply provide directory mtimes): callers MUST NOT time-expire + * entries with mtimeMs === 0; resolve their age from the jobs table instead. + */ + mtimeMs: number; +} + +const VALID_KEY = /^(uploads|outputs)\/[A-Za-z0-9][A-Za-z0-9._-]*\/[^/\0]+$/; + +function assertValidKey(key: string): void { + if (!VALID_KEY.test(key) || key.includes("..")) { + throw new Error(`Invalid object key: ${key}`); + } +} + +function localPath(key: string): string { + const p = normalize(join(env.WORKSPACE_PATH, key)); + if (!p.startsWith(normalize(env.WORKSPACE_PATH) + sep)) { + throw new Error(`Invalid object key: ${key}`); + } + return p; +} + +function useS3(): boolean { + return env.STORAGE_MODE === "s3"; +} + +import type { S3StorageModule } from "@snapotter/enterprise"; + +let s3Mod: S3StorageModule | null = null; +// Concurrent first calls may double-configure; configureS3 is idempotent. +async function getS3(): Promise { + if (!s3Mod) { + const { loadS3Storage } = await import("@snapotter/enterprise"); + const mod = await loadS3Storage(); + mod.configureS3({ + bucket: env.S3_BUCKET, + region: env.S3_REGION, + endpoint: env.S3_ENDPOINT, + accessKeyId: env.S3_ACCESS_KEY_ID, + secretAccessKey: env.S3_SECRET_ACCESS_KEY, + forcePathStyle: env.S3_FORCE_PATH_STYLE, + prefix: env.S3_PREFIX, + }); + s3Mod = mod; + } + return s3Mod; +} + +// ── Capacity guard (local backend only) ────────────────────────── +// Thresholds copied from the former workspace.ts checkWorkspaceCapacity. +// The scan-and-delete cleanup is removed; the TTL sweeper now owns that. + +/** Minimum free space (GB) below which writes are rejected with 503. */ +export const CAPACITY_CRITICAL_GB = 0.5; + +/** + * Pure threshold check exported for unit testing. + * Returns true when freeBytes is below the critical threshold. + */ +export function isBelowCapacity(freeBytes: number): boolean { + return freeBytes / 1024 ** 3 < CAPACITY_CRITICAL_GB; +} + +/** + * Asserts that the local storage volume has enough free space. + * Called by putObject / putObjectStream for the local backend only. + * S3 backend skips this check entirely. + */ +export async function assertLocalCapacity(): Promise { + const root = env.WORKSPACE_PATH; + if (!existsSync(root)) return; + let fsStats: Awaited>; + try { + fsStats = await statfs(root); + } catch { + return; // statfs unavailable (e.g. some CI envs) -- allow the write + } + const freeBytes = fsStats.bavail * fsStats.bsize; + if (isBelowCapacity(freeBytes)) { + const error = new Error("Insufficient disk space for processing"); + (error as Error & { statusCode: number }).statusCode = 503; + throw error; + } +} + +export async function putObject(key: string, data: Buffer): Promise { + assertValidKey(key); + if (useS3()) { + const s3 = await getS3(); + await s3.putGenericObject(key, data); + return; + } + await assertLocalCapacity(); + const p = localPath(key); + await mkdir(dirname(p), { recursive: true }); + await writeFile(p, data); +} + +export async function putObjectStream( + key: string, + source: Readable, + opts: { maxBytes?: number } = {}, +): Promise { + assertValidKey(key); + let written = 0; + const counter = async function* (src: AsyncIterable) { + for await (const chunk of src) { + written += chunk.length; + if (opts.maxBytes && written > opts.maxBytes) { + throw new Error(`Upload exceeds the maximum allowed size (${opts.maxBytes} bytes)`); + } + yield chunk; + } + }; + if (useS3()) { + const s3 = await getS3(); + await s3.putGenericObjectStream(key, counter(source)); + return written; + } + await assertLocalCapacity(); + const p = localPath(key); + await mkdir(dirname(p), { recursive: true }); + try { + await pipeline(counter(source), createWriteStream(p)); + } catch (err) { + await unlink(p).catch(() => {}); + throw err; + } + return written; +} + +export async function getObjectStream( + key: string, + range?: { start: number; end?: number }, +): Promise { + assertValidKey(key); + if (useS3()) { + const s3 = await getS3(); + return s3.getGenericObjectStream(key, range); + } + return createReadStream(localPath(key), range ? { start: range.start, end: range.end } : {}); +} + +export async function getObjectBuffer(key: string): Promise { + const chunks: Buffer[] = []; + for await (const c of await getObjectStream(key)) chunks.push(c as Buffer); + return Buffer.concat(chunks); +} + +export async function getObjectSize(key: string): Promise { + assertValidKey(key); + if (useS3()) { + const s3 = await getS3(); + return s3.getGenericObjectSize(key); + } + return (await stat(localPath(key))).size; +} + +export async function objectExists(key: string): Promise { + try { + await getObjectSize(key); + return true; + } catch { + return false; + } +} + +export async function deleteObject(key: string): Promise { + assertValidKey(key); + if (useS3()) { + const s3 = await getS3(); + await s3.deleteGenericObject(key); + return; + } + await unlink(localPath(key)).catch(() => {}); +} + +export async function deletePrefix(prefix: string): Promise { + if (!/^(uploads|outputs)\/[A-Za-z0-9][A-Za-z0-9._-]*\/?$/.test(prefix)) { + throw new Error(`Invalid prefix: ${prefix}`); + } + if (useS3()) { + const s3 = await getS3(); + await s3.deleteGenericPrefix(prefix); + return; + } + await rm(join(env.WORKSPACE_PATH, prefix), { recursive: true, force: true }); +} + +export async function listObjects(prefix: string): Promise { + if (!/^(uploads|outputs)\/[A-Za-z0-9][A-Za-z0-9._-]*\/?$/.test(prefix) || prefix.includes("..")) { + throw new Error(`Invalid prefix: ${prefix}`); + } + if (useS3()) { + const s3 = await getS3(); + return s3.listGenericObjects(prefix); + } + const dir = join(env.WORKSPACE_PATH, prefix); + try { + const out: ObjectInfo[] = []; + for (const name of await readdir(dir)) { + const s = await stat(join(dir, name)).catch(() => null); + if (s?.isFile()) + out.push({ + key: `${prefix.replace(/\/?$/, "/")}${name}`, + size: s.size, + mtimeMs: s.mtimeMs, + }); + } + return out; + } catch { + return []; + } +} + +// Lists the top-level job directories under a prefix with their mtime so the +// TTL sweeper can expire whole jobs. S3 derives them from key listings. +export async function listJobDirs(prefix: "uploads" | "outputs"): Promise { + if (useS3()) { + const s3 = await getS3(); + return s3.listGenericJobDirs(prefix); + } + const root = join(env.WORKSPACE_PATH, prefix); + try { + const out: ObjectInfo[] = []; + for (const name of await readdir(root)) { + const s = await stat(join(root, name)).catch(() => null); + if (s?.isDirectory()) out.push({ key: `${prefix}/${name}`, size: 0, mtimeMs: s.mtimeMs }); + } + return out; + } catch { + return []; + } +} diff --git a/apps/api/src/lib/support-bundle.ts b/apps/api/src/lib/support-bundle.ts new file mode 100644 index 00000000..f13c8b2a --- /dev/null +++ b/apps/api/src/lib/support-bundle.ts @@ -0,0 +1,150 @@ +/** + * Assemble a diagnostic support bundle as a zip stream. + * + * Contents: + * logs/ -- all log files from LOG_DIR + * config.json -- redacted env + version metadata + * db-counts.json -- row counts per table + * failed-jobs.json -- last 20 failed/canceled jobs + * host.json -- OS / hardware snapshot + */ +import { readdirSync, readFileSync, statfsSync } from "node:fs"; +import * as os from "node:os"; +import { join } from "node:path"; +import { PassThrough, type Readable } from "node:stream"; +import { APP_VERSION } from "@snapotter/shared"; +import archiver from "archiver"; +import { desc, inArray, sql } from "drizzle-orm"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; + +// Keys whose values are fully replaced with "" +const REDACT_PATTERN = /PASSWORD|SECRET|KEY|DSN/i; + +// Keys that get userinfo-only redaction +const URL_REDACT_KEYS = new Set(["DATABASE_URL", "REDIS_URL"]); + +function redactEnv(): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (URL_REDACT_KEYS.has(key) && typeof value === "string") { + out[key] = value.replace(/:\/\/[^@]*@/, "://***@"); + } else if (REDACT_PATTERN.test(key)) { + out[key] = ""; + } else { + out[key] = value; + } + } + return out; +} + +async function dbCounts(): Promise> { + try { + const { rows } = await db.execute( + sql`SELECT relname, n_live_tup::int AS n_live_tup FROM pg_stat_user_tables ORDER BY relname`, + ); + return rows as Array<{ relname: string; n_live_tup: number }>; + } catch { + return []; + } +} + +async function failedJobs(): Promise { + try { + const rows = await db + .select({ + id: schema.jobs.id, + toolId: schema.jobs.toolId, + pool: schema.jobs.pool, + error: schema.jobs.error, + createdAt: schema.jobs.createdAt, + durationMs: schema.jobs.durationMs, + }) + .from(schema.jobs) + .where(inArray(schema.jobs.status, ["failed", "canceled"])) + .orderBy(desc(schema.jobs.createdAt)) + .limit(20); + return rows; + } catch { + return []; + } +} + +function hostInfo(): Record { + const info: Record = { + platform: os.platform(), + release: os.release(), + arch: os.arch(), + cpus: os.cpus().length, + totalMemBytes: os.totalmem(), + freeMemBytes: os.freemem(), + }; + try { + const stats = statfsSync(env.WORKSPACE_PATH); + info.workspaceFreeBytes = stats.bfree * stats.bsize; + } catch { + info.workspaceFreeBytes = null; + } + return info; +} + +/** + * Build the support bundle zip and return it as a readable stream. + * The caller is responsible for piping it to the HTTP response. + */ +export function buildSupportBundle(): Readable { + const passthrough = new PassThrough(); + const archive = archiver("zip", { zlib: { level: 6 } }); + + archive.pipe(passthrough); + archive.on("error", (err) => passthrough.destroy(err)); + + // Kick off the async assembly without blocking the return. + // Errors are forwarded to the passthrough so the HTTP layer sees them. + (async () => { + try { + // 1. Log files + try { + const files = readdirSync(env.LOG_DIR); + for (const file of files) { + const full = join(env.LOG_DIR, file); + try { + const content = readFileSync(full); + archive.append(content, { name: `logs/${file}` }); + } catch { + // skip unreadable files + } + } + } catch { + // LOG_DIR missing -- skip silently + } + + // 2. Redacted config + const config = { + ...redactEnv(), + version: APP_VERSION, + node: process.version, + }; + archive.append(JSON.stringify(config, null, 2), { name: "config.json" }); + + // 3. DB table counts + const counts = await dbCounts(); + archive.append(JSON.stringify(counts, null, 2), { name: "db-counts.json" }); + + // 4. Failed jobs + const jobs = await failedJobs(); + archive.append(JSON.stringify(jobs, null, 2), { name: "failed-jobs.json" }); + + // 5. Host info + const host = hostInfo(); + archive.append(JSON.stringify(host, null, 2), { name: "host.json" }); + + await archive.finalize(); + } catch (err) { + archive.destroy(); + passthrough.destroy(err instanceof Error ? err : new Error(String(err))); + } + })(); + + return passthrough; +} diff --git a/apps/api/src/lib/upload-stream.ts b/apps/api/src/lib/upload-stream.ts new file mode 100644 index 00000000..da73981f --- /dev/null +++ b/apps/api/src/lib/upload-stream.ts @@ -0,0 +1,29 @@ +import type { MultipartFile } from "@fastify/multipart"; +import { sanitizeFilename } from "./filename.js"; +import { putObjectStream } from "./object-storage.js"; + +export interface ReceivedUpload { + key: string; + /** + * Canonically sanitized filename (lib/filename.ts), the exact basename + * embedded in `key`. Callers MUST use this value when building download + * URLs or lookups; do NOT re-sanitize. + */ + filename: string; + size: number; +} + +/** + * Streams one multipart file part into uploads// without + * buffering it in memory. maxBytes aborts mid-stream (storage cleans up). + */ +export async function receiveUpload( + part: MultipartFile, + jobId: string, + opts: { maxBytes?: number } = {}, +): Promise { + const filename = sanitizeFilename(part.filename || "upload"); + const key = `uploads/${jobId}/${filename}`; + const size = await putObjectStream(key, part.file, { maxBytes: opts.maxBytes }); + return { key, filename, size }; +} diff --git a/apps/api/src/lib/workspace.ts b/apps/api/src/lib/workspace.ts deleted file mode 100644 index d01dbe03..00000000 --- a/apps/api/src/lib/workspace.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { existsSync } from "node:fs"; -import { mkdir, rm, statfs } from "node:fs/promises"; -import { join } from "node:path"; -import { env } from "../config.js"; - -/** - * Check available disk space before creating a workspace. - * Triggers cleanup if free space is low, and rejects with 503 if - * space remains critically low after cleanup. - */ -async function checkWorkspaceCapacity(workspaceRoot: string): Promise { - if (!existsSync(workspaceRoot)) return; - - let fsStats: Awaited>; - try { - fsStats = await statfs(workspaceRoot); - } catch { - return; - } - const freeBytes = fsStats.bavail * fsStats.bsize; - const freeGB = freeBytes / 1024 ** 3; - - if (freeGB < 1) { - // Attempt to reclaim space by cleaning up old workspaces - const { readdir, stat: fsStat } = await import("node:fs/promises"); - const entries = await readdir(workspaceRoot, { withFileTypes: true }).catch(() => []); - const now = Date.now(); - for (const entry of entries) { - const fullPath = join(workspaceRoot, entry.name); - try { - const s = await fsStat(fullPath); - // Remove workspaces older than 1 hour during emergency cleanup - if (now - s.mtimeMs > 60 * 60 * 1000) { - await rm(fullPath, { recursive: true, force: true }); - } - } catch { - // Skip entries that can't be stat'd - } - } - - // Recheck after cleanup - let recheckStats: Awaited>; - try { - recheckStats = await statfs(workspaceRoot); - } catch { - return; - } - const freeGB2 = (recheckStats.bavail * recheckStats.bsize) / 1024 ** 3; - if (freeGB2 < 0.5) { - const error = new Error("Insufficient disk space for processing"); - (error as Error & { statusCode: number }).statusCode = 503; - throw error; - } - } -} - -/** - * Create a workspace directory structure for a processing job. - * Returns the workspace root path. - */ -export async function createWorkspace(jobId: string): Promise { - await checkWorkspaceCapacity(env.WORKSPACE_PATH); - const root = getWorkspacePath(jobId); - try { - await mkdir(join(root, "input"), { recursive: true }); - await mkdir(join(root, "output"), { recursive: true }); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code === "EACCES") { - throw Object.assign(new Error("Workspace directory is not writable"), { statusCode: 503 }); - } - throw err; - } - return root; -} - -/** - * Get the workspace root path for a job. - */ -export function getWorkspacePath(jobId: string): string { - if (jobId.includes("..") || jobId.includes("/") || jobId.includes("\\") || jobId.includes("\0")) { - throw new Error("Invalid job ID"); - } - return join(env.WORKSPACE_PATH, jobId); -} - -/** - * Remove the entire workspace directory for a job. - */ -export async function cleanupWorkspace(jobId: string): Promise { - const root = getWorkspacePath(jobId); - await rm(root, { recursive: true, force: true }); -} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 40c09fa8..38022e47 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -909,6 +909,7 @@ function extractToken(request: FastifyRequest): string | null { const PUBLIC_PATHS = [ "/api/v1/health", + "/api/v1/readyz", "/api/v1/config/", "/api/auth/", "/api/v1/download/", diff --git a/apps/api/src/routes/admin-ops.ts b/apps/api/src/routes/admin-ops.ts new file mode 100644 index 00000000..b209c904 --- /dev/null +++ b/apps/api/src/routes/admin-ops.ts @@ -0,0 +1,174 @@ +/** + * Admin operations routes -- runtime log level, Prometheus metrics, + * diagnostic support bundle, and usage dashboard. + * + * GET /api/v1/admin/log-level -- read current pino log level + * POST /api/v1/admin/log-level -- change level at runtime + * GET /api/v1/metrics -- Prometheus scrape endpoint + * GET /api/v1/admin/support-bundle -- download redacted diagnostic zip + * GET /api/v1/admin/usage -- local usage dashboard data + */ +import { sql } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { formatZodErrors } from "../lib/errors.js"; +import { metricsText } from "../lib/metrics.js"; +import { buildSupportBundle } from "../lib/support-bundle.js"; +import { requirePermission } from "../permissions.js"; + +const logLevelSchema = z.object({ + level: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]), +}); + +export async function adminOpsRoutes(app: FastifyInstance): Promise { + // GET /api/v1/admin/log-level + app.get("/api/v1/admin/log-level", async (request: FastifyRequest, reply: FastifyReply) => { + const admin = await requirePermission("settings:write")(request, reply); + if (!admin) return; + return { level: app.log.level }; + }); + + // POST /api/v1/admin/log-level + app.post("/api/v1/admin/log-level", async (request: FastifyRequest, reply: FastifyReply) => { + const admin = await requirePermission("settings:write")(request, reply); + if (!admin) return; + const parsed = logLevelSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "Invalid log level", + code: "VALIDATION_ERROR", + details: formatZodErrors(parsed.error.issues), + }); + } + app.log.level = parsed.data.level; + return { level: app.log.level }; + }); + + // GET /api/v1/metrics -- Prometheus scrape endpoint + app.get("/api/v1/metrics", async (request: FastifyRequest, reply: FastifyReply) => { + const admin = await requirePermission("system:health")(request, reply); + if (!admin) return; + const text = await metricsText(); + return reply.type("text/plain; version=0.0.4").send(text); + }); + + // GET /api/v1/admin/support-bundle -- download diagnostic zip + app.get("/api/v1/admin/support-bundle", async (request: FastifyRequest, reply: FastifyReply) => { + const admin = await requirePermission("system:health")(request, reply); + if (!admin) return; + const date = new Date().toISOString().slice(0, 10); + const stream = buildSupportBundle(); + return reply + .type("application/zip") + .header("Content-Disposition", `attachment; filename=snapotter-support-${date}.zip`) + .send(stream); + }); + + // GET /api/v1/admin/usage -- local usage dashboard data + const usageQuerySchema = z.object({ + days: z + .string() + .optional() + .transform((v) => { + const n = v ? Number(v) : 30; + if (Number.isNaN(n)) return 30; + return Math.max(1, Math.min(365, Math.round(n))); + }), + }); + + app.get("/api/v1/admin/usage", async (request: FastifyRequest, reply: FastifyReply) => { + const admin = await requirePermission("audit:read")(request, reply); + if (!admin) return; + + const parsed = usageQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.status(400).send({ + error: "Invalid query parameters", + code: "VALIDATION_ERROR", + details: formatZodErrors(parsed.error.issues), + }); + } + const days = parsed.data.days; + + // Jobs per day + const jobsPerDayResult = await db.execute( + sql`SELECT date_trunc('day', created_at)::date::text AS day, count(*)::int AS total, + count(*) FILTER (WHERE status = 'completed')::int AS completed, + count(*) FILTER (WHERE status = 'failed')::int AS failed + FROM jobs WHERE created_at > now() - make_interval(days => ${days}) + GROUP BY 1 ORDER BY 1`, + ); + + // Top tools + const topToolsResult = await db.execute( + sql`SELECT tool_id, count(*)::int AS runs FROM jobs + WHERE tool_id IS NOT NULL AND created_at > now() - make_interval(days => ${days}) + GROUP BY 1 ORDER BY 2 DESC LIMIT 15`, + ); + + // Per user + const perUserResult = await db.execute( + sql`SELECT u.username, count(*)::int AS runs, coalesce(sum(j.bytes_in), 0)::text AS bytes_in + FROM jobs j LEFT JOIN users u ON u.id = j.user_id + WHERE j.created_at > now() - make_interval(days => ${days}) + GROUP BY 1 ORDER BY 2 DESC LIMIT 15`, + ); + + // Duration percentiles + const durationsResult = await db.execute( + sql`SELECT pool, + percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50, + percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95 + FROM jobs WHERE duration_ms IS NOT NULL AND created_at > now() - make_interval(days => ${days}) + GROUP BY pool`, + ); + + // Storage totals + const storageResult = await db.execute( + sql`SELECT coalesce(sum(size), 0)::text AS bytes, count(*)::int AS files FROM user_files`, + ); + + const jobsPerDay = (jobsPerDayResult.rows as Array>).map((r) => ({ + day: String(r.day), + total: Number(r.total), + completed: Number(r.completed), + failed: Number(r.failed), + })); + + const topTools = (topToolsResult.rows as Array>).map((r) => ({ + toolId: String(r.tool_id), + runs: Number(r.runs), + })); + + const perUser = (perUserResult.rows as Array>).map((r) => ({ + username: r.username != null ? String(r.username) : null, + runs: Number(r.runs), + bytesIn: String(r.bytes_in), + })); + + const durations = (durationsResult.rows as Array>).map((r) => ({ + pool: String(r.pool), + p50Ms: r.p50 != null ? Math.round(Number(r.p50)) : null, + p95Ms: r.p95 != null ? Math.round(Number(r.p95)) : null, + })); + + const storageRow = (storageResult.rows as Array>)[0] || { + bytes: "0", + files: 0, + }; + const storage = { + libraryBytes: String(storageRow.bytes), + libraryFiles: Number(storageRow.files), + }; + + return { + days, + jobsPerDay, + topTools, + perUser, + durations, + storage, + }; + }); +} diff --git a/apps/api/src/routes/batch.ts b/apps/api/src/routes/batch.ts index a9808ab1..771fc9e9 100644 --- a/apps/api/src/routes/batch.ts +++ b/apps/api/src/routes/batch.ts @@ -4,27 +4,34 @@ * POST /api/v1/tools/:toolId/batch * * Accepts multipart with multiple files + settings JSON. - * Processes all files through the tool using p-queue for concurrency control. + * Each file is enqueued as a batch-child BullMQ job; a batch-finalize + * parent assembles the manifest once all children complete. * Returns a ZIP file containing all processed images. */ import { randomUUID } from "node:crypto"; -import { extname } from "node:path"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import archiver from "archiver"; +import type { FlowJob } from "bullmq"; +import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import PQueue from "p-queue"; import sharp from "sharp"; import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { hasAiJobHandler } from "../jobs/ai-handlers.js"; +import { recordChildOutcome } from "../jobs/batch-progress.js"; +import { getFlowProducer, waitForJob } from "../jobs/enqueue.js"; +import { type Pool, queueName, type ToolJobData } from "../jobs/types.js"; import { autoOrient } from "../lib/auto-orient.js"; import { getSecurityHeaders } from "../lib/csp.js"; -import { resolveConcurrency } from "../lib/env.js"; import { formatZodErrors } from "../lib/errors.js"; import { isToolInstalled } from "../lib/feature-status.js"; import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js"; import { decodeHeic } from "../lib/heic-converter.js"; -import { type JobProgress, updateJobProgress } from "./progress.js"; +import { getObjectStream, putObject } from "../lib/object-storage.js"; +import { getAuthUser } from "../plugins/auth.js"; +import { updateJobProgress } from "./progress.js"; import { getToolConfig } from "./tool-factory.js"; interface ParsedFile { @@ -125,109 +132,230 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise { return reply.status(400).send({ error: "Settings must be valid JSON" }); } - // Create a job ID for progress tracking - const jobId = clientJobId || randomUUID(); + // ── Create job ID and initial progress ──────────────────────── + const parentId = clientJobId || randomUUID(); + const userId = getAuthUser(request)?.id ?? null; + const pool: Pool = hasAiJobHandler(toolId) || TOOL_BUNDLE_MAP[toolId] ? "ai" : "image"; - const progress: JobProgress = { - jobId, + // Insert the parent row BEFORE updateJobProgress, because the + // progress persist layer does a check-then-insert that races + // with our explicit insert below. + await db.insert(schema.jobs).values({ + id: parentId, + userId, + toolId, + pool: "system", + type: "batch", + status: "queued", + inputRefs: [], + settings: { flowChildCount: 0 }, + }); + + updateJobProgress({ + jobId: parentId, status: "processing", totalFiles: files.length, completedFiles: 0, failedFiles: 0, errors: [], - }; - updateJobProgress({ ...progress }); + }); - // Use p-queue for concurrency control - const queue = new PQueue({ concurrency: resolveConcurrency(env) }); + // ── Validate, decode, and upload each file ──────────────────── + const flowChildren: FlowJob[] = []; + const preFailures: Array<{ originalIndex: number; filename: string; error: string }> = []; + let flowChildIndex = 0; - // All processed buffers are held in memory until ZIP streaming begins. - // Peak memory scales with files.length * avg output size. MAX_BATCH_SIZE bounds this. - // Collect results in indexed array to preserve upload order - const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill( - null, - ); + for (let i = 0; i < files.length; i++) { + const file = files[i]; + let processBuffer = file.buffer; + let processFilename = file.filename; - // Process all files through the queue - try { - const tasks = files.map((file, index) => - queue.add(async () => { - progress.currentFile = file.filename; - updateJobProgress({ ...progress }); + const validation = await validateImageBuffer(processBuffer, processFilename); + if (!validation.valid) { + preFailures.push({ + originalIndex: i, + filename: file.filename, + error: `Invalid image: ${validation.reason}`, + }); + continue; + } - // Validate the image - const validation = await validateImageBuffer(file.buffer, file.filename); - if (!validation.valid) { - progress.failedFiles++; - progress.errors.push({ - filename: file.filename, - error: `Invalid image: ${validation.reason}`, - }); - progress.completedFiles++; - updateJobProgress({ ...progress }); - return; - } + // Decode chain (skip for metadata tools that handle all formats natively) + const skipPreprocess = toolId === "edit-metadata" || toolId === "strip-metadata"; + if (!skipPreprocess && validation.format === "heif") { + try { + processBuffer = await decodeHeic(processBuffer); + const ext = processFilename.match(/\.[^.]+$/)?.[0]; + if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`; + } catch { + preFailures.push({ + originalIndex: i, + filename: file.filename, + error: "Failed to decode HEIC file", + }); + continue; + } + } + + if (!skipPreprocess && needsCliDecode(validation.format)) { + try { + const fileExt = processFilename.split(".").pop()?.toLowerCase(); + processBuffer = await decodeToSharpCompat(processBuffer, validation.format, fileExt); + } catch { try { - let processBuffer = file.buffer; - let processFilename = file.filename; - // Skip HEIC decode and auto-orient for edit-metadata (ExifTool handles all formats natively) - const skipPreprocess = toolId === "edit-metadata" || toolId === "strip-metadata"; - if (!skipPreprocess && validation.format === "heif") { - processBuffer = await decodeHeic(processBuffer); - // Update extension to match decoded format (HEIC/HEIF → PNG) - const ext = processFilename.match(/\.[^.]+$/)?.[0]; - if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`; - } - if (!skipPreprocess && needsCliDecode(validation.format)) { - try { - processBuffer = await decodeToSharpCompat(processBuffer, validation.format); - } catch { - await sharp(processBuffer).metadata(); - } - const ext = processFilename.match(/\.[^.]+$/)?.[0]; - if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`; - } - if (!skipPreprocess) { - processBuffer = await autoOrient(processBuffer); - } - const result = await toolConfig.process(processBuffer, settings, processFilename); - - // Add tool suffix so downloads don't overwrite originals - let outFilename = result.filename; - if (outFilename === processFilename) { - const ext = extname(processFilename); - const base = ext ? processFilename.slice(0, -ext.length) : processFilename; - outFilename = `${base}_${toolId}${ext}`; - } - - results[index] = { buffer: result.buffer, filename: outFilename }; - - progress.completedFiles++; - updateJobProgress({ ...progress }); - } catch (err) { - progress.failedFiles++; - progress.errors.push({ - filename: file.filename, - error: err instanceof Error ? err.message : "Processing failed", - }); - progress.completedFiles++; - updateJobProgress({ ...progress }); + await sharp(processBuffer).metadata(); + } catch { + // Neither CLI decode nor Sharp can handle it; upload raw } - }), - ); + } + const ext = processFilename.match(/\.[^.]+$/)?.[0]; + if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`; + } - await Promise.all(tasks); - } catch (err) { - request.log.error({ err }, "Unexpected error in batch queue"); + if (!skipPreprocess) { + processBuffer = await autoOrient(processBuffer); + } + + // Upload decoded file to object storage + const childId = `${parentId}-f${flowChildIndex}`; + const key = `uploads/${childId}/${processFilename}`; + await putObject(key, processBuffer); + + // Insert child row + await db.insert(schema.jobs).values({ + id: childId, + userId, + toolId, + pool, + type: "batch-child", + status: "queued", + inputRefs: [key], + settings: settings as Record, + }); + + // Build flow child node + flowChildren.push({ + name: toolId, + queueName: queueName(pool), + data: { + kind: "batch-child", + jobId: childId, + toolId, + userId, + pool, + parentId, + totalFiles: files.length, + fileIndex: i, + inputRefs: [key], + filename: processFilename, + settings, + } satisfies ToolJobData, + // Children swallow failures via return markers, so a retry would + // never run; attempts: 1 makes that explicit. + opts: { jobId: childId, attempts: 1 }, + }); + + flowChildIndex++; } - // Finalize progress - progress.status = progress.failedFiles === progress.totalFiles ? "failed" : "completed"; - progress.currentFile = undefined; - updateJobProgress({ ...progress }); + // Record pre-failures in batch progress + for (const pf of preFailures) { + await recordChildOutcome(parentId, files.length, pf.filename, pf.error); + } - // Deduplicate filenames in original order and build X-File-Results header + if (flowChildren.length === 0) { + // All files failed validation + return reply.status(422).send({ + error: "All files failed processing", + errors: preFailures.map((f) => ({ filename: f.filename, error: f.error })), + }); + } + + // ── Build flow tree and enqueue ──────────────────────────────── + const batchTree: FlowJob = { + name: "batch-finalize", + queueName: queueName("system"), + data: { + kind: "batch-finalize", + jobId: parentId, + toolId, + userId, + pool: "system" as Pool, + totalFiles: files.length, + inputRefs: [], + filename: "", + settings: { flowChildCount: flowChildren.length }, + } satisfies ToolJobData, + opts: { jobId: parentId, attempts: 1 }, + children: flowChildren, + }; + + // Update the parent row with the final flow child count + await db + .update(schema.jobs) + .set({ settings: { flowChildCount: flowChildren.length } }) + .where(eq(schema.jobs.id, parentId)); + + await getFlowProducer().add(batchTree); + + // ── Wait for completion and stream ZIP ───────────────────────── + const batchResult = await waitForJob("system", parentId, 30 * 60_000); + + if (!batchResult) { + return reply.status(422).send({ error: "Batch processing timed out" }); + } + + const manifest = (batchResult.resultPayload?.manifest ?? []) as Array<{ + index: number; + filename: string; + outputRef?: string; + error?: string; + }>; + + // Combine manifest with pre-failures for the full ordered result + const allResults: Array<{ + originalIndex: number; + filename: string; + outputRef?: string; + error?: string; + }> = []; + + let fci = 0; + for (let i = 0; i < files.length; i++) { + const pf = preFailures.find((p) => p.originalIndex === i); + if (pf) { + allResults.push({ + originalIndex: i, + filename: pf.filename, + error: pf.error, + }); + } else { + const entry = manifest.find((m) => m.index === fci); + if (entry) { + allResults.push({ + originalIndex: i, + filename: entry.filename, + outputRef: entry.outputRef, + error: entry.error, + }); + } + fci++; + } + } + + const successEntries = allResults.filter((r) => r.outputRef); + const failedEntries = allResults.filter((r) => !r.outputRef); + + // If every file failed, return an error instead of an empty ZIP + if (successEntries.length === 0) { + return reply.status(422).send({ + error: "All files failed processing", + errors: failedEntries.map((f) => ({ filename: f.filename, error: f.error ?? "Failed" })), + }); + } + + // Deduplicate output filenames and build X-File-Results header const usedNames = new Set(); function getUniqueName(name: string): string { if (!usedNames.has(name)) { @@ -248,30 +376,19 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise { } const fileResultsMap: Record = {}; - for (let i = 0; i < results.length; i++) { - const entry = results[i]; - if (entry) { - const uniqueName = getUniqueName(entry.filename); - entry.filename = uniqueName; - fileResultsMap[String(i)] = uniqueName; - } - } - - // If every file failed, return an error instead of an empty ZIP - if (progress.status === "failed") { - return reply.status(422).send({ - error: "All files failed processing", - errors: progress.errors, - }); + for (const entry of successEntries) { + const uniqueName = getUniqueName(entry.filename); + entry.filename = uniqueName; + fileResultsMap[String(entry.originalIndex)] = uniqueName; } // Hijack and stream the ZIP response after all processing reply.hijack(); reply.raw.writeHead(200, { "Content-Type": "application/zip", - "Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`, + "Content-Disposition": `attachment; filename="batch-${toolId}-${parentId.slice(0, 8)}.zip"`, "Transfer-Encoding": "chunked", - "X-Job-Id": jobId, + "X-Job-Id": parentId, "X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)), ...getSecurityHeaders(), }); @@ -287,14 +404,21 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise { archive.pipe(reply.raw); - // Append results in original upload order - for (const result of results) { - if (result) { - archive.append(result.buffer, { name: result.filename }); + // Append results from object storage in original upload order + try { + for (const entry of successEntries) { + const stream = await getObjectStream(entry.outputRef!); + archive.append(stream, { name: entry.filename }); + } + + await archive.finalize(); + } catch (err) { + request.log.error({ err }, "Failed to stream ZIP entries during batch processing"); + archive.abort(); + if (!reply.raw.writableEnded) { + reply.raw.end(); } } - - await archive.finalize(); }, ); } diff --git a/apps/api/src/routes/features.ts b/apps/api/src/routes/features.ts index d2a00f21..9f5b82ea 100644 --- a/apps/api/src/routes/features.ts +++ b/apps/api/src/routes/features.ts @@ -1,10 +1,11 @@ /** * Feature bundle management routes. * - * GET /api/v1/features — List feature bundles and their statuses - * POST /api/v1/admin/features/:bundleId/install — Install a feature bundle (async) - * POST /api/v1/admin/features/:bundleId/uninstall — Uninstall a feature bundle - * GET /api/v1/admin/features/disk-usage — Get AI model disk usage + * GET /api/v1/features - List feature bundles and their statuses + * POST /api/v1/admin/features/:bundleId/install - Install a feature bundle (async) + * POST /api/v1/admin/features/:bundleId/uninstall - Uninstall a feature bundle + * GET /api/v1/admin/features/disk-usage - Get AI model disk usage + * POST /api/v1/admin/features/import - Import an offline bundle archive */ import { spawn } from "node:child_process"; @@ -30,6 +31,9 @@ import { getInstallScriptPath, getManifestPath, getModelsDir, + ImportLockError, + ImportValidationError, + importBundleArchive, invalidateCache, isDockerEnvironment, isFeatureInstalled, @@ -101,7 +105,7 @@ function getDirSize(dirPath: string): number { } export async function registerFeatureRoutes(app: FastifyInstance): Promise { - // GET /api/v1/features — List feature bundles and their statuses + // GET /api/v1/features - List feature bundles and their statuses app.get("/api/v1/features", async (request: FastifyRequest, reply: FastifyReply) => { const user = requireAuth(request, reply); if (!user) return; @@ -125,7 +129,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise return reply.send({ bundles: getFeatureStates() }); }); - // POST /api/v1/admin/features/:bundleId/install — Install a feature bundle + // POST /api/v1/admin/features/:bundleId/install - Install a feature bundle app.post( "/api/v1/admin/features/:bundleId/install", async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => { @@ -204,7 +208,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise }); } } catch { - // Not JSON progress — rembg/pip output noise, keep in lastStderrLines for error reporting + // Not JSON progress - rembg/pip output noise, keep in lastStderrLines for error reporting } } }); @@ -224,7 +228,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise }); } else { // Extract the structured error from Python's fail() function first. - // fail() writes {"error": "..."} to stderr — prefer this over raw lines. + // fail() writes {"error": "..."} to stderr - prefer this over raw lines. let errorMsg: string | undefined; for (let i = lastStderrLines.length - 1; i >= 0; i--) { const line = lastStderrLines[i]; @@ -276,7 +280,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise }, ); - // POST /api/v1/admin/features/:bundleId/uninstall — Uninstall a feature bundle + // POST /api/v1/admin/features/:bundleId/uninstall - Uninstall a feature bundle app.post( "/api/v1/admin/features/:bundleId/uninstall", async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => { @@ -351,7 +355,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise }, ); - // GET /api/v1/admin/features/disk-usage — Get AI model disk usage + // GET /api/v1/admin/features/disk-usage - Get AI model disk usage app.get( "/api/v1/admin/features/disk-usage", async (request: FastifyRequest, reply: FastifyReply) => { @@ -362,4 +366,47 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise return reply.send({ totalBytes }); }, ); + + // POST /api/v1/admin/features/import - Import an offline bundle archive + app.post( + "/api/v1/admin/features/import", + async (request: FastifyRequest, reply: FastifyReply) => { + const admin = await requirePermission("features:manage")(request, reply); + if (!admin) return; + + let part: Awaited>; + try { + part = await request.file(); + } catch { + return reply.status(400).send({ error: "Expected a multipart file upload" }); + } + + if (!part?.file) { + return reply.status(400).send({ error: "No file provided" }); + } + + try { + const result = await importBundleArchive(part.file); + invalidateCache(); + shutdownDispatcher(); + trackEvent(request, ANALYTICS_EVENTS.AI_BUNDLE_ACTION, { + bundle_id: result.bundleId, + action: "imported", + duration_ms: 0, + }); + return reply.send({ + bundleId: result.bundleId, + version: result.version, + }); + } catch (err) { + if (err instanceof ImportLockError) { + return reply.status(409).send({ error: err.message }); + } + if (err instanceof ImportValidationError) { + return reply.status(400).send({ error: err.message }); + } + throw err; + } + }, + ); } diff --git a/apps/api/src/routes/fetch-urls.ts b/apps/api/src/routes/fetch-urls.ts index c8082dbb..944116a3 100644 --- a/apps/api/src/routes/fetch-urls.ts +++ b/apps/api/src/routes/fetch-urls.ts @@ -5,18 +5,18 @@ * * Accepts a JSON body with { urls: string[] } (1-50 URLs). * Fetches each URL server-side with SSRF protection, validates as an image, - * saves to a workspace, generates a preview for non-browser formats, and + * saves to object storage, generates a preview for non-browser formats, and * returns results with download URLs. */ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { basename, join } from "node:path"; +import { basename } from "node:path"; import type { FastifyInstance } from "fastify"; import PQueue from "p-queue"; import sharp from "sharp"; import { z } from "zod"; import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; +import { putObject } from "../lib/object-storage.js"; import { FETCH_TIMEOUT_MS, MAX_URL_FETCH_SIZE, @@ -24,7 +24,6 @@ import { safeFetch, URL_FETCH_CONCURRENCY, } from "../lib/ssrf.js"; -import { createWorkspace } from "../lib/workspace.js"; /** Formats browsers can display natively (no preview needed). */ const BROWSER_PREVIEWABLE = new Set([ @@ -151,8 +150,6 @@ export async function registerFetchUrlsRoute(app: FastifyInstance): Promise queue.add(async () => { - resultSlots[index] = await fetchSingleUrl(url, jobId, outputDir, usedFilenames); + resultSlots[index] = await fetchSingleUrl(url, jobId, usedFilenames); }), ), ); @@ -179,7 +176,6 @@ export async function registerFetchUrlsRoute(app: FastifyInstance): Promise, ): Promise { try { @@ -246,8 +242,8 @@ async function fetchSingleUrl( return { success: false, url, error: validation.reason }; } - // Save to output directory - await writeFile(join(outputDir, filename), buffer); + // Save to object storage uploads prefix (raw fetched files) + await putObject(`uploads/${jobId}/${filename}`, buffer); const contentType = FORMAT_TO_MIME[validation.format] ?? "application/octet-stream"; const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`; @@ -258,7 +254,7 @@ async function fetchSingleUrl( try { const previewBuffer = await sharp(buffer).webp({ quality: 80 }).toBuffer(); const previewFilename = `preview-${filename.replace(/\.[^.]+$/, "")}.webp`; - await writeFile(join(outputDir, previewFilename), previewBuffer); + await putObject(`uploads/${jobId}/${previewFilename}`, previewBuffer); previewUrl = `/api/v1/download/${jobId}/${encodeURIComponent(previewFilename)}`; } catch { // Preview generation failed -- non-fatal, skip preview diff --git a/apps/api/src/routes/files.ts b/apps/api/src/routes/files.ts index b851b045..c26bf969 100644 --- a/apps/api/src/routes/files.ts +++ b/apps/api/src/routes/files.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; -import { readFile, stat, writeFile } from "node:fs/promises"; -import { extname, join } from "node:path"; +import { extname } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { readImageDimensions } from "../lib/exiftool.js"; @@ -8,8 +7,8 @@ import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js"; import { decodeHeic } from "../lib/heic-converter.js"; +import { getObjectSize, getObjectStream, putObject } from "../lib/object-storage.js"; import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js"; -import { createWorkspace, getWorkspacePath } from "../lib/workspace.js"; /** * Guard against path traversal in URL params. @@ -30,8 +29,6 @@ export async function fileRoutes(app: FastifyInstance): Promise { { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (request: FastifyRequest, reply: FastifyReply) => { const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const inputDir = join(workspacePath, "input"); const uploadedFiles: Array<{ name: string; @@ -66,12 +63,11 @@ export async function fileRoutes(app: FastifyInstance): Promise { // Sanitize SVG uploads to prevent XXE, SSRF, and script injection const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer; - // Sanitize filename + // Sanitize filename (canonical; do NOT re-sanitize downstream) const safeName = sanitizeFilename(part.filename ?? "upload"); - // Write to workspace input directory - const filePath = join(inputDir, safeName); - await writeFile(filePath, safeBuffer); + // Write to object storage uploads prefix + await putObject(`uploads/${jobId}/${safeName}`, safeBuffer); uploadedFiles.push({ name: safeName, @@ -107,32 +103,59 @@ export async function fileRoutes(app: FastifyInstance): Promise { return reply.status(400).send({ error: "Invalid path" }); } - const workspacePath = getWorkspacePath(jobId); - - // Try output directory first, then input - let filePath = join(workspacePath, "output", filename); + // Resolve from object storage: outputs/ first, then uploads/ + let key = `outputs/${jobId}/${filename}`; + let size: number; try { - await stat(filePath); + size = await getObjectSize(key); } catch { - filePath = join(workspacePath, "input", filename); + key = `uploads/${jobId}/${filename}`; try { - await stat(filePath); + size = await getObjectSize(key); } catch { return reply.status(404).send({ error: "File not found" }); } } - const buffer = await readFile(filePath); const ext = extname(filename).toLowerCase().replace(/^\./, ""); const contentType = getContentType(ext); - return reply + // Check Range header before setting content headers (a 416 must not + // carry the attachment Content-Type that would confuse serialization). + reply.header("Accept-Ranges", "bytes"); + + const range = request.headers.range; + if (range) { + const m = range.match(/^bytes=(\d+)-(\d*)$/); + const start = m ? Number.parseInt(m[1], 10) : Number.NaN; + const end = m?.[2] ? Number.parseInt(m[2], 10) : size - 1; + if (!m || Number.isNaN(start) || start >= size || end < start) { + return reply + .code(416) + .header("Content-Range", `bytes */${size}`) + .send({ error: "Range not satisfiable" }); + } + const clampedEnd = Math.min(end, size - 1); + return reply + .code(206) + .header("Content-Type", contentType) + .header( + "Content-Disposition", + `attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`, + ) + .header("Content-Range", `bytes ${start}-${clampedEnd}/${size}`) + .header("Content-Length", String(clampedEnd - start + 1)) + .send(await getObjectStream(key, { start, end: clampedEnd })); + } + + reply .header("Content-Type", contentType) .header( "Content-Disposition", `attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`, ) - .send(buffer); + .header("Content-Length", String(size)); + return reply.send(await getObjectStream(key)); }, ); diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index f750fb24..47333d60 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -1,37 +1,39 @@ /** * Pipeline execution, save, list, and delete routes. * - * POST /api/v1/pipeline/execute — Execute a pipeline (array of tool steps) - * POST /api/v1/pipeline/save — Save a pipeline definition - * GET /api/v1/pipeline/list — List saved pipelines - * DELETE /api/v1/pipeline/:id — Delete a saved pipeline + * POST /api/v1/pipeline/execute -- Execute a pipeline (array of tool steps) + * POST /api/v1/pipeline/save -- Save a pipeline definition + * GET /api/v1/pipeline/list -- List saved pipelines + * DELETE /api/v1/pipeline/:id -- Delete a saved pipeline + * POST /api/v1/pipeline/batch -- Batch pipeline execution (ZIP output) */ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import archiver from "archiver"; +import type { FlowJob } from "bullmq"; import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import PQueue from "p-queue"; import { z } from "zod"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; +import { hasAiJobHandler } from "../jobs/ai-handlers.js"; +import { recordChildOutcome } from "../jobs/batch-progress.js"; +import { getFlowProducer, waitForJob } from "../jobs/enqueue.js"; +import { type Pool, queueName, type ToolJobData } from "../jobs/types.js"; import { trackEvent } from "../lib/analytics.js"; import { autoOrient } from "../lib/auto-orient.js"; import { getSecurityHeaders } from "../lib/csp.js"; -import { resolveConcurrency } from "../lib/env.js"; import { formatZodErrors } from "../lib/errors.js"; import { isToolInstalled } from "../lib/feature-status.js"; import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js"; import { decodeHeic } from "../lib/heic-converter.js"; +import { getObjectStream, putObject } from "../lib/object-storage.js"; import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js"; -import { createWorkspace } from "../lib/workspace.js"; import { hasEffectivePermission } from "../permissions.js"; -import { requireAuth } from "../plugins/auth.js"; -import { type JobProgress, updateJobProgress, updateSingleFileProgress } from "./progress.js"; +import { getAuthUser, requireAuth } from "../plugins/auth.js"; +import { updateJobProgress, updateSingleFileProgress } from "./progress.js"; import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js"; /** Schema for a single pipeline step. */ @@ -60,6 +62,119 @@ const savePipelineSchema = z.object({ steps: stepsSchema, }); +// ── Helpers ──────────────────────────────────────────────────── + +function resolvePool(toolId: string): Pool { + if (hasAiJobHandler(toolId) || TOOL_BUNDLE_MAP[toolId]) return "ai"; + return "image"; +} + +interface ParsedStep { + toolId: string; + resolvedToolId: string; + parsedSettings: unknown; + pool: Pool; +} + +/** + * Build a FlowJob tree for a single-file pipeline. + * + * BullMQ children run BEFORE parents, so the sequential chain nests + * with step 0 deepest: + * + * finalize (parent) + * step N-1 + * step N-2 + * ... + * step 0 (deepest leaf, runs first) + */ +function buildPipelineFlowTree(opts: { + jobId: string; + userId: string | null; + parsedSteps: ParsedStep[]; + uploadKey: string; + filename: string; + clientJobId?: string; + parentId?: string; + totalFiles?: number; +}): { tree: FlowJob; stepJobIds: string[] } { + const { jobId, userId, parsedSteps, uploadKey, filename, clientJobId, parentId, totalFiles } = + opts; + const totalSteps = parsedSteps.length; + const stepJobIds = parsedSteps.map((_: unknown, i: number) => `${jobId}-s${i}`); + + // Build bottom-up: step 0 is the deepest leaf + // Steps swallow failures via return markers, so a retry would never + // run; attempts: 1 makes that explicit. + let currentNode: FlowJob = { + name: parsedSteps[0].resolvedToolId, + queueName: queueName(parsedSteps[0].pool), + data: { + kind: "pipeline-step", + jobId: stepJobIds[0], + toolId: parsedSteps[0].resolvedToolId, + userId, + pool: parsedSteps[0].pool, + stepIndex: 0, + totalSteps, + prevJobId: undefined, + clientJobId, + inputRefs: [uploadKey], + filename, + settings: parsedSteps[0].parsedSettings, + } satisfies ToolJobData, + opts: { jobId: stepJobIds[0], attempts: 1 }, + }; + + for (let i = 1; i < totalSteps; i++) { + currentNode = { + name: parsedSteps[i].resolvedToolId, + queueName: queueName(parsedSteps[i].pool), + data: { + kind: "pipeline-step", + jobId: stepJobIds[i], + toolId: parsedSteps[i].resolvedToolId, + userId, + pool: parsedSteps[i].pool, + stepIndex: i, + totalSteps, + prevJobId: stepJobIds[i - 1], + clientJobId, + inputRefs: [], + filename, + settings: parsedSteps[i].parsedSettings, + } satisfies ToolJobData, + opts: { jobId: stepJobIds[i], attempts: 1 }, + children: [currentNode], + }; + } + + // Finalize parent: runs on image pool (lightweight DB reads + one object copy; + // keeps the flow tree single-queue except batch parents; system pool is reserved for crons + batch manifest assembly) + const tree: FlowJob = { + name: "pipeline-finalize", + queueName: queueName("image"), + data: { + kind: "pipeline-finalize", + jobId, + toolId: "pipeline", + userId, + pool: "image" as Pool, + totalSteps, + clientJobId, + parentId, + totalFiles, + inputRefs: [], + filename, + settings: {}, + } satisfies ToolJobData, + opts: { jobId, attempts: 1 }, + children: [currentNode], + }; + + return { tree, stepJobIds }; +} + export async function registerPipelineRoutes(app: FastifyInstance): Promise { /** * POST /api/v1/pipeline/execute @@ -68,9 +183,8 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise { let fileBuffer: Buffer | null = null; @@ -121,7 +235,6 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise = []; - const totalSteps = pipeline.steps.length; + // ── Enqueue as a BullMQ flow ──────────────────────────────── - const reportProgress = (percent: number, stage?: string) => { - if (!clientJobId) return; + const startTime = Date.now(); + const jobId = randomUUID(); + const userId = getAuthUser(request)?.id ?? null; + const originalSize = fileBuffer.length; + + // Upload decoded file to object storage + const uploadKey = `uploads/${jobId}/${filename}`; + await putObject(uploadKey, fileBuffer); + + // Report initial progress + if (clientJobId) { updateSingleFileProgress({ jobId: clientJobId, phase: "processing", - percent, - stage, + percent: 0, + stage: "Preparing pipeline...", }); - }; + } + // Build the nested FlowJob tree + const { tree, stepJobIds } = buildPipelineFlowTree({ + jobId, + userId, + parsedSteps, + uploadKey, + filename, + clientJobId: clientJobId ?? jobId, + }); + + // Insert all durable rows before adding the flow. enqueueToolJob + // inserts row-then-add; for flows we insert ALL rows first, then + // one flow.add. + for (let i = 0; i < parsedSteps.length; i++) { + await db.insert(schema.jobs).values({ + id: stepJobIds[i], + userId, + toolId: parsedSteps[i].resolvedToolId, + pool: parsedSteps[i].pool, + type: "pipeline-step", + status: "queued", + inputRefs: i === 0 ? [uploadKey] : [], + settings: parsedSteps[i].parsedSettings as Record, + }); + } + + await db.insert(schema.jobs).values({ + id: jobId, + userId, + toolId: "pipeline", + pool: "image", + type: "pipeline", + status: "queued", + inputRefs: [], + settings: {}, + }); + + // Add the flow to BullMQ + await getFlowProducer().add(tree); + + // Wait for the finalize job (pipelines block to completion) try { - for (let i = 0; i < totalSteps; i++) { - const step = pipeline.steps[i]; - const stepPercent = Math.round((i / totalSteps) * 90); - reportProgress(stepPercent, `Step ${i + 1}/${totalSteps}: ${step.toolId}`); + const result = await waitForJob("image", jobId, 10 * 60_000); - // Route content-aware resize to its dedicated tool - const resolvedToolId = - step.toolId === "resize" && step.settings?.contentAware - ? "content-aware-resize" - : step.toolId; - - const toolConfig = getToolConfig(resolvedToolId); - if (!toolConfig) { - return reply.status(400).send({ - error: `Step ${i + 1} (${step.toolId}): Tool not found or not available`, - }); - } - - try { - const settings = toolConfig.settingsSchema.parse(step.settings); - const result = await toolConfig.process(currentBuffer, settings, currentFilename); - - stepResults.push({ - step: i + 1, - toolId: step.toolId, - size: result.buffer.length, - }); - - currentBuffer = result.buffer; - currentFilename = result.filename; - } catch (stepErr) { - const msg = stepErr instanceof Error ? stepErr.message : "Processing failed"; - throw new Error(`Step ${i + 1} (${step.toolId}): ${msg}`); - } + if (!result) { + trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, { + step_count: pipeline.steps.length, + tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId), + is_batch: false, + duration_ms: Date.now() - startTime, + status: "failed", + }); + return reply.status(422).send({ + error: "Pipeline processing timed out", + }); } - reportProgress(95, "Saving..."); + // Check for step failure reported by the finalize handler + if (result.resultPayload?.error) { + trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, { + step_count: pipeline.steps.length, + tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId), + is_batch: false, + duration_ms: Date.now() - startTime, + status: "failed", + }); + return reply.status(422).send({ + error: result.resultPayload.error as string, + completedSteps: result.resultPayload.steps, + }); + } + + trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, { + step_count: pipeline.steps.length, + tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId), + is_batch: false, + duration_ms: Date.now() - startTime, + status: "completed", + }); + + return reply.send({ + jobId, + downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`, + originalSize, + processedSize: result.processedSize, + stepsCompleted: result.resultPayload?.stepsCompleted ?? parsedSteps.length, + steps: result.resultPayload?.steps ?? [], + }); } catch (err) { - const message = err instanceof Error ? err.message : "Pipeline processing failed"; trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, { step_count: pipeline.steps.length, tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId), @@ -282,37 +457,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise s.toolId), - is_batch: false, - duration_ms: Date.now() - startTime, - status: "completed", - }); - - return reply.send({ - jobId, - downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(currentFilename)}`, - originalSize: fileBuffer.length, - processedSize: currentBuffer.length, - stepsCompleted: stepResults.length, - steps: stepResults, - }); }); /** @@ -449,8 +596,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise { // ── Parse multipart ────────────────────────────────────────────── @@ -525,24 +673,31 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise = []; + let flowChildIndex = 0; - const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill( - null, - ); + for (let fi = 0; fi < files.length; fi++) { + const file = files[fi]; + let processBuffer = file.buffer; + let processFilename = file.filename; - try { - const tasks = files.map((file, index) => - queue.add(async () => { - progress.currentFile = file.filename; - updateJobProgress({ ...progress }); + const fileValidation = await validateImageBuffer(processBuffer, processFilename); + if (!fileValidation.valid) { + preFailures.push({ + originalIndex: fi, + filename: file.filename, + error: `Invalid image: ${fileValidation.reason}`, + }); + continue; + } - // Validate the image - const validation = await validateImageBuffer(file.buffer, file.filename); - if (!validation.valid) { - progress.failedFiles++; - progress.errors.push({ - filename: file.filename, - error: `Invalid image: ${validation.reason}`, - }); - progress.completedFiles++; - updateJobProgress({ ...progress }); - return; - } + // Decode chain + if (fileValidation.format === "heif") { + try { + processBuffer = await decodeHeic(processBuffer); + const ext = processFilename.match(/\.[^.]+$/)?.[0]; + if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`; + } catch { + preFailures.push({ + originalIndex: fi, + filename: file.filename, + error: "Failed to decode HEIC file", + }); + continue; + } + } - try { - let currentBuffer = file.buffer; - let currentFilename = file.filename; + if (needsCliDecode(fileValidation.format)) { + try { + const fileExt = processFilename.split(".").pop()?.toLowerCase(); + processBuffer = await decodeToSharpCompat(processBuffer, fileValidation.format, fileExt); + const ext = processFilename.match(/\.[^.]+$/)?.[0]; + if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`; + } catch { + // Fall through -- tool might handle it + } + } - // Decode HEIC/HEIF if needed - if (validation.format === "heif") { - currentBuffer = await decodeHeic(currentBuffer); - const ext = currentFilename.match(/\.[^.]+$/)?.[0]; - if (ext) currentFilename = `${currentFilename.slice(0, -ext.length)}.png`; - } + if (isSvgBuffer(processBuffer)) { + processBuffer = sanitizeSvg(processBuffer); + } else { + processBuffer = await autoOrient(processBuffer); + } - // Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR) - if (needsCliDecode(validation.format)) { - currentBuffer = await decodeToSharpCompat(currentBuffer, validation.format); - const ext = currentFilename.match(/\.[^.]+$/)?.[0]; - if (ext) currentFilename = `${currentFilename.slice(0, -ext.length)}.png`; - } + // Upload decoded file + const perFileJobId = `${parentId}-f${flowChildIndex}`; + const uploadKey = `uploads/${perFileJobId}-s0/${processFilename}`; + await putObject(uploadKey, processBuffer); - // Sanitize SVG or normalize EXIF orientation - if (isSvgBuffer(currentBuffer)) { - currentBuffer = sanitizeSvg(currentBuffer); - } else { - currentBuffer = await autoOrient(currentBuffer); - } + // Build per-file pipeline chain + const { tree: perFileTree, stepJobIds } = buildPipelineFlowTree({ + jobId: perFileJobId, + userId, + parsedSteps, + uploadKey, + filename: processFilename, + parentId, + totalFiles: files.length, + }); - // Run through all pipeline steps sequentially - for (let i = 0; i < pipeline.steps.length; i++) { - const step = pipeline.steps[i]; + // Insert step + finalize rows for this file + for (let si = 0; si < parsedSteps.length; si++) { + await db.insert(schema.jobs).values({ + id: stepJobIds[si], + userId, + toolId: parsedSteps[si].resolvedToolId, + pool: parsedSteps[si].pool, + type: "pipeline-step", + status: "queued", + inputRefs: si === 0 ? [uploadKey] : [], + settings: parsedSteps[si].parsedSettings as Record, + }); + } - // Route content-aware resize to its dedicated tool - const resolvedToolId = - step.toolId === "resize" && step.settings?.contentAware - ? "content-aware-resize" - : step.toolId; + await db.insert(schema.jobs).values({ + id: perFileJobId, + userId, + toolId: "pipeline", + pool: "image", + type: "pipeline-finalize", + status: "queued", + inputRefs: [], + settings: {}, + }); - const toolConfig = getToolConfig(resolvedToolId); - if (!toolConfig) { - throw new Error(`Step ${i + 1} (${step.toolId}): Tool not found or not available`); - } - - try { - const settings = toolConfig.settingsSchema.parse(step.settings); - const result = await toolConfig.process(currentBuffer, settings, currentFilename); - currentBuffer = result.buffer; - currentFilename = result.filename; - } catch (stepErr) { - const msg = stepErr instanceof Error ? stepErr.message : "Processing failed"; - throw new Error(`Step ${i + 1} (${step.toolId}): ${msg}`); - } - } - - results[index] = { buffer: currentBuffer, filename: currentFilename }; - - progress.completedFiles++; - updateJobProgress({ ...progress }); - } catch (err) { - progress.failedFiles++; - progress.errors.push({ - filename: file.filename, - error: err instanceof Error ? err.message : "Pipeline processing failed", - }); - progress.completedFiles++; - updateJobProgress({ ...progress }); - } - }), - ); - - await Promise.all(tasks); - } catch (err) { - request.log.error({ err }, "Unexpected error in pipeline batch queue"); + perFileChildren.push(perFileTree); + flowChildIndex++; } - // ── Finalize progress ──────────────────────────────────────────── - progress.status = progress.failedFiles === progress.totalFiles ? "failed" : "completed"; - progress.currentFile = undefined; - updateJobProgress({ ...progress }); + // Record pre-failures in batch progress + for (const pf of preFailures) { + await recordChildOutcome(parentId, files.length, pf.filename, pf.error); + } - // ── Deduplicate output filenames ───────────────────────────────── + if (perFileChildren.length === 0) { + // All files failed validation + trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, { + step_count: pipeline.steps.length, + tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId), + is_batch: true, + file_count: files.length, + duration_ms: Date.now() - batchStartTime, + status: "failed", + }); + return reply.status(422).send({ + error: "All files failed processing", + errors: preFailures.map((f) => ({ filename: f.filename, error: f.error })), + }); + } + + // Build batch-finalize parent + const batchTree: FlowJob = { + name: "batch-finalize", + queueName: queueName("system"), + data: { + kind: "batch-finalize", + jobId: parentId, + toolId: "pipeline-batch", + userId, + pool: "system" as Pool, + totalFiles: files.length, + inputRefs: [], + filename: "", + settings: { flowChildCount: perFileChildren.length }, + } satisfies ToolJobData, + opts: { jobId: parentId, attempts: 1 }, + children: perFileChildren, + }; + + // Update the parent row with the final flow child count + await db + .update(schema.jobs) + .set({ settings: { flowChildCount: perFileChildren.length } }) + .where(eq(schema.jobs.id, parentId)); + + await getFlowProducer().add(batchTree); + + // Wait for batch completion + const batchResult = await waitForJob("system", parentId, 30 * 60_000); + + if (!batchResult) { + trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, { + step_count: pipeline.steps.length, + tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId), + is_batch: true, + file_count: files.length, + duration_ms: Date.now() - batchStartTime, + status: "failed", + }); + return reply.status(422).send({ error: "Pipeline batch processing timed out" }); + } + + const manifest = (batchResult.resultPayload?.manifest ?? []) as Array<{ + index: number; + filename: string; + outputRef?: string; + error?: string; + }>; + + // Combine manifest with pre-failures + const allResults: Array<{ + originalIndex: number; + filename: string; + outputRef?: string; + error?: string; + }> = []; + + // Map flow indices back to original file indices + let fci = 0; + for (let fi = 0; fi < files.length; fi++) { + const pf = preFailures.find((p) => p.originalIndex === fi); + if (pf) { + allResults.push({ + originalIndex: fi, + filename: pf.filename, + error: pf.error, + }); + } else { + const entry = manifest.find((m) => m.index === fci); + if (entry) { + allResults.push({ + originalIndex: fi, + filename: entry.filename, + outputRef: entry.outputRef, + error: entry.error, + }); + } + fci++; + } + } + + // Deduplicate output filenames const usedNames = new Set(); function getUniqueName(name: string): string { if (!usedNames.has(name)) { @@ -697,18 +970,10 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise = {}; - for (let i = 0; i < results.length; i++) { - const entry = results[i]; - if (entry) { - const uniqueName = getUniqueName(entry.filename); - entry.filename = uniqueName; - fileResultsMap[String(i)] = uniqueName; - } - } + const successEntries = allResults.filter((r) => r.outputRef); + const failedEntries = allResults.filter((r) => !r.outputRef); - // If every file failed, return an error instead of an empty ZIP - if (progress.status === "failed") { + if (successEntries.length === 0) { trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, { step_count: pipeline.steps.length, tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId), @@ -719,7 +984,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise ({ filename: f.filename, error: f.error ?? "Failed" })), }); } @@ -732,13 +997,20 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise = {}; + for (const entry of successEntries) { + const uniqueName = getUniqueName(entry.filename); + entry.filename = uniqueName; + fileResultsMap[String(entry.originalIndex)] = uniqueName; + } + // ── Stream ZIP response ────────────────────────────────────────── reply.hijack(); reply.raw.writeHead(200, { "Content-Type": "application/zip", - "Content-Disposition": `attachment; filename="pipeline-batch-${jobId.slice(0, 8)}.zip"`, + "Content-Disposition": `attachment; filename="pipeline-batch-${parentId.slice(0, 8)}.zip"`, "Transfer-Encoding": "chunked", - "X-Job-Id": jobId, + "X-Job-Id": parentId, "X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)), ...getSecurityHeaders(), }); @@ -754,14 +1026,21 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise; } -/** In-memory store of job progress, keyed by jobId. */ -const jobProgressStore = new Map(); +// ── Redis channels / keys ────────────────────────────────────── -/** Terminal single-file events kept for SSE reconnect replay. */ -const singleFileCompletions = new Map(); +const progressChannel = () => `${bullPrefix()}:progress`; +const terminalKey = (jobId: string) => `${bullPrefix()}:terminal:${jobId}`; +const TERMINAL_TTL_S = 600; -/** SSE listeners waiting for updates, keyed by jobId. */ -const listeners = new Map void>>(); - -// ── DB persistence helpers ────────────────────────────────────────── +// ── DB persistence helpers ───────────────────────────────────── /** - * Per-job serialization queues. Fire-and-forget persist calls for the same + * Per-job serialization queues. Fire-and-forget persist calls for the same * jobId must run sequentially so that the final "completed" write is never - * overwritten by a late-arriving "processing" write. Without this, the - * async Postgres round-trips can re-order concurrent writes. + * overwritten by a late-arriving "processing" write. */ -// TODO(phase-2): delete when progress persistence moves to BullMQ job events. const persistQueues = new Map>(); -/** Await any pending persist writes for a specific job (used by tests). */ -export async function drainPersistQueue(jobId: string): Promise { - const pending = persistQueues.get(jobId); - if (pending) await pending; -} - function enqueuePersist(jobId: string, fn: () => Promise): void { const prev = persistQueues.get(jobId) ?? Promise.resolve(); const next = prev.then(fn, fn); // run even if prior rejected @@ -74,8 +69,10 @@ function enqueuePersist(jobId: string, fn: () => Promise): void { async function persistJobProgress(progress: JobProgress): Promise { try { - const completionRatio = - progress.totalFiles > 0 ? progress.completedFiles / progress.totalFiles : 0; + const percent = + progress.totalFiles > 0 + ? Math.round((progress.completedFiles / progress.totalFiles) * 100) + : 0; const [existing] = await db .select({ id: schema.jobs.id }) .from(schema.jobs) @@ -86,8 +83,11 @@ async function persistJobProgress(progress: JobProgress): Promise { .update(schema.jobs) .set({ status: progress.status, - progress: completionRatio, - error: progress.errors.length > 0 ? JSON.stringify(progress.errors) : null, + progress: { percent }, + error: + progress.errors.length > 0 + ? { message: `${progress.errors.length} file(s) failed`, details: progress.errors } + : null, completedAt: progress.status === "completed" || progress.status === "failed" ? new Date() : null, }) @@ -97,9 +97,12 @@ async function persistJobProgress(progress: JobProgress): Promise { id: progress.jobId, type: "batch", status: progress.status, - progress: completionRatio, - inputFiles: { totalFiles: progress.totalFiles }, - error: progress.errors.length > 0 ? JSON.stringify(progress.errors) : null, + progress: { percent }, + inputRefs: [], + error: + progress.errors.length > 0 + ? { message: `${progress.errors.length} file(s) failed`, details: progress.errors } + : null, }); } } catch { @@ -117,6 +120,8 @@ async function persistSingleFileProgress( : progress.phase === "failed" ? "failed" : "processing"; + const progressJsonb: { percent: number; stage?: string } = { percent: progress.percent }; + if (progress.stage) progressJsonb.stage = progress.stage; const [existing] = await db .select({ id: schema.jobs.id }) .from(schema.jobs) @@ -127,8 +132,8 @@ async function persistSingleFileProgress( .update(schema.jobs) .set({ status, - progress: progress.percent / 100, - error: progress.error ?? null, + progress: progressJsonb, + error: progress.error ? { message: progress.error } : null, completedAt: status === "completed" || status === "failed" ? new Date() : null, }) .where(eq(schema.jobs.id, progress.jobId)); @@ -137,9 +142,9 @@ async function persistSingleFileProgress( id: progress.jobId, type: "single", status, - progress: progress.percent / 100, - inputFiles: [], - error: progress.error ?? null, + progress: progressJsonb, + inputRefs: [], + error: progress.error ? { message: progress.error } : null, }); } } catch { @@ -147,90 +152,115 @@ async function persistSingleFileProgress( } } -/** - * Mark any jobs left in "processing" or "queued" state as failed. - * Called once at startup to recover from unclean shutdown. - */ -export async function recoverStaleJobs(): Promise { - try { - const result = await db - .update(schema.jobs) - .set({ - status: "failed", - error: "Server restarted while job was in progress", - completedAt: new Date(), - }) - .where(eq(schema.jobs.status, "processing")); - const result2 = await db - .update(schema.jobs) - .set({ - status: "failed", - error: "Server restarted while job was queued", - completedAt: new Date(), - }) - .where(eq(schema.jobs.status, "queued")); - const total = (result.rowCount ?? 0) + (result2.rowCount ?? 0); - if (total > 0) { - console.log(`Recovered ${total} stale jobs from previous run`); - } - } catch { - // DB not ready +async function persistDurable( + payload: (JobProgress & { type: "batch" }) | SingleFileProgress, +): Promise { + if (payload.type === "single") { + const { type: _, ...rest } = payload; + await persistSingleFileProgress(rest); + } else { + await persistJobProgress(payload); } } -// ── Public API (unchanged signatures) ─────────────────────────────── +// ── Publish (Redis pub/sub + terminal cache + durable persist) ── + +function publish(payload: (JobProgress & { type: "batch" }) | SingleFileProgress): void { + const json = JSON.stringify(payload); + const isTerminal = + payload.type === "single" + ? payload.phase === "complete" || payload.phase === "failed" + : payload.status === "completed" || payload.status === "failed"; + + // Terminal events write the replay cache BEFORE publishing, so a client + // connecting right after the live event always finds the terminal key. + const announce = isTerminal + ? sharedRedis() + .setex(terminalKey(payload.jobId), TERMINAL_TTL_S, json) + .catch(() => {}) + .then(() => sharedRedis().publish(progressChannel(), json)) + : sharedRedis().publish(progressChannel(), json); + void Promise.resolve(announce).catch(() => {}); + + enqueuePersist(payload.jobId, () => persistDurable(payload)); +} + +// ── Public API (unchanged signatures) ────────────────────────── /** - * Create or update progress for a job. + * Create or update progress for a batch job. */ export function updateJobProgress(progress: JobProgress): void { - jobProgressStore.set(progress.jobId, progress); - enqueuePersist(progress.jobId, () => persistJobProgress(progress)); - // Notify all SSE listeners (add type: "batch" so the frontend can distinguish - // batch events from single-file events in the shared SSE stream) - const subs = listeners.get(progress.jobId); - if (subs) { - const event = { ...progress, type: "batch" } as JobProgress & { type: "batch" }; - for (const cb of subs) { - cb(event); - } - // If the job is done, clean up listeners after a brief delay - if (progress.status === "completed" || progress.status === "failed") { - setTimeout(() => { - listeners.delete(progress.jobId); - jobProgressStore.delete(progress.jobId); - }, 5000); - } - } + const event = { ...progress, type: "batch" } as JobProgress & { type: "batch" }; + publish(event); } export function updateSingleFileProgress(progress: Omit): void { const event: SingleFileProgress = { ...progress, type: "single" }; - enqueuePersist(progress.jobId, () => persistSingleFileProgress(progress)); - - if (progress.phase === "complete" || progress.phase === "failed") { - if (singleFileCompletions.size >= 10_000) { - const oldest = singleFileCompletions.keys().next().value; - if (oldest) singleFileCompletions.delete(oldest); - } - singleFileCompletions.set(progress.jobId, event); - setTimeout(() => singleFileCompletions.delete(progress.jobId), 600_000); - } - - const subs = listeners.get(progress.jobId); - if (subs) { - for (const cb of subs) { - cb(event); - } - if (progress.phase === "complete" || progress.phase === "failed") { - setTimeout(() => { - listeners.delete(progress.jobId); - }, 5000); - } - } + publish(event); } +/** + * Publish a progress event to Redis pub/sub and set the terminal replay + * key, but do NOT persist to the durable DB row. Used by the worker's + * cancel path so that live SSE clients receive a terminal frame while + * the authoritative DB row stays "canceled" (not overwritten to "failed"). + */ +export function publishEphemeral( + payload: (JobProgress & { type: "batch" }) | SingleFileProgress, +): void { + const json = JSON.stringify(payload); + const isTerminal = + payload.type === "single" + ? payload.phase === "complete" || payload.phase === "failed" + : payload.status === "completed" || payload.status === "failed"; + + const announce = isTerminal + ? sharedRedis() + .setex(terminalKey(payload.jobId), TERMINAL_TTL_S, json) + .catch(() => {}) + .then(() => sharedRedis().publish(progressChannel(), json)) + : sharedRedis().publish(progressChannel(), json); + void Promise.resolve(announce).catch(() => {}); +} + +// ── SSE subscriber (module-level, shared across all connections) ─ + +type FrameCallback = (json: string) => void; +const sseListeners = new Map>(); +let sseSubscriber: ReturnType | null = null; + +function ensureSubscriber(): void { + if (sseSubscriber) return; + sseSubscriber = createRedisConnection(); + // ioredis auto-resubscribes after reconnects; the handler keeps connection + // errors observable without crashing (ioredis silentEmits, but be explicit). + sseSubscriber.on("error", (err) => { + console.error("SSE progress subscriber error", err); + }); + void sseSubscriber.subscribe(progressChannel()); + sseSubscriber.on("message", (_channel: string, message: string) => { + try { + const parsed = JSON.parse(message) as { jobId?: string }; + if (!parsed.jobId) return; + const subs = sseListeners.get(parsed.jobId); + if (subs) { + for (const cb of subs) { + cb(message); + } + } + } catch { + // Malformed message; ignore + } + }); +} + +// ── SSE endpoint ─────────────────────────────────────────────── + export async function registerProgressRoutes(app: FastifyInstance): Promise { + // Ensure the Redis subscriber is running when routes are registered + ensureSubscriber(); + app.get( "/api/v1/jobs/:jobId/progress", async (request: FastifyRequest<{ Params: { jobId: string } }>, reply: FastifyReply) => { @@ -253,13 +283,12 @@ export async function registerProgressRoutes(app: FastifyInstance): Promise { - reply.raw.write(`data: ${JSON.stringify(data)}\n\n`); + // Helper to send an SSE frame + const sendFrame = (json: string) => { + reply.raw.write(`data: ${json}\n\n`); }; - // Send keepalive comments every 20s to prevent reverse proxies - // (Caddy, Nginx, ALBs) from killing idle SSE connections. + // Keepalive comments every 20s const keepaliveInterval = setInterval(() => { try { reply.raw.write(": keepalive\n\n"); @@ -268,60 +297,106 @@ export async function registerProgressRoutes(app: FastifyInstance): Promise { - if (ended) return; - sendEvent(data); + // 2. Check the durable DB row for terminal state + try { + const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); if ( - ("status" in data && (data.status === "completed" || data.status === "failed")) || - ("phase" in data && (data.phase === "complete" || data.phase === "failed")) + row && + (row.status === "completed" || row.status === "failed" || row.status === "canceled") ) { - ended = true; - clearInterval(keepaliveInterval); - const subs = listeners.get(jobId); - if (subs) { - subs.delete(callback); - if (subs.size === 0) listeners.delete(jobId); + // Synthesize a legacy event from the DB row + let syntheticJson: string; + if (row.type === "single") { + const phase = row.status === "completed" ? "complete" : "failed"; + const errorMsg = (row.error as { message?: string } | null)?.message; + syntheticJson = JSON.stringify({ + jobId, + type: "single", + phase, + percent: phase === "complete" ? 100 : 0, + ...(errorMsg ? { error: errorMsg } : {}), + }); + } else { + syntheticJson = JSON.stringify({ + jobId, + type: "batch", + status: row.status === "canceled" ? "failed" : row.status, + totalFiles: 0, + completedFiles: 0, + failedFiles: 0, + errors: [], + }); } + sendFrame(syntheticJson); + clearInterval(keepaliveInterval); reply.raw.end(); + return; + } + } catch { + // DB unavailable; fall through to live stream + } + + // 3. Live-stream: subscribe to updates for this jobId + let ended = false; + + const callback: FrameCallback = (json: string) => { + if (ended) return; + sendFrame(json); + + // End the stream on terminal events + try { + const parsed = JSON.parse(json) as { + type?: string; + status?: string; + phase?: string; + }; + const isTerminal = + (parsed.type === "single" && + (parsed.phase === "complete" || parsed.phase === "failed")) || + (parsed.type === "batch" && + (parsed.status === "completed" || parsed.status === "failed")); + if (isTerminal) { + ended = true; + clearInterval(keepaliveInterval); + const subs = sseListeners.get(jobId); + if (subs) { + subs.delete(callback); + if (subs.size === 0) sseListeners.delete(jobId); + } + reply.raw.end(); + } + } catch { + // Parse failure; keep streaming } }; - listeners.get(jobId)?.add(callback); + if (!sseListeners.has(jobId)) { + sseListeners.set(jobId, new Set()); + } + sseListeners.get(jobId)!.add(callback); // Clean up on client disconnect request.raw.on("close", () => { + ended = true; clearInterval(keepaliveInterval); - const subs = listeners.get(jobId); + const subs = sseListeners.get(jobId); if (subs) { subs.delete(callback); - if (subs.size === 0) { - listeners.delete(jobId); - } + if (subs.size === 0) sseListeners.delete(jobId); } }); }, diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 2f20cc2c..0f31ae9f 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -1,27 +1,30 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { extname, join } from "node:path"; import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared"; -import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import type { z } from "zod"; -import { db, schema } from "../db/index.js"; +import { env } from "../config.js"; +import { enqueueToolJob, waitForJob } from "../jobs/enqueue.js"; import { trackEvent } from "../lib/analytics.js"; import { autoOrient } from "../lib/auto-orient.js"; import { formatZodErrors, stripInternalPaths } from "../lib/errors.js"; import { isToolInstalled } from "../lib/feature-status.js"; import { validateImageBuffer } from "../lib/file-validation.js"; -import { sanitizeFilename } from "../lib/filename.js"; import { decodeAnyFormat, decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js"; import { decodeHeic } from "../lib/heic-converter.js"; -import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js"; +import { getObjectBuffer, putObject } from "../lib/object-storage.js"; import { decompressSvgz, sanitizeSvg } from "../lib/svg-sanitize.js"; -import { computeTimeout } from "../lib/timeout.js"; -import { getWorkerPool } from "../lib/worker-pool.js"; -import { createWorkspace } from "../lib/workspace.js"; +import { receiveUpload } from "../lib/upload-stream.js"; +import { getAuthUser } from "../plugins/auth.js"; import { updateSingleFileProgress } from "./progress.js"; +/** Context passed to tool process functions for cooperative cancellation, scratch storage, and progress. */ +export interface ToolProcessCtx { + signal: AbortSignal; + scratchDir: string; + report: (percent: number, stage?: string) => void; +} + export interface ToolRouteConfig { /** Unique tool identifier, used as the URL path segment. */ toolId: string; @@ -32,6 +35,7 @@ export interface ToolRouteConfig { inputBuffer: Buffer, settings: T, filename: string, + ctx?: ToolProcessCtx, ) => Promise<{ buffer: Buffer; filename: string; contentType: string }>; } @@ -43,6 +47,7 @@ export interface AnyToolRouteConfig { inputBuffer: Buffer, settings: unknown, filename: string, + ctx?: ToolProcessCtx, ) => Promise<{ buffer: Buffer; filename: string; contentType: string }>; } @@ -52,18 +57,6 @@ export interface AnyToolRouteConfig { */ const toolRegistry = new Map(); -/** - * Worker threads are disabled for all tools. - * - * AI tools skip workers because they use the Python bridge. - * Sharp-based tools skip workers because they complete in milliseconds - * and the worker initialization (which imports the full tool registry - * and reads SQLite) can deadlock under Docker volume filesystems. - * - * The Piscina pool is kept in the codebase for potential future use - * with long-running CPU-bound operations. - */ - /** * Retrieve a registered tool config by its ID. */ @@ -95,12 +88,12 @@ export function registerToolProcessFn(config: AnyToolRouteConfig): void { * - A "settings" field containing a JSON string * * The factory handles: - * - Multipart parsing - * - File validation + * - Multipart parsing (streamed to object storage via receiveUpload) + * - File validation + decode chain (HEIC, CLI, SVG, AVIF) * - Settings validation via Zod - * - Workspace management + * - Enqueue to BullMQ + sync-wait for the worker result * - Error handling - * - Response formatting + * - Response formatting (legacy envelope) */ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig): void { // Register in the tool registry for batch processing (cast to type-erased form) @@ -110,14 +103,15 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig `/api/v1/tools/${config.toolId}`, { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (request: FastifyRequest, reply: FastifyReply) => { - let fileBuffer: Buffer | null = null; + const jobId = randomUUID(); let filename = "image"; let settingsRaw: string | null = null; let fileId: string | null = null; let clientJobId: string | null = null; let fileCount = 0; + let inputKey: string | null = null; - // Parse multipart parts + // Parse multipart parts (file parts stream to object storage) try { const parts = request.parts(); @@ -131,13 +125,12 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig } continue; } - // Consume the file stream into a buffer - const chunks: Buffer[] = []; - for await (const chunk of part.file) { - chunks.push(chunk); - } - fileBuffer = Buffer.concat(chunks); - filename = sanitizeFilename(part.filename ?? "image"); + const upload = await receiveUpload(part, jobId, { + maxBytes: + env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : undefined, + }); + inputKey = upload.key; + filename = upload.filename; } else { // Field part if (part.fieldname === "settings") { @@ -168,13 +161,13 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig } // Require a file - if (!fileBuffer || fileBuffer.length === 0) { + if (!inputKey) { return reply.status(400).send({ error: "No image file provided" }); } - // Capture the original upload size before any decoding (HEIC, CLI) - // mutates fileBuffer into a larger intermediate PNG. - const uploadedSize = fileBuffer.length; + // Read back the uploaded file for validation/decode chain + let fileBuffer = await getObjectBuffer(inputKey); + const originalBuffer = fileBuffer; const reportProgress = (percent: number, stage?: string) => { if (!clientJobId) return; @@ -191,6 +184,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig // Validate the uploaded image const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); } @@ -205,6 +199,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig const ext = filename.match(/\.[^.]+$/)?.[0]; if (ext) filename = `${filename.slice(0, -ext.length)}.png`; } catch (err) { + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply.status(422).send({ error: "Failed to decode HEIC file. Ensure libheif-examples is installed.", details: stripInternalPaths(err instanceof Error ? err.message : String(err)), @@ -225,6 +220,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig try { await sharp(fileBuffer).metadata(); } catch (err) { + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply.status(422).send({ error: `Failed to decode ${validation.format.toUpperCase()} file`, details: stripInternalPaths(err instanceof Error ? err.message : String(err)), @@ -242,6 +238,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig fileBuffer = decompressSvgz(fileBuffer); fileBuffer = sanitizeSvg(fileBuffer); } catch (err) { + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply.status(400).send({ error: err instanceof Error ? err.message : "Invalid SVG", }); @@ -261,6 +258,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig const ext = filename.match(/\.[^.]+$/)?.[0]; if (ext) filename = `${filename.slice(0, -ext.length)}.png`; } catch (fallbackErr) { + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply.status(422).send({ error: "Failed to decode AVIF file", details: stripInternalPaths( @@ -271,10 +269,17 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig } } + // Auto-orient non-SVG images: physically rotate pixels to match + // the EXIF orientation tag so the worker sees upright pixels. + if (!isSvg) { + fileBuffer = await autoOrient(fileBuffer); + } + reportProgress(15, "Preparing..."); // Parse and validate settings if (settingsRaw && settingsRaw.length > 65536) { + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply.status(400).send({ error: "Settings payload too large (max 64KB)" }); } let settings: T; @@ -282,6 +287,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; const result = config.settingsSchema.safeParse(parsed); if (!result.success) { + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply.status(400).send({ error: "Invalid settings", details: formatZodErrors(result.error.issues), @@ -289,6 +295,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig } settings = result.data; } catch { + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply.status(400).send({ error: "Settings must be valid JSON" }); } @@ -296,6 +303,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig const bundleId = TOOL_BUNDLE_MAP[config.toolId]; if (bundleId && !isToolInstalled(config.toolId)) { const bundle = getBundleForTool(config.toolId); + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply.status(501).send({ error: "Feature not installed", code: "FEATURE_NOT_INSTALLED", @@ -305,219 +313,57 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig }); } - // Process the image (worker thread or main thread) + // If decode/orient transformed the buffer or changed the filename, + // write the final version so the worker processes the correct data. + // Skip re-upload when the buffer is reference-identical to the + // originally streamed bytes and the filename hasn't changed. + const decodedName = filename; + const decodedKey = `uploads/${jobId}/${decodedName}`; + if (decodedKey !== inputKey) { + await putObject(decodedKey, fileBuffer); + inputKey = decodedKey; + } else if (fileBuffer !== originalBuffer) { + await putObject(inputKey, fileBuffer); + } + const startTime = Date.now(); + + // Enqueue for the BullMQ worker + await enqueueToolJob({ + jobId, + toolId: config.toolId, + userId: getAuthUser(request)?.id ?? null, + pool: "image", + inputRefs: [inputKey], + filename, + settings, + fileId: fileId ?? undefined, + clientJobId: clientJobId ?? undefined, + kind: "tool", + }); + try { - let result: { buffer: Buffer; filename: string; contentType: string }; + const result = await waitForJob("image", jobId); + if (result) { + trackEvent(request, ANALYTICS_EVENTS.TOOL_USED, { + tool_id: config.toolId, + status: "completed", + duration_ms: Date.now() - startTime, + category: TOOLS.find((t) => t.id === config.toolId)?.category ?? "unknown", + is_ai_tool: getBundleForTool(config.toolId) !== null, + }); - reportProgress(20, "Processing..."); - - // Offload to worker thread for non-AI tools. - // Falls back to main-thread processing on any worker error. - // Disabled in test environments where worker_threads can't load .ts files. - const useWorker = false; - if (useWorker) { - try { - const pool = getWorkerPool(); - const workerInput: WorkerInput = { - toolId: config.toolId, - inputBuffer: fileBuffer, - settings, - filename, - inputFormat: validation.format, - }; - const meta = await sharp(fileBuffer).metadata(); - const megapixels = ((meta.width ?? 0) * (meta.height ?? 0)) / 1_000_000; - const timeoutMs = computeTimeout(megapixels, "sharp"); - const workerResult: WorkerOutput = await pool.run(workerInput, { - signal: AbortSignal.timeout(timeoutMs), - }); - result = { - buffer: Buffer.from(workerResult.buffer), - filename: workerResult.filename, - contentType: workerResult.contentType, - }; - } catch (workerErr) { - // Worker failed - fall back to main-thread processing - request.log.warn( - { workerErr, toolId: config.toolId }, - "Worker processing failed, falling back to main thread", - ); - const processBuffer = isSvg ? fileBuffer : await autoOrient(fileBuffer); - result = await config.process(processBuffer, settings, filename); - } - } else { - // AI tools: always main thread (they use Python bridge) - const processBuffer = isSvg ? fileBuffer : await autoOrient(fileBuffer); - result = await config.process(processBuffer, settings, filename); + return reply.send({ + jobId, + downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`, + previewUrl: result.previewRef ? `/api/v1/download/${jobId}/preview.webp` : undefined, + originalSize: result.originalSize, + processedSize: result.processedSize, + savedFileId: result.savedFileId, + }); } - - reportProgress(75, "Saving..."); - - // Add a tool-specific suffix to the filename so the download - // doesn't silently overwrite the user's original file. - // Skip if the tool already changed the filename (e.g. convert, split). - if (result.filename === filename) { - const ext = extname(filename); - const base = ext ? filename.slice(0, -ext.length) : filename; - result.filename = `${base}_${config.toolId}${ext}`; - } - - // Fix extension mismatch: when the output format differs from the - // original (e.g. SVG input -> PNG output), update the filename - // extension so the download endpoint serves the correct Content-Type. - const CONTENT_TYPE_TO_EXT: Record = { - "image/jpeg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", - "image/gif": ".gif", - "image/tiff": ".tiff", - "image/avif": ".avif", - "image/svg+xml": ".svg", - "image/bmp": ".bmp", - "image/heic": ".heic", - "image/heif": ".heif", - "image/jxl": ".jxl", - "image/x-icon": ".ico", - "image/vnd.adobe.photoshop": ".psd", - "image/x-exr": ".exr", - "image/vnd.radiance": ".hdr", - "image/x-targa": ".tga", - "image/jp2": ".jp2", - "image/qoi": ".qoi", - "application/postscript": ".eps", - "image/vnd.ms-dds": ".dds", - "image/x-dpx": ".dpx", - "image/fits": ".fits", - }; - const expectedExt = CONTENT_TYPE_TO_EXT[result.contentType]; - if (expectedExt) { - const currentExt = extname(result.filename).toLowerCase(); - if (currentExt && currentExt !== expectedExt) { - result.filename = result.filename.slice(0, -currentExt.length) + expectedExt; - } - } - - // Create workspace and save output - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const outputPath = join(workspacePath, "output", result.filename); - await writeFile(outputPath, result.buffer); - - // Generate a browser-previewable WebP thumbnail for formats that - // browsers cannot render in tags (HEIC, TIFF, etc.) - const BROWSER_PREVIEWABLE = new Set([ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/svg+xml", - "image/bmp", - "image/avif", - ]); - let previewUrl: string | undefined; - if (!BROWSER_PREVIEWABLE.has(result.contentType)) { - reportProgress(85, "Generating preview..."); - try { - let previewInput = result.buffer; - // Sharp can't decode HEIC - use system decoder first - if (result.contentType === "image/heic" || result.contentType === "image/heif") { - previewInput = await decodeHeic(result.buffer); - } - const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer(); - const previewPath = join(workspacePath, "output", "preview.webp"); - await writeFile(previewPath, previewBuffer); - previewUrl = `/api/v1/download/${jobId}/preview.webp`; - } catch (previewErr) { - request.log.warn( - { previewErr, contentType: result.contentType, toolId: config.toolId }, - "Failed to generate preview thumbnail, falling back to input buffer", - ); - // Retry with the original input buffer (pre-processing) which - // was already validated and decoded during the intake phase. - try { - const fallbackBuffer = await sharp(fileBuffer).webp({ quality: 80 }).toBuffer(); - const previewPath = join(workspacePath, "output", "preview.webp"); - await writeFile(previewPath, fallbackBuffer); - previewUrl = `/api/v1/download/${jobId}/preview.webp`; - } catch { - // Both attempts failed - frontend will use the upload preview as fallback - } - } - } - - // Also save the original input for reference/download - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); - - reportProgress(95, "Finishing..."); - - // Auto-save to persistent file store when a fileId is provided - let savedFileId: string | undefined; - if (fileId) { - try { - const { saveFile } = await import("../lib/file-storage.js"); - const [parent] = await db - .select() - .from(schema.userFiles) - .where(eq(schema.userFiles.id, fileId)); - if (parent) { - const newVersion = parent.version + 1; - const parentChain: string[] = parent.toolChain ?? []; - const newToolChain = [...parentChain, config.toolId]; - const storedName = await saveFile(result.buffer, result.filename); - // Get image dimensions from the processed output - let width: number | null = null; - let height: number | null = null; - try { - const meta = await sharp(result.buffer).metadata(); - width = meta.width ?? null; - height = meta.height ?? null; - } catch { - // dimensions are non-critical - } - const newId = randomUUID(); - await db.insert(schema.userFiles).values({ - id: newId, - userId: parent.userId, - originalName: result.filename, - storedName, - mimeType: result.contentType, - size: result.buffer.length, - width, - height, - version: newVersion, - parentId: fileId, - toolChain: newToolChain, - }); - savedFileId = newId; - } - } catch (saveErr) { - // Non-fatal — tool processing already succeeded - request.log.warn({ saveErr, fileId }, "Failed to auto-save processed file"); - } - } - - trackEvent(request, ANALYTICS_EVENTS.TOOL_USED, { - tool_id: config.toolId, - status: "completed", - duration_ms: Date.now() - startTime, - category: TOOLS.find((t) => t.id === config.toolId)?.category ?? "unknown", - is_ai_tool: getBundleForTool(config.toolId) !== null, - }); - - return reply.send({ - jobId, - downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`, - previewUrl, - originalSize: uploadedSize, - processedSize: result.buffer.length, - savedFileId, - }); + return reply.status(202).send({ jobId: clientJobId || jobId, async: true }); } catch (err) { - // Catch Sharp / processing errors and return a clean API error - const message = err instanceof Error ? err.message : "Image processing failed"; - request.log.error({ err, toolId: config.toolId }, "Tool processing failed"); trackEvent(request, ANALYTICS_EVENTS.TOOL_USED, { tool_id: config.toolId, status: "failed", @@ -525,11 +371,12 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig category: TOOLS.find((t) => t.id === config.toolId)?.category ?? "unknown", is_ai_tool: getBundleForTool(config.toolId) !== null, error_code: err instanceof Error ? err.constructor.name : "UnknownError", - error_message: message.slice(0, 200), + error_message: + err instanceof Error ? err.message.slice(0, 200) : "Image processing failed", }); return reply.status(422).send({ error: "Processing failed", - details: stripInternalPaths(message), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } }, diff --git a/apps/api/src/routes/tools/ai-canvas-expand.ts b/apps/api/src/routes/tools/ai-canvas-expand.ts index 85acea93..dd193ef6 100644 --- a/apps/api/src/routes/tools/ai-canvas-expand.ts +++ b/apps/api/src/routes/tools/ai-canvas-expand.ts @@ -1,39 +1,26 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { outpaint } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; +import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; +import { enqueueToolJob } from "../../jobs/enqueue.js"; import { autoOrient } from "../../lib/auto-orient.js"; -import { formatZodErrors } from "../../lib/errors.js"; +import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; -import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { encodeJxl } from "../../lib/format-encoders.js"; import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; -import { createWorkspace } from "../../lib/workspace.js"; -import { updateSingleFileProgress } from "../progress.js"; +import { receiveUpload } from "../../lib/upload-stream.js"; import { registerToolProcessFn } from "../tool-factory.js"; -const EXT_MAP: Record = { - jpeg: "jpg", - jpg: "jpg", - png: "png", - webp: "webp", - tiff: "tiff", - gif: "gif", - avif: "avif", - heic: "heic", - heif: "heif", - jxl: "jxl", -}; - -const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]); - const settingsSchema = z.object({ extendTop: z.number().int().min(0).default(0), extendRight: z.number().int().min(0).default(0), @@ -48,6 +35,99 @@ const settingsSchema = z.object({ type Settings = z.infer; +// ── AI job handler ──────────────────────────────────────────────── +registerAiJobHandler("ai-canvas-expand", async (input, data, ctx) => { + const settings = settingsSchema.parse(data.settings); + let format: string = settings.format; + let quality = settings.quality; + + if (format === "auto") { + const detected = await resolveOutputFormat(input, data.filename); + format = detected.format === "jpeg" ? "jpg" : detected.format; + quality = detected.quality; + } + + const resultBuffer = await outpaint( + input, + { + extendTop: settings.extendTop, + extendRight: settings.extendRight, + extendBottom: settings.extendBottom, + extendLeft: settings.extendLeft, + tier: settings.tier, + }, + ctx.scratchDir, + (percent, stage) => ctx.report(percent, stage), + ); + + // Convert to requested output format + const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format); + let outputBuffer: Buffer; + let finalFormat = format; + + if (needsNodeConversion) { + if (format === "heic" || format === "heif") { + outputBuffer = await encodeHeic(resultBuffer, quality); + finalFormat = format; + } else if (format === "jxl") { + outputBuffer = await encodeJxl(resultBuffer, quality); + finalFormat = "jxl"; + } else { + outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer(); + finalFormat = "avif"; + } + } else if (format === "jpg" || format === "jpeg") { + outputBuffer = await sharp(resultBuffer).jpeg({ quality }).toBuffer(); + finalFormat = "jpg"; + } else if (format === "webp") { + outputBuffer = await sharp(resultBuffer).webp({ quality }).toBuffer(); + finalFormat = "webp"; + } else if (format === "tiff") { + outputBuffer = await sharp(resultBuffer).tiff({ quality }).toBuffer(); + finalFormat = "tiff"; + } else if (format === "gif") { + outputBuffer = await sharp(resultBuffer).gif().toBuffer(); + finalFormat = "gif"; + } else { + outputBuffer = resultBuffer; + finalFormat = "png"; + } + + const EXT_MAP: Record = { + jpeg: "jpg", + jpg: "jpg", + png: "png", + webp: "webp", + tiff: "tiff", + gif: "gif", + avif: "avif", + heic: "heic", + heif: "heif", + jxl: "jxl", + }; + const ext = EXT_MAP[finalFormat] || "png"; + const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_extended.${ext}`; + + const CONTENT_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + webp: "image/webp", + tiff: "image/tiff", + gif: "image/gif", + avif: "image/avif", + heic: "image/heic", + heif: "image/heif", + jxl: "image/jxl", + }; + + return { + buffer: outputBuffer, + filename: outputFilename, + contentType: CONTENT_TYPES[finalFormat] || "image/png", + }; +}); + export function registerAiCanvasExpand(app: FastifyInstance) { app.post( "/api/v1/tools/ai-canvas-expand", @@ -64,21 +144,20 @@ export function registerAiCanvasExpand(app: FastifyInstance) { }); } + const jobId = randomUUID(); let fileBuffer: Buffer | null = null; let filename = "image"; let settingsRaw: string | null = null; let clientJobId: string | null = null; + let inputKey: string | null = null; try { const parts = request.parts(); for await (const part of parts) { if (part.type === "file") { - const chunks: Buffer[] = []; - for await (const chunk of part.file) { - chunks.push(chunk); - } - fileBuffer = Buffer.concat(chunks); - filename = sanitizeFilename(part.filename ?? "image"); + const upload = await receiveUpload(part, jobId); + inputKey = upload.key; + filename = upload.filename; } else if (part.fieldname === "settings") { settingsRaw = part.value as string; } else if (part.fieldname === "clientJobId") { @@ -91,14 +170,16 @@ export function registerAiCanvasExpand(app: FastifyInstance) { } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", - details: err instanceof Error ? err.message : String(err), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } - if (!fileBuffer || fileBuffer.length === 0) { + if (!inputKey) { return reply.status(400).send({ error: "No image file provided" }); } + fileBuffer = await getObjectBuffer(inputKey); + const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); @@ -131,216 +212,87 @@ export function registerAiCanvasExpand(app: FastifyInstance) { }); } - let format: string = settings.format; - let quality = settings.quality; - - if (format === "auto") { - const detected = await resolveOutputFormat(fileBuffer, filename); - format = detected.format === "jpeg" ? "jpg" : detected.format; - quality = detected.quality; - } - try { - // Decode HEIC/HEIF input if (validation.format === "heif") { fileBuffer = await decodeHeic(fileBuffer); } - - // Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR) if (needsCliDecode(validation.format)) { fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format); } - - // Auto-orient to fix EXIF rotation fileBuffer = await autoOrient(fileBuffer); } catch (err) { request.log.error({ err, toolId: "ai-canvas-expand" }, "Input decoding failed"); return reply.status(422).send({ error: "AI canvas expand failed", - details: err instanceof Error ? err.message : "Unknown error", + details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }); } - const originalSize = fileBuffer.length; - const jobId = randomUUID(); + const decodedKey = `uploads/${jobId}/${filename}`; + if (decodedKey !== inputKey) { + await putObject(decodedKey, fileBuffer); + inputKey = decodedKey; + } else { + await putObject(inputKey, fileBuffer); + } + const progressJobId = clientJobId || jobId; - let workspacePath: string; - try { - workspacePath = await createWorkspace(jobId); - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); - } catch (err) { - request.log.error({ err, toolId: "ai-canvas-expand" }, "Workspace creation failed"); - return reply.status(422).send({ - error: "AI canvas expand failed", - details: err instanceof Error ? err.message : "Unknown error", - }); - } - const log = request.log; - log.info( - { - toolId: "ai-canvas-expand", - imageSize: originalSize, - extendTop: settings.extendTop, - extendRight: settings.extendRight, - extendBottom: settings.extendBottom, - extendLeft: settings.extendLeft, - tier: settings.tier, - format, - }, - "Starting AI canvas expand", - ); - - // Reply immediately so the HTTP connection closes within proxy timeout limits. - // The result will be delivered via the SSE progress channel. - reply.status(202).send({ jobId: progressJobId, async: true }); - - const onProgress = (percent: number, stage: string) => { - updateSingleFileProgress({ - jobId: progressJobId, - phase: "processing", - stage, - percent, - }); - }; - - // Fire-and-forget: processing happens after the response is sent - (async () => { - const resultBuffer = await outpaint( - fileBuffer, - { - extendTop: settings.extendTop, - extendRight: settings.extendRight, - extendBottom: settings.extendBottom, - extendLeft: settings.extendLeft, - tier: settings.tier, - }, - join(workspacePath, "output"), - onProgress, - ); - - // Convert to the requested output format using Sharp - const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format); - let outputBuffer: Buffer; - let finalFormat = format; - - if (needsNodeConversion) { - if (format === "heic" || format === "heif") { - outputBuffer = await encodeHeic(resultBuffer, quality); - finalFormat = format; - } else if (format === "jxl") { - outputBuffer = await encodeJxl(resultBuffer, quality); - finalFormat = "jxl"; - } else { - outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer(); - finalFormat = "avif"; - } - } else if (format === "jpg" || format === "jpeg") { - outputBuffer = await sharp(resultBuffer).jpeg({ quality }).toBuffer(); - finalFormat = "jpg"; - } else if (format === "webp") { - outputBuffer = await sharp(resultBuffer).webp({ quality }).toBuffer(); - finalFormat = "webp"; - } else if (format === "tiff") { - outputBuffer = await sharp(resultBuffer).tiff({ quality }).toBuffer(); - finalFormat = "tiff"; - } else if (format === "gif") { - outputBuffer = await sharp(resultBuffer).gif().toBuffer(); - finalFormat = "gif"; - } else { - outputBuffer = resultBuffer; - finalFormat = "png"; - } - - // Save output - const ext = EXT_MAP[finalFormat] || "png"; - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_extended.${ext}`; - const outputPath = join(workspacePath, "output", outputFilename); - await writeFile(outputPath, outputBuffer); - - // Generate browser-compatible preview for non-previewable formats - let previewUrl: string | undefined; - if (!BROWSER_PREVIEWABLE.has(finalFormat)) { - try { - const previewInput = - finalFormat === "heic" || finalFormat === "heif" - ? await decodeHeic(outputBuffer) - : outputBuffer; - const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer(); - const previewPath = join(workspacePath, "output", "preview.webp"); - await writeFile(previewPath, previewBuffer); - previewUrl = `/api/v1/download/${jobId}/preview.webp`; - } catch { - // Non-fatal - frontend will show fallback - } - } - - const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; - updateSingleFileProgress({ - jobId: progressJobId, - phase: "complete", - percent: 100, - result: { - jobId, - downloadUrl, - previewUrl, - originalSize, - processedSize: outputBuffer.length, - }, - }); - - log.info({ toolId: "ai-canvas-expand", jobId, downloadUrl }, "AI canvas expand complete"); - })().catch((err) => { - log.error({ err, toolId: "ai-canvas-expand" }, "AI canvas expand failed"); - updateSingleFileProgress({ - jobId: progressJobId, - phase: "failed", - percent: 0, - error: err instanceof Error ? err.message : "AI canvas expand failed", - }); + await enqueueToolJob({ + jobId, + toolId, + userId: null, + pool: "ai", + inputRefs: [inputKey], + filename, + settings, + clientJobId: clientJobId ?? undefined, + kind: "ai-tool", }); + + return reply.status(202).send({ jobId: progressJobId, async: true }); }, ); - // Register in the pipeline/batch registry so this tool can be used - // as a step in automation pipelines (without progress callbacks). + // Register in the pipeline/batch registry registerToolProcessFn({ toolId: "ai-canvas-expand", settingsSchema, - process: async (inputBuffer, settings, filename) => { + process: async (inputBuffer, settings, filename, ctx) => { const s = settings as Settings; - // Decode HEIC/HEIF for pipeline/batch mode const ext = filename.split(".").pop()?.toLowerCase() ?? ""; let buf = inputBuffer; if (["heic", "heif", "hif"].includes(ext)) { buf = await decodeHeic(buf); } - // Decode CLI-decoded formats for pipeline/batch mode const cliCheck = await validateImageBuffer(inputBuffer, filename); if (cliCheck.valid && needsCliDecode(cliCheck.format)) { buf = await decodeToSharpCompat(inputBuffer, cliCheck.format); } const orientedBuffer = await autoOrient(buf); - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); + const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID()); + const needsCleanup = !ctx?.scratchDir; + if (needsCleanup) await mkdir(scratchDir, { recursive: true }); + try { + const resultBuffer = await outpaint( + orientedBuffer, + { + extendTop: s.extendTop, + extendRight: s.extendRight, + extendBottom: s.extendBottom, + extendLeft: s.extendLeft, + tier: s.tier, + }, + scratchDir, + ); - const resultBuffer = await outpaint( - orientedBuffer, - { - extendTop: s.extendTop, - extendRight: s.extendRight, - extendBottom: s.extendBottom, - extendLeft: s.extendLeft, - tier: s.tier, - }, - join(workspacePath, "output"), - ); - - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_extended.png`; - return { buffer: resultBuffer, filename: outputFilename, contentType: "image/png" }; + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_extended.png`; + return { buffer: resultBuffer, filename: outputFilename, contentType: "image/png" }; + } finally { + if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); + } }, }); } diff --git a/apps/api/src/routes/tools/barcode-read.ts b/apps/api/src/routes/tools/barcode-read.ts index 7e7fe4e6..b6dd519b 100644 --- a/apps/api/src/routes/tools/barcode-read.ts +++ b/apps/api/src/routes/tools/barcode-read.ts @@ -1,6 +1,4 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; @@ -11,8 +9,8 @@ import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; +import { putObject } from "../../lib/object-storage.js"; import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; -import { createWorkspace } from "../../lib/workspace.js"; const settingsSchema = z.object({ tryHarder: z.boolean().default(true), @@ -233,25 +231,22 @@ export function registerBarcodeRead(app: FastifyInstance) { // --- Generate annotated image --- const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); // Save original input - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); + await putObject(`uploads/${jobId}/${filename}`, fileBuffer); // Build SVG overlay with bounding boxes const overlaySvg = buildOverlaySvg(width, height, barcodes); const stem = filename.replace(/\.[^.]+$/, ""); const outputFilename = `annotated-${stem}.png`; - const outputPath = join(workspacePath, "output", outputFilename); const annotatedBuffer = await sharp(fileBuffer) .composite([{ input: Buffer.from(overlaySvg), top: 0, left: 0 }]) .png() .toBuffer(); - await writeFile(outputPath, annotatedBuffer); + await putObject(`outputs/${jobId}/${outputFilename}`, annotatedBuffer); const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; diff --git a/apps/api/src/routes/tools/beautify.ts b/apps/api/src/routes/tools/beautify.ts index fad917fd..071c442d 100644 --- a/apps/api/src/routes/tools/beautify.ts +++ b/apps/api/src/routes/tools/beautify.ts @@ -1,6 +1,4 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { autoOrient } from "../../lib/auto-orient.js"; @@ -23,8 +21,8 @@ import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; +import { putObject } from "../../lib/object-storage.js"; import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; -import { createWorkspace } from "../../lib/workspace.js"; import { registerToolProcessFn } from "../tool-factory.js"; const ALPHA_FORMATS = new Set(["png", "webp", "avif"]); @@ -308,9 +306,7 @@ export function registerBeautify(app: FastifyInstance) { const outFilename = resolveOutputFilename(filename, settings); const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const outputPath = join(workspacePath, "output", outFilename); - await writeFile(outputPath, outputBuf); + await putObject(`outputs/${jobId}/${outFilename}`, outputBuf); return reply.send({ jobId, diff --git a/apps/api/src/routes/tools/blur-faces.ts b/apps/api/src/routes/tools/blur-faces.ts index b934455c..64ede39a 100644 --- a/apps/api/src/routes/tools/blur-faces.ts +++ b/apps/api/src/routes/tools/blur-faces.ts @@ -1,21 +1,23 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { blurFaces } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; +import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; +import { enqueueToolJob } from "../../jobs/enqueue.js"; import { autoOrient } from "../../lib/auto-orient.js"; -import { formatZodErrors } from "../../lib/errors.js"; +import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; -import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; -import { createWorkspace } from "../../lib/workspace.js"; -import { updateSingleFileProgress } from "../progress.js"; +import { receiveUpload } from "../../lib/upload-stream.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -23,6 +25,43 @@ const settingsSchema = z.object({ sensitivity: z.number().min(0).max(1).default(0.5), }); +// ── AI job handler (runs inside the BullMQ worker) ──────────────── +registerAiJobHandler("blur-faces", async (input, data, ctx) => { + const settings = settingsSchema.parse(data.settings); + const { blurRadius, sensitivity } = settings; + + const result = await blurFaces( + input, + ctx.scratchDir, + { blurRadius, sensitivity }, + (percent, stage) => ctx.report(percent, stage), + ); + + const outputFormat = await resolveOutputFormat(input, data.filename); + let outputBuffer = result.buffer; + if (outputFormat.format !== "png") { + outputBuffer = await sharp(result.buffer) + .toFormat(outputFormat.format, { quality: outputFormat.quality }) + .toBuffer(); + } + + const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format; + const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_blurred.${ext}`; + + return { + buffer: outputBuffer, + filename: outputFilename, + contentType: outputFormat.contentType, + resultPayload: { + facesDetected: result.facesDetected, + faces: result.faces, + ...(result.facesDetected === 0 && { + warning: "No faces detected in this image. Try increasing detection sensitivity.", + }), + }, + }; +}); + /** Face detection and blurring route. */ export function registerBlurFaces(app: FastifyInstance) { app.post("/api/v1/tools/blur-faces", async (request: FastifyRequest, reply: FastifyReply) => { @@ -38,21 +77,20 @@ export function registerBlurFaces(app: FastifyInstance) { }); } + const jobId = randomUUID(); let fileBuffer: Buffer | null = null; let filename = "image"; let settingsRaw: string | null = null; let clientJobId: string | null = null; + let inputKey: string | null = null; try { const parts = request.parts(); for await (const part of parts) { if (part.type === "file") { - const chunks: Buffer[] = []; - for await (const chunk of part.file) { - chunks.push(chunk); - } - fileBuffer = Buffer.concat(chunks); - filename = sanitizeFilename(part.filename ?? "image"); + const upload = await receiveUpload(part, jobId); + inputKey = upload.key; + filename = upload.filename; } else if (part.fieldname === "settings") { settingsRaw = part.value as string; } else if (part.fieldname === "clientJobId") { @@ -65,14 +103,16 @@ export function registerBlurFaces(app: FastifyInstance) { } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", - details: err instanceof Error ? err.message : String(err), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } - if (!fileBuffer || fileBuffer.length === 0) { + if (!inputKey) { return reply.status(400).send({ error: "No image file provided" }); } + fileBuffer = await getObjectBuffer(inputKey); + const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); @@ -92,127 +132,55 @@ export function registerBlurFaces(app: FastifyInstance) { return reply.status(400).send({ error: "Settings must be valid JSON" }); } - const { blurRadius, sensitivity } = settings; - try { if (validation.format === "heif") { fileBuffer = await decodeHeic(fileBuffer); } - - // Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR) if (needsCliDecode(validation.format)) { fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format); } - fileBuffer = await autoOrient(fileBuffer); } catch (err) { request.log.error({ err, toolId: "blur-faces" }, "Input decoding failed"); return reply.status(422).send({ error: "Face blur failed", - details: err instanceof Error ? err.message : "Unknown error", + details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }); } - const originalSize = fileBuffer.length; - const jobId = randomUUID(); + const decodedKey = `uploads/${jobId}/${filename}`; + if (decodedKey !== inputKey) { + await putObject(decodedKey, fileBuffer); + inputKey = decodedKey; + } else { + await putObject(inputKey, fileBuffer); + } + const progressJobId = clientJobId || jobId; - let workspacePath: string; - try { - workspacePath = await createWorkspace(jobId); - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); - } catch (err) { - request.log.error({ err, toolId: "blur-faces" }, "Workspace creation failed"); - return reply.status(422).send({ - error: "Face blur failed", - details: err instanceof Error ? err.message : "Unknown error", - }); - } - const log = request.log; - log.info( - { toolId: "blur-faces", imageSize: originalSize, blurRadius, sensitivity }, - "Starting face blur", - ); - - // Reply immediately so the HTTP connection closes within proxy timeout limits. - // The result will be delivered via the SSE progress channel. - reply.status(202).send({ jobId: progressJobId, async: true }); - - const onProgress = (percent: number, stage: string) => { - updateSingleFileProgress({ - jobId: progressJobId, - phase: "processing", - stage, - percent, - }); - }; - - // Fire-and-forget: processing happens after the response is sent - (async () => { - const result = await blurFaces( - fileBuffer, - join(workspacePath, "output"), - { - blurRadius, - sensitivity, - }, - onProgress, - ); - - // Resolve output format to match input - const outputFormat = await resolveOutputFormat(fileBuffer, filename); - let outputBuffer = result.buffer; - if (outputFormat.format !== "png") { - outputBuffer = await sharp(result.buffer) - .toFormat(outputFormat.format, { quality: outputFormat.quality }) - .toBuffer(); - } - - const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format; - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.${ext}`; - const outputPath = join(workspacePath, "output", outputFilename); - await writeFile(outputPath, outputBuffer); - - const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; - updateSingleFileProgress({ - jobId: progressJobId, - phase: "complete", - percent: 100, - result: { - jobId, - downloadUrl, - originalSize, - processedSize: outputBuffer.length, - facesDetected: result.facesDetected, - faces: result.faces, - ...(result.facesDetected === 0 && { - warning: "No faces detected in this image. Try increasing detection sensitivity.", - }), - }, - }); - - log.info({ toolId: "blur-faces", jobId, downloadUrl }, "Face blur complete"); - })().catch((err) => { - log.error({ err, toolId: "blur-faces" }, "Face blur failed"); - updateSingleFileProgress({ - jobId: progressJobId, - phase: "failed", - percent: 0, - error: err instanceof Error ? err.message : "Face blur failed", - }); + await enqueueToolJob({ + jobId, + toolId, + userId: null, + pool: "ai", + inputRefs: [inputKey], + filename, + settings, + clientJobId: clientJobId ?? undefined, + kind: "ai-tool", }); + + return reply.status(202).send({ jobId: progressJobId, async: true }); }); - // Register in the pipeline/batch registry so this tool can be used - // as a step in automation pipelines (without progress callbacks). + // Register in the pipeline/batch registry registerToolProcessFn({ toolId: "blur-faces", settingsSchema: z.object({ blurRadius: z.number().min(1).max(100).default(30), sensitivity: z.number().min(0).max(1).default(0.5), }), - process: async (inputBuffer, settings, filename) => { + process: async (inputBuffer, settings, filename, ctx) => { const s = settings as { blurRadius?: number; sensitivity?: number }; let decoded = inputBuffer; const validation = await validateImageBuffer(decoded, filename); @@ -227,26 +195,31 @@ export function registerBlurFaces(app: FastifyInstance) { } } const orientedBuffer = await autoOrient(decoded); - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const result = await blurFaces(orientedBuffer, join(workspacePath, "output"), { - blurRadius: s.blurRadius ?? 30, - sensitivity: s.sensitivity ?? 0.5, - }); - const outputFormat = await resolveOutputFormat(inputBuffer, filename); - let outputBuffer = result.buffer; - if (outputFormat.format !== "png") { - outputBuffer = await sharp(result.buffer) - .toFormat(outputFormat.format, { quality: outputFormat.quality }) - .toBuffer(); + const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID()); + const needsCleanup = !ctx?.scratchDir; + if (needsCleanup) await mkdir(scratchDir, { recursive: true }); + try { + const result = await blurFaces(orientedBuffer, scratchDir, { + blurRadius: s.blurRadius ?? 30, + sensitivity: s.sensitivity ?? 0.5, + }); + const outputFormat = await resolveOutputFormat(inputBuffer, filename); + let outputBuffer = result.buffer; + if (outputFormat.format !== "png") { + outputBuffer = await sharp(result.buffer) + .toFormat(outputFormat.format, { quality: outputFormat.quality }) + .toBuffer(); + } + const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format; + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.${ext}`; + return { + buffer: outputBuffer, + filename: outputFilename, + contentType: outputFormat.contentType, + }; + } finally { + if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); } - const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format; - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.${ext}`; - return { - buffer: outputBuffer, - filename: outputFilename, - contentType: outputFormat.contentType, - }; }, }); } diff --git a/apps/api/src/routes/tools/collage.ts b/apps/api/src/routes/tools/collage.ts index 422a8abd..62afa179 100644 --- a/apps/api/src/routes/tools/collage.ts +++ b/apps/api/src/routes/tools/collage.ts @@ -1,6 +1,4 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; @@ -11,8 +9,8 @@ import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { encodeJxl } from "../../lib/format-encoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; +import { putObject } from "../../lib/object-storage.js"; import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; -import { createWorkspace } from "../../lib/workspace.js"; // ── Template definitions (mirrors the frontend) ───────────────────── // We only need the grid proportions and cell definitions here. @@ -694,10 +692,8 @@ export function registerCollage(app: FastifyInstance) { const finalBuffer = outputExt === "jxl" ? await encodeJxl(result, settings.quality) : result; const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); const filename = `collage.${outputExt}`; - const outputPath = join(workspacePath, "output", filename); - await writeFile(outputPath, finalBuffer); + await putObject(`outputs/${jobId}/${filename}`, finalBuffer); return reply.send({ jobId, diff --git a/apps/api/src/routes/tools/colorize.ts b/apps/api/src/routes/tools/colorize.ts index 891c15b2..6d1036b4 100644 --- a/apps/api/src/routes/tools/colorize.ts +++ b/apps/api/src/routes/tools/colorize.ts @@ -1,21 +1,23 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { colorize } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; +import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; +import { enqueueToolJob } from "../../jobs/enqueue.js"; import { autoOrient } from "../../lib/auto-orient.js"; -import { formatZodErrors } from "../../lib/errors.js"; +import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; -import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; -import { createWorkspace } from "../../lib/workspace.js"; -import { updateSingleFileProgress } from "../progress.js"; +import { receiveUpload } from "../../lib/upload-stream.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -23,6 +25,40 @@ const settingsSchema = z.object({ model: z.enum(["auto", "ddcolor", "opencv"]).default("auto"), }); +// ── AI job handler ──────────────────────────────────────────────── +registerAiJobHandler("colorize", async (input, data, ctx) => { + const settings = settingsSchema.parse(data.settings); + + const result = await colorize( + input, + ctx.scratchDir, + { intensity: settings.intensity, model: settings.model }, + (percent, stage) => ctx.report(percent, stage), + ); + + const outputFormat = await resolveOutputFormat(input, data.filename); + let outputBuffer = result.buffer; + if (outputFormat.format !== "png") { + outputBuffer = await sharp(result.buffer) + .toFormat(outputFormat.format, { quality: outputFormat.quality }) + .toBuffer(); + } + + const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format; + const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_colorized.${ext}`; + + return { + buffer: outputBuffer, + filename: outputFilename, + contentType: outputFormat.contentType, + resultPayload: { + width: result.width, + height: result.height, + method: result.method, + }, + }; +}); + /** * AI photo colorization route. * Converts B&W / grayscale photos to full color using DDColor, @@ -42,21 +78,20 @@ export function registerColorize(app: FastifyInstance) { }); } + const jobId = randomUUID(); let fileBuffer: Buffer | null = null; let filename = "image"; let settingsRaw: string | null = null; let clientJobId: string | null = null; + let inputKey: string | null = null; try { const parts = request.parts(); for await (const part of parts) { if (part.type === "file") { - const chunks: Buffer[] = []; - for await (const chunk of part.file) { - chunks.push(chunk); - } - fileBuffer = Buffer.concat(chunks); - filename = sanitizeFilename(part.filename ?? "image"); + const upload = await receiveUpload(part, jobId); + inputKey = upload.key; + filename = upload.filename; } else if (part.fieldname === "settings") { settingsRaw = part.value as string; } else if (part.fieldname === "clientJobId") { @@ -69,14 +104,16 @@ export function registerColorize(app: FastifyInstance) { } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", - details: err instanceof Error ? err.message : String(err), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } - if (!fileBuffer || fileBuffer.length === 0) { + if (!inputKey) { return reply.status(400).send({ error: "No image file provided" }); } + fileBuffer = await getObjectBuffer(inputKey); + const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); @@ -96,139 +133,45 @@ export function registerColorize(app: FastifyInstance) { return reply.status(400).send({ error: "Settings must be valid JSON" }); } - const { intensity, model } = settings; - try { - // Decode HEIC/HEIF input if (validation.format === "heif") { fileBuffer = await decodeHeic(fileBuffer); } - - // Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR) if (needsCliDecode(validation.format)) { fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format); } - - // Auto-orient to fix EXIF rotation fileBuffer = await autoOrient(fileBuffer); } catch (err) { request.log.error({ err, toolId: "colorize" }, "Input decoding failed"); return reply.status(422).send({ error: "Colorization failed", - details: err instanceof Error ? err.message : "Unknown error", + details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }); } - const originalSize = fileBuffer.length; - const jobId = randomUUID(); + const decodedKey = `uploads/${jobId}/${filename}`; + if (decodedKey !== inputKey) { + await putObject(decodedKey, fileBuffer); + inputKey = decodedKey; + } else { + await putObject(inputKey, fileBuffer); + } + const progressJobId = clientJobId || jobId; - let workspacePath: string; - try { - workspacePath = await createWorkspace(jobId); - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); - } catch (err) { - request.log.error({ err, toolId: "colorize" }, "Workspace creation failed"); - return reply.status(422).send({ - error: "Colorization failed", - details: err instanceof Error ? err.message : "Unknown error", - }); - } - const log = request.log; - log.info( - { toolId: "colorize", imageSize: originalSize, intensity, model }, - "Starting colorization", - ); - - // Reply immediately so the HTTP connection closes within proxy timeout limits. - // The result will be delivered via the SSE progress channel. - reply.status(202).send({ jobId: progressJobId, async: true }); - - const onProgress = (percent: number, stage: string) => { - updateSingleFileProgress({ - jobId: progressJobId, - phase: "processing", - stage, - percent, - }); - }; - - // Fire-and-forget: processing happens after the response is sent - (async () => { - // Process with Python sidecar - const result = await colorize( - fileBuffer, - join(workspacePath, "output"), - { intensity, model }, - onProgress, - ); - - // Resolve output format to match input - const outputFormat = await resolveOutputFormat(fileBuffer, filename); - let outputBuffer = result.buffer; - - // Convert from PNG (Python output) to target format - if (outputFormat.format !== "png") { - outputBuffer = await sharp(result.buffer) - .toFormat(outputFormat.format, { quality: outputFormat.quality }) - .toBuffer(); - } - - // Save output - const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format; - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_colorized.${ext}`; - const outputPath = join(workspacePath, "output", outputFilename); - await writeFile(outputPath, outputBuffer); - - // Generate browser-compatible preview for non-previewable formats - const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]); - let previewUrl: string | undefined; - if (!BROWSER_PREVIEWABLE.has(ext)) { - try { - const previewBuffer = await sharp(outputBuffer).webp({ quality: 80 }).toBuffer(); - const previewPath = join(workspacePath, "output", "preview.webp"); - await writeFile(previewPath, previewBuffer); - previewUrl = `/api/v1/download/${jobId}/preview.webp`; - } catch { - // Non-fatal - } - } - - if (model !== "auto" && result.method !== model) { - log.warn( - { toolId: "colorize", requested: model, actual: result.method }, - `Colorize model mismatch: requested ${model} but used ${result.method}`, - ); - } - - const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; - updateSingleFileProgress({ - jobId: progressJobId, - phase: "complete", - percent: 100, - result: { - jobId, - downloadUrl, - previewUrl, - originalSize, - processedSize: outputBuffer.length, - width: result.width, - height: result.height, - method: result.method, - }, - }); - - log.info({ toolId: "colorize", jobId, downloadUrl }, "Colorize complete"); - })().catch((err) => { - log.error({ err, toolId: "colorize" }, "Colorization failed"); - updateSingleFileProgress({ - jobId: progressJobId, - phase: "failed", - percent: 0, - error: err instanceof Error ? err.message : "Colorization failed", - }); + await enqueueToolJob({ + jobId, + toolId, + userId: null, + pool: "ai", + inputRefs: [inputKey], + filename, + settings, + clientJobId: clientJobId ?? undefined, + kind: "ai-tool", }); + + return reply.status(202).send({ jobId: progressJobId, async: true }); }); // Register in the pipeline/batch registry @@ -238,16 +181,21 @@ export function registerColorize(app: FastifyInstance) { intensity: z.number().min(0).max(1).default(1.0), model: z.enum(["auto", "ddcolor", "opencv"]).default("auto"), }), - process: async (inputBuffer, settings, filename) => { + process: async (inputBuffer, settings, filename, ctx) => { const orientedBuffer = await autoOrient(inputBuffer); - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const result = await colorize(orientedBuffer, join(workspacePath, "output"), { - intensity: (settings as { intensity?: number }).intensity ?? 1.0, - model: (settings as { model?: string }).model ?? "auto", - }); - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_colorized.png`; - return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" }; + const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID()); + const needsCleanup = !ctx?.scratchDir; + if (needsCleanup) await mkdir(scratchDir, { recursive: true }); + try { + const result = await colorize(orientedBuffer, scratchDir, { + intensity: (settings as { intensity?: number }).intensity ?? 1.0, + model: (settings as { model?: string }).model ?? "auto", + }); + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_colorized.png`; + return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" }; + } finally { + if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); + } }, }); } diff --git a/apps/api/src/routes/tools/compare.ts b/apps/api/src/routes/tools/compare.ts index ffe21887..0879242d 100644 --- a/apps/api/src/routes/tools/compare.ts +++ b/apps/api/src/routes/tools/compare.ts @@ -1,14 +1,12 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { autoOrient } from "../../lib/auto-orient.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; +import { putObject } from "../../lib/object-storage.js"; import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; -import { createWorkspace } from "../../lib/workspace.js"; /** * Compare two images: compute a pixel-level diff and similarity score. @@ -179,10 +177,8 @@ export function registerCompare(app: FastifyInstance) { .toBuffer(); const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); const diffFilename = "diff.png"; - const outputPath = join(workspacePath, "output", diffFilename); - await writeFile(outputPath, diffBuffer); + await putObject(`outputs/${jobId}/${diffFilename}`, diffBuffer); return reply.send({ jobId, diff --git a/apps/api/src/routes/tools/compose.ts b/apps/api/src/routes/tools/compose.ts index 5108842f..7e346812 100644 --- a/apps/api/src/routes/tools/compose.ts +++ b/apps/api/src/routes/tools/compose.ts @@ -1,6 +1,4 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; @@ -10,8 +8,8 @@ import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; +import { putObject } from "../../lib/object-storage.js"; import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; -import { createWorkspace } from "../../lib/workspace.js"; async function decodeBuffer(inputBuffer: Buffer, filename: string): Promise { const validation = await validateImageBuffer(inputBuffer, filename); @@ -150,9 +148,7 @@ export function registerCompose(app: FastifyInstance) { .toBuffer(); const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const outputPath = join(workspacePath, "output", filename); - await writeFile(outputPath, result); + await putObject(`outputs/${jobId}/${filename}`, result); return reply.send({ jobId, diff --git a/apps/api/src/routes/tools/content-aware-resize.ts b/apps/api/src/routes/tools/content-aware-resize.ts index 27d48d7a..19db9f01 100644 --- a/apps/api/src/routes/tools/content-aware-resize.ts +++ b/apps/api/src/routes/tools/content-aware-resize.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { seamCarve } from "@snapotter/ai"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; @@ -10,7 +11,7 @@ import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; -import { createWorkspace } from "../../lib/workspace.js"; +import { putObject } from "../../lib/object-storage.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -127,35 +128,38 @@ export function registerContentAwareResize(app: FastifyInstance) { fileBuffer = await autoOrient(fileBuffer); const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); + const scratchDir = join(tmpdir(), "snapotter-scratch", jobId); + await mkdir(scratchDir, { recursive: true }); - // Save input - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); + try { + // Save input to object storage + await putObject(`uploads/${jobId}/${filename}`, fileBuffer); - // Process with caire - const result = await seamCarve(fileBuffer, join(workspacePath, "output"), { - width: settings.width, - height: settings.height, - protectFaces: settings.protectFaces, - blurRadius: settings.blurRadius, - sobelThreshold: settings.sobelThreshold, - square: settings.square, - }); + // Process with caire + const result = await seamCarve(fileBuffer, scratchDir, { + width: settings.width, + height: settings.height, + protectFaces: settings.protectFaces, + blurRadius: settings.blurRadius, + sobelThreshold: settings.sobelThreshold, + square: settings.square, + }); - // Save output - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_seam.png`; - const outputPath = join(workspacePath, "output", outputFilename); - await writeFile(outputPath, result.buffer); + // Save output to object storage + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_seam.png`; + await putObject(`outputs/${jobId}/${outputFilename}`, result.buffer); - return reply.send({ - jobId, - downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, - originalSize: fileBuffer.length, - processedSize: result.buffer.length, - width: result.width, - height: result.height, - }); + return reply.send({ + jobId, + downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, + originalSize: fileBuffer.length, + processedSize: result.buffer.length, + width: result.width, + height: result.height, + }); + } finally { + await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); + } } catch (err) { request.log.error({ err, toolId: "content-aware-resize" }, "Content-aware resize failed"); return reply.status(422).send({ @@ -170,7 +174,7 @@ export function registerContentAwareResize(app: FastifyInstance) { registerToolProcessFn({ toolId: "content-aware-resize", settingsSchema, - process: async (inputBuffer, settings, filename) => { + process: async (inputBuffer, settings, filename, ctx) => { const s = settings as Settings; // Decode HEIC/HEIF for pipeline/batch mode const ext = filename.split(".").pop()?.toLowerCase() ?? ""; @@ -184,18 +188,23 @@ export function registerContentAwareResize(app: FastifyInstance) { buf = await decodeToSharpCompat(inputBuffer, cliCheck.format); } const orientedBuffer = await autoOrient(buf); - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const result = await seamCarve(orientedBuffer, join(workspacePath, "output"), { - width: s.width, - height: s.height, - protectFaces: s.protectFaces, - blurRadius: s.blurRadius, - sobelThreshold: s.sobelThreshold, - square: s.square, - }); - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_seam.png`; - return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" }; + const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID()); + const needsCleanup = !ctx?.scratchDir; + if (needsCleanup) await mkdir(scratchDir, { recursive: true }); + try { + const result = await seamCarve(orientedBuffer, scratchDir, { + width: s.width, + height: s.height, + protectFaces: s.protectFaces, + blurRadius: s.blurRadius, + sobelThreshold: s.sobelThreshold, + square: s.square, + }); + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_seam.png`; + return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" }; + } finally { + if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); + } }, }); } diff --git a/apps/api/src/routes/tools/edit-metadata.ts b/apps/api/src/routes/tools/edit-metadata.ts index 821b4de9..ced7b1d5 100644 --- a/apps/api/src/routes/tools/edit-metadata.ts +++ b/apps/api/src/routes/tools/edit-metadata.ts @@ -1,6 +1,4 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; @@ -14,7 +12,7 @@ import { import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeHeic } from "../../lib/heic-converter.js"; -import { createWorkspace } from "../../lib/workspace.js"; +import { putObject } from "../../lib/object-storage.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -177,11 +175,9 @@ export function registerEditMetadata(app: FastifyInstance) { // Determine content type from validated format const contentType = MIME_BY_FORMAT[validation.format] ?? "image/jpeg"; - // Create workspace and save output + // Save output to object storage const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const outputPath = join(workspacePath, "output", filename); - await writeFile(outputPath, outputBuffer); + await putObject(`outputs/${jobId}/${filename}`, outputBuffer); // Generate preview for non-browser-previewable formats (HEIF, TIFF) let previewUrl: string | undefined; @@ -192,8 +188,7 @@ export function registerEditMetadata(app: FastifyInstance) { previewInput = await decodeHeic(outputBuffer); } const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer(); - const previewPath = join(workspacePath, "output", "preview.webp"); - await writeFile(previewPath, previewBuffer); + await putObject(`outputs/${jobId}/preview.webp`, previewBuffer); previewUrl = `/api/v1/download/${jobId}/preview.webp`; } catch { // Non-fatal - frontend shows fallback diff --git a/apps/api/src/routes/tools/enhance-faces.ts b/apps/api/src/routes/tools/enhance-faces.ts index 1bb79a18..ed9e1e7e 100644 --- a/apps/api/src/routes/tools/enhance-faces.ts +++ b/apps/api/src/routes/tools/enhance-faces.ts @@ -1,20 +1,21 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { enhanceFaces } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import sharp from "sharp"; import { z } from "zod"; +import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; +import { enqueueToolJob } from "../../jobs/enqueue.js"; import { autoOrient } from "../../lib/auto-orient.js"; -import { formatZodErrors } from "../../lib/errors.js"; +import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; -import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; -import { createWorkspace } from "../../lib/workspace.js"; -import { updateSingleFileProgress } from "../progress.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; +import { receiveUpload } from "../../lib/upload-stream.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -24,6 +25,36 @@ const settingsSchema = z.object({ sensitivity: z.number().min(0).max(1).default(0.5), }); +// ── AI job handler ──────────────────────────────────────────────── +registerAiJobHandler("enhance-faces", async (input, data, ctx) => { + const settings = settingsSchema.parse(data.settings); + + const result = await enhanceFaces( + input, + ctx.scratchDir, + { + model: settings.model, + strength: settings.strength, + onlyCenterFace: settings.onlyCenterFace, + sensitivity: settings.sensitivity, + }, + (percent, stage) => ctx.report(percent, stage), + ); + + const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_enhanced.png`; + + return { + buffer: result.buffer, + filename: outputFilename, + contentType: "image/png", + resultPayload: { + facesDetected: result.facesDetected, + faces: result.faces, + model: result.model, + }, + }; +}); + /** Face enhancement route using GFPGAN/CodeFormer. */ export function registerEnhanceFaces(app: FastifyInstance) { app.post("/api/v1/tools/enhance-faces", async (request: FastifyRequest, reply: FastifyReply) => { @@ -39,21 +70,20 @@ export function registerEnhanceFaces(app: FastifyInstance) { }); } + const jobId = randomUUID(); let fileBuffer: Buffer | null = null; let filename = "image"; let settingsRaw: string | null = null; let clientJobId: string | null = null; + let inputKey: string | null = null; try { const parts = request.parts(); for await (const part of parts) { if (part.type === "file") { - const chunks: Buffer[] = []; - for await (const chunk of part.file) { - chunks.push(chunk); - } - fileBuffer = Buffer.concat(chunks); - filename = sanitizeFilename(part.filename ?? "image"); + const upload = await receiveUpload(part, jobId); + inputKey = upload.key; + filename = upload.filename; } else if (part.fieldname === "settings") { settingsRaw = part.value as string; } else if (part.fieldname === "clientJobId") { @@ -66,14 +96,16 @@ export function registerEnhanceFaces(app: FastifyInstance) { } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", - details: err instanceof Error ? err.message : String(err), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } - if (!fileBuffer || fileBuffer.length === 0) { + if (!inputKey) { return reply.status(400).send({ error: "No image file provided" }); } + fileBuffer = await getObjectBuffer(inputKey); + const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); @@ -93,127 +125,48 @@ export function registerEnhanceFaces(app: FastifyInstance) { return reply.status(400).send({ error: "Settings must be valid JSON" }); } - const { model, strength, onlyCenterFace, sensitivity } = settings; - try { - // Decode HEIC/HEIF input via system decoder if (validation.format === "heif") { fileBuffer = await decodeHeic(fileBuffer); } - - // Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR) if (needsCliDecode(validation.format)) { fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format); } - - // Auto-orient to fix EXIF rotation before face detection fileBuffer = await autoOrient(fileBuffer); } catch (err) { request.log.error({ err, toolId: "enhance-faces" }, "Input decoding failed"); return reply.status(422).send({ error: "Face enhancement failed", - details: err instanceof Error ? err.message : "Unknown error", + details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }); } - const originalSize = fileBuffer.length; - const jobId = randomUUID(); + const decodedKey = `uploads/${jobId}/${filename}`; + if (decodedKey !== inputKey) { + await putObject(decodedKey, fileBuffer); + inputKey = decodedKey; + } else { + await putObject(inputKey, fileBuffer); + } + const progressJobId = clientJobId || jobId; - let workspacePath: string; - try { - workspacePath = await createWorkspace(jobId); - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); - } catch (err) { - request.log.error({ err, toolId: "enhance-faces" }, "Workspace creation failed"); - return reply.status(422).send({ - error: "Face enhancement failed", - details: err instanceof Error ? err.message : "Unknown error", - }); - } - const log = request.log; - log.info( - { toolId: "enhance-faces", imageSize: originalSize, model, strength }, - "Starting face enhancement", - ); - - // Reply immediately so the HTTP connection closes within proxy timeout limits. - // The result will be delivered via the SSE progress channel. - reply.status(202).send({ jobId: progressJobId, async: true }); - - const onProgress = (percent: number, stage: string) => { - updateSingleFileProgress({ - jobId: progressJobId, - phase: "processing", - stage, - percent, - }); - }; - - // Fire-and-forget: processing happens after the response is sent - (async () => { - const result = await enhanceFaces( - fileBuffer, - join(workspacePath, "output"), - { model, strength, onlyCenterFace, sensitivity }, - onProgress, - ); - - // Save output - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_enhanced.png`; - const outputPath = join(workspacePath, "output", outputFilename); - await writeFile(outputPath, result.buffer); - - // Generate webp preview for the frontend - let previewUrl: string | undefined; - try { - const previewBuffer = await sharp(result.buffer).webp({ quality: 80 }).toBuffer(); - const previewPath = join(workspacePath, "output", "preview.webp"); - await writeFile(previewPath, previewBuffer); - previewUrl = `/api/v1/download/${jobId}/preview.webp`; - } catch { - // Non-fatal - frontend will show fallback - } - - if (model !== "auto" && result.model !== model) { - log.warn( - { toolId: "enhance-faces", requested: model, actual: result.model }, - `Face enhance model mismatch: requested ${model} but used ${result.model}`, - ); - } - - const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; - updateSingleFileProgress({ - jobId: progressJobId, - phase: "complete", - percent: 100, - result: { - jobId, - downloadUrl, - previewUrl, - originalSize, - processedSize: result.buffer.length, - facesDetected: result.facesDetected, - faces: result.faces, - model: result.model, - }, - }); - - log.info({ toolId: "enhance-faces", jobId, downloadUrl }, "Face enhancement complete"); - })().catch((err) => { - log.error({ err, toolId: "enhance-faces" }, "Face enhancement failed"); - updateSingleFileProgress({ - jobId: progressJobId, - phase: "failed", - percent: 0, - error: err instanceof Error ? err.message : "Face enhancement failed", - }); + await enqueueToolJob({ + jobId, + toolId, + userId: null, + pool: "ai", + inputRefs: [inputKey], + filename, + settings, + clientJobId: clientJobId ?? undefined, + kind: "ai-tool", }); + + return reply.status(202).send({ jobId: progressJobId, async: true }); }); - // Register in the pipeline/batch registry so this tool can be used - // as a step in automation pipelines (without progress callbacks). + // Register in the pipeline/batch registry registerToolProcessFn({ toolId: "enhance-faces", settingsSchema: z.object({ @@ -222,7 +175,7 @@ export function registerEnhanceFaces(app: FastifyInstance) { onlyCenterFace: z.boolean().default(false), sensitivity: z.number().min(0).max(1).default(0.5), }), - process: async (inputBuffer, settings, filename) => { + process: async (inputBuffer, settings, filename, ctx) => { const s = settings as { model?: "auto" | "gfpgan" | "codeformer"; strength?: number; @@ -230,16 +183,21 @@ export function registerEnhanceFaces(app: FastifyInstance) { sensitivity?: number; }; const orientedBuffer = await autoOrient(inputBuffer); - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const result = await enhanceFaces(orientedBuffer, join(workspacePath, "output"), { - model: s.model ?? "auto", - strength: s.strength ?? 0.8, - onlyCenterFace: s.onlyCenterFace ?? false, - sensitivity: s.sensitivity ?? 0.5, - }); - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_enhanced.png`; - return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" }; + const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID()); + const needsCleanup = !ctx?.scratchDir; + if (needsCleanup) await mkdir(scratchDir, { recursive: true }); + try { + const result = await enhanceFaces(orientedBuffer, scratchDir, { + model: s.model ?? "auto", + strength: s.strength ?? 0.8, + onlyCenterFace: s.onlyCenterFace ?? false, + sensitivity: s.sensitivity ?? 0.5, + }); + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_enhanced.png`; + return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" }; + } finally { + if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); + } }, }); } diff --git a/apps/api/src/routes/tools/erase-object.ts b/apps/api/src/routes/tools/erase-object.ts index 3d97cee1..0272044c 100644 --- a/apps/api/src/routes/tools/erase-object.ts +++ b/apps/api/src/routes/tools/erase-object.ts @@ -1,36 +1,20 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import { inpaint } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; +import { enqueueToolJob } from "../../jobs/enqueue.js"; import { autoOrient } from "../../lib/auto-orient.js"; +import { stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; -import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { encodeJxl } from "../../lib/format-encoders.js"; import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; -import { createWorkspace } from "../../lib/workspace.js"; -import { updateSingleFileProgress } from "../progress.js"; - -const EXT_MAP: Record = { - jpeg: "jpg", - jpg: "jpg", - png: "png", - webp: "webp", - tiff: "tiff", - gif: "gif", - avif: "avif", - heic: "heic", - heif: "heif", - jxl: "jxl", -}; - -const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]); +import { receiveUpload } from "../../lib/upload-stream.js"; const settingsSchema = z.object({ format: z @@ -42,6 +26,10 @@ const settingsSchema = z.object({ /** * Object eraser / inpainting route. * Accepts an image and a mask image, erases masked areas using LaMa. + * + * Enqueues with kind "ai-tool" and uses registerAiJobHandler for the + * worker. The mask is passed as the second entry in inputRefs and read + * via getObjectBuffer(data.inputRefs[1]) inside the handler. */ export function registerEraseObject(app: FastifyInstance) { app.post("/api/v1/tools/erase-object", async (request: FastifyRequest, reply: FastifyReply) => { @@ -57,27 +45,27 @@ export function registerEraseObject(app: FastifyInstance) { }); } + const jobId = randomUUID(); let imageBuffer: Buffer | null = null; let maskBuffer: Buffer | null = null; let filename = "image"; let clientJobId: string | null = null; let format = "png"; let quality = 95; + let imageKey: string | null = null; + let maskKey: string | null = null; try { const parts = request.parts(); for await (const part of parts) { if (part.type === "file") { - const chunks: Buffer[] = []; - for await (const chunk of part.file) { - chunks.push(chunk); - } - const buf = Buffer.concat(chunks); if (part.fieldname === "mask") { - maskBuffer = buf; + const upload = await receiveUpload(part, jobId); + maskKey = upload.key; } else { - imageBuffer = buf; - filename = sanitizeFilename(part.filename ?? "image"); + const upload = await receiveUpload(part, jobId); + imageKey = upload.key; + filename = upload.filename; } } else if (part.fieldname === "clientJobId") { const raw = part.value as string; @@ -93,19 +81,22 @@ export function registerEraseObject(app: FastifyInstance) { } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", - details: err instanceof Error ? err.message : String(err), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } - if (!imageBuffer || imageBuffer.length === 0) { + if (!imageKey) { return reply.status(400).send({ error: "No image file provided" }); } - if (!maskBuffer || maskBuffer.length === 0) { + if (!maskKey) { return reply.status(400).send({ error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'", }); } + imageBuffer = await getObjectBuffer(imageKey); + maskBuffer = await getObjectBuffer(maskKey); + const imageValidation = await validateImageBuffer(imageBuffer, filename); if (!imageValidation.valid) { return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` }); @@ -135,154 +126,128 @@ export function registerEraseObject(app: FastifyInstance) { } try { - // Decode HEIC/HEIF input via system decoder if (imageValidation.format === "heif") { imageBuffer = await decodeHeic(imageBuffer); } - - // Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR) if (needsCliDecode(imageValidation.format)) { imageBuffer = await decodeToSharpCompat(imageBuffer, imageValidation.format); } - - // Auto-orient to fix EXIF rotation imageBuffer = await autoOrient(imageBuffer); } catch (err) { request.log.error({ err, toolId: "erase-object" }, "Input decoding failed"); return reply.status(422).send({ error: "Object erasing failed", - details: err instanceof Error ? err.message : "Unknown error", + details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }); } - const originalSize = imageBuffer.length; - const jobId = randomUUID(); + // Write decoded image for the worker + const decodedKey = `uploads/${jobId}/${filename}`; + if (decodedKey !== imageKey) { + await putObject(decodedKey, imageBuffer); + imageKey = decodedKey; + } else { + await putObject(imageKey, imageBuffer); + } + const progressJobId = clientJobId || jobId; - let workspacePath: string; - try { - workspacePath = await createWorkspace(jobId); - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, imageBuffer); - } catch (err) { - request.log.error({ err, toolId: "erase-object" }, "Workspace creation failed"); - return reply.status(422).send({ - error: "Object erasing failed", - details: err instanceof Error ? err.message : "Unknown error", - }); - } - const log = request.log; - log.info( - { - toolId: "erase-object", - imageSize: originalSize, - maskSize: maskBuffer.length, - format, - }, - "Starting object erasure", - ); - - // Reply immediately so the HTTP connection closes within proxy timeout limits. - // The result will be delivered via the SSE progress channel. - reply.status(202).send({ jobId: progressJobId, async: true }); - - const onProgress = (percent: number, stage: string) => { - updateSingleFileProgress({ - jobId: progressJobId, - phase: "processing", - stage, - percent, - }); - }; - - // Fire-and-forget: processing happens after the response is sent - (async () => { - const resultBuffer = await inpaint( - imageBuffer, - maskBuffer, - join(workspacePath, "output"), - onProgress, - ); - - // Convert to the requested output format using Sharp - const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format); - let outputBuffer: Buffer; - let finalFormat = format; - - if (needsNodeConversion) { - if (format === "heic" || format === "heif") { - outputBuffer = await encodeHeic(resultBuffer, quality); - finalFormat = format; - } else if (format === "jxl") { - outputBuffer = await encodeJxl(resultBuffer, quality); - finalFormat = "jxl"; - } else { - outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer(); - finalFormat = "avif"; - } - } else if (format === "jpg" || format === "jpeg") { - outputBuffer = await sharp(resultBuffer).jpeg({ quality }).toBuffer(); - finalFormat = "jpg"; - } else if (format === "webp") { - outputBuffer = await sharp(resultBuffer).webp({ quality }).toBuffer(); - finalFormat = "webp"; - } else if (format === "tiff") { - outputBuffer = await sharp(resultBuffer).tiff({ quality }).toBuffer(); - finalFormat = "tiff"; - } else if (format === "gif") { - outputBuffer = await sharp(resultBuffer).gif().toBuffer(); - finalFormat = "gif"; - } else { - outputBuffer = resultBuffer; - finalFormat = "png"; - } - - // Save output - const ext = EXT_MAP[finalFormat] || "png"; - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_erased.${ext}`; - const outputPath = join(workspacePath, "output", outputFilename); - await writeFile(outputPath, outputBuffer); - - // Generate browser-compatible preview for non-previewable formats - let previewUrl: string | undefined; - if (!BROWSER_PREVIEWABLE.has(finalFormat)) { - try { - const previewInput = - finalFormat === "heic" || finalFormat === "heif" - ? await decodeHeic(outputBuffer) - : outputBuffer; - const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer(); - const previewPath = join(workspacePath, "output", "preview.webp"); - await writeFile(previewPath, previewBuffer); - previewUrl = `/api/v1/download/${jobId}/preview.webp`; - } catch { - // Non-fatal - frontend will show fallback - } - } - - const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; - updateSingleFileProgress({ - jobId: progressJobId, - phase: "complete", - percent: 100, - result: { - jobId, - downloadUrl, - previewUrl, - originalSize, - processedSize: outputBuffer.length, - }, - }); - - log.info({ toolId: "erase-object", jobId, downloadUrl }, "Object erasure complete"); - })().catch((err) => { - log.error({ err, toolId: "erase-object" }, "Object erasing failed"); - updateSingleFileProgress({ - jobId: progressJobId, - phase: "failed", - percent: 0, - error: err instanceof Error ? err.message : "Object erasing failed", - }); + // Enqueue with both image and mask as inputRefs; the worker handler + // reads them via getObjectBuffer. + await enqueueToolJob({ + jobId, + toolId, + userId: null, + pool: "ai", + inputRefs: [imageKey, maskKey], + filename, + settings: { format, quality }, + clientJobId: clientJobId ?? undefined, + kind: "ai-tool", }); + + return reply.status(202).send({ jobId: progressJobId, async: true }); }); } + +// ── AI job handler (separate import for the worker) ─────────────── +import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; + +registerAiJobHandler("erase-object", async (input, data, ctx) => { + // Second inputRef is the mask + const maskBuffer = await getObjectBuffer(data.inputRefs[1]); + const settings = settingsSchema.parse(data.settings); + const format = settings.format; + const quality = settings.quality; + + const resultBuffer = await inpaint(input, maskBuffer, ctx.scratchDir, (percent, stage) => + ctx.report(percent, stage), + ); + + // Convert to requested output format + const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format); + let outputBuffer: Buffer; + let finalFormat = format; + + if (needsNodeConversion) { + if (format === "heic" || format === "heif") { + outputBuffer = await encodeHeic(resultBuffer, quality); + finalFormat = format; + } else if (format === "jxl") { + outputBuffer = await encodeJxl(resultBuffer, quality); + finalFormat = "jxl"; + } else { + outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer(); + finalFormat = "avif"; + } + } else if (format === "jpg" || format === "jpeg") { + outputBuffer = await sharp(resultBuffer).jpeg({ quality }).toBuffer(); + finalFormat = "jpg"; + } else if (format === "webp") { + outputBuffer = await sharp(resultBuffer).webp({ quality }).toBuffer(); + finalFormat = "webp"; + } else if (format === "tiff") { + outputBuffer = await sharp(resultBuffer).tiff({ quality }).toBuffer(); + finalFormat = "tiff"; + } else if (format === "gif") { + outputBuffer = await sharp(resultBuffer).gif().toBuffer(); + finalFormat = "gif"; + } else { + outputBuffer = resultBuffer; + finalFormat = "png"; + } + + const EXT_MAP: Record = { + jpeg: "jpg", + jpg: "jpg", + png: "png", + webp: "webp", + tiff: "tiff", + gif: "gif", + avif: "avif", + heic: "heic", + heif: "heif", + jxl: "jxl", + }; + const ext = EXT_MAP[finalFormat] || "png"; + const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_erased.${ext}`; + + const CONTENT_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + webp: "image/webp", + tiff: "image/tiff", + gif: "image/gif", + avif: "image/avif", + heic: "image/heic", + heif: "image/heif", + jxl: "image/jxl", + }; + + return { + buffer: outputBuffer, + filename: outputFilename, + contentType: CONTENT_TYPES[finalFormat] || "image/png", + }; +}); diff --git a/apps/api/src/routes/tools/html-to-image.ts b/apps/api/src/routes/tools/html-to-image.ts index baefac62..85c5fca8 100644 --- a/apps/api/src/routes/tools/html-to-image.ts +++ b/apps/api/src/routes/tools/html-to-image.ts @@ -1,12 +1,10 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { captureHtml, capturePage, isBrowserAvailable } from "../../lib/browser-service.js"; import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; +import { putObject } from "../../lib/object-storage.js"; import { validateFetchUrl } from "../../lib/ssrf.js"; -import { createWorkspace } from "../../lib/workspace.js"; const DEVICE_PRESETS = { desktop: { width: 1280, height: 720, isMobile: false }, @@ -95,10 +93,9 @@ export function registerHtmlToImage(app: FastifyInstance) { : await capturePage(settings.url!, captureOpts); const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); const ext = settings.format; const filename = `screenshot.${ext}`; - await writeFile(join(workspacePath, "output", filename), buffer); + await putObject(`outputs/${jobId}/${filename}`, buffer); return reply.send({ jobId, diff --git a/apps/api/src/routes/tools/image-enhancement.ts b/apps/api/src/routes/tools/image-enhancement.ts index 14a49b17..fecdaf66 100644 --- a/apps/api/src/routes/tools/image-enhancement.ts +++ b/apps/api/src/routes/tools/image-enhancement.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import { mkdir } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { noiseRemoval } from "@snapotter/ai"; import { analyzeImage, applyCorrections } from "@snapotter/image-engine"; @@ -12,7 +13,6 @@ import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; -import { createWorkspace } from "../../lib/workspace.js"; import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -82,12 +82,10 @@ async function processImageEnhancement( } if (settings.deepEnhance && isToolInstalled("noise-removal")) { + const scratchDir = join(tmpdir(), "snapotter-scratch", randomUUID()); try { - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const outputDir = join(workspacePath, "output"); - await mkdir(outputDir, { recursive: true }); - const result = await noiseRemoval(buffer, outputDir, { + await mkdir(scratchDir, { recursive: true }); + const result = await noiseRemoval(buffer, scratchDir, { tier: "quality", strength: 35, detailPreservation: 70, @@ -96,6 +94,8 @@ async function processImageEnhancement( buffer = result.buffer; } catch { // SCUNet unavailable -- fall back to Sharp-only result + } finally { + await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); } } diff --git a/apps/api/src/routes/tools/image-to-pdf.ts b/apps/api/src/routes/tools/image-to-pdf.ts index 463be23a..a6862da6 100644 --- a/apps/api/src/routes/tools/image-to-pdf.ts +++ b/apps/api/src/routes/tools/image-to-pdf.ts @@ -1,7 +1,4 @@ import { randomUUID } from "node:crypto"; -import { createWriteStream } from "node:fs"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import archiver from "archiver"; import type { FastifyInstance } from "fastify"; import PDFDocument from "pdfkit"; @@ -13,8 +10,8 @@ import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; -import { createWorkspace } from "../../lib/workspace.js"; const targetSizeSchema = z.object({ value: z.number().positive(), @@ -272,8 +269,6 @@ export function registerImageToPdf(app: FastifyInstance) { } const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const outputDir = join(workspacePath, "output"); const originalSize = files.reduce((s, f) => s + f.buffer.length, 0); if (settings.collate) { @@ -284,7 +279,7 @@ export function registerImageToPdf(app: FastifyInstance) { } const filename = "images.pdf"; - await writeFile(join(outputDir, filename), pdfBuffer); + await putObject(`outputs/${jobId}/${filename}`, pdfBuffer); return reply.send({ jobId, @@ -297,30 +292,34 @@ export function registerImageToPdf(app: FastifyInstance) { } let totalProcessedSize = 0; - const pdfFilenames: string[] = []; + const pdfNames: string[] = []; for (let i = 0; i < imageBuffers.length; i++) { const pdfBuffer = await buildPdf([imageBuffers[i]]); const baseName = files[i].filename.replace(/\.[^.]+$/, ""); const pdfName = `${baseName}.pdf`; - await writeFile(join(outputDir, pdfName), pdfBuffer); - pdfFilenames.push(pdfName); + await putObject(`outputs/${jobId}/${pdfName}`, pdfBuffer); + pdfNames.push(pdfName); totalProcessedSize += pdfBuffer.length; } + // Build ZIP by streaming each entry from object storage (O(1-entry) peak) const zipFilename = "images.zip"; - const zipPath = join(outputDir, zipFilename); - await new Promise((resolve, reject) => { - const output = createWriteStream(zipPath); - const archive = archiver("zip", { zlib: { level: 5 } }); - output.on("close", resolve); + const archive = archiver("zip", { zlib: { level: 5 } }); + const zipChunks: Buffer[] = []; + archive.on("data", (chunk: Buffer) => zipChunks.push(chunk)); + const zipDone = new Promise((resolve, reject) => { + archive.on("end", resolve); archive.on("error", reject); - archive.pipe(output); - for (const name of pdfFilenames) { - archive.file(join(outputDir, name), { name }); - } - archive.finalize(); }); + for (const name of pdfNames) { + const buf = await getObjectBuffer(`outputs/${jobId}/${name}`); + archive.append(buf, { name }); + } + await archive.finalize(); + await zipDone; + const zipBuffer = Buffer.concat(zipChunks); + await putObject(`outputs/${jobId}/${zipFilename}`, zipBuffer); return reply.send({ jobId, diff --git a/apps/api/src/routes/tools/meme-generator.ts b/apps/api/src/routes/tools/meme-generator.ts index 5ea03508..8b033d87 100644 --- a/apps/api/src/routes/tools/meme-generator.ts +++ b/apps/api/src/routes/tools/meme-generator.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; @@ -11,8 +10,8 @@ import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; import { renderMemeTextSvg } from "../../lib/meme-text-renderer.js"; +import { putObject } from "../../lib/object-storage.js"; import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; -import { createWorkspace } from "../../lib/workspace.js"; import { registerToolProcessFn } from "../tool-factory.js"; // --------------------------------------------------------------------------- @@ -331,9 +330,7 @@ export function registerMemeGenerator(app: FastifyInstance) { const output = await processMeme(imageBuffer, settings, filename, templateTextBoxes); const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const outputPath = join(workspacePath, "output", output.filename); - await writeFile(outputPath, output.buffer); + await putObject(`outputs/${jobId}/${output.filename}`, output.buffer); return reply.send({ jobId, diff --git a/apps/api/src/routes/tools/noise-removal.ts b/apps/api/src/routes/tools/noise-removal.ts index 9071251e..cc5bd5e2 100644 --- a/apps/api/src/routes/tools/noise-removal.ts +++ b/apps/api/src/routes/tools/noise-removal.ts @@ -1,19 +1,21 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { noiseRemoval } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; +import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; +import { enqueueToolJob } from "../../jobs/enqueue.js"; import { autoOrient } from "../../lib/auto-orient.js"; -import { formatZodErrors } from "../../lib/errors.js"; +import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; -import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; -import { createWorkspace } from "../../lib/workspace.js"; -import { updateSingleFileProgress } from "../progress.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; +import { receiveUpload } from "../../lib/upload-stream.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -25,6 +27,42 @@ const settingsSchema = z.object({ quality: z.union([z.number(), z.string()]).transform(Number).default(90), }); +// ── AI job handler ──────────────────────────────────────────────── +registerAiJobHandler("noise-removal", async (input, data, ctx) => { + const settings = settingsSchema.parse(data.settings); + + const result = await noiseRemoval( + input, + ctx.scratchDir, + { + tier: settings.tier, + strength: settings.strength, + detailPreservation: settings.detailPreservation, + colorNoise: settings.colorNoise, + format: settings.format, + quality: settings.quality, + }, + (percent, stage) => ctx.report(percent, stage), + ); + + const ext = result.format === "jpeg" ? "jpg" : result.format; + const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_denoised.${ext}`; + + const CONTENT_TYPES: Record = { + png: "image/png", + jpeg: "image/jpeg", + jpg: "image/jpeg", + webp: "image/webp", + avif: "image/avif", + }; + + return { + buffer: result.buffer, + filename: outputFilename, + contentType: CONTENT_TYPES[result.format] || "image/png", + }; +}); + /** * AI noise removal route. * Uses the Python sidecar for multi-tier denoising. @@ -43,21 +81,20 @@ export function registerNoiseRemoval(app: FastifyInstance) { }); } + const jobId = randomUUID(); let fileBuffer: Buffer | null = null; let filename = "image"; let settingsRaw: string | null = null; let clientJobId: string | null = null; + let inputKey: string | null = null; try { const parts = request.parts(); for await (const part of parts) { if (part.type === "file") { - const chunks: Buffer[] = []; - for await (const chunk of part.file) { - chunks.push(chunk); - } - fileBuffer = Buffer.concat(chunks); - filename = sanitizeFilename(part.filename ?? "image"); + const upload = await receiveUpload(part, jobId); + inputKey = upload.key; + filename = upload.filename; } else if (part.fieldname === "settings") { settingsRaw = part.value as string; } else if (part.fieldname === "clientJobId") { @@ -70,14 +107,16 @@ export function registerNoiseRemoval(app: FastifyInstance) { } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", - details: err instanceof Error ? err.message : String(err), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } - if (!fileBuffer || fileBuffer.length === 0) { + if (!inputKey) { return reply.status(400).send({ error: "No image file provided" }); } + fileBuffer = await getObjectBuffer(inputKey); + const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); @@ -109,88 +148,36 @@ export function registerNoiseRemoval(app: FastifyInstance) { request.log.error({ err, toolId: "noise-removal" }, "Input decoding failed"); return reply.status(422).send({ error: "Noise removal failed", - details: err instanceof Error ? err.message : "Unknown error", + details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }); } - const originalSize = fileBuffer.length; - const jobId = randomUUID(); + const decodedKey = `uploads/${jobId}/${filename}`; + if (decodedKey !== inputKey) { + await putObject(decodedKey, fileBuffer); + inputKey = decodedKey; + } else { + await putObject(inputKey, fileBuffer); + } + const progressJobId = clientJobId || jobId; - let workspacePath: string; - try { - workspacePath = await createWorkspace(jobId); - } catch (err) { - request.log.error({ err, toolId: "noise-removal" }, "Workspace creation failed"); - return reply.status(422).send({ - error: "Noise removal failed", - details: err instanceof Error ? err.message : "Unknown error", - }); - } - const log = request.log; - log.info( - { toolId: "noise-removal", imageSize: originalSize, tier: parsed.tier }, - "Starting noise removal", - ); - - reply.status(202).send({ jobId: progressJobId, async: true }); - - const onProgress = (percent: number, stage: string) => { - updateSingleFileProgress({ - jobId: progressJobId, - phase: "processing", - stage, - percent, - }); - }; - - (async () => { - const result = await noiseRemoval( - fileBuffer, - join(workspacePath, "output"), - { - tier: parsed.tier, - strength: parsed.strength, - detailPreservation: parsed.detailPreservation, - colorNoise: parsed.colorNoise, - format: parsed.format, - quality: parsed.quality, - }, - onProgress, - ); - - const ext = result.format === "jpeg" ? "jpg" : result.format; - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_denoised.${ext}`; - const outputPath = join(workspacePath, "output", outputFilename); - await writeFile(outputPath, result.buffer); - - const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; - updateSingleFileProgress({ - jobId: progressJobId, - phase: "complete", - percent: 100, - result: { - jobId, - downloadUrl, - originalSize, - processedSize: result.buffer.length, - }, - }); - - log.info({ toolId: "noise-removal", jobId, downloadUrl }, "Noise removal complete"); - })().catch((err) => { - log.error({ err, toolId: "noise-removal" }, "Noise removal failed"); - updateSingleFileProgress({ - jobId: progressJobId, - phase: "failed", - percent: 0, - error: err instanceof Error ? err.message : "Noise removal failed", - }); + await enqueueToolJob({ + jobId, + toolId, + userId: null, + pool: "ai", + inputRefs: [inputKey], + filename, + settings: parsed, + clientJobId: clientJobId ?? undefined, + kind: "ai-tool", }); + + return reply.status(202).send({ jobId: progressJobId, async: true }); }); - // Register in the pipeline/batch registry so this tool can be used - // as a step in automation pipelines (without progress callbacks). + // Register in the pipeline/batch registry registerToolProcessFn({ toolId: "noise-removal", settingsSchema: z.object({ @@ -201,33 +188,38 @@ export function registerNoiseRemoval(app: FastifyInstance) { format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"), quality: z.union([z.number(), z.string()]).transform(Number).default(90), }), - process: async (inputBuffer, settings, filename) => { + process: async (inputBuffer, settings, filename, ctx) => { const s = settings as z.infer; const orientedBuffer = await autoOrient(inputBuffer); - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const result = await noiseRemoval(orientedBuffer, join(workspacePath, "output"), { - tier: s.tier, - strength: s.strength, - detailPreservation: s.detailPreservation, - colorNoise: s.colorNoise, - format: s.format, - quality: s.quality, - }); - const ext = result.format === "jpeg" ? "jpg" : result.format; - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_denoised.${ext}`; - const CONTENT_TYPES: Record = { - png: "image/png", - jpeg: "image/jpeg", - jpg: "image/jpeg", - webp: "image/webp", - avif: "image/avif", - }; - return { - buffer: result.buffer, - filename: outputFilename, - contentType: CONTENT_TYPES[result.format] || "image/png", - }; + const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID()); + const needsCleanup = !ctx?.scratchDir; + if (needsCleanup) await mkdir(scratchDir, { recursive: true }); + try { + const result = await noiseRemoval(orientedBuffer, scratchDir, { + tier: s.tier, + strength: s.strength, + detailPreservation: s.detailPreservation, + colorNoise: s.colorNoise, + format: s.format, + quality: s.quality, + }); + const ext = result.format === "jpeg" ? "jpg" : result.format; + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_denoised.${ext}`; + const CONTENT_TYPES: Record = { + png: "image/png", + jpeg: "image/jpeg", + jpg: "image/jpeg", + webp: "image/webp", + avif: "image/avif", + }; + return { + buffer: result.buffer, + filename: outputFilename, + contentType: CONTENT_TYPES[result.format] || "image/png", + }; + } finally { + if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); + } }, }); } diff --git a/apps/api/src/routes/tools/ocr.ts b/apps/api/src/routes/tools/ocr.ts index 2adc37c8..12381565 100644 --- a/apps/api/src/routes/tools/ocr.ts +++ b/apps/api/src/routes/tools/ocr.ts @@ -1,4 +1,7 @@ import { randomUUID } from "node:crypto"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { extractText } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; @@ -10,7 +13,6 @@ import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; -import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; const settingsSchema = z.object({ @@ -79,6 +81,7 @@ export function registerOcr(app: FastifyInstance) { return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); } + let scratchDir = ""; try { // Decode HEIC/HEIF input via system decoder if (validation.format === "heif") { @@ -123,7 +126,8 @@ export function registerOcr(app: FastifyInstance) { "Starting OCR", ); const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); + scratchDir = join(tmpdir(), "snapotter-scratch", jobId); + await mkdir(scratchDir, { recursive: true }); const jobIdForProgress = clientJobId; const onProgress = jobIdForProgress @@ -152,7 +156,7 @@ export function registerOcr(app: FastifyInstance) { try { const result = await extractText( fileBuffer, - workspacePath, + scratchDir, { quality: tier, language: settings.language, @@ -225,6 +229,8 @@ export function registerOcr(app: FastifyInstance) { error: "OCR failed", details: err instanceof Error ? err.message : "Unknown error", }); + } finally { + if (scratchDir) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); } }); } diff --git a/apps/api/src/routes/tools/passport-photo.ts b/apps/api/src/routes/tools/passport-photo.ts index 3792405b..705510d7 100644 --- a/apps/api/src/routes/tools/passport-photo.ts +++ b/apps/api/src/routes/tools/passport-photo.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import { readFile, writeFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { detectFaceLandmarks, removeBackground } from "@snapotter/ai"; import { @@ -18,7 +19,7 @@ import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; -import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { updateSingleFileProgress } from "../progress.js"; import { registerToolProcessFn } from "../tool-factory.js"; @@ -203,11 +204,11 @@ export function registerPassportPhoto(app: FastifyInstance) { ); const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); + const scratchDir = join(tmpdir(), "snapotter-scratch", jobId); + await mkdir(scratchDir, { recursive: true }); - // Save original to workspace for generate phase - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); + // Save original to object storage for generate phase + await putObject(`uploads/${jobId}/${filename}`, fileBuffer); // Progress callback const jobIdForProgress = clientJobId; @@ -253,16 +254,21 @@ export function registerPassportPhoto(app: FastifyInstance) { } : undefined; - const bgRemovedBuffer = await removeBackground( - fileBuffer, - join(workspacePath, "output"), - { model: "birefnet-portrait" }, - bgProgress, - ); + let bgRemovedBuffer: Buffer; + try { + bgRemovedBuffer = await removeBackground( + fileBuffer, + scratchDir, + { model: "birefnet-portrait" }, + bgProgress, + ); + } finally { + await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); + } - // Save bg-removed image to workspace + // Save bg-removed image to object storage for generate phase const bgRemovedFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`; - await writeFile(join(workspacePath, "output", bgRemovedFilename), bgRemovedBuffer); + await putObject(`outputs/${jobId}/${bgRemovedFilename}`, bgRemovedBuffer); // Create a smaller preview for fast transfer (max 800px wide) const meta = await sharp(bgRemovedBuffer).metadata(); @@ -383,10 +389,9 @@ export function registerPassportPhoto(app: FastifyInstance) { }; try { - const workspacePath = getWorkspacePath(jobId); const bgRemovedFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`; - const bgRemovedBuffer = await readFile(join(workspacePath, "output", bgRemovedFilename)); + const bgRemovedBuffer = await getObjectBuffer(`outputs/${jobId}/${bgRemovedFilename}`); // Use actual bg-removed image dimensions for crop (may differ from // the original image dimensions reported by the analyze endpoint). @@ -494,8 +499,7 @@ export function registerPassportPhoto(app: FastifyInstance) { // Save output const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_passport.jpg`; - const outputPath = join(workspacePath, "output", outputFilename); - await writeFile(outputPath, cropped); + await putObject(`outputs/${jobId}/${outputFilename}`, cropped); const response: Record = { jobId, @@ -526,7 +530,7 @@ export function registerPassportPhoto(app: FastifyInstance) { if (printBuffer) { const printFilename = `${filename.replace(/\.[^.]+$/, "")}_passport_print_${printLayout}.jpg`; - await writeFile(join(workspacePath, "output", printFilename), printBuffer); + await putObject(`outputs/${jobId}/${printFilename}`, printBuffer); response.printDownloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(printFilename)}`; } } @@ -555,7 +559,7 @@ export function registerPassportPhoto(app: FastifyInstance) { registerToolProcessFn({ toolId: "passport-photo", settingsSchema: pipelineSettingsSchema, - process: async (inputBuffer, settings, filename) => { + process: async (inputBuffer, settings, filename, ctx) => { const s = settings as z.infer; const orientedBuffer = await autoOrient(inputBuffer); @@ -572,16 +576,18 @@ export function registerPassportPhoto(app: FastifyInstance) { const imgH = landmarksResult.imageHeight; // Step 2: Remove background - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); + const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID()); + const needsCleanup = !ctx?.scratchDir; + if (needsCleanup) await mkdir(scratchDir, { recursive: true }); - const bgRemovedBuffer = await removeBackground( - orientedBuffer, - join(workspacePath, "output"), - { + let bgRemovedBuffer: Buffer; + try { + bgRemovedBuffer = await removeBackground(orientedBuffer, scratchDir, { model: "birefnet-portrait", - }, - ); + }); + } finally { + if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); + } // Step 3: Look up spec and compute crop const countrySpec = PASSPORT_SPECS.find((sp) => sp.code === s.countryCode); diff --git a/apps/api/src/routes/tools/pdf-to-image.ts b/apps/api/src/routes/tools/pdf-to-image.ts index 7e156bcf..d4ffd649 100644 --- a/apps/api/src/routes/tools/pdf-to-image.ts +++ b/apps/api/src/routes/tools/pdf-to-image.ts @@ -1,7 +1,4 @@ import { randomUUID } from "node:crypto"; -import { createWriteStream } from "node:fs"; -import { stat, writeFile } from "node:fs/promises"; -import { join } from "node:path"; import archiver from "archiver"; import type { FastifyInstance } from "fastify"; import * as mupdf from "mupdf"; @@ -11,7 +8,7 @@ import { env } from "../../config.js"; import { formatZodErrors } from "../../lib/errors.js"; import { encodeJxl } from "../../lib/format-encoders.js"; import { encodeHeic } from "../../lib/heic-converter.js"; -import { createWorkspace } from "../../lib/workspace.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; // ── Settings schema ────────────────────────────────────────────── const settingsSchema = z.object({ @@ -329,9 +326,8 @@ export function registerPdfToImage(app: FastifyInstance) { const ext = FORMAT_EXT[settings.format] ?? ".png"; const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const outputDir = join(workspacePath, "output"); const pages: Array<{ page: number; downloadUrl: string; size: number }> = []; + const pageFilenames: string[] = []; for (const pageNum of selectedPages) { const pngBytes = renderPage(doc, pageNum - 1, settings.dpi); @@ -342,8 +338,8 @@ export function registerPdfToImage(app: FastifyInstance) { settings.colorMode, ); const filename = `page-${pageNum}${ext}`; - const filePath = join(outputDir, filename); - await writeFile(filePath, imageBuffer); + await putObject(`outputs/${jobId}/${filename}`, imageBuffer); + pageFilenames.push(filename); pages.push({ page: pageNum, downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`, @@ -354,23 +350,23 @@ export function registerPdfToImage(app: FastifyInstance) { doc.destroy(); doc = null; - // Generate ZIP + // Build ZIP by streaming each entry from object storage (O(1-entry) peak) const zipFilename = "pdf-pages.zip"; - const zipPath = join(outputDir, zipFilename); - await new Promise((resolve, reject) => { - const output = createWriteStream(zipPath); - const archive = archiver("zip", { zlib: { level: 5 } }); - output.on("close", resolve); + const archive = archiver("zip", { zlib: { level: 5 } }); + const zipChunks: Buffer[] = []; + archive.on("data", (chunk: Buffer) => zipChunks.push(chunk)); + const zipDone = new Promise((resolve, reject) => { + archive.on("end", resolve); archive.on("error", reject); - archive.pipe(output); - for (const p of pages) { - const fname = `page-${p.page}${ext}`; - archive.file(join(outputDir, fname), { name: fname }); - } - archive.finalize(); }); - - const zipStat = await stat(zipPath); + for (const fname of pageFilenames) { + const buf = await getObjectBuffer(`outputs/${jobId}/${fname}`); + archive.append(buf, { name: fname }); + } + await archive.finalize(); + await zipDone; + const zipBuffer = Buffer.concat(zipChunks); + await putObject(`outputs/${jobId}/${zipFilename}`, zipBuffer); return reply.send({ jobId, @@ -379,7 +375,7 @@ export function registerPdfToImage(app: FastifyInstance) { format: settings.format, pages, zipUrl: `/api/v1/download/${jobId}/${encodeURIComponent(zipFilename)}`, - zipSize: zipStat.size, + zipSize: zipBuffer.length, }); } catch (err) { doc?.destroy(); diff --git a/apps/api/src/routes/tools/qr-generate.ts b/apps/api/src/routes/tools/qr-generate.ts index eaf0a797..2023318e 100644 --- a/apps/api/src/routes/tools/qr-generate.ts +++ b/apps/api/src/routes/tools/qr-generate.ts @@ -1,11 +1,9 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import QRCode from "qrcode"; import { z } from "zod"; import { formatZodErrors } from "../../lib/errors.js"; -import { createWorkspace } from "../../lib/workspace.js"; +import { putObject } from "../../lib/object-storage.js"; const settingsSchema = z.object({ text: z.string().min(1).max(2000), @@ -57,10 +55,8 @@ export function registerQrGenerate(app: FastifyInstance) { }); const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); const filename = "qrcode.png"; - const outputPath = join(workspacePath, "output", filename); - await writeFile(outputPath, buffer); + await putObject(`outputs/${jobId}/${filename}`, buffer); return reply.send({ jobId, diff --git a/apps/api/src/routes/tools/red-eye-removal.ts b/apps/api/src/routes/tools/red-eye-removal.ts index 0b2219b2..8b00e7ed 100644 --- a/apps/api/src/routes/tools/red-eye-removal.ts +++ b/apps/api/src/routes/tools/red-eye-removal.ts @@ -1,19 +1,21 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeRedEye } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; +import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; +import { enqueueToolJob } from "../../jobs/enqueue.js"; import { autoOrient } from "../../lib/auto-orient.js"; -import { formatZodErrors } from "../../lib/errors.js"; +import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; -import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; -import { createWorkspace } from "../../lib/workspace.js"; -import { updateSingleFileProgress } from "../progress.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; +import { receiveUpload } from "../../lib/upload-stream.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -23,6 +25,35 @@ const settingsSchema = z.object({ quality: z.number().min(1).max(100).default(90), }); +// ── AI job handler ──────────────────────────────────────────────── +registerAiJobHandler("red-eye-removal", async (input, data, ctx) => { + const settings = settingsSchema.parse(data.settings); + + const result = await removeRedEye( + input, + ctx.scratchDir, + { + sensitivity: settings.sensitivity, + strength: settings.strength, + format: settings.format, + quality: settings.quality, + }, + (percent, stage) => ctx.report(percent, stage), + ); + + const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_redeye_fixed.png`; + + return { + buffer: result.buffer, + filename: outputFilename, + contentType: "image/png", + resultPayload: { + facesDetected: result.facesDetected, + eyesCorrected: result.eyesCorrected, + }, + }; +}); + /** Red eye detection and removal route. */ export function registerRedEyeRemoval(app: FastifyInstance) { app.post( @@ -40,21 +71,20 @@ export function registerRedEyeRemoval(app: FastifyInstance) { }); } + const jobId = randomUUID(); let fileBuffer: Buffer | null = null; let filename = "image"; let settingsRaw: string | null = null; let clientJobId: string | null = null; + let inputKey: string | null = null; try { const parts = request.parts(); for await (const part of parts) { if (part.type === "file") { - const chunks: Buffer[] = []; - for await (const chunk of part.file) { - chunks.push(chunk); - } - fileBuffer = Buffer.concat(chunks); - filename = sanitizeFilename(part.filename ?? "image"); + const upload = await receiveUpload(part, jobId); + inputKey = upload.key; + filename = upload.filename; } else if (part.fieldname === "settings") { settingsRaw = part.value as string; } else if (part.fieldname === "clientJobId") { @@ -67,14 +97,16 @@ export function registerRedEyeRemoval(app: FastifyInstance) { } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", - details: err instanceof Error ? err.message : String(err), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } - if (!fileBuffer || fileBuffer.length === 0) { + if (!inputKey) { return reply.status(400).send({ error: "No image file provided" }); } + fileBuffer = await getObjectBuffer(inputKey); + const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); @@ -94,112 +126,49 @@ export function registerRedEyeRemoval(app: FastifyInstance) { return reply.status(400).send({ error: "Settings must be valid JSON" }); } - const { sensitivity, strength, format: outputFormat, quality } = settings; - try { if (validation.format === "heif") { fileBuffer = await decodeHeic(fileBuffer); } - - // Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR) if (needsCliDecode(validation.format)) { fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format); } - fileBuffer = await autoOrient(fileBuffer); } catch (err) { request.log.error({ err, toolId: "red-eye-removal" }, "Input decoding failed"); return reply.status(422).send({ error: "Red eye removal failed", - details: err instanceof Error ? err.message : "Unknown error", + details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }); } - const originalSize = fileBuffer.length; - const jobId = randomUUID(); + const decodedKey = `uploads/${jobId}/${filename}`; + if (decodedKey !== inputKey) { + await putObject(decodedKey, fileBuffer); + inputKey = decodedKey; + } else { + await putObject(inputKey, fileBuffer); + } + const progressJobId = clientJobId || jobId; - let workspacePath: string; - try { - workspacePath = await createWorkspace(jobId); - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); - } catch (err) { - request.log.error({ err, toolId: "red-eye-removal" }, "Workspace creation failed"); - return reply.status(422).send({ - error: "Red eye removal failed", - details: err instanceof Error ? err.message : "Unknown error", - }); - } - const log = request.log; - log.info( - { toolId: "red-eye-removal", imageSize: originalSize, sensitivity, strength }, - "Starting red eye removal", - ); - - // Reply immediately so the HTTP connection closes within proxy timeout limits. - // The result will be delivered via the SSE progress channel. - reply.status(202).send({ jobId: progressJobId, async: true }); - - const onProgress = (percent: number, stage: string) => { - updateSingleFileProgress({ - jobId: progressJobId, - phase: "processing", - stage, - percent, - }); - }; - - // Fire-and-forget: processing happens after the response is sent - (async () => { - const result = await removeRedEye( - fileBuffer, - join(workspacePath, "output"), - { - sensitivity, - strength, - format: outputFormat, - quality, - }, - onProgress, - ); - - // Save output - const name = filename.replace(/\.[^.]+$/, ""); - const outputFilename = `${name}_redeye_fixed.png`; - const outputPath = join(workspacePath, "output", outputFilename); - await writeFile(outputPath, result.buffer); - - const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; - updateSingleFileProgress({ - jobId: progressJobId, - phase: "complete", - percent: 100, - result: { - jobId, - downloadUrl, - originalSize, - processedSize: result.buffer.length, - facesDetected: result.facesDetected, - eyesCorrected: result.eyesCorrected, - }, - }); - - log.info({ toolId: "red-eye-removal", jobId, downloadUrl }, "Red eye removal complete"); - })().catch((err) => { - log.error({ err, toolId: "red-eye-removal" }, "Red eye removal failed"); - updateSingleFileProgress({ - jobId: progressJobId, - phase: "failed", - percent: 0, - error: err instanceof Error ? err.message : "Red eye removal failed", - }); + await enqueueToolJob({ + jobId, + toolId, + userId: null, + pool: "ai", + inputRefs: [inputKey], + filename, + settings, + clientJobId: clientJobId ?? undefined, + kind: "ai-tool", }); + + return reply.status(202).send({ jobId: progressJobId, async: true }); }, ); - // Register in the pipeline/batch registry so this tool can be used - // as a step in automation pipelines (without progress callbacks). + // Register in the pipeline/batch registry registerToolProcessFn({ toolId: "red-eye-removal", settingsSchema: z.object({ @@ -208,7 +177,7 @@ export function registerRedEyeRemoval(app: FastifyInstance) { format: z.string().optional(), quality: z.number().min(1).max(100).default(90), }), - process: async (inputBuffer, settings, filename) => { + process: async (inputBuffer, settings, filename, ctx) => { const s = settings as { sensitivity?: number; strength?: number; @@ -228,16 +197,21 @@ export function registerRedEyeRemoval(app: FastifyInstance) { } } const orientedBuffer = await autoOrient(decoded); - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const result = await removeRedEye(orientedBuffer, join(workspacePath, "output"), { - sensitivity: s.sensitivity ?? 50, - strength: s.strength ?? 70, - format: s.format, - quality: s.quality ?? 90, - }); - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_redeye_fixed.png`; - return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" }; + const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID()); + const needsCleanup = !ctx?.scratchDir; + if (needsCleanup) await mkdir(scratchDir, { recursive: true }); + try { + const result = await removeRedEye(orientedBuffer, scratchDir, { + sensitivity: s.sensitivity ?? 50, + strength: s.strength ?? 70, + format: s.format, + quality: s.quality ?? 90, + }); + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_redeye_fixed.png`; + return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" }; + } finally { + if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); + } }, }); } diff --git a/apps/api/src/routes/tools/remove-background.ts b/apps/api/src/routes/tools/remove-background.ts index 66b494e9..96186172 100644 --- a/apps/api/src/routes/tools/remove-background.ts +++ b/apps/api/src/routes/tools/remove-background.ts @@ -1,24 +1,27 @@ import { randomUUID } from "node:crypto"; -import { readFile, writeFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeBackground } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; +import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; +import { enqueueToolJob } from "../../jobs/enqueue.js"; import { autoOrient } from "../../lib/auto-orient.js"; import { applyEffects, BG_FORMAT_CONTENT_TYPES, type BgOutputFormat, } from "../../lib/bg-effects.js"; -import { formatZodErrors } from "../../lib/errors.js"; +import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; -import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js"; -import { updateSingleFileProgress } from "../progress.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; +import { receiveUpload } from "../../lib/upload-stream.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -37,6 +40,43 @@ const settingsSchema = z.object({ decontaminate: z.boolean().optional(), }); +// ── AI job handler (runs inside the BullMQ worker) ──────────────── +registerAiJobHandler("remove-background", async (input, data, ctx) => { + const settings = settingsSchema.parse(data.settings); + + // Phase 1: AI background removal -> transparent PNG + const transparentResult = await removeBackground( + input, + ctx.scratchDir, + { + model: settings.model, + edgeRefine: settings.edgeRefine, + decontaminate: settings.decontaminate, + }, + (percent, stage) => ctx.report(percent, stage), + ); + + // The mask IS the transparent result; cache original for effects re-apply + const maskFilename = `${data.filename.replace(/\.[^.]+$/, "")}_mask.png`; + const originalFilename = `${data.filename.replace(/\.[^.]+$/, "")}_original.png`; + + const maskUrl = `/api/v1/download/${data.jobId}/${encodeURIComponent(maskFilename)}`; + const originalUrl = `/api/v1/download/${data.jobId}/${encodeURIComponent(originalFilename)}`; + + return { + buffer: transparentResult, + filename: maskFilename, + contentType: "image/png", + resultPayload: { + maskUrl, + originalUrl, + filename: data.filename, + model: settings.model, + }, + extraOutputs: [{ name: originalFilename, buffer: input, contentType: "image/png" }], + }; +}); + /** * AI background removal with two-phase flow: * @@ -65,19 +105,20 @@ export function registerRemoveBackground(app: FastifyInstance) { }); } + const jobId = randomUUID(); let fileBuffer: Buffer | null = null; let filename = "image"; let settingsRaw: string | null = null; let clientJobId: string | null = null; + let inputKey: string | null = null; try { const parts = request.parts(); for await (const part of parts) { if (part.type === "file") { - const chunks: Buffer[] = []; - for await (const chunk of part.file) chunks.push(chunk); - fileBuffer = Buffer.concat(chunks); - filename = sanitizeFilename(part.filename ?? "image"); + const upload = await receiveUpload(part, jobId); + inputKey = upload.key; + filename = upload.filename; } else if (part.fieldname === "settings") { settingsRaw = part.value as string; } else if (part.fieldname === "clientJobId") { @@ -90,16 +131,23 @@ export function registerRemoveBackground(app: FastifyInstance) { } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", - details: err instanceof Error ? err.message : String(err), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } + if (!inputKey) { + return reply.status(400).send({ error: "No image file provided" }); + } + + fileBuffer = await getObjectBuffer(inputKey); + if (!fileBuffer || fileBuffer.length === 0) { return reply.status(400).send({ error: "No image file provided" }); } const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); } @@ -108,12 +156,14 @@ export function registerRemoveBackground(app: FastifyInstance) { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; const result = settingsSchema.safeParse(parsed); if (!result.success) { + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply .status(400) .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } settings = result.data; } catch { + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply.status(400).send({ error: "Settings must be valid JSON" }); } @@ -138,98 +188,36 @@ export function registerRemoveBackground(app: FastifyInstance) { request.log.error({ err, toolId: "remove-background" }, "Input decoding failed"); return reply.status(422).send({ error: "Background removal failed", - details: err instanceof Error ? err.message : "Unknown error", + details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }); } - const originalSize = fileBuffer.length; - const jobId = randomUUID(); + // Write decoded input for the worker + const decodedKey = `uploads/${jobId}/${filename}`; + if (decodedKey !== inputKey) { + await putObject(decodedKey, fileBuffer); + inputKey = decodedKey; + } else { + await putObject(inputKey, fileBuffer); + } + const progressJobId = clientJobId || jobId; - let workspacePath: string; - try { - workspacePath = await createWorkspace(jobId); - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); - } catch (err) { - request.log.error({ err, toolId: "remove-background" }, "Workspace creation failed"); - return reply.status(422).send({ - error: "Background removal failed", - details: err instanceof Error ? err.message : "Unknown error", - }); - } - const log = request.log; - log.info( - { toolId: "remove-background", imageSize: originalSize, model: settings.model }, - "Starting background removal", - ); - - // Reply immediately so the HTTP connection closes within proxy timeout limits. - // The result will be delivered via the SSE progress channel. - reply.status(202).send({ jobId: progressJobId, async: true }); - - const onProgress = (percent: number, stage: string) => { - updateSingleFileProgress({ - jobId: progressJobId, - phase: "processing", - stage, - percent: Math.min(percent, 95), - }); - }; - - // Fire-and-forget: processing happens after the response is sent - (async () => { - // Phase 1: AI background removal -> transparent PNG - const transparentResult = await removeBackground( - fileBuffer, - join(workspacePath, "output"), - { - model: settings.model, - edgeRefine: settings.edgeRefine, - decontaminate: settings.decontaminate, - }, - onProgress, - ); - - // Cache the mask (transparent PNG) and original for effects re-apply - const maskFilename = `${filename.replace(/\.[^.]+$/, "")}_mask.png`; - const originalFilename = `${filename.replace(/\.[^.]+$/, "")}_original.png`; - await writeFile(join(workspacePath, "output", maskFilename), transparentResult); - await writeFile(join(workspacePath, "output", originalFilename), fileBuffer); - - const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`; - const maskUrl = `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`; - const originalUrl = `/api/v1/download/${jobId}/${encodeURIComponent(originalFilename)}`; - - updateSingleFileProgress({ - jobId: progressJobId, - phase: "complete", - percent: 100, - result: { - jobId, - downloadUrl, - maskUrl, - originalUrl, - originalSize, - processedSize: transparentResult.length, - filename, - model: settings.model, - }, - }); - - log.info( - { toolId: "remove-background", jobId, downloadUrl }, - "Background removal complete", - ); - })().catch((err) => { - log.error({ err, toolId: "remove-background" }, "Background removal failed"); - updateSingleFileProgress({ - jobId: progressJobId, - phase: "failed", - percent: 0, - error: err instanceof Error ? err.message : "Background removal failed", - }); + // Enqueue on the AI pool + await enqueueToolJob({ + jobId, + toolId, + userId: null, + pool: "ai", + inputRefs: [inputKey], + filename, + settings, + clientJobId: clientJobId ?? undefined, + kind: "ai-tool", }); + + // AI tools always return 202 (no sync window) + return reply.status(202).send({ jobId: progressJobId, async: true }); }, ); @@ -256,7 +244,7 @@ export function registerRemoveBackground(app: FastifyInstance) { } catch (err) { return reply.status(400).send({ error: "Failed to parse request", - details: err instanceof Error ? err.message : String(err), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } @@ -297,15 +285,13 @@ export function registerRemoveBackground(app: FastifyInstance) { const { jobId, filename } = settings; - const workspacePath = getWorkspacePath(jobId); - const baseName = filename.replace(/\.[^.]+$/, ""); - const maskPath = join(workspacePath, "output", `${baseName}_mask.png`); - const originalPath = join(workspacePath, "output", `${baseName}_original.png`); + const maskKey = `outputs/${jobId}/${baseName}_mask.png`; + const originalKey = `outputs/${jobId}/${baseName}_original.png`; const [maskBuffer, originalBuffer] = await Promise.all([ - readFile(maskPath), - readFile(originalPath), + getObjectBuffer(maskKey), + getObjectBuffer(originalKey), ]); // Decode HEIC/HEIF background image if needed @@ -337,8 +323,7 @@ export function registerRemoveBackground(app: FastifyInstance) { // Save the final output const outputFilename = `${baseName}_nobg.${fmt}`; - const outputPath = join(workspacePath, "output", outputFilename); - await writeFile(outputPath, resultBuffer); + await putObject(`outputs/${jobId}/${outputFilename}`, resultBuffer); return reply.send({ jobId, @@ -349,7 +334,7 @@ export function registerRemoveBackground(app: FastifyInstance) { request.log.error({ err }, "Effects processing failed"); return reply.status(422).send({ error: "Effects processing failed", - details: err instanceof Error ? err.message : "Unknown error", + details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }); } }, @@ -359,38 +344,42 @@ export function registerRemoveBackground(app: FastifyInstance) { registerToolProcessFn({ toolId: "remove-background", settingsSchema, - process: async (inputBuffer, settings, filename) => { + process: async (inputBuffer, settings, filename, ctx) => { const s = settings as z.infer; const orientedBuffer = await autoOrient(inputBuffer); - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); + const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID()); + const needsCleanup = !ctx?.scratchDir; + if (needsCleanup) await mkdir(scratchDir, { recursive: true }); + try { + const transparentResult = await removeBackground(orientedBuffer, scratchDir, { + model: s.model, + edgeRefine: s.edgeRefine, + decontaminate: s.decontaminate, + }); - const transparentResult = await removeBackground( - orientedBuffer, - join(workspacePath, "output"), - { model: s.model, edgeRefine: s.edgeRefine, decontaminate: s.decontaminate }, - ); + const fmt = (s.outputFormat ?? "png") as BgOutputFormat; + const resultBuffer = await applyEffects(transparentResult, orientedBuffer, { + backgroundType: s.backgroundType, + backgroundColor: s.backgroundColor, + gradientColor1: s.gradientColor1, + gradientColor2: s.gradientColor2, + gradientAngle: s.gradientAngle, + blurEnabled: s.blurEnabled, + blurIntensity: s.blurIntensity, + shadowEnabled: s.shadowEnabled, + shadowOpacity: s.shadowOpacity, + outputFormat: fmt, + }); - const fmt = (s.outputFormat ?? "png") as BgOutputFormat; - const resultBuffer = await applyEffects(transparentResult, orientedBuffer, { - backgroundType: s.backgroundType, - backgroundColor: s.backgroundColor, - gradientColor1: s.gradientColor1, - gradientColor2: s.gradientColor2, - gradientAngle: s.gradientAngle, - blurEnabled: s.blurEnabled, - blurIntensity: s.blurIntensity, - shadowEnabled: s.shadowEnabled, - shadowOpacity: s.shadowOpacity, - outputFormat: fmt, - }); - - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.${fmt}`; - return { - buffer: resultBuffer, - filename: outputFilename, - contentType: BG_FORMAT_CONTENT_TYPES[fmt], - }; + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.${fmt}`; + return { + buffer: resultBuffer, + filename: outputFilename, + contentType: BG_FORMAT_CONTENT_TYPES[fmt], + }; + } finally { + if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); + } }, }); } diff --git a/apps/api/src/routes/tools/restore-photo.ts b/apps/api/src/routes/tools/restore-photo.ts index b82ea67b..d75ff220 100644 --- a/apps/api/src/routes/tools/restore-photo.ts +++ b/apps/api/src/routes/tools/restore-photo.ts @@ -1,21 +1,23 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { restorePhoto } from "@snapotter/ai"; import { getBundleForTool } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; +import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; +import { enqueueToolJob } from "../../jobs/enqueue.js"; import { autoOrient } from "../../lib/auto-orient.js"; -import { formatZodErrors } from "../../lib/errors.js"; +import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; -import { sanitizeFilename } from "../../lib/filename.js"; import { decodeAnyFormat, decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; -import { createWorkspace } from "../../lib/workspace.js"; -import { updateSingleFileProgress } from "../progress.js"; +import { receiveUpload } from "../../lib/upload-stream.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -28,6 +30,52 @@ const settingsSchema = z.object({ colorizeStrength: z.number().min(0).max(100).default(85), }); +// ── AI job handler ──────────────────────────────────────────────── +registerAiJobHandler("restore-photo", async (input, data, ctx) => { + const settings = settingsSchema.parse(data.settings); + + const result = await restorePhoto( + input, + ctx.scratchDir, + { + scratchRemoval: settings.scratchRemoval, + faceEnhancement: settings.faceEnhancement, + fidelity: settings.fidelity, + denoise: settings.denoise, + denoiseStrength: settings.denoiseStrength, + colorize: settings.colorize, + colorizeStrength: settings.colorizeStrength, + }, + (percent, stage) => ctx.report(percent, stage), + ); + + const outputFormat = await resolveOutputFormat(input, data.filename); + let outputBuffer = result.buffer; + if (outputFormat.format !== "png") { + outputBuffer = await sharp(result.buffer) + .toFormat(outputFormat.format, { quality: outputFormat.quality }) + .toBuffer(); + } + + const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format; + const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_restored.${ext}`; + + return { + buffer: outputBuffer, + filename: outputFilename, + contentType: outputFormat.contentType, + resultPayload: { + width: result.width, + height: result.height, + steps: result.steps, + scratchCoverage: result.scratchCoverage, + facesEnhanced: result.facesEnhanced, + isGrayscale: result.isGrayscale, + colorized: result.colorized, + }, + }; +}); + /** * AI photo restoration route. * Multi-step pipeline: scratch repair, face enhancement, denoising, @@ -46,21 +94,20 @@ export function registerRestorePhoto(app: FastifyInstance) { }); } + const jobId = randomUUID(); let fileBuffer: Buffer | null = null; let filename = "image"; let settingsRaw: string | null = null; let clientJobId: string | null = null; + let inputKey: string | null = null; try { const parts = request.parts(); for await (const part of parts) { if (part.type === "file") { - const chunks: Buffer[] = []; - for await (const chunk of part.file) { - chunks.push(chunk); - } - fileBuffer = Buffer.concat(chunks); - filename = sanitizeFilename(part.filename ?? "image"); + const upload = await receiveUpload(part, jobId); + inputKey = upload.key; + filename = upload.filename; } else if (part.fieldname === "settings") { settingsRaw = part.value as string; } else if (part.fieldname === "clientJobId") { @@ -73,14 +120,16 @@ export function registerRestorePhoto(app: FastifyInstance) { } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", - details: err instanceof Error ? err.message : String(err), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } - if (!fileBuffer || fileBuffer.length === 0) { + if (!inputKey) { return reply.status(400).send({ error: "No image file provided" }); } + fileBuffer = await getObjectBuffer(inputKey); + const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); @@ -101,30 +150,17 @@ export function registerRestorePhoto(app: FastifyInstance) { } try { - // Decode HEIC/HEIF input if (validation.format === "heif") { fileBuffer = await decodeHeic(fileBuffer); } - - // Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR) if (needsCliDecode(validation.format)) { fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format); } - - // Auto-orient to fix EXIF rotation fileBuffer = await autoOrient(fileBuffer); - - // AVIF can pass metadata validation but fail pixel decode when - // Sharp's bundled libheif lacks support for the bitstream version. - // Convert early (the sidecar needs PNG anyway); fall back to ImageMagick. if (validation.format === "avif") { try { fileBuffer = await sharp(fileBuffer).png().toBuffer(); } catch { - request.log.warn( - { toolId: "restore-photo" }, - "Sharp AVIF decode failed, using ImageMagick", - ); fileBuffer = await decodeAnyFormat(fileBuffer, "avif"); } } @@ -132,122 +168,33 @@ export function registerRestorePhoto(app: FastifyInstance) { request.log.error({ err, toolId: "restore-photo" }, "Input decoding failed"); return reply.status(422).send({ error: "Photo restoration failed", - details: err instanceof Error ? err.message : "Unknown error", + details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }); } - const originalSize = fileBuffer.length; - const jobId = randomUUID(); + const decodedKey = `uploads/${jobId}/${filename}`; + if (decodedKey !== inputKey) { + await putObject(decodedKey, fileBuffer); + inputKey = decodedKey; + } else { + await putObject(inputKey, fileBuffer); + } + const progressJobId = clientJobId || jobId; - let workspacePath: string; - try { - workspacePath = await createWorkspace(jobId); - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); - } catch (err) { - request.log.error({ err, toolId: "restore-photo" }, "Workspace creation failed"); - return reply.status(422).send({ - error: "Photo restoration failed", - details: err instanceof Error ? err.message : "Unknown error", - }); - } - const log = request.log; - log.info({ toolId: "restore-photo", imageSize: originalSize }, "Starting photo restoration"); - - // Reply immediately so the HTTP connection closes within proxy timeout limits. - // The result will be delivered via the SSE progress channel. - reply.status(202).send({ jobId: progressJobId, async: true }); - - const onProgress = (percent: number, stage: string) => { - updateSingleFileProgress({ - jobId: progressJobId, - phase: "processing", - stage, - percent, - }); - }; - - // Fire-and-forget: processing happens after the response is sent - (async () => { - // Process with Python sidecar - const result = await restorePhoto( - fileBuffer, - join(workspacePath, "output"), - { - scratchRemoval: settings.scratchRemoval, - faceEnhancement: settings.faceEnhancement, - fidelity: settings.fidelity, - denoise: settings.denoise, - denoiseStrength: settings.denoiseStrength, - colorize: settings.colorize, - colorizeStrength: settings.colorizeStrength, - }, - onProgress, - ); - - // Resolve output format to match input - const outputFormat = await resolveOutputFormat(fileBuffer, filename); - let outputBuffer = result.buffer; - - // Convert from PNG (Python output) to target format - if (outputFormat.format !== "png") { - outputBuffer = await sharp(result.buffer) - .toFormat(outputFormat.format, { quality: outputFormat.quality }) - .toBuffer(); - } - - // Save output - const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format; - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_restored.${ext}`; - const outputPath = join(workspacePath, "output", outputFilename); - await writeFile(outputPath, outputBuffer); - - // Generate browser-compatible preview for non-previewable formats - const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]); - let previewUrl: string | undefined; - if (!BROWSER_PREVIEWABLE.has(ext)) { - try { - const previewBuffer = await sharp(outputBuffer).webp({ quality: 80 }).toBuffer(); - const previewPath = join(workspacePath, "output", "preview.webp"); - await writeFile(previewPath, previewBuffer); - previewUrl = `/api/v1/download/${jobId}/preview.webp`; - } catch { - // Non-fatal - } - } - - const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; - updateSingleFileProgress({ - jobId: progressJobId, - phase: "complete", - percent: 100, - result: { - jobId, - downloadUrl, - previewUrl, - originalSize, - processedSize: outputBuffer.length, - width: result.width, - height: result.height, - steps: result.steps, - scratchCoverage: result.scratchCoverage, - facesEnhanced: result.facesEnhanced, - isGrayscale: result.isGrayscale, - colorized: result.colorized, - }, - }); - - log.info({ toolId: "restore-photo", jobId, downloadUrl }, "Photo restoration complete"); - })().catch((err) => { - log.error({ err, toolId: "restore-photo" }, "Photo restoration failed"); - updateSingleFileProgress({ - jobId: progressJobId, - phase: "failed", - percent: 0, - error: err instanceof Error ? err.message : "Photo restoration failed", - }); + await enqueueToolJob({ + jobId, + toolId: "restore-photo", + userId: null, + pool: "ai", + inputRefs: [inputKey], + filename, + settings, + clientJobId: clientJobId ?? undefined, + kind: "ai-tool", }); + + return reply.status(202).send({ jobId: progressJobId, async: true }); }); // Register in the pipeline/batch registry @@ -262,34 +209,39 @@ export function registerRestorePhoto(app: FastifyInstance) { colorize: z.boolean().default(false), colorizeStrength: z.number().min(0).max(100).default(85), }), - process: async (inputBuffer, settings, filename) => { + process: async (inputBuffer, settings, filename, ctx) => { const s = settings as z.infer; const orientedBuffer = await autoOrient(inputBuffer); - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const result = await restorePhoto(orientedBuffer, join(workspacePath, "output"), { - scratchRemoval: s.scratchRemoval, - faceEnhancement: s.faceEnhancement, - fidelity: s.fidelity, - denoise: s.denoise, - denoiseStrength: s.denoiseStrength, - colorize: s.colorize, - colorizeStrength: s.colorizeStrength, - }); - const outputFormat = await resolveOutputFormat(inputBuffer, filename); - let outputBuffer = result.buffer; - if (outputFormat.format !== "png") { - outputBuffer = await sharp(result.buffer) - .toFormat(outputFormat.format, { quality: outputFormat.quality }) - .toBuffer(); + const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID()); + const needsCleanup = !ctx?.scratchDir; + if (needsCleanup) await mkdir(scratchDir, { recursive: true }); + try { + const result = await restorePhoto(orientedBuffer, scratchDir, { + scratchRemoval: s.scratchRemoval, + faceEnhancement: s.faceEnhancement, + fidelity: s.fidelity, + denoise: s.denoise, + denoiseStrength: s.denoiseStrength, + colorize: s.colorize, + colorizeStrength: s.colorizeStrength, + }); + const outputFormat = await resolveOutputFormat(inputBuffer, filename); + let outputBuffer = result.buffer; + if (outputFormat.format !== "png") { + outputBuffer = await sharp(result.buffer) + .toFormat(outputFormat.format, { quality: outputFormat.quality }) + .toBuffer(); + } + const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format; + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_restored.${ext}`; + return { + buffer: outputBuffer, + filename: outputFilename, + contentType: outputFormat.contentType, + }; + } finally { + if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); } - const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format; - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_restored.${ext}`; - return { - buffer: outputBuffer, - filename: outputFilename, - contentType: outputFormat.contentType, - }; }, }); } diff --git a/apps/api/src/routes/tools/stitch.ts b/apps/api/src/routes/tools/stitch.ts index 03773d4f..204e3e74 100644 --- a/apps/api/src/routes/tools/stitch.ts +++ b/apps/api/src/routes/tools/stitch.ts @@ -1,6 +1,4 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; @@ -12,8 +10,8 @@ import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { encodeJxl } from "../../lib/format-encoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; +import { putObject } from "../../lib/object-storage.js"; import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; -import { createWorkspace } from "../../lib/workspace.js"; const settingsSchema = z.object({ direction: z.enum(["horizontal", "vertical", "grid"]).default("horizontal"), @@ -289,10 +287,8 @@ export function registerStitch(app: FastifyInstance) { } const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); const filename = `stitch.${settings.format}`; - const outputPath = join(workspacePath, "output", filename); - await writeFile(outputPath, result); + await putObject(`outputs/${jobId}/${filename}`, result); return reply.send({ jobId, diff --git a/apps/api/src/routes/tools/svg-to-raster.ts b/apps/api/src/routes/tools/svg-to-raster.ts index 3cdac0cb..240a5221 100644 --- a/apps/api/src/routes/tools/svg-to-raster.ts +++ b/apps/api/src/routes/tools/svg-to-raster.ts @@ -1,6 +1,4 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import archiver from "archiver"; import type { FastifyInstance } from "fastify"; import PQueue from "p-queue"; @@ -13,8 +11,8 @@ import { formatZodErrors } from "../../lib/errors.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { encodeJxl } from "../../lib/format-encoders.js"; import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js"; +import { putObject } from "../../lib/object-storage.js"; import { decompressSvgz, isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js"; -import { createWorkspace } from "../../lib/workspace.js"; import { updateJobProgress } from "../progress.js"; const NON_PREVIEWABLE = new Set(["tiff", "heif"]); @@ -422,9 +420,7 @@ export function registerSvgToRaster(app: FastifyInstance) { ext, } = await convertSvg(fileBuffer, filename, settings); const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const outputPath = join(workspacePath, "output", outFilename); - await writeFile(outputPath, buffer); + await putObject(`outputs/${jobId}/${outFilename}`, buffer); let previewUrl: string | undefined; if (NON_PREVIEWABLE.has(ext)) { @@ -435,8 +431,7 @@ export function registerSvgToRaster(app: FastifyInstance) { .resize(1200, 1200, { fit: "inside" }) .webp({ quality: 80 }) .toBuffer(); - const previewPath = join(workspacePath, "output", "preview.webp"); - await writeFile(previewPath, previewBuffer); + await putObject(`outputs/${jobId}/preview.webp`, previewBuffer); previewUrl = `/api/v1/download/${jobId}/preview.webp`; } catch { // Non-fatal - frontend shows success card fallback diff --git a/apps/api/src/routes/tools/transparency-fixer.ts b/apps/api/src/routes/tools/transparency-fixer.ts index fca7a77a..1d349add 100644 --- a/apps/api/src/routes/tools/transparency-fixer.ts +++ b/apps/api/src/routes/tools/transparency-fixer.ts @@ -1,20 +1,22 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeBackground } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; +import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; +import { enqueueToolJob } from "../../jobs/enqueue.js"; import { autoOrient } from "../../lib/auto-orient.js"; -import { formatZodErrors } from "../../lib/errors.js"; +import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; -import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; -import { createWorkspace } from "../../lib/workspace.js"; -import { updateSingleFileProgress } from "../progress.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; +import { receiveUpload } from "../../lib/upload-stream.js"; import { registerToolProcessFn } from "../tool-factory.js"; const TOOL_ID = "transparency-fixer"; @@ -29,10 +31,6 @@ const settingsSchema = z.object({ /** * Sharp-based defringe post-processing. - * - * Removes semi-transparent fringe pixels that rembg sometimes leaves around - * hair, fur, and fine edges. Works by blurring the alpha channel and zeroing - * out pixels whose alpha falls below a computed threshold. */ async function applyDefringe(buffer: Buffer, intensity: number): Promise { if (intensity <= 0) return buffer; @@ -44,13 +42,11 @@ async function applyDefringe(buffer: Buffer, intensity: number): Promise const { data, info } = await img.raw().toBuffer({ resolveWithObject: true }); const pixelCount = info.width * info.height; - // Extract alpha channel const alpha = Buffer.alloc(pixelCount); for (let i = 0; i < pixelCount; i++) { alpha[i] = data[i * 4 + 3]; } - // Blur the alpha channel const blurRadius = Math.max(0.3, Math.round(intensity / 20)); const blurredAlphaRaw = await sharp(alpha, { raw: { width: info.width, height: info.height, channels: 1 }, @@ -59,7 +55,6 @@ async function applyDefringe(buffer: Buffer, intensity: number): Promise .raw() .toBuffer(); - // Threshold: zero out fringe pixels const threshold = Math.round(128 + (intensity / 100) * 80); const result = Buffer.from(data); for (let i = 0; i < pixelCount; i++) { @@ -129,6 +124,31 @@ async function processTransparencyFix( return resultBuffer; } +// ── AI job handler ──────────────────────────────────────────────── +registerAiJobHandler("transparency-fixer", async (input, data, ctx) => { + const settings = settingsSchema.parse(data.settings); + + const resultBuffer = await processTransparencyFix( + input, + settings, + ctx.scratchDir, + (percent, stage) => ctx.report(Math.min(percent, 95), stage), + ); + + const outputExt = settings.outputFormat === "webp" ? "webp" : "png"; + const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_fixed.${outputExt}`; + const contentType = outputExt === "webp" ? "image/webp" : "image/png"; + + return { + buffer: resultBuffer, + filename: outputFilename, + contentType, + resultPayload: { + filename: data.filename, + }, + }; +}); + export function registerTransparencyFixer(app: FastifyInstance) { app.post( "/api/v1/tools/transparency-fixer", @@ -144,19 +164,20 @@ export function registerTransparencyFixer(app: FastifyInstance) { }); } + const jobId = randomUUID(); let fileBuffer: Buffer | null = null; let filename = "image"; let settingsRaw: string | null = null; let clientJobId: string | null = null; + let inputKey: string | null = null; try { const parts = request.parts(); for await (const part of parts) { if (part.type === "file") { - const chunks: Buffer[] = []; - for await (const chunk of part.file) chunks.push(chunk); - fileBuffer = Buffer.concat(chunks); - filename = sanitizeFilename(part.filename ?? "image"); + const upload = await receiveUpload(part, jobId); + inputKey = upload.key; + filename = upload.filename; } else if (part.fieldname === "settings") { settingsRaw = part.value as string; } else if (part.fieldname === "clientJobId") { @@ -169,14 +190,16 @@ export function registerTransparencyFixer(app: FastifyInstance) { } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", - details: err instanceof Error ? err.message : String(err), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } - if (!fileBuffer || fileBuffer.length === 0) { + if (!inputKey) { return reply.status(400).send({ error: "No image file provided" }); } + fileBuffer = await getObjectBuffer(inputKey); + const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); @@ -197,104 +220,48 @@ export function registerTransparencyFixer(app: FastifyInstance) { } try { - // Decode HEIC/HEIF before processing if (validation.format === "heif") { fileBuffer = await decodeHeic(fileBuffer); const ext = filename.match(/\.[^.]+$/)?.[0]; if (ext) filename = `${filename.slice(0, -ext.length)}.png`; } - - // Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR) if (needsCliDecode(validation.format)) { fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format); const ext = filename.match(/\.[^.]+$/)?.[0]; if (ext) filename = `${filename.slice(0, -ext.length)}.png`; } - - // Auto-orient to fix EXIF rotation fileBuffer = await autoOrient(fileBuffer); } catch (err) { request.log.error({ err, toolId: TOOL_ID }, "Input decoding failed"); return reply.status(422).send({ error: "Transparency fix failed", - details: err instanceof Error ? err.message : "Unknown error", + details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }); } - const originalSize = fileBuffer.length; - const jobId = randomUUID(); + const decodedKey = `uploads/${jobId}/${filename}`; + if (decodedKey !== inputKey) { + await putObject(decodedKey, fileBuffer); + inputKey = decodedKey; + } else { + await putObject(inputKey, fileBuffer); + } + const progressJobId = clientJobId || jobId; - let workspacePath: string; - try { - workspacePath = await createWorkspace(jobId); - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); - } catch (err) { - request.log.error({ err, toolId: TOOL_ID }, "Workspace creation failed"); - return reply.status(422).send({ - error: "Transparency fix failed", - details: err instanceof Error ? err.message : "Unknown error", - }); - } - const log = request.log; - log.info( - { toolId: TOOL_ID, imageSize: originalSize, model: DEFAULT_MODEL }, - "Starting transparency fix", - ); - - // Reply immediately so the HTTP connection closes within proxy timeout limits. - // The result will be delivered via the SSE progress channel. - reply.status(202).send({ jobId: progressJobId, async: true }); - - const onProgress = (percent: number, stage: string) => { - updateSingleFileProgress({ - jobId: progressJobId, - phase: "processing", - stage, - percent: Math.min(percent, 95), - }); - }; - - const outputExt = settings.outputFormat === "webp" ? "webp" : "png"; - - // Fire-and-forget: processing happens after the response is sent - (async () => { - const resultBuffer = await processTransparencyFix( - fileBuffer, - settings, - join(workspacePath, "output"), - onProgress, - ); - - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_fixed.${outputExt}`; - await writeFile(join(workspacePath, "output", outputFilename), resultBuffer); - - const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; - - updateSingleFileProgress({ - jobId: progressJobId, - phase: "complete", - percent: 100, - result: { - jobId, - downloadUrl, - originalSize, - processedSize: resultBuffer.length, - filename, - }, - }); - - log.info({ toolId: TOOL_ID, jobId, downloadUrl }, "Transparency fix complete"); - })().catch((err) => { - log.error({ err, toolId: TOOL_ID }, "Transparency fix failed"); - updateSingleFileProgress({ - jobId: progressJobId, - phase: "failed", - percent: 0, - error: err instanceof Error ? err.message : "Transparency fix failed", - }); + await enqueueToolJob({ + jobId, + toolId: TOOL_ID, + userId: null, + pool: "ai", + inputRefs: [inputKey], + filename, + settings, + clientJobId: clientJobId ?? undefined, + kind: "ai-tool", }); + + return reply.status(202).send({ jobId: progressJobId, async: true }); }, ); @@ -302,22 +269,22 @@ export function registerTransparencyFixer(app: FastifyInstance) { registerToolProcessFn({ toolId: TOOL_ID, settingsSchema, - process: async (inputBuffer, settings, filename) => { + process: async (inputBuffer, settings, filename, ctx) => { const s = settings as z.infer; const orientedBuffer = await autoOrient(inputBuffer); - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); + const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID()); + const needsCleanup = !ctx?.scratchDir; + if (needsCleanup) await mkdir(scratchDir, { recursive: true }); + try { + const resultBuffer = await processTransparencyFix(orientedBuffer, s, scratchDir); - const resultBuffer = await processTransparencyFix( - orientedBuffer, - s, - join(workspacePath, "output"), - ); - - const outputExt = s.outputFormat === "webp" ? "webp" : "png"; - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_fixed.${outputExt}`; - const contentType = outputExt === "webp" ? "image/webp" : "image/png"; - return { buffer: resultBuffer, filename: outputFilename, contentType }; + const outputExt = s.outputFormat === "webp" ? "webp" : "png"; + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_fixed.${outputExt}`; + const contentType = outputExt === "webp" ? "image/webp" : "image/png"; + return { buffer: resultBuffer, filename: outputFilename, contentType }; + } finally { + if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); + } }, }); } diff --git a/apps/api/src/routes/tools/upscale.ts b/apps/api/src/routes/tools/upscale.ts index e3d19194..d04e181f 100644 --- a/apps/api/src/routes/tools/upscale.ts +++ b/apps/api/src/routes/tools/upscale.ts @@ -1,22 +1,24 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { upscale } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; +import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; +import { enqueueToolJob } from "../../jobs/enqueue.js"; import { autoOrient } from "../../lib/auto-orient.js"; -import { formatZodErrors } from "../../lib/errors.js"; +import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; -import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { encodeJxl } from "../../lib/format-encoders.js"; import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js"; +import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; -import { createWorkspace } from "../../lib/workspace.js"; -import { updateSingleFileProgress } from "../progress.js"; +import { receiveUpload } from "../../lib/upload-stream.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -28,6 +30,86 @@ const settingsSchema = z.object({ quality: z.union([z.number(), z.string()]).transform(Number).default(95), }); +// ── AI job handler (runs inside the BullMQ worker) ──────────────── +registerAiJobHandler("upscale", async (input, data, ctx) => { + const settings = settingsSchema.parse(data.settings); + const scale = settings.scale; + const model = settings.model; + const faceEnhance = settings.faceEnhance; + const denoise = settings.denoise; + let format = settings.format; + const outputQuality = settings.quality; + + if (format === "auto") { + const detected = await resolveOutputFormat(input, data.filename); + format = detected.format === "jpeg" ? "jpg" : detected.format; + } + + const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format); + const pythonFormat = needsNodeConversion ? "png" : format; + + const result = await upscale( + input, + ctx.scratchDir, + { scale, model, faceEnhance, denoise, format: pythonFormat, quality: outputQuality }, + (percent, stage) => ctx.report(percent, stage), + ); + + let outputBuffer = result.buffer; + let finalFormat = result.format; + if (needsNodeConversion) { + if (format === "heic" || format === "heif") { + outputBuffer = await encodeHeic(result.buffer, outputQuality); + finalFormat = format; + } else if (format === "jxl") { + outputBuffer = await encodeJxl(result.buffer, outputQuality); + finalFormat = "jxl"; + } else if (format === "avif") { + outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer(); + finalFormat = "avif"; + } + } + + const EXT_MAP: Record = { + jpeg: "jpg", + jpg: "jpg", + png: "png", + webp: "webp", + tiff: "tiff", + gif: "gif", + avif: "avif", + heic: "heic", + heif: "heif", + jxl: "jxl", + }; + const ext = EXT_MAP[finalFormat] || "png"; + const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`; + + const CONTENT_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + webp: "image/webp", + tiff: "image/tiff", + gif: "image/gif", + avif: "image/avif", + heic: "image/heic", + heif: "image/heif", + jxl: "image/jxl", + }; + + return { + buffer: outputBuffer, + filename: outputFilename, + contentType: CONTENT_TYPES[finalFormat] || "image/png", + resultPayload: { + width: result.width, + height: result.height, + method: result.method, + }, + }; +}); + /** * AI image upscaling route. * Uses Real-ESRGAN when available, falls back to Lanczos. @@ -46,21 +128,20 @@ export function registerUpscale(app: FastifyInstance) { }); } + const jobId = randomUUID(); let fileBuffer: Buffer | null = null; let filename = "image"; let settingsRaw: string | null = null; let clientJobId: string | null = null; + let inputKey: string | null = null; try { const parts = request.parts(); for await (const part of parts) { if (part.type === "file") { - const chunks: Buffer[] = []; - for await (const chunk of part.file) { - chunks.push(chunk); - } - fileBuffer = Buffer.concat(chunks); - filename = sanitizeFilename(part.filename ?? "image"); + const upload = await receiveUpload(part, jobId); + inputKey = upload.key; + filename = upload.filename; } else if (part.fieldname === "settings") { settingsRaw = part.value as string; } else if (part.fieldname === "clientJobId") { @@ -73,16 +154,19 @@ export function registerUpscale(app: FastifyInstance) { } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", - details: err instanceof Error ? err.message : String(err), + details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }); } - if (!fileBuffer || fileBuffer.length === 0) { + if (!inputKey) { return reply.status(400).send({ error: "No image file provided" }); } + fileBuffer = await getObjectBuffer(inputKey); + const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); } @@ -100,169 +184,46 @@ export function registerUpscale(app: FastifyInstance) { return reply.status(400).send({ error: "Settings must be valid JSON" }); } - const scale = settings.scale; - const model = settings.model; - const faceEnhance = settings.faceEnhance; - const denoise = settings.denoise; - let format = settings.format; - const outputQuality = settings.quality; - try { - if (format === "auto") { - const detected = await resolveOutputFormat(fileBuffer, filename); - format = detected.format === "jpeg" ? "jpg" : detected.format; - } - - // Decode HEIC/HEIF input via system decoder if (validation.format === "heif") { fileBuffer = await decodeHeic(fileBuffer); } - - // Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR) if (needsCliDecode(validation.format)) { fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format); } - - // Auto-orient to fix EXIF rotation before upscaling fileBuffer = await autoOrient(fileBuffer); } catch (err) { request.log.error({ err, toolId: "upscale" }, "Input decoding failed"); return reply.status(422).send({ error: "Upscaling failed", - details: err instanceof Error ? err.message : "Unknown error", + details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }); } - const originalSize = fileBuffer.length; - const jobId = randomUUID(); + // Write decoded input for the worker + const decodedKey = `uploads/${jobId}/${filename}`; + if (decodedKey !== inputKey) { + await putObject(decodedKey, fileBuffer); + inputKey = decodedKey; + } else { + await putObject(inputKey, fileBuffer); + } + const progressJobId = clientJobId || jobId; - let workspacePath: string; - try { - workspacePath = await createWorkspace(jobId); - const inputPath = join(workspacePath, "input", filename); - await writeFile(inputPath, fileBuffer); - } catch (err) { - request.log.error({ err, toolId: "upscale" }, "Workspace creation failed"); - return reply.status(422).send({ - error: "Upscaling failed", - details: err instanceof Error ? err.message : "Unknown error", - }); - } - const log = request.log; - log.info( - { toolId: "upscale", imageSize: originalSize, scale, model, format }, - "Starting upscale", - ); - - // Reply immediately so the HTTP connection closes within proxy timeout limits. - // The result will be delivered via the SSE progress channel. - reply.status(202).send({ jobId: progressJobId, async: true }); - - const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format); - const pythonFormat = needsNodeConversion ? "png" : format; - - const onProgress = (percent: number, stage: string) => { - updateSingleFileProgress({ - jobId: progressJobId, - phase: "processing", - stage, - percent, - }); - }; - - // Fire-and-forget: processing happens after the response is sent - (async () => { - const result = await upscale( - fileBuffer, - join(workspacePath, "output"), - { scale, model, faceEnhance, denoise, format: pythonFormat, quality: outputQuality }, - onProgress, - ); - - let outputBuffer = result.buffer; - let finalFormat = result.format; - if (needsNodeConversion) { - if (format === "heic" || format === "heif") { - outputBuffer = await encodeHeic(result.buffer, outputQuality); - finalFormat = format; - } else if (format === "jxl") { - outputBuffer = await encodeJxl(result.buffer, outputQuality); - finalFormat = "jxl"; - } else if (format === "avif") { - outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer(); - finalFormat = "avif"; - } - } - - const EXT_MAP: Record = { - jpeg: "jpg", - jpg: "jpg", - png: "png", - webp: "webp", - tiff: "tiff", - gif: "gif", - avif: "avif", - heic: "heic", - heif: "heif", - jxl: "jxl", - }; - const ext = EXT_MAP[finalFormat] || "png"; - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`; - const outputPath = join(workspacePath, "output", outputFilename); - await writeFile(outputPath, outputBuffer); - - const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]); - let previewUrl: string | undefined; - if (!BROWSER_PREVIEWABLE.has(finalFormat)) { - try { - const previewInput = - finalFormat === "heic" || finalFormat === "heif" - ? await decodeHeic(outputBuffer) - : outputBuffer; - const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer(); - const previewPath = join(workspacePath, "output", "preview.webp"); - await writeFile(previewPath, previewBuffer); - previewUrl = `/api/v1/download/${jobId}/preview.webp`; - } catch { - // Non-fatal - } - } - - if (model !== "auto" && result.method !== model) { - log.warn( - { toolId: "upscale", requested: model, actual: result.method }, - `Upscale model mismatch: requested ${model} but used ${result.method}`, - ); - } - - const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; - updateSingleFileProgress({ - jobId: progressJobId, - phase: "complete", - percent: 100, - result: { - jobId, - downloadUrl, - previewUrl, - originalSize, - processedSize: outputBuffer.length, - width: result.width, - height: result.height, - method: result.method, - }, - }); - - log.info({ toolId: "upscale", jobId, downloadUrl }, "Upscale complete"); - })().catch((err) => { - log.error({ err, toolId: "upscale" }, "Upscaling failed"); - updateSingleFileProgress({ - jobId: progressJobId, - phase: "failed", - percent: 0, - error: err instanceof Error ? err.message : "Upscale failed", - }); + await enqueueToolJob({ + jobId, + toolId, + userId: null, + pool: "ai", + inputRefs: [inputKey], + filename, + settings, + clientJobId: clientJobId ?? undefined, + kind: "ai-tool", }); + + return reply.status(202).send({ jobId: progressJobId, async: true }); }); // Register in the pipeline/batch registry so this tool can be used @@ -272,26 +233,31 @@ export function registerUpscale(app: FastifyInstance) { settingsSchema: z.object({ scale: z.union([z.number(), z.string()]).transform(Number).default(2), }), - process: async (inputBuffer, settings, filename) => { + process: async (inputBuffer, settings, filename, ctx) => { const scale = Number((settings as { scale?: number }).scale) || 2; const orientedBuffer = await autoOrient(inputBuffer); - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const result = await upscale(orientedBuffer, join(workspacePath, "output"), { scale }); - const outputFormat = await resolveOutputFormat(inputBuffer, filename); - let outputBuffer = result.buffer; - if (outputFormat.format !== "png") { - outputBuffer = await sharp(result.buffer) - .toFormat(outputFormat.format, { quality: outputFormat.quality }) - .toBuffer(); + const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID()); + const needsCleanup = !ctx?.scratchDir; + if (needsCleanup) await mkdir(scratchDir, { recursive: true }); + try { + const result = await upscale(orientedBuffer, scratchDir, { scale }); + const outputFormat = await resolveOutputFormat(inputBuffer, filename); + let outputBuffer = result.buffer; + if (outputFormat.format !== "png") { + outputBuffer = await sharp(result.buffer) + .toFormat(outputFormat.format, { quality: outputFormat.quality }) + .toBuffer(); + } + const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format; + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`; + return { + buffer: outputBuffer, + filename: outputFilename, + contentType: outputFormat.contentType, + }; + } finally { + if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); } - const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format; - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`; - return { - buffer: outputBuffer, - filename: outputFilename, - contentType: outputFormat.contentType, - }; }, }); } diff --git a/apps/api/src/routes/tools/vectorize.ts b/apps/api/src/routes/tools/vectorize.ts index cb6a7e97..7bb90fc6 100644 --- a/apps/api/src/routes/tools/vectorize.ts +++ b/apps/api/src/routes/tools/vectorize.ts @@ -1,6 +1,4 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import { vectorize as vtrace } from "@neplex/vectorizer"; import type { FastifyInstance } from "fastify"; import potrace from "potrace"; @@ -12,8 +10,8 @@ import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; +import { putObject } from "../../lib/object-storage.js"; import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; -import { createWorkspace } from "../../lib/workspace.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -189,9 +187,7 @@ export function registerVectorize(app: FastifyInstance) { const result = await vectorizeBuffer(fileBuffer, settings, filename); const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const outputPath = join(workspacePath, "output", result.filename); - await writeFile(outputPath, result.buffer); + await putObject(`outputs/${jobId}/${result.filename}`, result.buffer); return reply.send({ jobId, diff --git a/apps/api/src/routes/tools/watermark-image.ts b/apps/api/src/routes/tools/watermark-image.ts index f9babe2f..40c136e3 100644 --- a/apps/api/src/routes/tools/watermark-image.ts +++ b/apps/api/src/routes/tools/watermark-image.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; @@ -7,6 +8,7 @@ import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; +import { putObject } from "../../lib/object-storage.js"; import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; const settingsSchema = z.object({ @@ -226,16 +228,8 @@ export function registerWatermarkImage(app: FastifyInstance) { .composite([{ input: wmBuffer, top, left }]) .toBuffer(); - // Use tool-factory's workspace pattern - const { randomUUID } = await import("node:crypto"); - const { writeFile } = await import("node:fs/promises"); - const { join } = await import("node:path"); - const { createWorkspace } = await import("../../lib/workspace.js"); - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const outputPath = join(workspacePath, "output", filename); - await writeFile(outputPath, result); + await putObject(`outputs/${jobId}/${filename}`, result); return reply.send({ jobId, diff --git a/apps/web/src/components/common/progress-card.tsx b/apps/web/src/components/common/progress-card.tsx index 30be0e9e..ee2675bf 100644 --- a/apps/web/src/components/common/progress-card.tsx +++ b/apps/web/src/components/common/progress-card.tsx @@ -1,4 +1,7 @@ -import { Loader2, Upload } from "lucide-react"; +import { Loader2, Upload, X } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useFileStore } from "@/stores/file-store"; interface ProgressCardProps { active: boolean; @@ -10,6 +13,11 @@ interface ProgressCardProps { } export function ProgressCard({ active, phase, label, stage, percent, elapsed }: ProgressCardProps) { + const { t } = useTranslation(); + const activeJobId = useFileStore((s) => s.activeJobId); + const cancelCurrentJob = useFileStore((s) => s.cancelCurrentJob); + const [canceling, setCanceling] = useState(false); + if (!active) return null; const icon = @@ -45,6 +53,24 @@ export function ProgressCard({ active, phase, label, stage, percent, elapsed }: style={{ width: `${Math.min(100, percent)}%` }} /> + {activeJobId && cancelCurrentJob && ( + + )} ); } diff --git a/apps/web/src/components/settings/ai-features-section.tsx b/apps/web/src/components/settings/ai-features-section.tsx index 1cb6886a..df676464 100644 --- a/apps/web/src/components/settings/ai-features-section.tsx +++ b/apps/web/src/components/settings/ai-features-section.tsx @@ -1,18 +1,11 @@ import type { FeatureBundleState } from "@snapotter/shared"; -import { Clock, Download, Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react"; +import { Clock, Download, Loader2, RefreshCw, RotateCcw, Trash2, Upload } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "@/contexts/i18n-context"; -import { apiGet } from "@/lib/api"; -import { format } from "@/lib/format"; +import { apiGet, formatHeaders } from "@/lib/api"; +import { format, formatFileSize } from "@/lib/format"; import { useFeaturesStore } from "@/stores/features-store"; -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; -} - function formatTimeRemaining(ms: number): string { if (ms < 60000) return "Less than a minute left"; const mins = Math.ceil(ms / 60000); @@ -131,7 +124,101 @@ export function AiFeaturesSection() { {diskUsage !== null && (

- {format(t.settings.aiFeatures.diskUsage, { size: formatBytes(diskUsage) })} + {format(t.settings.aiFeatures.diskUsage, { size: formatFileSize(diskUsage) })} +

+ )} + + { + fetch(); + loadDiskUsage(); + }} + /> + + ); +} + +function ImportBundleSection({ onImported }: { onImported: () => void }) { + const { t } = useTranslation(); + const [importing, setImporting] = useState(false); + const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>( + null, + ); + const fileRef = useRef(null); + + const handleImport = async (file: File) => { + setImporting(true); + setFeedback(null); + + const formData = new FormData(); + formData.append("file", file); + + try { + const res = await fetch("/api/v1/admin/features/import", { + method: "POST", + headers: formatHeaders(), + body: formData, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + throw new Error(body.error || `Import failed: ${res.status}`); + } + + setFeedback({ type: "success", message: t.settings.aiFeatures.importSuccess }); + onImported(); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + setFeedback({ + type: "error", + message: format(t.settings.aiFeatures.importError, { error: msg }), + }); + } finally { + setImporting(false); + if (fileRef.current) fileRef.current.value = ""; + } + }; + + return ( +
+
+

+ {t.settings.aiFeatures.importBundle} +

+

+ {t.settings.aiFeatures.importDescription} +

+
+
+ { + const file = e.target.files?.[0]; + if (file) handleImport(file); + }} + /> + +
+ {feedback && ( +

+ {feedback.message}

)}
diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index 47f1f0c9..b910ceca 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -1,5 +1,6 @@ import { APP_VERSION, CATEGORIES, SUPPORTED_LOCALES, TOOLS } from "@snapotter/shared"; import { + BarChart3, Check, Copy, Eye, @@ -40,6 +41,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import { useThemeStore } from "@/stores/theme-store"; import { OtterLogo } from "../common/otter-logo"; import { AiFeaturesSection } from "./ai-features-section"; +import { UsageSection } from "./usage-section"; interface SettingsDialogProps { open: boolean; @@ -54,6 +56,7 @@ type Section = | "teams" | "roles" | "audit-log" + | "usage" | "api-keys" | "ai-features" | "tools" @@ -107,6 +110,12 @@ function useNavItems() { icon: FileText, requiredPermission: "audit:read", }, + { + id: "usage", + label: t.settings.nav.usage, + icon: BarChart3, + requiredPermission: "audit:read", + }, { id: "api-keys", label: t.settings.nav.apiKeys, icon: Key }, { id: "ai-features", @@ -204,6 +213,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { {section === "teams" && } {section === "roles" && } {section === "audit-log" && } + {section === "usage" && } {section === "api-keys" && } {section === "ai-features" && } {section === "tools" && } @@ -274,6 +284,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { {section === "teams" && } {section === "roles" && } {section === "audit-log" && } + {section === "usage" && } {section === "api-keys" && } {section === "ai-features" && } {section === "tools" && } @@ -513,6 +524,8 @@ function SystemSection() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [saveMsg, setSaveMsg] = useState(null); + const [bundleLoading, setBundleLoading] = useState(false); + const [bundleError, setBundleError] = useState(null); useEffect(() => { apiGet<{ settings: Record }>("/v1/settings") @@ -696,6 +709,46 @@ function SystemSection() { )} + +
+ + + + {bundleError &&

{bundleError}

} +
); } diff --git a/apps/web/src/components/settings/usage-section.tsx b/apps/web/src/components/settings/usage-section.tsx new file mode 100644 index 00000000..b33cdf2e --- /dev/null +++ b/apps/web/src/components/settings/usage-section.tsx @@ -0,0 +1,267 @@ +import { Loader2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; +import { apiGet } from "@/lib/api"; +import { format, formatFileSize } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +interface UsageData { + days: number; + jobsPerDay: Array<{ day: string; total: number; completed: number; failed: number }>; + topTools: Array<{ toolId: string; runs: number }>; + perUser: Array<{ username: string | null; runs: number; bytesIn: string }>; + durations: Array<{ pool: string; p50Ms: number | null; p95Ms: number | null }>; + storage: { libraryBytes: string; libraryFiles: number }; +} + +export function UsageSection() { + const { t } = useTranslation(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [days, setDays] = useState(30); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await apiGet(`/v1/admin/usage?days=${days}`); + setData(result); + } catch { + setError("Failed to load usage data."); + setData(null); + } finally { + setLoading(false); + } + }, [days]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return ( +
+
+
+

{t.settings.usage.heading}

+

{t.settings.usage.description}

+
+
+ {t.settings.usage.periodLabel} + {[7, 30, 90].map((d) => ( + + ))} +
+
+ + {loading ? ( +
+ +
+ ) : error ? ( +

{error}

+ ) : data ? ( +
+ {/* Jobs per day */} +
+

+ {t.settings.usage.jobsPerDayHeading} +

+ {data.jobsPerDay.length === 0 ? ( +

{t.settings.usage.noData}

+ ) : ( +
+ {(() => { + const maxJobsTotal = Math.max(...data.jobsPerDay.map((r) => r.total), 1); + return data.jobsPerDay.map((row) => { + const pct = (row.total / maxJobsTotal) * 100; + return ( +
+ + {row.day} + +
+
+ + {row.total} + +
+ + {row.completed} + + + {row.failed} + +
+ ); + }); + })()} +
+ + + {t.settings.usage.completedColumn} + {t.settings.usage.failedColumn} +
+
+ )} +
+ + {/* Top tools */} +
+

+ {t.settings.usage.topToolsHeading} +

+ {data.topTools.length === 0 ? ( +

{t.settings.usage.noData}

+ ) : ( +
+ {(() => { + const maxToolRuns = Math.max(...data.topTools.map((r) => r.runs), 1); + return data.topTools.map((row) => { + const pct = (row.runs / maxToolRuns) * 100; + return ( +
+ + {row.toolId} + +
+
+
+ + {row.runs} + +
+ ); + }); + })()} +
+ )} +
+ + {/* Per-user volume */} +
+

+ {t.settings.usage.perUserHeading} +

+ {data.perUser.length === 0 ? ( +

{t.settings.usage.noData}

+ ) : ( + + + + + + + + + + {data.perUser.map((row, i) => ( + + + + + + ))} + +
+ {t.settings.usage.userColumn} + + {t.settings.usage.runsColumn} + + {t.settings.usage.bytesInColumn} +
+ {row.username ?? t.settings.usage.unknownUser} + + {row.runs} + + {formatFileSize(Number(row.bytesIn))} +
+ )} +
+ + {/* Durations + Storage */} +
+ {/* Duration percentiles */} +
+

+ {t.settings.usage.durationsHeading} +

+ {data.durations.length === 0 ? ( +

{t.settings.usage.noData}

+ ) : ( + + + + + + + + + + {data.durations.map((row) => ( + + + + + + ))} + +
+ {t.settings.usage.poolColumn} + + {t.settings.usage.p50Column} + + {t.settings.usage.p95Column} +
{row.pool} + {row.p50Ms != null ? `${row.p50Ms}ms` : "-"} + + {row.p95Ms != null ? `${row.p95Ms}ms` : "-"} +
+ )} +
+ + {/* Storage */} +
+

+ {t.settings.usage.storageHeading} +

+
+

+ {formatFileSize(Number(data.storage.libraryBytes))} +

+

+ {format(t.settings.usage.storageFiles, { count: data.storage.libraryFiles })} +

+
+
+
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index 59224ff9..c1e51414 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -1,5 +1,6 @@ import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders, parseApiError } from "@/lib/api"; import { generateId } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; @@ -37,8 +38,17 @@ const UPLOAD_WEIGHT = 15; const SSE_STALL_TIMEOUT_MS = 300_000; export function useToolProcessor(toolId: string) { - const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } = - useFileStore(); + const { t } = useTranslation(); + const { + processing, + error, + processedUrl, + originalSize, + processedSize, + setProcessing, + setError, + setActiveJob, + } = useFileStore(); const [progress, setProgress] = useState(IDLE_PROGRESS); const [warning, setWarning] = useState(null); @@ -53,6 +63,24 @@ export function useToolProcessor(toolId: string) { const isAiTool = AI_PYTHON_TOOLS.has(toolId); const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId; + const clearActiveJob = useCallback(() => { + activeJobIdRef.current = null; + setActiveJob(null, null); + }, [setActiveJob]); + + const cancelCurrentJob = useCallback(async () => { + const jobId = activeJobIdRef.current; + if (!jobId) return; + try { + await fetch(`/api/v1/jobs/${jobId}/cancel`, { + method: "POST", + headers: formatHeaders(), + }); + } catch { + // Cancel request failed; SSE handler or stall timeout will clean up + } + }, []); + const reconnectSSE = useCallback(() => { const jobId = activeJobIdRef.current; if (!jobId) return; @@ -82,7 +110,7 @@ export function useToolProcessor(toolId: string) { eventSourceRef.current = null; } if (elapsedRef.current) clearInterval(elapsedRef.current); - activeJobIdRef.current = null; + clearActiveJob(); setError( "Processing timed out with no progress for 5 minutes. Try again or use a smaller image.", ); @@ -96,7 +124,7 @@ export function useToolProcessor(toolId: string) { if (elapsedRef.current) clearInterval(elapsedRef.current); es.close(); eventSourceRef.current = null; - activeJobIdRef.current = null; + clearActiveJob(); const result = data.result as ProcessResult; setWarning(result.warning ?? null); @@ -120,7 +148,7 @@ export function useToolProcessor(toolId: string) { if (elapsedRef.current) clearInterval(elapsedRef.current); es.close(); eventSourceRef.current = null; - activeJobIdRef.current = null; + clearActiveJob(); setError(data.error || "Processing failed"); setProcessing(false); setProgress(IDLE_PROGRESS); @@ -150,7 +178,7 @@ export function useToolProcessor(toolId: string) { } catch { // EventSource creation failed } - }, [setError, setProcessing]); + }, [clearActiveJob, setError, setProcessing]); // Reconnect SSE when tab becomes visible again (mobile tab recovery) useEffect(() => { @@ -224,6 +252,7 @@ export function useToolProcessor(toolId: string) { eventSourceRef.current = null; } if (elapsedRef.current) clearInterval(elapsedRef.current); + clearActiveJob(); useFileStore.getState().updateEntry(capturedIndex, { status: "failed", error: "Processing timed out", @@ -254,7 +283,7 @@ export function useToolProcessor(toolId: string) { if (elapsedRef.current) clearInterval(elapsedRef.current); es.close(); eventSourceRef.current = null; - activeJobIdRef.current = null; + clearActiveJob(); const result = data.result as ProcessResult; setWarning(result.warning ?? null); @@ -277,7 +306,7 @@ export function useToolProcessor(toolId: string) { if (elapsedRef.current) clearInterval(elapsedRef.current); es.close(); eventSourceRef.current = null; - activeJobIdRef.current = null; + clearActiveJob(); setError(data.error || "Processing failed"); setProcessing(false); setProgress(IDLE_PROGRESS); @@ -354,6 +383,7 @@ export function useToolProcessor(toolId: string) { if (xhr.status === 202) { asyncMode = true; asyncModeRef.current = true; + setActiveJob(clientJobId, cancelCurrentJob); resetStallTimer(); return; } @@ -398,7 +428,7 @@ export function useToolProcessor(toolId: string) { setProcessing(false); setProgress(IDLE_PROGRESS); - activeJobIdRef.current = null; + clearActiveJob(); }; xhr.onerror = () => { @@ -411,7 +441,7 @@ export function useToolProcessor(toolId: string) { setError("Processing was interrupted. Retry when reconnected."); setProcessing(false); setProgress(IDLE_PROGRESS); - activeJobIdRef.current = null; + clearActiveJob(); }; xhr.ontimeout = () => { @@ -424,7 +454,7 @@ export function useToolProcessor(toolId: string) { setError("Request timed out - the server may be overloaded. Try again."); setProcessing(false); setProgress(IDLE_PROGRESS); - activeJobIdRef.current = null; + clearActiveJob(); }; xhr.open("POST", `/api/v1/tools/${toolId}`); @@ -433,7 +463,16 @@ export function useToolProcessor(toolId: string) { }); xhr.send(formData); }, - [toolId, isAiTool, setProcessing, setError, toolName], + [ + toolId, + isAiTool, + setProcessing, + setError, + setActiveJob, + clearActiveJob, + cancelCurrentJob, + toolName, + ], ); const processAllFiles = useCallback( @@ -572,7 +611,7 @@ export function useToolProcessor(toolId: string) { setProcessing(false); setProgress(IDLE_PROGRESS); - activeJobIdRef.current = null; + clearActiveJob(); } catch (err) { if (elapsedRef.current) clearInterval(elapsedRef.current); if (eventSourceRef.current) { @@ -582,17 +621,18 @@ export function useToolProcessor(toolId: string) { setError(err instanceof Error ? err.message : "Batch processing failed"); setProcessing(false); setProgress(IDLE_PROGRESS); - activeJobIdRef.current = null; + clearActiveJob(); } }, - [toolId, processFiles, setProcessing, setError, toolName], + [toolId, processFiles, setProcessing, setError, clearActiveJob, toolName], ); return { processFiles, processAllFiles, + cancelCurrentJob, processing, - error, + error: error === "Canceled" ? t.tools.processing.canceled : error, warning, downloadUrl: processedUrl, originalSize, diff --git a/apps/web/src/stores/file-store.ts b/apps/web/src/stores/file-store.ts index e64585cf..2e23bf73 100644 --- a/apps/web/src/stores/file-store.ts +++ b/apps/web/src/stores/file-store.ts @@ -93,6 +93,8 @@ interface FileState { batchZipFilename: string | null; processing: boolean; error: string | null; + activeJobId: string | null; + cancelCurrentJob: (() => Promise) | null; // Derived from entries (selected entry fields) readonly files: File[]; @@ -116,6 +118,7 @@ interface FileState { setBatchZip: (blob: Blob, filename: string) => void; setProcessing: (v: boolean) => void; setError: (e: string | null) => void; + setActiveJob: (id: string | null, cancelFn: (() => Promise) | null) => void; setJobId: (id: string) => void; setProcessedUrl: (url: string | null, previewUrl?: string | null) => void; setSizes: (original: number, processed: number) => void; @@ -130,6 +133,8 @@ export const useFileStore = create((set, get) => ({ batchZipFilename: null, processing: false, error: null, + activeJobId: null, + cancelCurrentJob: null, // Initial derived values (empty state) files: [], @@ -271,6 +276,8 @@ export const useFileStore = create((set, get) => ({ setError: (e) => set(e ? { error: e, processing: false } : { error: null }), + setActiveJob: (id, cancelFn) => set({ activeJobId: id, cancelCurrentJob: cancelFn }), + setJobId: (_id) => { // no-op for backward compat }, @@ -332,6 +339,8 @@ export const useFileStore = create((set, get) => ({ batchZipFilename: null, processing: false, error: null, + activeJobId: null, + cancelCurrentJob: null, files: deriveFiles(resetEntries), ...deriveSelected(resetEntries, selectedIndex), }); @@ -347,6 +356,8 @@ export const useFileStore = create((set, get) => ({ batchZipFilename: null, processing: false, error: null, + activeJobId: null, + cancelCurrentJob: null, files: [], ...deriveSelected([], 0), }); diff --git a/docker/Dockerfile b/docker/Dockerfile index 1648f890..67618976 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -316,6 +316,7 @@ ENV PORT=1349 \ SESSION_DURATION_HOURS=168 \ LOGIN_ATTEMPT_LIMIT=30 \ LOG_LEVEL=info \ + LOG_DIR=/data/logs \ TRUST_PROXY=true \ OIDC_ENABLED=false \ EXTERNAL_URL= \ diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index 80ccbf68..9fc35c27 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -17,9 +17,10 @@ services: - DEFAULT_USERNAME=admin - DEFAULT_PASSWORD=admin - DATABASE_URL=postgres://snapotter:snapotter@postgres:5432/snapotter_test - # Tells tests/global-setup.ts to use this server instead of spawning a - # testcontainer (no docker daemon inside the test container). + # Tells tests/global-setup.ts to use these servers instead of spawning + # testcontainers (no docker daemon inside the test container). - TEST_DATABASE_URL=postgres://snapotter:snapotter@postgres:5432/snapotter_test + - TEST_REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379 - WORKSPACE_PATH=/tmp/test-workspace - MAX_MEGAPIXELS=100 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index bcec56e4..a971a3f1 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -126,7 +126,7 @@ if [ "$(id -u)" = "0" ]; then fi # Ensure all writable subdirectories exist before chown - mkdir -p /data/files /data/ai/models /data/ai/pip-cache /data/ai/venv /tmp/workspace + mkdir -p /data/files /data/logs /data/ai/models /data/ai/pip-cache /data/ai/venv /tmp/workspace # Chown writable directories (/data is the persistent volume, /tmp/workspace is ephemeral). # /app and /opt/venv are read-only at runtime -- no chown needed. diff --git a/package.json b/package.json index 16b3d584..e0d6abe1 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", "@testcontainers/postgresql": "^12.0.1", + "@testcontainers/redis": "^12.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7e5d8af3..31c6db33 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -11,7 +11,8 @@ "clean": "rm -rf dist" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1063.0", + "@aws-sdk/client-s3": "^3.1066.0", + "@aws-sdk/lib-storage": "^3.1066.0", "@snapotter/shared": "workspace:*" }, "devDependencies": { diff --git a/packages/enterprise/src/storage-s3.ts b/packages/enterprise/src/storage-s3.ts index 34337ac1..acf3bca5 100644 --- a/packages/enterprise/src/storage-s3.ts +++ b/packages/enterprise/src/storage-s3.ts @@ -1,11 +1,15 @@ import type { Readable } from "node:stream"; import { DeleteObjectCommand, + DeleteObjectsCommand, GetObjectCommand, HeadBucketCommand, + HeadObjectCommand, + ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; +import { Upload } from "@aws-sdk/lib-storage"; export interface S3Config { bucket: string; @@ -142,3 +146,179 @@ export async function deleteThumbnail(storedName: string): Promise { // Thumbnail may not exist } } + +// --------------------------------------------------------------------------- +// Generic object operations for uploads/ and outputs/ processing artifacts. +// Called by apps/api/src/lib/object-storage.ts when STORAGE_MODE=s3. +// Keys are passed verbatim (e.g. "outputs//result.png") and joined +// with the configured S3_PREFIX, consistent with fileKey/thumbKey above. +// --------------------------------------------------------------------------- + +export interface GenericObjectInfo { + key: string; + size: number; + mtimeMs: number; +} + +function genericKey(key: string): string { + const prefix = cfg().prefix ? `${cfg().prefix}/` : ""; + return `${prefix}${key}`; +} + +export async function putGenericObject(key: string, data: Buffer): Promise { + await getClient().send( + new PutObjectCommand({ + Bucket: cfg().bucket, + Key: genericKey(key), + Body: data, + }), + ); +} + +export async function putGenericObjectStream( + key: string, + source: AsyncIterable, +): Promise { + const upload = new Upload({ + client: getClient(), + params: { + Bucket: cfg().bucket, + Key: genericKey(key), + Body: source as unknown as Readable, + }, + }); + await upload.done(); +} + +export async function getGenericObjectStream( + key: string, + range?: { start: number; end?: number }, +): Promise { + const params: { Bucket: string; Key: string; Range?: string } = { + Bucket: cfg().bucket, + Key: genericKey(key), + }; + if (range) { + params.Range = `bytes=${range.start}-${range.end ?? ""}`; + } + const response = await getClient().send(new GetObjectCommand(params)); + return response.Body as Readable; +} + +export async function getGenericObjectSize(key: string): Promise { + const response = await getClient().send( + new HeadObjectCommand({ + Bucket: cfg().bucket, + Key: genericKey(key), + }), + ); + return response.ContentLength ?? 0; +} + +export async function deleteGenericObject(key: string): Promise { + try { + await getClient().send( + new DeleteObjectCommand({ + Bucket: cfg().bucket, + Key: genericKey(key), + }), + ); + } catch { + // Object already gone or doesn't exist + } +} + +export async function deleteGenericPrefix(prefix: string): Promise { + const fullPrefix = genericKey(prefix.endsWith("/") ? prefix : `${prefix}/`); + let continuationToken: string | undefined; + do { + const list = await getClient().send( + new ListObjectsV2Command({ + Bucket: cfg().bucket, + Prefix: fullPrefix, + ContinuationToken: continuationToken, + }), + ); + const keys = (list.Contents ?? []).map((o) => o.Key).filter((k): k is string => !!k); + if (keys.length > 0) { + // DeleteObjects supports up to 1000 keys per call + for (let i = 0; i < keys.length; i += 1000) { + const batch = keys.slice(i, i + 1000); + const deleteResult = await getClient().send( + new DeleteObjectsCommand({ + Bucket: cfg().bucket, + Delete: { Objects: batch.map((Key) => ({ Key })) }, + }), + ); + if (deleteResult.Errors && deleteResult.Errors.length > 0) { + const summary = deleteResult.Errors.map((e) => `${e.Key}: ${e.Code}`).join(", "); + throw new Error(`S3 DeleteObjects partial failure: ${summary}`); + } + } + } + continuationToken = list.IsTruncated ? list.NextContinuationToken : undefined; + } while (continuationToken); +} + +export async function listGenericObjects(prefix: string): Promise { + const fullPrefix = genericKey(prefix.endsWith("/") ? prefix : `${prefix}/`); + const s3Prefix = cfg().prefix ? `${cfg().prefix}/` : ""; + const out: GenericObjectInfo[] = []; + let continuationToken: string | undefined; + do { + const list = await getClient().send( + new ListObjectsV2Command({ + Bucket: cfg().bucket, + Prefix: fullPrefix, + ContinuationToken: continuationToken, + }), + ); + for (const obj of list.Contents ?? []) { + if (!obj.Key) continue; + // Strip the S3_PREFIX to return keys in the caller's namespace + const key = + s3Prefix && obj.Key.startsWith(s3Prefix) ? obj.Key.slice(s3Prefix.length) : obj.Key; + out.push({ + key, + size: obj.Size ?? 0, + mtimeMs: obj.LastModified ? obj.LastModified.getTime() : 0, + }); + } + continuationToken = list.IsTruncated ? list.NextContinuationToken : undefined; + } while (continuationToken); + return out; +} + +// Lists the top-level "job directories" under a prefix (uploads/ or outputs/). +// Uses ListObjectsV2 with Delimiter="/" to get CommonPrefixes. S3 does not +// store directory mtime, so we set mtimeMs=0. The TTL sweeper should instead +// rely on the jobs table's updatedAt column for expiry decisions; this listing +// only provides the directory keys for matching against job records. +export async function listGenericJobDirs( + prefix: "uploads" | "outputs", +): Promise { + const fullPrefix = genericKey(`${prefix}/`); + const s3Prefix = cfg().prefix ? `${cfg().prefix}/` : ""; + const out: GenericObjectInfo[] = []; + let continuationToken: string | undefined; + do { + const list = await getClient().send( + new ListObjectsV2Command({ + Bucket: cfg().bucket, + Prefix: fullPrefix, + Delimiter: "/", + ContinuationToken: continuationToken, + }), + ); + for (const cp of list.CommonPrefixes ?? []) { + if (!cp.Prefix) continue; + // Strip S3_PREFIX and trailing slash to normalize: "outputs/jobId" + let key = + s3Prefix && cp.Prefix.startsWith(s3Prefix) ? cp.Prefix.slice(s3Prefix.length) : cp.Prefix; + key = key.replace(/\/$/, ""); + out.push({ key, size: 0, mtimeMs: 0 }); + } + continuationToken = list.IsTruncated ? list.NextContinuationToken : undefined; + } while (continuationToken); + return out; +} diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index aaec42c7..effb4d54 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -270,6 +270,7 @@ export const ar: TranslationKeys = { name: "منشئ Pipeline", description: "ربط عدة أدوات في سير عمل واحد", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1446,6 +1447,7 @@ export const ar: TranslationKeys = { teams: "الفرق", roles: "الأدوار", auditLog: "سجل التدقيق", + usage: "Usage", apiKeys: "مفاتيح API", aiFeatures: "ميزات AI", tools: "الأدوات", @@ -1506,6 +1508,10 @@ export const ar: TranslationKeys = { maxSplitGrid: "شبكة التقسيم القصوى", maxPdfPages: "صفحات PDF القصوى", sessionDuration: "مدة الجلسة", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "الأمان", @@ -1656,6 +1662,33 @@ export const ar: TranslationKeys = { hoursAgo: "منذ {hrs} ساعة", daysAgo: "منذ {days} يوم", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "مفاتيح API", description: "إدارة مفاتيح API للوصول البرمجي إلى SnapOtter.", @@ -1717,6 +1750,12 @@ export const ar: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "حول", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index f37ab793..7342c4f7 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -275,6 +275,7 @@ export const de: TranslationKeys = { name: "Pipeline-Builder", description: "Mehrere Werkzeuge zu einem Workflow verketten", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1460,6 +1461,7 @@ export const de: TranslationKeys = { teams: "Teams", roles: "Rollen", auditLog: "Audit-Protokoll", + usage: "Usage", apiKeys: "API-Schluessel", aiFeatures: "AI-Funktionen", tools: "Werkzeuge", @@ -1521,6 +1523,10 @@ export const de: TranslationKeys = { maxSplitGrid: "Maximales Aufteilungsraster", maxPdfPages: "Maximale PDF-Seiten", sessionDuration: "Sitzungsdauer", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Sicherheit", @@ -1676,6 +1682,33 @@ export const de: TranslationKeys = { hoursAgo: "Vor {hrs} Std.", daysAgo: "Vor {days} Tagen", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "API-Schluessel", description: "API-Schluessel fuer programmatischen Zugriff auf SnapOtter verwalten.", @@ -1742,6 +1775,12 @@ export const de: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "Info", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 63a8b23f..1038dbdc 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -227,6 +227,7 @@ export const en = { description: "Convert images to base64 strings for embedding in HTML, CSS, and more", }, pipeline: { name: "Pipeline Builder", description: "Chain multiple tools into a workflow" }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1405,6 +1406,7 @@ export const en = { teams: "Teams", roles: "Roles", auditLog: "Audit Log", + usage: "Usage", apiKeys: "API Keys", aiFeatures: "AI Features", tools: "Tools", @@ -1464,6 +1466,10 @@ export const en = { maxSplitGrid: "Max Split Grid", maxPdfPages: "Max PDF Pages", sessionDuration: "Session Duration", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Security", @@ -1616,6 +1622,33 @@ export const en = { hoursAgo: "{hrs}h ago", daysAgo: "{days}d ago", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "API Keys", description: "Manage API keys for programmatic access to SnapOtter.", @@ -1661,6 +1694,12 @@ export const en = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, fileManagement: { title: "File Management", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index ce05b7bb..569db266 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -260,6 +260,7 @@ export const es: TranslationKeys = { name: "Constructor de Pipeline", description: "Encadena multiples herramientas en un flujo de trabajo", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1442,6 +1443,7 @@ export const es: TranslationKeys = { teams: "Equipos", roles: "Roles", auditLog: "Registro de auditoria", + usage: "Usage", apiKeys: "Claves API", aiFeatures: "Funciones de AI", tools: "Herramientas", @@ -1503,6 +1505,10 @@ export const es: TranslationKeys = { maxSplitGrid: "Cuadricula maxima de division", maxPdfPages: "Paginas maximas de PDF", sessionDuration: "Duracion de la sesion", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Seguridad", @@ -1656,6 +1662,33 @@ export const es: TranslationKeys = { hoursAgo: "hace {hrs}h", daysAgo: "hace {days}d", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "Claves API", description: "Administra las claves API para acceso programatico a SnapOtter.", @@ -1721,6 +1754,12 @@ export const es: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "Acerca de", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index 0f7b7ad8..539483ce 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -276,6 +276,7 @@ export const fr: TranslationKeys = { name: "Constructeur de Pipeline", description: "Enchainez plusieurs outils dans un flux de travail", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1461,6 +1462,7 @@ export const fr: TranslationKeys = { teams: "Equipes", roles: "Roles", auditLog: "Journal d'audit", + usage: "Usage", apiKeys: "Cles API", aiFeatures: "Fonctionnalites AI", tools: "Outils", @@ -1522,6 +1524,10 @@ export const fr: TranslationKeys = { maxSplitGrid: "Grille de decoupage maximale", maxPdfPages: "Pages PDF maximales", sessionDuration: "Duree de la session", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Securite", @@ -1676,6 +1682,33 @@ export const fr: TranslationKeys = { hoursAgo: "il y a {hrs}h", daysAgo: "il y a {days}j", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "Cles API", description: "Gerez les cles API pour l'acces programmatique a SnapOtter.", @@ -1740,6 +1773,12 @@ export const fr: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "A propos", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index 526f8e52..3c2e64ff 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -267,6 +267,7 @@ export const hi: TranslationKeys = { name: "Pipeline बिल्डर", description: "कई टूल्स को एक वर्कफ्लो में चेन करें", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1443,6 +1444,7 @@ export const hi: TranslationKeys = { teams: "टीमें", roles: "भूमिकाएं", auditLog: "ऑडिट लॉग", + usage: "Usage", apiKeys: "API कुंजियां", aiFeatures: "AI फीचर्स", tools: "टूल्स", @@ -1502,6 +1504,10 @@ export const hi: TranslationKeys = { maxSplitGrid: "अधिकतम स्प्लिट ग्रिड", maxPdfPages: "अधिकतम PDF पेज", sessionDuration: "सत्र अवधि", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "सुरक्षा", @@ -1652,6 +1658,33 @@ export const hi: TranslationKeys = { hoursAgo: "{hrs} घंटे पहले", daysAgo: "{days} दिन पहले", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "API कुंजियां", description: "SnapOtter तक प्रोग्रामेटिक एक्सेस के लिए API कुंजियों का प्रबंधन करें।", @@ -1713,6 +1746,12 @@ export const hi: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "जानकारी", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index 7091a5d0..2a59df2f 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -275,6 +275,7 @@ export const id: TranslationKeys = { name: "Pipeline Builder", description: "Rangkai beberapa alat menjadi alur kerja", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1455,6 +1456,7 @@ export const id: TranslationKeys = { teams: "Tim", roles: "Peran", auditLog: "Log Audit", + usage: "Usage", apiKeys: "Kunci API", aiFeatures: "Fitur AI", tools: "Alat", @@ -1514,6 +1516,10 @@ export const id: TranslationKeys = { maxSplitGrid: "Grid Split Maks", maxPdfPages: "Halaman PDF Maks", sessionDuration: "Durasi Sesi", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Keamanan", @@ -1665,6 +1671,33 @@ export const id: TranslationKeys = { hoursAgo: "{hrs}j lalu", daysAgo: "{days}h lalu", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "Kunci API", description: "Kelola kunci API untuk akses programatik ke SnapOtter.", @@ -1729,6 +1762,12 @@ export const id: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "Tentang", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index 7a5d6f12..96411463 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -274,6 +274,7 @@ export const it: TranslationKeys = { name: "Costruttore di Pipeline", description: "Concatena piu strumenti in un flusso di lavoro", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1454,6 +1455,7 @@ export const it: TranslationKeys = { teams: "Team", roles: "Ruoli", auditLog: "Registro di audit", + usage: "Usage", apiKeys: "Chiavi API", aiFeatures: "Funzionalita AI", tools: "Strumenti", @@ -1515,6 +1517,10 @@ export const it: TranslationKeys = { maxSplitGrid: "Griglia divisione massima", maxPdfPages: "Pagine PDF massime", sessionDuration: "Durata della sessione", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Sicurezza", @@ -1669,6 +1675,33 @@ export const it: TranslationKeys = { hoursAgo: "{hrs}h fa", daysAgo: "{days}g fa", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "Chiavi API", description: "Gestisci le chiavi API per l'accesso programmatico a SnapOtter.", @@ -1735,6 +1768,12 @@ export const it: TranslationKeys = { repair: "Ripara", uninstall: "Disinstalla", installing: "Installazione...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "Informazioni", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index d1500a03..4478295c 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -235,6 +235,7 @@ export const ja: TranslationKeys = { description: "画像をBase64文字列に変換してHTML、CSS等に埋め込み", }, pipeline: { name: "Pipelineビルダー", description: "複数のツールをワークフローに連結" }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1413,6 +1414,7 @@ export const ja: TranslationKeys = { teams: "チーム", roles: "ロール", auditLog: "監査ログ", + usage: "Usage", apiKeys: "APIキー", aiFeatures: "AI機能", tools: "ツール", @@ -1472,6 +1474,10 @@ export const ja: TranslationKeys = { maxSplitGrid: "最大分割グリッド", maxPdfPages: "最大PDFページ数", sessionDuration: "セッション期間", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "セキュリティ", @@ -1623,6 +1629,33 @@ export const ja: TranslationKeys = { hoursAgo: "{hrs}時間前", daysAgo: "{days}日前", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "APIキー", description: "SnapOtterへのプログラムアクセス用APIキーを管理。", @@ -1686,6 +1719,12 @@ export const ja: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "SnapOtterについて", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index 1357f04f..281b5506 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -222,6 +222,7 @@ export const ko: TranslationKeys = { description: "이미지를 Base64 문자열로 변환하여 HTML, CSS 등에 임베드", }, pipeline: { name: "Pipeline 빌더", description: "여러 도구를 워크플로로 연결" }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1398,6 +1399,7 @@ export const ko: TranslationKeys = { teams: "팀", roles: "역할", auditLog: "감사 로그", + usage: "Usage", apiKeys: "API 키", aiFeatures: "AI 기능", tools: "도구", @@ -1457,6 +1459,10 @@ export const ko: TranslationKeys = { maxSplitGrid: "최대 분할 그리드", maxPdfPages: "최대 PDF 페이지 수", sessionDuration: "세션 기간", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "보안", @@ -1608,6 +1614,33 @@ export const ko: TranslationKeys = { hoursAgo: "{hrs}시간 전", daysAgo: "{days}일 전", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "API 키", description: "SnapOtter에 프로그래밍 방식으로 접근하기 위한 API 키를 관리하세요.", @@ -1671,6 +1704,12 @@ export const ko: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "정보", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 7768086c..081c43d3 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -275,6 +275,7 @@ export const nl: TranslationKeys = { name: "Pipeline-builder", description: "Meerdere tools koppelen tot een workflow", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1457,6 +1458,7 @@ export const nl: TranslationKeys = { teams: "Teams", roles: "Rollen", auditLog: "Auditlog", + usage: "Usage", apiKeys: "API-sleutels", aiFeatures: "AI-functies", tools: "Gereedschap", @@ -1517,6 +1519,10 @@ export const nl: TranslationKeys = { maxSplitGrid: "Max splitraster", maxPdfPages: "Max PDF-pagina's", sessionDuration: "Sessieduur", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Beveiliging", @@ -1669,6 +1675,33 @@ export const nl: TranslationKeys = { hoursAgo: "{hrs} uur geleden", daysAgo: "{days} dagen geleden", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "API-sleutels", description: "Beheer API-sleutels voor programmatische toegang tot SnapOtter.", @@ -1732,6 +1765,12 @@ export const nl: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "Over", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index dfd864c3..281b0d61 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -276,6 +276,7 @@ export const pl: TranslationKeys = { name: "Konstruktor Pipeline", description: "Łączenie wielu narzędzi w przepływ pracy", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1458,6 +1459,7 @@ export const pl: TranslationKeys = { teams: "Zespoły", roles: "Role", auditLog: "Dziennik audytu", + usage: "Usage", apiKeys: "Klucze API", aiFeatures: "Funkcje AI", tools: "Narzędzia", @@ -1519,6 +1521,10 @@ export const pl: TranslationKeys = { maxSplitGrid: "Maks. siatka podziału", maxPdfPages: "Maks. stron PDF", sessionDuration: "Czas trwania sesji", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Bezpieczeństwo", @@ -1673,6 +1679,33 @@ export const pl: TranslationKeys = { hoursAgo: "{hrs} godz. temu", daysAgo: "{days} dn. temu", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "Klucze API", description: "Zarządzanie kluczami API do programowego dostępu do SnapOtter.", @@ -1738,6 +1771,12 @@ export const pl: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "Informacje", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 34648c88..49b335f8 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -273,6 +273,7 @@ export const ptBR: TranslationKeys = { name: "Construtor de Pipeline", description: "Encadeie varias ferramentas em um fluxo de trabalho", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1454,6 +1455,7 @@ export const ptBR: TranslationKeys = { teams: "Equipes", roles: "Funcoes", auditLog: "Registro de auditoria", + usage: "Usage", apiKeys: "Chaves API", aiFeatures: "Recursos de AI", tools: "Ferramentas", @@ -1515,6 +1517,10 @@ export const ptBR: TranslationKeys = { maxSplitGrid: "Grade maxima de divisao", maxPdfPages: "Paginas maximas de PDF", sessionDuration: "Duracao da sessao", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Seguranca", @@ -1667,6 +1673,33 @@ export const ptBR: TranslationKeys = { hoursAgo: "ha {hrs}h", daysAgo: "ha {days}d", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "Chaves API", description: "Gerencie as chaves API para acesso programatico ao SnapOtter.", @@ -1731,6 +1764,12 @@ export const ptBR: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "Sobre", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index 119dc0fe..3849645e 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -275,6 +275,7 @@ export const ru: TranslationKeys = { name: "Конструктор Pipeline", description: "Объединение нескольких инструментов в рабочий процесс", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1456,6 +1457,7 @@ export const ru: TranslationKeys = { teams: "Команды", roles: "Роли", auditLog: "Журнал аудита", + usage: "Usage", apiKeys: "API-ключи", aiFeatures: "AI-функции", tools: "Инструменты", @@ -1515,6 +1517,10 @@ export const ru: TranslationKeys = { maxSplitGrid: "Макс. сетка разделения", maxPdfPages: "Макс. страниц PDF", sessionDuration: "Длительность сессии", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Безопасность", @@ -1666,6 +1672,33 @@ export const ru: TranslationKeys = { hoursAgo: "{hrs} ч. назад", daysAgo: "{days} дн. назад", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "API-ключи", description: "Управление API-ключами для программного доступа к SnapOtter.", @@ -1731,6 +1764,12 @@ export const ru: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "О программе", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index 052c5d7a..9129c543 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -273,6 +273,7 @@ export const sv: TranslationKeys = { name: "Pipeline-byggare", description: "Kedja samman flera verktyg till ett arbetsflode", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1453,6 +1454,7 @@ export const sv: TranslationKeys = { teams: "Team", roles: "Roller", auditLog: "Granskningslogg", + usage: "Usage", apiKeys: "API-nycklar", aiFeatures: "AI-funktioner", tools: "Verktyg", @@ -1512,6 +1514,10 @@ export const sv: TranslationKeys = { maxSplitGrid: "Max uppdelningsrutnat", maxPdfPages: "Max PDF-sidor", sessionDuration: "Sessionslangd", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Sakerhet", @@ -1664,6 +1670,33 @@ export const sv: TranslationKeys = { hoursAgo: "{hrs} tim sedan", daysAgo: "{days} dagar sedan", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "API-nycklar", description: "Hantera API-nycklar for programmatisk atkomst till SnapOtter.", @@ -1727,6 +1760,12 @@ export const sv: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "Om", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index 43af168c..e949bba1 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -268,6 +268,7 @@ export const th: TranslationKeys = { name: "ตัวสร้าง Pipeline", description: "เชื่อมต่อหลายเครื่องมือเป็นขั้นตอนทำงาน", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1435,6 +1436,7 @@ export const th: TranslationKeys = { teams: "ทีม", roles: "บทบาท", auditLog: "บันทึกการตรวจสอบ", + usage: "Usage", apiKeys: "คีย์ API", aiFeatures: "ฟีเจอร์ AI", tools: "เครื่องมือ", @@ -1494,6 +1496,10 @@ export const th: TranslationKeys = { maxSplitGrid: "กริดแบ่งสูงสุด", maxPdfPages: "จำนวนหน้า PDF สูงสุด", sessionDuration: "ระยะเวลาเซสชัน", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "ความปลอดภัย", @@ -1644,6 +1650,33 @@ export const th: TranslationKeys = { hoursAgo: "{hrs} ชั่วโมงที่แล้ว", daysAgo: "{days} วันที่แล้ว", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "คีย์ API", description: "จัดการคีย์ API สำหรับเข้าถึง SnapOtter แบบโปรแกรม", @@ -1705,6 +1738,12 @@ export const th: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "เกี่ยวกับ", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index dc206983..342bc47b 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -276,6 +276,7 @@ export const tr: TranslationKeys = { name: "Pipeline Oluşturucu", description: "Birden fazla aracı bir iş akışında zincirleyin", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1458,6 +1459,7 @@ export const tr: TranslationKeys = { teams: "Takımlar", roles: "Roller", auditLog: "Denetim Günlüğü", + usage: "Usage", apiKeys: "API Anahtarları", aiFeatures: "AI Özellikleri", tools: "Araçlar", @@ -1518,6 +1520,10 @@ export const tr: TranslationKeys = { maxSplitGrid: "Maksimum Bölme Izgarası", maxPdfPages: "Maksimum PDF Sayfası", sessionDuration: "Oturum Süresi", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Güvenlik", @@ -1670,6 +1676,33 @@ export const tr: TranslationKeys = { hoursAgo: "{hrs}sa önce", daysAgo: "{days}g önce", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "API Anahtarları", description: "SnapOtter'a programatik erişim için API anahtarlarını yönetin.", @@ -1735,6 +1768,12 @@ export const tr: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "Hakkında", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index d790e623..f563c200 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -275,6 +275,7 @@ export const uk: TranslationKeys = { name: "Конструктор Pipeline", description: "Об'єднання кількох інструментів у робочий процес", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1456,6 +1457,7 @@ export const uk: TranslationKeys = { teams: "Команди", roles: "Ролі", auditLog: "Журнал аудиту", + usage: "Usage", apiKeys: "API-ключі", aiFeatures: "AI-функції", tools: "Інструменти", @@ -1515,6 +1517,10 @@ export const uk: TranslationKeys = { maxSplitGrid: "Макс. сітка розділення", maxPdfPages: "Макс. сторінок PDF", sessionDuration: "Тривалість сесії", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Безпека", @@ -1666,6 +1672,33 @@ export const uk: TranslationKeys = { hoursAgo: "{hrs} год тому", daysAgo: "{days} дн. тому", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "API-ключі", description: "Керування API-ключами для програмного доступу до SnapOtter.", @@ -1731,6 +1764,12 @@ export const uk: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "Про програму", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index c4274f47..06b8b431 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -276,6 +276,7 @@ export const vi: TranslationKeys = { name: "Trình xây dựng Pipeline", description: "Kết nối nhiều công cụ thành một quy trình làm việc", }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1455,6 +1456,7 @@ export const vi: TranslationKeys = { teams: "Nhóm", roles: "Vai trò", auditLog: "Nhật ký kiểm tra", + usage: "Usage", apiKeys: "Khóa API", aiFeatures: "Tính năng AI", tools: "Công cụ", @@ -1514,6 +1516,10 @@ export const vi: TranslationKeys = { maxSplitGrid: "Lưới tách tối đa", maxPdfPages: "Số trang PDF tối đa", sessionDuration: "Thời lượng phiên", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "Bảo mật", @@ -1666,6 +1672,33 @@ export const vi: TranslationKeys = { hoursAgo: "{hrs} giờ trước", daysAgo: "{days} ngày trước", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "Khóa API", description: "Quản lý khóa API để truy cập SnapOtter theo chương trình.", @@ -1727,6 +1760,12 @@ export const vi: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "Giới thiệu", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 1dd655c3..17884dd4 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -222,6 +222,7 @@ export const zhCN: TranslationKeys = { description: "将图片转换为 Base64 字符串,可嵌入 HTML、CSS 等", }, pipeline: { name: "Pipeline 构建器", description: "将多个工具串联为工作流" }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1387,6 +1388,7 @@ export const zhCN: TranslationKeys = { teams: "团队", roles: "角色", auditLog: "审计日志", + usage: "Usage", apiKeys: "API 密钥", aiFeatures: "AI 功能", tools: "工具", @@ -1446,6 +1448,10 @@ export const zhCN: TranslationKeys = { maxSplitGrid: "最大分割网格", maxPdfPages: "最大 PDF 页数", sessionDuration: "会话持续时间", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "安全", @@ -1596,6 +1602,33 @@ export const zhCN: TranslationKeys = { hoursAgo: "{hrs} 小时前", daysAgo: "{days} 天前", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "API 密钥", description: "管理用于程序化访问 SnapOtter 的 API 密钥。", @@ -1657,6 +1690,12 @@ export const zhCN: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "关于", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index 40b2c82c..734288fb 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -221,6 +221,7 @@ export const zhTW: TranslationKeys = { description: "將影像轉換為Base64字串,嵌入HTML、CSS等", }, pipeline: { name: "Pipeline建構器", description: "將多個工具串聯為工作流程" }, + processing: { canceled: "Processing canceled" }, }, toolSettings: { compress: { @@ -1385,6 +1386,7 @@ export const zhTW: TranslationKeys = { teams: "團隊", roles: "角色", auditLog: "稽核記錄", + usage: "Usage", apiKeys: "API金鑰", aiFeatures: "AI功能", tools: "工具", @@ -1444,6 +1446,10 @@ export const zhTW: TranslationKeys = { maxSplitGrid: "最大分割格線", maxPdfPages: "最大PDF頁數", sessionDuration: "工作階段時長", + supportBundleButton: "Download Support Bundle", + supportBundleDescription: + "Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.", + supportBundleFailed: "Failed to download support bundle.", }, security: { heading: "安全性", @@ -1594,6 +1600,33 @@ export const zhTW: TranslationKeys = { hoursAgo: "{hrs}小時前", daysAgo: "{days}天前", }, + usage: { + heading: "Usage", + description: "Local usage statistics from jobs and file storage.", + periodLabel: "Period", + days7: "7 days", + days30: "30 days", + days90: "90 days", + jobsPerDayHeading: "Jobs per day", + topToolsHeading: "Top tools", + perUserHeading: "Per-user volume", + durationsHeading: "Duration percentiles", + noData: "No data for this period.", + dayColumn: "Day", + totalColumn: "Total", + completedColumn: "Completed", + failedColumn: "Failed", + toolColumn: "Tool", + runsColumn: "Runs", + userColumn: "User", + bytesInColumn: "Bytes in", + poolColumn: "Pool", + p50Column: "p50", + p95Column: "p95", + storageHeading: "Library storage", + storageFiles: "{count} files", + unknownUser: "(unknown)", + }, apiKeys: { heading: "API金鑰", description: "管理用於程式化存取SnapOtter的API金鑰。", @@ -1655,6 +1688,12 @@ export const zhTW: TranslationKeys = { repair: "Repair", uninstall: "Uninstall", installing: "Installing...", + importBundle: "Import Bundle", + importDescription: "Upload a .tar.gz bundle archive exported from another installation.", + importButton: "Import from file", + importing: "Importing...", + importSuccess: "Bundle imported successfully.", + importError: "Bundle import failed: {error}", }, about: { heading: "關於", diff --git a/playwright.analytics-local.config.ts b/playwright.analytics-local.config.ts index e9c4c187..62196ad9 100644 --- a/playwright.analytics-local.config.ts +++ b/playwright.analytics-local.config.ts @@ -58,6 +58,8 @@ export default defineConfig({ SKIP_MUST_CHANGE_PASSWORD: "true", ANALYTICS_ENABLED: "true", DATABASE_URL: e2eDatabaseUrl, + REDIS_URL: process.env.REDIS_URL ?? "redis://localhost:6379", + BULLMQ_PREFIX: e2eDbName, PORT: String(TEST_API_PORT), }, timeout: 30_000, diff --git a/playwright.config.ts b/playwright.config.ts index 8191e840..9489071c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -121,6 +121,8 @@ export default defineConfig({ SKIP_MUST_CHANGE_PASSWORD: "true", ANALYTICS_ENABLED: "false", DATABASE_URL: e2eDatabaseUrl, + REDIS_URL: process.env.REDIS_URL ?? "redis://localhost:6379", + BULLMQ_PREFIX: e2eDbName, // The in-repo docker/feature-manifest.json makes the API think it is // inside Docker and try to mkdir /data; point it somewhere writable. DATA_DIR: path.join(__dirname, "test-results", ".e2e-data"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41d888c3..33d3cfe6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@testcontainers/postgresql': specifier: ^12.0.1 version: 12.0.1 + '@testcontainers/redis': + specifier: ^12.0.1 + version: 12.0.1 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -158,6 +161,9 @@ importers: better-sqlite3: specifier: ^11.7.0 version: 11.10.0 + bullmq: + specifier: ^5.78.0 + version: 5.78.0 dotenv: specifier: ^16.4.0 version: 16.6.1 @@ -173,6 +179,9 @@ importers: fflate: specifier: ^0.8.3 version: 0.8.3 + ioredis: + specifier: ^5.10.1 + version: 5.10.1 js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -194,6 +203,9 @@ importers: pg: specifier: ^8.21.0 version: 8.21.0 + pino-roll: + specifier: ^4.0.0 + version: 4.0.0 piscina: specifier: ^5.1.4 version: 5.1.4 @@ -206,12 +218,18 @@ importers: potrace: specifier: ^2.1.8 version: 2.1.8 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 qrcode: specifier: ^1.5.4 version: 1.5.4 sharp: specifier: ^0.34.5 version: 0.34.5 + tar: + specifier: '>=7.5.11' + version: 7.5.16 tsx: specifier: ^4.22.4 version: 4.22.4 @@ -249,6 +267,9 @@ importers: '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 + '@types/tar': + specifier: ^7.0.87 + version: 7.0.87 drizzle-kit: specifier: ^0.31.0 version: 0.31.10 @@ -464,8 +485,11 @@ importers: packages/enterprise: dependencies: '@aws-sdk/client-s3': - specifier: ^3.1063.0 - version: 3.1063.0 + specifier: ^3.1066.0 + version: 3.1066.0 + '@aws-sdk/lib-storage': + specifier: ^3.1066.0 + version: 3.1066.0(@aws-sdk/client-s3@3.1066.0) '@snapotter/shared': specifier: workspace:* version: link:../shared @@ -644,80 +668,90 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/checksums@3.1000.2': - resolution: {integrity: sha512-PIha+kauTbp6IRmOpYktPTrlfrrSqDVixvhO/EUOFOf62DPX81CaJoHJreuA1m9HYpSKyXf99BKjU1dvJPeUfw==} + '@aws-sdk/checksums@3.1000.5': + resolution: {integrity: sha512-zOXUUnilC6lgCsQtp77p/QNPmRlTES9Xi6tlDwbR6kfC/kz5PCzZckgHWm5z+8DskdwuMAbFDq61x3zr10GEEQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1063.0': - resolution: {integrity: sha512-ETn+vvmZVK1MmOZwVBXmWANpmD5iTbzojIqyEIoZ86qo+8oWy35S8QyQNE/ZDI+WHgMU1dS+VSYbpRl1QkEySg==} + '@aws-sdk/client-s3@3.1066.0': + resolution: {integrity: sha512-jfJTg6Xbyws8+YF5sVQXgCA01elkenGEYeX6+HQOovu9m/Td1Ln2xcY3lHKetVNltdArp5rykjNd56z7bnA9dw==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.18': - resolution: {integrity: sha512-JDYCPI0j7zGrzXTDFsLB346cxss7J/AxH7+O0MzWlqppJBEyB9Qe6TQXRL6iwLUo/xZkNv9KFmBL2hqElmwW0g==} + '@aws-sdk/core@3.974.20': + resolution: {integrity: sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.44': - resolution: {integrity: sha512-3hKJVrZ7bqXzDAXCQp+OaQ1ASN+vWstaNuEH418wQVl//cRZhqhfR9Bjk1qIWmgUGe8/D3gdO73PgidRj378EQ==} + '@aws-sdk/credential-provider-env@3.972.46': + resolution: {integrity: sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.46': - resolution: {integrity: sha512-VhwC9pGAZHhiQ2xSViyOPDFqvr9aRxGCAXZtADsUhU3R65nad7y//CwynE6mQnWNR+suRlqE79W36IVayL+m1g==} + '@aws-sdk/credential-provider-http@3.972.48': + resolution: {integrity: sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.50': - resolution: {integrity: sha512-09Xi6ovxiK42+De/qBGF71sT5F2bWgYM+1fFyDwSOpy1xpsQ5R/naIu7MVDpH6Dic36QNc8dAv4KADtMGK2JYg==} + '@aws-sdk/credential-provider-ini@3.972.53': + resolution: {integrity: sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.49': - resolution: {integrity: sha512-EfJF/1Fh9mI4pZyoheU2RY9xUhTcugIZNkD63+orXMkYj/QXacJNbKVDUK90Yv5hE+aX+rt9J/EZ9Qr3vKOa7g==} + '@aws-sdk/credential-provider-login@3.972.52': + resolution: {integrity: sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.52': - resolution: {integrity: sha512-7QX+PbyiWBEOVipJq8Nke/TqXT6lAPLE7fvTaopa39/IVWuLfS+Fzdy71sZJONf/mLGgmtj6aU17+REw3+aRrw==} + '@aws-sdk/credential-provider-node@3.972.55': + resolution: {integrity: sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.44': - resolution: {integrity: sha512-V+UUhZpRP7QDRhi+qgBDisM9tUBnYmMje8Bk77A6MZsfeGeGdMsQXmaHP1CDYFcept0o/Rz5g2Y0TMeVlG9dzg==} + '@aws-sdk/credential-provider-process@3.972.46': + resolution: {integrity: sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.49': - resolution: {integrity: sha512-9QqOYGuh5tZ76OzaT68kwI78AH+5lS/uZGGvkfxb3fc8FzRrIz2jOufNTliEBEeSAwmgK2rWLNsK+IB3zbtNPA==} + '@aws-sdk/credential-provider-sso@3.972.52': + resolution: {integrity: sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.49': - resolution: {integrity: sha512-IYx1lN38MnnPXv+NBLpuATu0cZakbZ321TAfjW+aVkw7HIJF38YnEwdeEO55MSl3pl7hIX1IvvnD6EmnAzmAJw==} + '@aws-sdk/credential-provider-web-identity@3.972.52': + resolution: {integrity: sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.974.27': - resolution: {integrity: sha512-bZqezPLdllFC4VAeV/f+EIc/hz56ab3TD/+4zNCgOgmG5ZHAE5dMHrX1gtTwdcQXbPr3KR7x3zTC3zuCTE6+ng==} + '@aws-sdk/lib-storage@3.1066.0': + resolution: {integrity: sha512-KH3KHhfS2BhdLwEANbaOtslqwFEPaowoMckB9ELvu8UCOrwVXOrbU8/yrk4SNN+gzcB+n3EgihYUbvavjJOqVg==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@aws-sdk/client-s3': ^3.1066.0 + + '@aws-sdk/middleware-flexible-checksums@3.974.30': + resolution: {integrity: sha512-OaIhub+3yTgfFWPzKO8OzOZFIMUoJaiS5v67y3spQg7SoULGoMx4jKVBbE+uhnzkiZXQ+rEDS0RqrK4/aD1yJw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.48': - resolution: {integrity: sha512-MRTqx8wD/T3REt6LTT3/yN8rrp6+xIHrbUekkDYJTYWVch70mwtdJBovR4qKJz1jIPlbN+9R/Sn6R04BfsglzA==} + '@aws-sdk/middleware-sdk-s3@3.972.51': + resolution: {integrity: sha512-keQgcIUTcHL0Qn7guhsuLaxQU36r9norCrxgaPH4DNCwon4TPtXdI/UdYuycl9vj3Dlwc3YR1dfL3U+6iIwJ6w==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.17': - resolution: {integrity: sha512-lDRgraoTfKRawUyc176Ow93mrNrOho/x+EoK4C+lKU+vKkHWhNhzvSMVAx0WEJUJoeQxxDN5ZdKMfiGEyNejig==} + '@aws-sdk/nested-clients@3.997.20': + resolution: {integrity: sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.32': - resolution: {integrity: sha512-llvApLcsWtmRFhG2wT3WIp1CmDeRaIYutqty1ZZXoMzK7TiJ6MOLOimk9eXUS8PwgG4ew4pa4QAbt0lfhn++1w==} + '@aws-sdk/signature-v4-multi-region@3.996.34': + resolution: {integrity: sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1063.0': - resolution: {integrity: sha512-nYDaWWdzjKiDP5xj8k4oUgcYd4WPgzfAOgdU5vJsaqH/07Dfvm7ffisHCFJ+NEl7kUC9JEIUxh0kznvenbo3NQ==} + '@aws-sdk/token-providers@3.1066.0': + resolution: {integrity: sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==} engines: {node: '>=20.0.0'} '@aws-sdk/types@3.973.11': resolution: {integrity: sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.12': + resolution: {integrity: sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.965.6': resolution: {integrity: sha512-ZfHjfwSzeXj+Lg9AK5ZNmeDkXev6V+w2tn1t4kgDdRtUaRCthepTQiFwbD06EF9oNGH4LaLg+Mb6U16Ypv5bSw==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.28': - resolution: {integrity: sha512-lI/l3c/vPvsxmspzV63NfS3x9q4CkMmdhJy4QiM+NThAufVkDvi/PZZQ6xETnICL0UD7jI808pY83gllf86RFg==} + '@aws-sdk/xml-builder@3.972.29': + resolution: {integrity: sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.2.4': @@ -1914,10 +1948,17 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@istanbuljs/schema@0.1.6': resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} engines: {node: '>=8'} @@ -2281,6 +2322,36 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/nice-android-arm-eabi@1.1.1': resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} engines: {node: '>= 10'} @@ -3119,6 +3190,9 @@ packages: '@testcontainers/postgresql@12.0.1': resolution: {integrity: sha512-6SyyduUM6lTAo4UwKG1aCochP6wTe0k7/W/m5uODZQMUrV3STk6/28Km3474JLGZdw/P793BUEF2IeU7rDyoEg==} + '@testcontainers/redis@12.0.1': + resolution: {integrity: sha512-x5iDR6Y2Mc+2rTJYSQlRTtBMCPt77pKxDogpqRwXgJVmWoDrWFpiFcA/dw6IEoNuyxYUg03tbS8/VzCJI5JX6A==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -3322,6 +3396,10 @@ packages: '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/tar@7.0.87': + resolution: {integrity: sha512-3IxNBV8LeY5oi2ZFpvAhOtW1+mHswkzM7BuisVrwJgPv67GBO2rkLPQlEKtzfHuLdhDDczhkCZeT+RuizMay4A==} + deprecated: This is a stub types definition. tar provides its own type definitions, so you do not need this installed. + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -3709,6 +3787,9 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} @@ -3754,6 +3835,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.6.0: + resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -3764,6 +3848,15 @@ packages: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} + bullmq@5.78.0: + resolution: {integrity: sha512-tT9jJmbobk9ueEfFc22egLmgwCcMGgOjZ5Y1cvgczBPv1JUmC7iHQVbQtqku2YBE5dE9uzdVpxIrBvL/YAjGwA==} + engines: {node: '>=12.22.0'} + peerDependencies: + redis: '>=5.0.0' + peerDependenciesMeta: + redis: + optional: true + byline@5.0.0: resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} engines: {node: '>=0.10.0'} @@ -3836,6 +3929,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} @@ -3893,6 +3990,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -4007,6 +4108,10 @@ packages: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4029,6 +4134,9 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -4060,6 +4168,10 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -4775,6 +4887,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + ipaddr.js@2.3.0: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} @@ -5096,12 +5212,18 @@ packages: lodash.capitalize@4.2.1: resolution: {integrity: sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} lodash.groupby@4.6.0: resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} @@ -5146,6 +5268,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -5354,6 +5480,10 @@ packages: minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -5381,6 +5511,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.2: + resolution: {integrity: sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ==} + mupdf@1.27.0: resolution: {integrity: sha512-vEPUYwZeu5NgiFLz4e20R7Vp2pNY7szirGEvTxHyQQpQs6ab4DeGdonwT6sH1JZG5EhyHSrojZrZn2/0ee6qZQ==} @@ -5451,10 +5588,17 @@ packages: resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} engines: {node: '>=10'} + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-emoji@2.2.0: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} @@ -5818,6 +5962,9 @@ packages: pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + pino-roll@4.0.0: + resolution: {integrity: sha512-axI1aQaIxXdw1F4OFFli1EDxIrdYNGLowkw/ZoZogX8oCSLHUghzwVVXUS8U+xD/Savwa5IXpiXmsSGKFX/7Sg==} + pino-std-serializers@7.1.0: resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} @@ -5935,6 +6082,10 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -6106,6 +6257,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} @@ -6407,6 +6566,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -6414,6 +6576,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + stream-combiner2@1.1.1: resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} @@ -6561,6 +6726,13 @@ packages: tar-stream@3.1.8: resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==} + tar@7.5.16: + resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + engines: {node: '>=18'} + + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -7068,6 +7240,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -7324,14 +7500,14 @@ snapshots: '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/types': 3.973.12 tslib: 2.8.1 '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/types': 3.973.12 '@aws-sdk/util-locate-window': 3.965.6 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -7341,7 +7517,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/types': 3.973.12 '@aws-sdk/util-locate-window': 3.965.6 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -7349,7 +7525,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/types': 3.973.12 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -7358,42 +7534,42 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.11 + '@aws-sdk/types': 3.973.12 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/checksums@3.1000.2': + '@aws-sdk/checksums@3.1000.5': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.974.18 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1063.0': + '@aws-sdk/client-s3@3.1066.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.18 - '@aws-sdk/credential-provider-node': 3.972.52 - '@aws-sdk/middleware-flexible-checksums': 3.974.27 - '@aws-sdk/middleware-sdk-s3': 3.972.48 - '@aws-sdk/signature-v4-multi-region': 3.996.32 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-node': 3.972.55 + '@aws-sdk/middleware-flexible-checksums': 3.974.30 + '@aws-sdk/middleware-sdk-s3': 3.972.51 + '@aws-sdk/signature-v4-multi-region': 3.996.34 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/fetch-http-handler': 5.4.6 '@smithy/node-http-handler': 4.7.7 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/core@3.974.18': + '@aws-sdk/core@3.974.20': dependencies: - '@aws-sdk/types': 3.973.11 - '@aws-sdk/xml-builder': 3.972.28 + '@aws-sdk/types': 3.973.12 + '@aws-sdk/xml-builder': 3.972.29 '@aws/lambda-invoke-store': 0.2.4 '@smithy/core': 3.24.6 '@smithy/signature-v4': 5.4.6 @@ -7401,129 +7577,139 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.44': + '@aws-sdk/credential-provider-env@3.972.46': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.46': + '@aws-sdk/credential-provider-http@3.972.48': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/fetch-http-handler': 5.4.6 '@smithy/node-http-handler': 4.7.7 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.50': + '@aws-sdk/credential-provider-ini@3.972.53': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/credential-provider-env': 3.972.44 - '@aws-sdk/credential-provider-http': 3.972.46 - '@aws-sdk/credential-provider-login': 3.972.49 - '@aws-sdk/credential-provider-process': 3.972.44 - '@aws-sdk/credential-provider-sso': 3.972.49 - '@aws-sdk/credential-provider-web-identity': 3.972.49 - '@aws-sdk/nested-clients': 3.997.17 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-env': 3.972.46 + '@aws-sdk/credential-provider-http': 3.972.48 + '@aws-sdk/credential-provider-login': 3.972.52 + '@aws-sdk/credential-provider-process': 3.972.46 + '@aws-sdk/credential-provider-sso': 3.972.52 + '@aws-sdk/credential-provider-web-identity': 3.972.52 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/credential-provider-imds': 4.3.8 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.49': + '@aws-sdk/credential-provider-login@3.972.52': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/nested-clients': 3.997.17 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.52': + '@aws-sdk/credential-provider-node@3.972.55': dependencies: - '@aws-sdk/credential-provider-env': 3.972.44 - '@aws-sdk/credential-provider-http': 3.972.46 - '@aws-sdk/credential-provider-ini': 3.972.50 - '@aws-sdk/credential-provider-process': 3.972.44 - '@aws-sdk/credential-provider-sso': 3.972.49 - '@aws-sdk/credential-provider-web-identity': 3.972.49 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/credential-provider-env': 3.972.46 + '@aws-sdk/credential-provider-http': 3.972.48 + '@aws-sdk/credential-provider-ini': 3.972.53 + '@aws-sdk/credential-provider-process': 3.972.46 + '@aws-sdk/credential-provider-sso': 3.972.52 + '@aws-sdk/credential-provider-web-identity': 3.972.52 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/credential-provider-imds': 4.3.8 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.44': + '@aws-sdk/credential-provider-process@3.972.46': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.49': + '@aws-sdk/credential-provider-sso@3.972.52': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/nested-clients': 3.997.17 - '@aws-sdk/token-providers': 3.1063.0 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/token-providers': 3.1066.0 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.49': + '@aws-sdk/credential-provider-web-identity@3.972.52': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/nested-clients': 3.997.17 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.974.27': + '@aws-sdk/lib-storage@3.1066.0(@aws-sdk/client-s3@3.1066.0)': dependencies: - '@aws-sdk/checksums': 3.1000.2 + '@aws-sdk/client-s3': 3.1066.0 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + buffer: 5.6.0 + events: 3.3.0 + stream-browserify: 3.0.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.48': + '@aws-sdk/middleware-flexible-checksums@3.974.30': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/signature-v4-multi-region': 3.996.32 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/checksums': 3.1000.5 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.51': + dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/signature-v4-multi-region': 3.996.34 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.17': + '@aws-sdk/nested-clients@3.997.20': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.18 - '@aws-sdk/signature-v4-multi-region': 3.996.32 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/signature-v4-multi-region': 3.996.34 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/fetch-http-handler': 5.4.6 '@smithy/node-http-handler': 4.7.7 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.32': + '@aws-sdk/signature-v4-multi-region@3.996.34': dependencies: - '@aws-sdk/types': 3.973.11 + '@aws-sdk/types': 3.973.12 '@smithy/signature-v4': 5.4.6 '@smithy/types': 4.14.3 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1063.0': + '@aws-sdk/token-providers@3.1066.0': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/nested-clients': 3.997.17 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.6 '@smithy/types': 4.14.3 tslib: 2.8.1 @@ -7533,11 +7719,16 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@aws-sdk/types@3.973.12': + dependencies: + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.6': dependencies: tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.28': + '@aws-sdk/xml-builder@3.972.29': dependencies: '@smithy/types': 4.14.3 fast-xml-parser: 5.7.3 @@ -8536,6 +8727,8 @@ snapshots: optionalDependencies: '@types/node': 25.8.0 + '@ioredis/commands@1.5.1': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -8545,6 +8738,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@istanbuljs/schema@0.1.6': {} '@jimp/bmp@0.14.0(@jimp/custom@0.14.0)': @@ -9080,6 +9277,24 @@ snapshots: '@lukeed/ms@2.0.2': {} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/nice-android-arm-eabi@1.1.1': optional: true @@ -9935,6 +10150,15 @@ snapshots: - react-native-b4a - supports-color + '@testcontainers/redis@12.0.1': + dependencies: + testcontainers: 12.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -10164,6 +10388,10 @@ snapshots: dependencies: '@types/node': 18.19.130 + '@types/tar@7.0.87': + dependencies: + tar: 7.5.16 + '@types/trusted-types@2.0.7': optional: true @@ -10571,6 +10799,8 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 + bintrees@1.0.2: {} + birpc@2.9.0: {} bl@4.1.0: @@ -10615,6 +10845,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.6.0: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -10628,6 +10863,17 @@ snapshots: buildcheck@0.0.7: optional: true + bullmq@5.78.0: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.10.1 + msgpackr: 2.0.2 + node-abort-controller: 3.1.1 + semver: 7.8.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + byline@5.0.0: {} cac@6.7.14: {} @@ -10691,6 +10937,8 @@ snapshots: chownr@1.1.4: {} + chownr@3.0.0: {} + cjs-module-lexer@2.2.0: {} clean-stack@2.2.0: {} @@ -10755,6 +11003,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -10862,6 +11112,10 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -10888,6 +11142,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + date-fns@4.4.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -10908,6 +11164,8 @@ snapshots: deep-extend@0.6.0: {} + denque@2.1.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -11637,6 +11895,20 @@ snapshots: ini@1.3.8: {} + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@2.3.0: {} is-arrayish@0.2.1: {} @@ -11959,10 +12231,14 @@ snapshots: lodash.capitalize@4.2.1: {} + lodash.defaults@4.2.0: {} + lodash.escaperegexp@4.1.2: {} lodash.groupby@4.6.0: {} + lodash.isarguments@3.1.0: {} + lodash.isplainobject@4.0.6: {} lodash.isstring@4.0.1: {} @@ -12002,6 +12278,8 @@ snapshots: dependencies: react: 19.2.7 + luxon@3.7.2: {} + lz-string@1.5.0: {} magic-string@0.30.21: @@ -12306,6 +12584,10 @@ snapshots: minisearch@7.2.0: {} + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mitt@3.0.1: {} mkdirp-classic@0.5.3: {} @@ -12326,6 +12608,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.2: + optionalDependencies: + msgpackr-extract: 3.0.4 + mupdf@1.27.0: {} mutation-server-protocol@0.4.1: @@ -12388,7 +12686,9 @@ snapshots: node-abi@3.89.0: dependencies: - semver: 7.8.0 + semver: 7.8.1 + + node-abort-controller@3.1.1: {} node-emoji@2.2.0: dependencies: @@ -12397,6 +12697,11 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-releases@2.0.36: {} normalize-package-data@6.0.2: @@ -12408,7 +12713,7 @@ snapshots: normalize-package-data@8.0.0: dependencies: hosted-git-info: 9.0.2 - semver: 7.7.4 + semver: 7.8.1 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -12659,6 +12964,11 @@ snapshots: dependencies: split2: 4.2.0 + pino-roll@4.0.0: + dependencies: + date-fns: 4.4.0 + sonic-boom: 4.2.1 + pino-std-serializers@7.1.0: {} pino@10.3.1: @@ -12786,6 +13096,11 @@ snapshots: progress@2.0.3: {} + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.1 + tdigest: 0.1.2 + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -12980,6 +13295,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + regenerator-runtime@0.13.11: {} regex-recursion@6.0.2: @@ -13352,10 +13673,17 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} std-env@3.10.0: {} + stream-browserify@3.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-combiner2@1.1.1: dependencies: duplexer2: 0.1.4 @@ -13521,6 +13849,18 @@ snapshots: - bare-buffer - react-native-b4a + tar@7.5.16: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + teex@1.0.1: dependencies: streamx: 2.25.0 @@ -14096,6 +14436,8 @@ snapshots: yallist@3.1.1: {} + yallist@5.0.0: {} + yaml@2.8.3: {} yargs-parser@18.1.3: diff --git a/scripts/export-ai-bundle.mjs b/scripts/export-ai-bundle.mjs new file mode 100755 index 00000000..67ef75a4 --- /dev/null +++ b/scripts/export-ai-bundle.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +/** + * Export an installed AI feature bundle as a gzipped tar archive for offline + * transfer to air-gapped SnapOtter installations. + * + * Usage: + * node scripts/export-ai-bundle.mjs [--data-dir /data] [outFile] + * + * The archive contains: + * bundle.json - { bundleId, version, models } + * models/<...> - model files mirroring MODELS_DIR layout + * + * Defaults: + * --data-dir /data + * outFile -.tar.gz (in cwd) + * + * Requires the `tar` npm package from apps/api/node_modules (resolved via + * createRequire, same pattern as tests/global-setup.ts). + */ +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const apiRequire = createRequire(join(__dirname, "../apps/api/package.json")); +const tar = apiRequire("tar"); + +// ── CLI argument parsing ─────────────────────────────────────────────── + +function usage() { + process.stderr.write( + "Usage: node scripts/export-ai-bundle.mjs [--data-dir /data] [outFile]\n", + ); + process.exit(1); +} + +const args = process.argv.slice(2); +if (args.length === 0 || args.includes("--help") || args.includes("-h")) { + usage(); +} + +let bundleId = null; +let dataDir = "/data"; +let outFile = null; + +for (let i = 0; i < args.length; i++) { + if (args[i] === "--data-dir") { + i++; + if (!args[i]) { + process.stderr.write("Error: --data-dir requires a value\n"); + process.exit(1); + } + dataDir = args[i]; + } else if (!bundleId) { + bundleId = args[i]; + } else if (!outFile) { + outFile = args[i]; + } else { + process.stderr.write(`Error: unexpected argument "${args[i]}"\n`); + usage(); + } +} + +if (!bundleId) { + process.stderr.write("Error: bundleId is required\n"); + usage(); +} + +// ── Resolve paths ────────────────────────────────────────────────────── + +const aiDir = join(dataDir, "ai"); +const installedPath = join(aiDir, "installed.json"); +const modelsDir = join(aiDir, "models"); + +if (!existsSync(installedPath)) { + process.stderr.write(`Error: ${installedPath} not found. No bundles are installed.\n`); + process.exit(1); +} + +let installedData; +try { + installedData = JSON.parse(readFileSync(installedPath, "utf-8")); +} catch (err) { + process.stderr.write(`Error: cannot parse ${installedPath}: ${err.message}\n`); + process.exit(1); +} + +const bundleInfo = installedData.bundles?.[bundleId]; +if (!bundleInfo) { + const available = Object.keys(installedData.bundles || {}); + process.stderr.write(`Error: bundle "${bundleId}" is not installed.\n`); + if (available.length > 0) { + process.stderr.write(`Installed bundles: ${available.join(", ")}\n`); + } else { + process.stderr.write("No bundles are currently installed.\n"); + } + process.exit(1); +} + +const version = bundleInfo.version; +const models = bundleInfo.models || []; + +if (!outFile) { + outFile = `${bundleId}-${version}.tar.gz`; +} + +// ── Build staging area with bundle.json ──────────────────────────────── + +const stagingDir = join(tmpdir(), `snapotter-export-${randomUUID()}`); +mkdirSync(stagingDir, { recursive: true }); + +const bundleDescriptor = { + bundleId, + version, + models, +}; + +writeFileSync(join(stagingDir, "bundle.json"), JSON.stringify(bundleDescriptor, null, 2), "utf-8"); + +// ── Collect model file paths ─────────────────────────────────────────── + +const modelEntries = []; +for (const modelPath of models) { + const fullPath = join(modelsDir, modelPath); + if (!existsSync(fullPath)) { + process.stderr.write(`Warning: model file not found, skipping: ${modelPath}\n`); + continue; + } + modelEntries.push(modelPath); +} + +if (modelEntries.length === 0) { + process.stderr.write("Warning: no model files found; archive will only contain bundle.json\n"); +} + +process.stderr.write(`Exporting bundle "${bundleId}" v${version} (${modelEntries.length} model files)...\n`); + +// ── Create the archive ───────────────────────────────────────────────── + +const outPath = resolve(outFile); + +// We create the tar in two phases: bundle.json from staging, models from modelsDir. +// tar.create needs a single cwd, so we use a two-step approach with the staging dir. +// First, symlink models dir into staging so everything is under one root. +import { symlinkSync } from "node:fs"; + +const stagingModelsLink = join(stagingDir, "models"); +try { + symlinkSync(modelsDir, stagingModelsLink); +} catch (err) { + process.stderr.write(`Error: cannot create symlink for models: ${err.message}\n`); + rmSync(stagingDir, { recursive: true, force: true }); + process.exit(1); +} + +const fileList = ["bundle.json", ...modelEntries.map((m) => `models/${m}`)]; + +try { + await tar.create( + { + gzip: true, + file: outPath, + cwd: stagingDir, + }, + fileList, + ); +} catch (err) { + process.stderr.write(`Error: failed to create archive: ${err.message}\n`); + rmSync(stagingDir, { recursive: true, force: true }); + process.exit(1); +} + +// ── Cleanup ──────────────────────────────────────────────────────────── + +rmSync(stagingDir, { recursive: true, force: true }); + +process.stderr.write(`Created ${outPath}\n`); +process.exit(0); diff --git a/tests/global-setup.ts b/tests/global-setup.ts index 1f3e3edc..2f980c30 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -1,6 +1,7 @@ import { createRequire } from "node:module"; import { join } from "node:path"; import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; +import { RedisContainer, type StartedRedisContainer } from "@testcontainers/redis"; // pg and drizzle-orm live in the api workspace's node_modules. Global-setup // files run outside Vite's transform pipeline, so vitest resolve.alias does @@ -15,6 +16,7 @@ const { migrate } = apiRequire( ) as typeof import("drizzle-orm/node-postgres/migrator"); let container: StartedPostgreSqlContainer | undefined; +let redisContainer: StartedRedisContainer | undefined; export async function setup(): Promise { // Base server: testcontainer by default, or an existing server via @@ -43,8 +45,18 @@ export async function setup(): Promise { } finally { await pool.end(); } + + // Redis server: testcontainer by default, or an existing server via + // TEST_REDIS_URL (e.g. inside Docker where testcontainers cannot spawn). + if (process.env.TEST_REDIS_URL) { + process.env.TEST_REDIS_BASE_URL = process.env.TEST_REDIS_URL; + } else { + redisContainer = await new RedisContainer("redis:8-alpine").start(); + process.env.TEST_REDIS_BASE_URL = redisContainer.getConnectionUrl(); + } } export async function teardown(): Promise { + await redisContainer?.stop(); await container?.stop(); } diff --git a/tests/integration/batch.test.ts b/tests/integration/batch.test.ts index 3c20d480..fd11fecc 100644 --- a/tests/integration/batch.test.ts +++ b/tests/integration/batch.test.ts @@ -6,10 +6,13 @@ * non-existent tools, and ZIP response format validation. */ +import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import AdmZip from "adm-zip"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { sharedRedis } from "../../apps/api/src/jobs/connection.js"; +import { bullPrefix } from "../../apps/api/src/jobs/types.js"; import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; const FIXTURES = join(__dirname, "..", "fixtures"); @@ -525,3 +528,56 @@ describe("Batch preserves upload order", () => { expect(fileResults["2"]).toContain("gamma"); }); }); + +// ── Legacy batch SSE semantics ──────────────────────────────── +describe("Legacy batch SSE wire parity", () => { + it("terminal SSE frame has completedFiles === totalFiles on mixed batch", async () => { + // Mixed batch: 2 valid images + 1 invalid file (fails pre-validation) + const clientJobId = randomUUID(); + + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "good1.png", contentType: "image/png", content: PNG }, + { + name: "file", + filename: "bad.txt", + contentType: "text/plain", + content: Buffer.from("not an image"), + }, + { name: "file", filename: "good2.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({ width: 50 }) }, + { name: "clientJobId", content: clientJobId }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize/batch", + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + body, + }); + + // Batch succeeds overall (2 of 3 files processed) + expect(res.statusCode).toBe(200); + + // Read the terminal SSE replay frame from Redis + const terminalKeyName = `${bullPrefix()}:terminal:${clientJobId}`; + let frame: string | null = null; + for (let i = 0; i < 40; i++) { + frame = await sharedRedis().get(terminalKeyName); + if (frame) break; + await new Promise((r) => setTimeout(r, 100)); + } + + expect(frame).not.toBeNull(); + const parsed = JSON.parse(frame!); + + // Legacy semantics: completedFiles = total finished (successes + failures) + expect(parsed.totalFiles).toBe(3); + expect(parsed.completedFiles).toBe(3); + expect(parsed.failedFiles).toBe(1); + expect(parsed.status).toBe("completed"); + expect(parsed.type).toBe("batch"); + }); +}); diff --git a/tests/integration/cleanup.test.ts b/tests/integration/cleanup.test.ts index 60d019bc..4e2814ed 100644 --- a/tests/integration/cleanup.test.ts +++ b/tests/integration/cleanup.test.ts @@ -1,17 +1,8 @@ -import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { eq } from "drizzle-orm"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { env } from "../../apps/api/src/config.js"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { db, schema } from "../../apps/api/src/db/index.js"; import { runMigrations } from "../../apps/api/src/db/migrate.js"; -import { - getMaxAgeMs, - shouldRunStartupCleanup, - startCleanupCron, -} from "../../apps/api/src/lib/cleanup.js"; +import { getMaxAgeMs, shouldRunStartupCleanup } from "../../apps/api/src/lib/cleanup.js"; beforeAll(async () => { await runMigrations(); @@ -33,12 +24,6 @@ async function removeSetting(key: string) { await db.delete(schema.settings).where(eq(schema.settings.key, key)); } -async function waitForCleanup(): Promise { - for (let i = 0; i < 100; i++) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - afterEach(async () => { await removeSetting("tempFileMaxAgeHours"); await removeSetting("startupCleanup"); @@ -104,186 +89,3 @@ describe("shouldRunStartupCleanup", () => { expect(await shouldRunStartupCleanup()).toBe(true); }); }); - -describe("startCleanupCron", () => { - let tempDir: string; - let originalWorkspacePath: string; - - beforeEach(async () => { - tempDir = join(tmpdir(), `cleanup-test-${randomUUID().slice(0, 8)}`); - originalWorkspacePath = env.WORKSPACE_PATH; - env.WORKSPACE_PATH = tempDir; - await setSetting("startupCleanup", "false"); - }); - - afterEach(async () => { - env.WORKSPACE_PATH = originalWorkspacePath; - await removeSetting("startupCleanup"); - if (existsSync(tempDir)) { - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it("returns object with stop() method", async () => { - vi.useFakeTimers(); - const cron = await startCleanupCron(); - expect(typeof cron.stop).toBe("function"); - cron.stop(); - vi.useRealTimers(); - }); - - it("creates workspace directory", async () => { - vi.useFakeTimers(); - expect(existsSync(tempDir)).toBe(false); - const cron = await startCleanupCron(); - expect(existsSync(tempDir)).toBe(true); - cron.stop(); - vi.useRealTimers(); - }); - - it("stop() clears intervals", async () => { - vi.useFakeTimers(); - const clearSpy = vi.spyOn(globalThis, "clearInterval"); - const cron = await startCleanupCron(); - cron.stop(); - expect(clearSpy).toHaveBeenCalledTimes(2); - clearSpy.mockRestore(); - vi.useRealTimers(); - }); - - it("removes old files on startup cleanup", async () => { - mkdirSync(tempDir, { recursive: true }); - const oldFile = join(tempDir, "old-file.txt"); - writeFileSync(oldFile, "old content"); - const pastTime = new Date(Date.now() - 2 * 60 * 60 * 1000); - utimesSync(oldFile, pastTime, pastTime); - - await setSetting("startupCleanup", "true"); - const cron = await startCleanupCron(); - await waitForCleanup(); - expect(existsSync(oldFile)).toBe(false); - cron.stop(); - }); - - it("keeps recent files on startup cleanup", async () => { - mkdirSync(tempDir, { recursive: true }); - const recentFile = join(tempDir, "recent-file.txt"); - writeFileSync(recentFile, "recent content"); - - await setSetting("startupCleanup", "true"); - const cron = await startCleanupCron(); - await waitForCleanup(); - expect(existsSync(recentFile)).toBe(true); - cron.stop(); - }); - - it("removes old subdirectory when its mtime is expired", async () => { - mkdirSync(tempDir, { recursive: true }); - const oldDir = join(tempDir, "old-dir"); - mkdirSync(oldDir); - const nestedFile = join(oldDir, "nested.txt"); - writeFileSync(nestedFile, "nested"); - const pastTime = new Date(Date.now() - 2 * 60 * 60 * 1000); - utimesSync(nestedFile, pastTime, pastTime); - utimesSync(oldDir, pastTime, pastTime); - - await setSetting("startupCleanup", "true"); - const cron = await startCleanupCron(); - await waitForCleanup(); - expect(existsSync(oldDir)).toBe(false); - cron.stop(); - }); - - it("skips startup cleanup when startupCleanup is false", async () => { - mkdirSync(tempDir, { recursive: true }); - const oldFile = join(tempDir, "skip-old.txt"); - writeFileSync(oldFile, "old"); - const pastTime = new Date(Date.now() - 2 * 60 * 60 * 1000); - utimesSync(oldFile, pastTime, pastTime); - - await setSetting("startupCleanup", "false"); - const cron = await startCleanupCron(); - await waitForCleanup(); - expect(existsSync(oldFile)).toBe(true); - cron.stop(); - }); - - it("purges expired sessions on startup when enabled", async () => { - const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000); - - const userId = `test-user-${randomUUID().slice(0, 8)}`; - const [existing] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); - if (!existing) { - await db.insert(schema.users).values({ - id: userId, - username: `cleanup-test-${randomUUID().slice(0, 8)}`, - passwordHash: "hash", - role: "user", - team: "Default", - mustChangePassword: false, - }); - } - - const sessionId = `sess-${randomUUID().slice(0, 8)}`; - await db.insert(schema.sessions).values({ - id: sessionId, - userId, - expiresAt: pastDate, - }); - - await setSetting("startupCleanup", "true"); - const cron = await startCleanupCron(); - - const [session] = await db - .select() - .from(schema.sessions) - .where(eq(schema.sessions.id, sessionId)); - expect(session).toBeUndefined(); - cron.stop(); - - await db.delete(schema.users).where(eq(schema.users.id, userId)); - }); - - it("does not purge non-expired sessions", async () => { - const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); - - const userId = `test-user-${randomUUID().slice(0, 8)}`; - await db.insert(schema.users).values({ - id: userId, - username: `cleanup-keep-${randomUUID().slice(0, 8)}`, - passwordHash: "hash", - role: "user", - team: "Default", - mustChangePassword: false, - }); - - const sessionId = `sess-${randomUUID().slice(0, 8)}`; - await db.insert(schema.sessions).values({ - id: sessionId, - userId, - expiresAt: futureDate, - }); - - await setSetting("startupCleanup", "true"); - const cron = await startCleanupCron(); - - const [session] = await db - .select() - .from(schema.sessions) - .where(eq(schema.sessions.id, sessionId)); - expect(session).toBeDefined(); - cron.stop(); - - await db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)); - await db.delete(schema.users).where(eq(schema.users.id, userId)); - }); - - it("handles empty workspace directory gracefully", async () => { - mkdirSync(tempDir, { recursive: true }); - await setSetting("startupCleanup", "true"); - const cron = await startCleanupCron(); - await waitForCleanup(); - expect(existsSync(tempDir)).toBe(true); - cron.stop(); - }); -}); diff --git a/tests/integration/download-range.test.ts b/tests/integration/download-range.test.ts new file mode 100644 index 00000000..f766fcc8 --- /dev/null +++ b/tests/integration/download-range.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { deletePrefix, putObject } from "../../apps/api/src/lib/object-storage.js"; +import { buildTestApp, type TestApp } from "./test-server.js"; + +describe("download endpoint (object storage + Range)", () => { + let testApp: TestApp; + const jobId = `dltest-${process.pid}`; + + beforeAll(async () => { + testApp = await buildTestApp(); + await putObject(`outputs/${jobId}/result.txt`, Buffer.from("0123456789")); + }); + + afterAll(async () => { + await deletePrefix(`outputs/${jobId}/`); + await testApp.cleanup(); + }); + + it("serves full content with Accept-Ranges", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/result.txt`, + }); + expect(res.statusCode).toBe(200); + expect(res.headers["accept-ranges"]).toBe("bytes"); + expect(res.headers["content-length"]).toBe("10"); + expect(res.body).toBe("0123456789"); + }); + + it("serves a byte range as 206 with Content-Range", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/result.txt`, + headers: { range: "bytes=2-5" }, + }); + expect(res.statusCode).toBe(206); + expect(res.headers["content-range"]).toBe("bytes 2-5/10"); + expect(res.body).toBe("2345"); + }); + + it("rejects unsatisfiable ranges with 416", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/result.txt`, + headers: { range: "bytes=50-60" }, + }); + expect(res.statusCode).toBe(416); + }); + + it("404s for missing objects and rejects traversal", async () => { + expect( + (await testApp.app.inject({ method: "GET", url: `/api/v1/download/${jobId}/nope.txt` })) + .statusCode, + ).toBe(404); + expect( + (await testApp.app.inject({ method: "GET", url: `/api/v1/download/..%2F..%2Fetc/passwd` })) + .statusCode, + ).toBe(400); + }); + + // ── Range edge cases ─────────────────────────────────────────── + + it("bytes=0-0 returns single first byte", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/result.txt`, + headers: { range: "bytes=0-0" }, + }); + expect(res.statusCode).toBe(206); + expect(res.headers["content-range"]).toBe("bytes 0-0/10"); + expect(res.headers["content-length"]).toBe("1"); + expect(res.body).toBe("0"); + }); + + it("bytes=9-9 returns single last byte", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/result.txt`, + headers: { range: "bytes=9-9" }, + }); + expect(res.statusCode).toBe(206); + expect(res.headers["content-range"]).toBe("bytes 9-9/10"); + expect(res.headers["content-length"]).toBe("1"); + expect(res.body).toBe("9"); + }); + + it("bytes=9-50 clamps end to file size", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/result.txt`, + headers: { range: "bytes=9-50" }, + }); + expect(res.statusCode).toBe(206); + expect(res.headers["content-range"]).toBe("bytes 9-9/10"); + expect(res.headers["content-length"]).toBe("1"); + expect(res.body).toBe("9"); + }); + + it("bytes=10- returns 416 when start equals size", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/result.txt`, + headers: { range: "bytes=10-" }, + }); + expect(res.statusCode).toBe(416); + expect(res.headers["content-range"]).toBe("bytes */10"); + expect(JSON.parse(res.body).error).toBe("Range not satisfiable"); + }); + + it("open-ended bytes=5- returns remaining bytes", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/result.txt`, + headers: { range: "bytes=5-" }, + }); + expect(res.statusCode).toBe(206); + expect(res.headers["content-range"]).toBe("bytes 5-9/10"); + expect(res.headers["content-length"]).toBe("5"); + expect(res.body).toBe("56789"); + }); + + it("multi-range bytes=0-1,3-4 returns 416", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/result.txt`, + headers: { range: "bytes=0-1,3-4" }, + }); + expect(res.statusCode).toBe(416); + expect(res.headers["content-range"]).toBe("bytes */10"); + }); + + it("416 omits Content-Disposition and returns JSON error", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/result.txt`, + headers: { range: "bytes=50-60" }, + }); + expect(res.statusCode).toBe(416); + expect(res.headers["content-disposition"]).toBeUndefined(); + expect(JSON.parse(res.body)).toEqual({ error: "Range not satisfiable" }); + }); +}); diff --git a/tests/integration/feature-import.test.ts b/tests/integration/feature-import.test.ts new file mode 100644 index 00000000..2dd5e606 --- /dev/null +++ b/tests/integration/feature-import.test.ts @@ -0,0 +1,420 @@ +/** + * Integration tests for offline AI bundle import. + * + * Sets DATA_DIR to a temp directory BEFORE importing feature-status (which + * reads it at module load time), then exercises the importBundleArchive + * helper and the POST /api/v1/admin/features/import endpoint. + */ +import { randomUUID } from "node:crypto"; +import { + createReadStream, + existsSync, + mkdirSync, + readFileSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +// tar lives in apps/api/node_modules; resolve via createRequire (same +// pattern as tests/global-setup.ts for pg/drizzle). +const apiRequire = createRequire(join(process.cwd(), "apps/api/package.json")); +const tar = apiRequire("tar") as typeof import("tar"); + +// ── Temp environment for isolated DATA_DIR ─────────────────────── +const testRoot = join(tmpdir(), `snapotter-import-test-${randomUUID()}`); +const aiDir = join(testRoot, "ai"); +const modelsDir = join(aiDir, "models"); +const installedPath = join(aiDir, "installed.json"); + +// Must be set BEFORE any feature-status import +process.env.DATA_DIR = testRoot; +// Point at the real manifest so bundleId validation passes +process.env.FEATURE_MANIFEST_PATH = join(process.cwd(), "docker/feature-manifest.json"); + +mkdirSync(modelsDir, { recursive: true }); +writeFileSync(installedPath, JSON.stringify({ bundles: {} }), "utf-8"); + +// ── Dynamic imports (after env is set) ─────────────────────────── + +const { + importBundleArchive, + acquireInstallLock, + releaseInstallLock, + invalidateCache, + ImportLockError, + ImportValidationError, +} = await import("../../apps/api/src/lib/feature-status.js"); + +// Test helpers from test-server +const { createMultipartPayload, loginAsAdmin } = await import("./test-server.js"); + +// Use face-detection as smallest bundle from the manifest +const testBundleId = "face-detection"; +const testVersion = "1.0.0-test"; + +// ── Helpers ────────────────────────────────────────────────────── + +async function buildArchive( + bundleJson: Record, + modelFiles: Array<{ path: string; content: Buffer }>, +): Promise { + const stagingDir = join(tmpdir(), `snapotter-archive-build-${randomUUID()}`); + mkdirSync(stagingDir, { recursive: true }); + + writeFileSync(join(stagingDir, "bundle.json"), JSON.stringify(bundleJson), "utf-8"); + + const entries = ["bundle.json"]; + for (const mf of modelFiles) { + const dest = join(stagingDir, "models", mf.path); + mkdirSync(join(dest, ".."), { recursive: true }); + writeFileSync(dest, mf.content); + entries.push(`models/${mf.path}`); + } + + const archivePath = join(tmpdir(), `test-bundle-${randomUUID()}.tar.gz`); + + await tar.create({ gzip: true, file: archivePath, cwd: stagingDir }, entries); + + return archivePath; +} + +/** + * Build a traversal archive by creating a file at `../evil/pwned.txt` path. + * We achieve this by creating the file inside a directory that we then + * reference with prefix to get `..` in the path. + */ +async function buildTraversalArchive(): Promise { + const stagingDir = join(tmpdir(), `snapotter-traversal-build-${randomUUID()}`); + // Create the directory structure: evil/pwned.txt + // Then tar with prefix "../" so entries become ../evil/pwned.txt + mkdirSync(join(stagingDir, "evil"), { recursive: true }); + writeFileSync(join(stagingDir, "evil", "pwned.txt"), "hacked"); + + const archivePath = join(tmpdir(), `test-traversal-${randomUUID()}.tar.gz`); + + // Use preservePaths to allow ".." in generated paths + await tar.create( + { + gzip: true, + file: archivePath, + cwd: stagingDir, + prefix: "..", + preservePaths: true, + }, + ["evil/pwned.txt"], + ); + + return archivePath; +} + +/** + * Build an archive containing a SymbolicLink entry under models/. + * tar.create preserves symlinks as SymbolicLink entries by default + * (follow defaults to false). + */ +async function buildSymlinkArchive(): Promise { + const stagingDir = join(tmpdir(), `snapotter-symlink-build-${randomUUID()}`); + mkdirSync(join(stagingDir, "models"), { recursive: true }); + + // Valid bundle.json so extraction reaches the filter + writeFileSync( + join(stagingDir, "bundle.json"), + JSON.stringify({ + bundleId: testBundleId, + version: testVersion, + models: ["evil"], + }), + "utf-8", + ); + + // Create a symlink: models/evil -> ../bundle.json + symlinkSync("../bundle.json", join(stagingDir, "models", "evil")); + + const archivePath = join(tmpdir(), `test-symlink-${randomUUID()}.tar.gz`); + await tar.create({ gzip: true, file: archivePath, cwd: stagingDir }, [ + "bundle.json", + "models/evil", + ]); + + return archivePath; +} + +function resetState(): void { + writeFileSync(installedPath, JSON.stringify({ bundles: {} }), "utf-8"); + invalidateCache(); + try { + releaseInstallLock(); + } catch { + // no lock to release + } +} + +// ── Tests ──────────────────────────────────────────────────────── + +describe("importBundleArchive", () => { + beforeEach(resetState); + + it("imports a valid bundle archive", async () => { + const fakeModel = Buffer.alloc(64, 0xab); + const archivePath = await buildArchive( + { + bundleId: testBundleId, + version: testVersion, + models: ["fake.bin"], + }, + [{ path: "fake.bin", content: fakeModel }], + ); + + const stream = createReadStream(archivePath); + const result = await importBundleArchive(stream); + + expect(result.bundleId).toBe(testBundleId); + expect(result.version).toBe(testVersion); + expect(result.models).toEqual(["fake.bin"]); + + // Verify installed.json was updated + const installed = JSON.parse(readFileSync(installedPath, "utf-8")); + expect(installed.bundles[testBundleId]).toBeDefined(); + expect(installed.bundles[testBundleId].version).toBe(testVersion); + + // Verify the model file landed in MODELS_DIR + const modelPath = join(modelsDir, "fake.bin"); + expect(existsSync(modelPath)).toBe(true); + expect(readFileSync(modelPath)).toEqual(fakeModel); + }); + + it("rejects archive with path traversal", async () => { + const traversalPath = await buildTraversalArchive(); + const stream = createReadStream(traversalPath); + + await expect(importBundleArchive(stream)).rejects.toThrow(/unsafe archive entry|Blocked/); + + // Verify nothing was written outside the staging dir + const escapeDest = join(testRoot, "evil", "pwned.txt"); + expect(existsSync(escapeDest)).toBe(false); + }); + + it("rejects archive with unknown bundleId", async () => { + const archivePath = await buildArchive( + { bundleId: "nonexistent-bundle", version: "1.0.0", models: [] }, + [], + ); + + const stream = createReadStream(archivePath); + await expect(importBundleArchive(stream)).rejects.toThrow(/Unknown bundleId/); + }); + + it("rejects archive without bundle.json", async () => { + const stagingDir = join(tmpdir(), `snapotter-no-bundle-${randomUUID()}`); + mkdirSync(stagingDir, { recursive: true }); + writeFileSync(join(stagingDir, "random.txt"), "nothing useful"); + + const archivePath = join(tmpdir(), `no-bundle-${randomUUID()}.tar.gz`); + await tar.create({ gzip: true, file: archivePath, cwd: stagingDir }, ["random.txt"]); + + const stream = createReadStream(archivePath); + await expect(importBundleArchive(stream)).rejects.toThrow(/missing bundle\.json/); + }); + + it("throws ImportLockError when lock is held", async () => { + const locked = acquireInstallLock("some-bundle"); + expect(locked).toBe(true); + + try { + const archivePath = await buildArchive( + { bundleId: testBundleId, version: testVersion, models: [] }, + [], + ); + + const stream = createReadStream(archivePath); + await expect(importBundleArchive(stream)).rejects.toThrow(ImportLockError); + } finally { + releaseInstallLock(); + } + }); + + it("rejects archive containing a SymbolicLink entry", async () => { + const archivePath = await buildSymlinkArchive(); + const stream = createReadStream(archivePath); + + await expect(importBundleArchive(stream)).rejects.toThrow(/Unsupported entry type/); + + // Verify no symlink landed in MODELS_DIR + expect(existsSync(join(modelsDir, "evil"))).toBe(false); + }); + + it("rejects bundle.json with traversal in models array", async () => { + const archivePath = await buildArchive( + { + bundleId: testBundleId, + version: testVersion, + models: ["../escape.bin"], + }, + [{ path: "legit.bin", content: Buffer.alloc(16, 0xaa) }], + ); + + const stream = createReadStream(archivePath); + await expect(importBundleArchive(stream)).rejects.toThrow(ImportValidationError); + await expect(importBundleArchive(createReadStream(archivePath))).rejects.toThrow( + /invalid model path/, + ); + }); +}); + +describe("POST /api/v1/admin/features/import", () => { + let app: Awaited>["default"] extends ( + ...args: infer _A + ) => infer R + ? R + : never; + let token: string; + + beforeAll(async () => { + const Fastify = (await import("fastify")).default; + const multipartPlugin = (await import("@fastify/multipart")).default; + const cookie = (await import("@fastify/cookie")).default; + const cors = (await import("@fastify/cors")).default; + + app = Fastify({ logger: false, bodyLimit: 100 * 1024 * 1024 }); + + await app.register(cors, { origin: true }); + await app.register(multipartPlugin, { + limits: { fileSize: 100 * 1024 * 1024 }, + }); + await app.register(cookie, { secret: "test-cookie-secret", hook: "onRequest" }); + + const { authMiddleware, authRoutes, ensureBuiltinRoles, ensureDefaultAdmin } = await import( + "../../apps/api/src/plugins/auth.js" + ); + await authMiddleware(app); + await authRoutes(app); + await ensureBuiltinRoles(); + await ensureDefaultAdmin(); + + // Clear mustChangePassword for admin + const { db, schema } = await import("../../apps/api/src/db/index.js"); + const { eq } = await import("drizzle-orm"); + await db + .update(schema.users) + .set({ mustChangePassword: false }) + .where(eq(schema.users.username, "admin")); + + const { registerFeatureRoutes } = await import("../../apps/api/src/routes/features.js"); + await registerFeatureRoutes(app); + + token = await loginAsAdmin(app); + }); + + beforeEach(resetState); + + afterAll(async () => { + if (app) await app.close(); + }); + + it("imports a valid bundle via multipart POST", async () => { + const fakeModel = Buffer.alloc(128, 0xcd); + const archivePath = await buildArchive( + { + bundleId: testBundleId, + version: testVersion, + models: ["fake-endpoint.bin"], + }, + [{ path: "fake-endpoint.bin", content: fakeModel }], + ); + + const archiveBuffer = readFileSync(archivePath); + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "test-bundle.tar.gz", + contentType: "application/gzip", + content: archiveBuffer, + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/admin/features/import", + headers: { + "content-type": contentType, + authorization: `Bearer ${token}`, + }, + payload: body, + }); + + expect(res.statusCode).toBe(200); + const resBody = JSON.parse(res.body); + expect(resBody.bundleId).toBe(testBundleId); + expect(resBody.version).toBe(testVersion); + + // Verify installed.json + const installed = JSON.parse(readFileSync(installedPath, "utf-8")); + expect(installed.bundles[testBundleId]).toBeDefined(); + + // Verify model file + expect(existsSync(join(modelsDir, "fake-endpoint.bin"))).toBe(true); + }); + + it("returns 400 for invalid archive", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "bad.tar.gz", + contentType: "application/gzip", + content: Buffer.from("not a real tarball"), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/admin/features/import", + headers: { + "content-type": contentType, + authorization: `Bearer ${token}`, + }, + payload: body, + }); + + expect(res.statusCode).toBeGreaterThanOrEqual(400); + }); + + it("returns 409 when lock is held", async () => { + const locked = acquireInstallLock("blocking-bundle"); + expect(locked).toBe(true); + + try { + const archivePath = await buildArchive( + { bundleId: testBundleId, version: testVersion, models: [] }, + [], + ); + + const archiveBuffer = readFileSync(archivePath); + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "locked.tar.gz", + contentType: "application/gzip", + content: archiveBuffer, + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/admin/features/import", + headers: { + "content-type": contentType, + authorization: `Bearer ${token}`, + }, + payload: body, + }); + + expect(res.statusCode).toBe(409); + } finally { + releaseInstallLock(); + } + }); +}); diff --git a/tests/integration/job-spine.test.ts b/tests/integration/job-spine.test.ts new file mode 100644 index 00000000..4a217a63 --- /dev/null +++ b/tests/integration/job-spine.test.ts @@ -0,0 +1,176 @@ +/** + * Integration tests for the BullMQ job spine. + * + * Tests the full enqueue -> worker -> result cycle and cooperative + * cancellation. + */ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { requestCancel } from "../../apps/api/src/jobs/cancel.js"; +import { sharedRedis } from "../../apps/api/src/jobs/connection.js"; +import { enqueueToolJob, waitForJob } from "../../apps/api/src/jobs/enqueue.js"; +import { bullPrefix, type ToolJobData } from "../../apps/api/src/jobs/types.js"; +import { putObject } from "../../apps/api/src/lib/object-storage.js"; +import { + registerToolProcessFn, + type ToolProcessCtx, +} from "../../apps/api/src/routes/tool-factory.js"; +import { buildTestApp, type TestApp } from "./test-server.js"; + +// Register test-only tools for the spine tests +registerToolProcessFn({ + toolId: "spine-echo", + settingsSchema: { parse: (v: unknown) => v } as never, + process: async (inputBuffer: Buffer, _settings: unknown, filename: string) => { + return { + buffer: inputBuffer, + filename, + contentType: "image/png", + }; + }, +}); + +registerToolProcessFn({ + toolId: "spine-slow", + settingsSchema: { parse: (v: unknown) => v } as never, + process: async ( + inputBuffer: Buffer, + _settings: unknown, + filename: string, + ctx?: ToolProcessCtx, + ) => { + // Simulate slow work that respects cancellation + for (let i = 0; i < 50; i++) { + if (ctx?.signal?.aborted) { + throw new Error("Job was canceled"); + } + await new Promise((r) => setTimeout(r, 100)); + } + return { + buffer: inputBuffer, + filename, + contentType: "image/png", + }; + }, +}); + +let testApp: TestApp; + +// Workers + cancel listener are started by test-server.ts (ensureSpine). +beforeAll(async () => { + testApp = await buildTestApp(); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Job spine", () => { + it("enqueue -> worker -> result round-trip (spine-echo)", async () => { + const jobId = randomUUID(); + const inputBuffer = Buffer.from("test-image-data"); + + // Store input in object storage so the worker can retrieve it + const inputRef = `uploads/${jobId}/test.png`; + await putObject(inputRef, inputBuffer); + + const data: ToolJobData = { + jobId, + toolId: "spine-echo", + userId: null, + pool: "image", + inputRefs: [inputRef], + filename: "test.png", + settings: {}, + kind: "tool", + }; + + await enqueueToolJob(data); + + const result = await waitForJob("image", jobId, 10_000); + expect(result).not.toBeNull(); + // buildOutputName adds _spine-echo suffix since filename is unchanged + expect(result!.filename).toBe("test_spine-echo.png"); + expect(result!.outputRefs.length).toBeGreaterThan(0); + + // Verify durable DB row + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + expect(job).toBeDefined(); + expect(job!.status).toBe("completed"); + expect(job!.bytesIn).toBeGreaterThan(0); + expect(job!.bytesOut).toBeGreaterThan(0); + expect(job!.durationMs).toBeGreaterThanOrEqual(0); + expect(job!.startedAt).not.toBeNull(); + expect(job!.completedAt).not.toBeNull(); + expect(job!.outputRefs).toBeDefined(); + expect((job!.outputRefs as string[]).length).toBeGreaterThan(0); + expect(result!.outputRefs).toEqual(job!.outputRefs); + }); + + it("cancel-active aborts a running job (spine-slow)", async () => { + const jobId = randomUUID(); + const inputBuffer = Buffer.from("test-image-data"); + + // Store input in object storage + const inputRef = `uploads/${jobId}/slow.png`; + await putObject(inputRef, inputBuffer); + + const data: ToolJobData = { + jobId, + toolId: "spine-slow", + userId: null, + pool: "image", + inputRefs: [inputRef], + filename: "slow.png", + settings: {}, + kind: "tool", + }; + + await enqueueToolJob(data); + + // Wait for the worker to pick up the job and start processing. + // Poll until the DB row shows "processing" (the worker sets this + // before entering the process function). + let started = false; + for (let i = 0; i < 30; i++) { + const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row?.status === "processing") { + started = true; + break; + } + await new Promise((r) => setTimeout(r, 100)); + } + expect(started).toBe(true); + + // Request cancellation + const canceled = await requestCancel(jobId); + expect(canceled).toBe(true); + + // Wait for the worker to finish aborting: poll until the status + // moves to a terminal state. + let finalStatus = "processing"; + for (let i = 0; i < 30; i++) { + const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && row.status !== "processing" && row.status !== "queued") { + finalStatus = row.status; + break; + } + await new Promise((r) => setTimeout(r, 200)); + } + + expect(finalStatus).toBe("canceled"); + + // Verify that a terminal SSE frame is retrievable after cancel. + // publishEphemeral should have written the terminal replay key + // so reconnecting SSE clients get the frame immediately. + const terminalKeyName = `${bullPrefix()}:terminal:${jobId}`; + const cached = await sharedRedis().get(terminalKeyName); + expect(cached).not.toBeNull(); + const parsed = JSON.parse(cached!); + expect(parsed.phase).toBe("failed"); + expect(parsed.error).toBe("Canceled"); + expect(parsed.jobId).toBe(jobId); + }); +}); diff --git a/tests/integration/jobs-schema.test.ts b/tests/integration/jobs-schema.test.ts new file mode 100644 index 00000000..5099c5ae --- /dev/null +++ b/tests/integration/jobs-schema.test.ts @@ -0,0 +1,34 @@ +import { sql } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { db } from "../../apps/api/src/db/index.js"; + +describe("jobs table (phase 2 spine)", () => { + it("has the spine columns and canceled status", async () => { + const cols = await db.execute( + sql`SELECT column_name FROM information_schema.columns WHERE table_name = 'jobs'`, + ); + const names = cols.rows.map((r) => r.column_name); + for (const expected of [ + "user_id", + "tool_id", + "pool", + "attempts", + "input_refs", + "output_refs", + "bytes_in", + "bytes_out", + "duration_ms", + "started_at", + "progress", + "error", + ]) { + expect(names).toContain(expected); + } + const enumRows = await db.execute(sql`SELECT unnest(enum_range(NULL::job_status))::text AS v`); + expect(enumRows.rows.map((r) => r.v)).toContain("canceled"); + await db.execute( + sql`INSERT INTO jobs (id, type, status, progress, input_refs, created_at) VALUES ('schema-test', 'single', 'canceled', '{"percent":50}', '["uploads/x"]', now())`, + ); + await db.execute(sql`DELETE FROM jobs WHERE id = 'schema-test'`); + }); +}); diff --git a/tests/integration/migrate-from-sqlite.test.ts b/tests/integration/migrate-from-sqlite.test.ts index 71c5f8d6..f317f6d3 100644 --- a/tests/integration/migrate-from-sqlite.test.ts +++ b/tests/integration/migrate-from-sqlite.test.ts @@ -61,6 +61,9 @@ function buildFixtureSqlite(path: string): void { s.prepare( "INSERT INTO jobs (id, type, status, progress, input_files, created_at, completed_at) VALUES (?,?,?,?,?,?,?)", ).run("j1", "batch", "completed", 1, '["a.png"]', now, null); + s.prepare( + "INSERT INTO jobs (id, type, status, progress, input_files, output_path, error, created_at) VALUES (?,?,?,?,?,?,?,?)", + ).run("j2", "single", "failed", 0.5, "[]", "/out/result.png", "Something broke", now); s.prepare( "INSERT INTO audit_log (id, actor_username, action, details, created_at) VALUES (?,?,?,?,?)", ).run("al1", "alice", "login", null, now); @@ -105,6 +108,18 @@ describe("migrate-from-sqlite", () => { expect(setting.value).toBe("not-json-value"); // settings.value stayed text, untouched const [job] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j1'`)).rows; expect(job.completed_at).toBeNull(); // explicit NULL preserved + // 1.x progress (real 1.0) became jsonb {percent: 100} + expect(job.progress).toEqual({ percent: 100 }); + // 1.x input_files became input_refs (empty array, dead paths discarded) + expect(job.input_refs).toEqual([]); + // 1.x error NULL preserved as null + expect(job.error).toBeNull(); + // Verify j2: error text became jsonb, progress 0.5 became {percent: 50}, output_path became output_refs + const [job2] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j2'`)).rows; + expect(job2.progress).toEqual({ percent: 50 }); + expect(job2.error).toEqual({ message: "Something broke" }); + expect(job2.input_refs).toEqual([]); + expect(job2.output_refs).toEqual([]); // audit_log with NULL details const alRows = (await db.execute(sql`SELECT * FROM audit_log WHERE id = 'al1'`)).rows; expect(alRows).toHaveLength(1); diff --git a/tests/integration/observability.test.ts b/tests/integration/observability.test.ts new file mode 100644 index 00000000..ed1ffa53 --- /dev/null +++ b/tests/integration/observability.test.ts @@ -0,0 +1,119 @@ +/** + * Integration tests for observability endpoints: + * - GET /api/v1/metrics (Prometheus scrape) + * - GET/POST /api/v1/admin/log-level (runtime log level) + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +// ── Metrics endpoint ────────────────────────────────────────── + +describe("GET /api/v1/metrics", () => { + it("rejects unauthenticated requests", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/metrics", + }); + expect([401, 403]).toContain(res.statusCode); + }); + + it("returns 200 with Prometheus text for admin", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/metrics", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toContain("text/plain"); + + const body = res.body; + + // Must contain queue gauge lines for all five pools + const pools = ["image", "media", "ai", "docs", "system"]; + for (const pool of pools) { + expect(body).toContain(`snapotter_queue_jobs{pool="${pool}",state="active"}`); + expect(body).toContain(`snapotter_queue_jobs{pool="${pool}",state="waiting"}`); + } + + // Must contain at least one default process metric + expect(body).toMatch(/process_/); + }); +}); + +// ── Log level endpoint ──────────────────────────────────────── + +describe("GET/POST /api/v1/admin/log-level", () => { + it("rejects unauthenticated requests", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/admin/log-level", + }); + expect([401, 403]).toContain(res.statusCode); + }); + + it("POST changes level and GET reflects it", async () => { + try { + // POST a new level + const postRes = await app.inject({ + method: "POST", + url: "/api/v1/admin/log-level", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { level: "debug" }, + }); + expect(postRes.statusCode).toBe(200); + const postBody = JSON.parse(postRes.body); + expect(postBody.level).toBe("debug"); + + // GET should reflect the new level + const getRes = await app.inject({ + method: "GET", + url: "/api/v1/admin/log-level", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(getRes.statusCode).toBe(200); + const getBody = JSON.parse(getRes.body); + expect(getBody.level).toBe("debug"); + } finally { + // Always restore "info" so other tests are not affected + await app.inject({ + method: "POST", + url: "/api/v1/admin/log-level", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { level: "info" }, + }); + } + }); + + it("returns 400 for invalid level", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/admin/log-level", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { level: "banana" }, + }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/progress-transport.test.ts b/tests/integration/progress-transport.test.ts new file mode 100644 index 00000000..fb50bafd --- /dev/null +++ b/tests/integration/progress-transport.test.ts @@ -0,0 +1,195 @@ +/** + * Integration test for the Redis-based progress transport. + * + * Verifies: + * 1. Terminal-key replay: updateSingleFileProgress publishes to Redis + * and stores a terminal key; SSE replay reads it back. + * 2. Durable DB persistence: the jobs row is written with the correct + * status mapping. + * + * This test runs standalone against the dev Redis (redis://localhost:6379) + * and the per-fork Postgres database. Per-fork Redis isolation arrives in + * Task 7; until then, run this file ALONE. + */ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { updateSingleFileProgress } from "../../apps/api/src/routes/progress.js"; +import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Redis progress transport", () => { + it("replays a terminal single-file event from the Redis terminal key", async () => { + const jobId = `tp-${randomUUID()}`; + + // Publish a terminal event + updateSingleFileProgress({ + jobId, + phase: "complete", + percent: 100, + result: { downloadUrl: "/x" }, + }); + + // Wait for pub/sub + setex round trip + await new Promise((r) => setTimeout(r, 500)); + + // Hit the SSE endpoint -- it should replay the cached terminal frame + const res = await app.inject({ + method: "GET", + url: `/api/v1/jobs/${jobId}/progress`, + headers: { + authorization: `Bearer ${adminToken}`, + }, + }); + + expect(res.statusCode).toBe(200); + const body = res.body; + + // Should contain an SSE data frame + expect(body).toContain("data: "); + + // Parse the SSE data + const dataMatch = body.match(/data: (.+)/); + expect(dataMatch).not.toBeNull(); + const event = JSON.parse(dataMatch![1]); + expect(event.type).toBe("single"); + expect(event.phase).toBe("complete"); + expect(event.result?.downloadUrl).toBe("/x"); + + // Verify durable DB row was written + // Poll briefly since persist is async + let job: typeof schema.jobs.$inferSelect | undefined; + const start = Date.now(); + while (Date.now() - start < 2000) { + const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && row.status === "completed") { + job = row; + break; + } + await new Promise((r) => setTimeout(r, 50)); + } + + expect(job).toBeDefined(); + expect(job!.status).toBe("completed"); + expect(job!.type).toBe("single"); + + // Clean up + await db.delete(schema.jobs).where(eq(schema.jobs.id, jobId)); + }); + + it("replays a terminal batch event from the Redis terminal key", async () => { + const jobId = `tp-batch-${randomUUID()}`; + + // Use the updateJobProgress export (imported indirectly via the module) + const { updateJobProgress } = await import("../../apps/api/src/routes/progress.js"); + updateJobProgress({ + jobId, + status: "completed", + totalFiles: 2, + completedFiles: 2, + failedFiles: 0, + errors: [], + }); + + await new Promise((r) => setTimeout(r, 500)); + + const res = await app.inject({ + method: "GET", + url: `/api/v1/jobs/${jobId}/progress`, + headers: { + authorization: `Bearer ${adminToken}`, + }, + }); + + expect(res.statusCode).toBe(200); + const dataMatch = res.body.match(/data: (.+)/); + expect(dataMatch).not.toBeNull(); + const event = JSON.parse(dataMatch![1]); + expect(event.type).toBe("batch"); + expect(event.status).toBe("completed"); + + // Clean up + await db.delete(schema.jobs).where(eq(schema.jobs.id, jobId)); + }); + + it("synthesizes a legacy event from the DB when terminal key has expired", async () => { + const jobId = `tp-db-${randomUUID()}`; + + // Insert a completed row directly (simulating expired terminal key) + await db.insert(schema.jobs).values({ + id: jobId, + type: "single", + status: "completed", + progress: { percent: 100 }, + inputRefs: [], + completedAt: new Date(), + }); + + const res = await app.inject({ + method: "GET", + url: `/api/v1/jobs/${jobId}/progress`, + headers: { + authorization: `Bearer ${adminToken}`, + }, + }); + + expect(res.statusCode).toBe(200); + const dataMatch = res.body.match(/data: (.+)/); + expect(dataMatch).not.toBeNull(); + const event = JSON.parse(dataMatch![1]); + expect(event.type).toBe("single"); + expect(event.phase).toBe("complete"); + expect(event.percent).toBe(100); + + // Clean up + await db.delete(schema.jobs).where(eq(schema.jobs.id, jobId)); + }); +}); + +// ── Cancel route auth ────────────────────────────────────────── + +describe("Cancel route auth", () => { + it("rejects unauthenticated cancel with 401", async () => { + const jobId = randomUUID(); + + const res = await app.inject({ + method: "POST", + url: `/api/v1/jobs/${jobId}/cancel`, + // No authorization header + }); + + expect(res.statusCode).toBe(401); + const body = JSON.parse(res.body); + expect(body.error).toContain("Authentication required"); + }); + + it("returns canceled:false for an unknown job when authenticated", async () => { + const jobId = randomUUID(); + + const res = await app.inject({ + method: "POST", + url: `/api/v1/jobs/${jobId}/cancel`, + headers: { + authorization: `Bearer ${adminToken}`, + }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.canceled).toBe(false); + }); +}); diff --git a/tests/integration/progress.test.ts b/tests/integration/progress.test.ts index b70229df..b6da47c0 100644 --- a/tests/integration/progress.test.ts +++ b/tests/integration/progress.test.ts @@ -20,12 +20,7 @@ import { join } from "node:path"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { db, schema } from "../../apps/api/src/db/index.js"; -import { - drainPersistQueue, - recoverStaleJobs, - updateJobProgress, - updateSingleFileProgress, -} from "../../apps/api/src/routes/progress.js"; +import { updateJobProgress, updateSingleFileProgress } from "../../apps/api/src/routes/progress.js"; import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; const FIXTURES = join(__dirname, "..", "fixtures"); @@ -61,8 +56,6 @@ const flushPersist = async ( await new Promise((r) => setTimeout(r, 200)); return; } - // Drain any pending persist writes before polling the DB - await drainPersistQueue(jobId); const start = Date.now(); while (Date.now() - start < maxMs) { const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); @@ -146,7 +139,7 @@ describe("Batch progress tracking", () => { expect(job).toBeDefined(); expect(job?.status).toBe("completed"); - expect(job?.progress).toBe(1); // 100% complete + expect((job?.progress as { percent: number })?.percent).toBe(100); expect(job?.completedAt).not.toBeNull(); }); @@ -221,7 +214,8 @@ describe("Batch progress tracking", () => { expect(job?.status).toBe("completed"); // Should have error info for the failed file if (job?.error) { - const errors = JSON.parse(job?.error); + const errorObj = job.error as { message: string }; + const errors = errorObj.details; expect(errors.length).toBeGreaterThanOrEqual(1); } }); @@ -320,9 +314,11 @@ describe("Job DB record structure", () => { expect(job).toBeDefined(); expect(job?.id).toBe(clientJobId); expect(job?.type).toBe("batch"); - expect(typeof job?.progress).toBe("number"); - expect(job?.progress).toBeGreaterThanOrEqual(0); - expect(job?.progress).toBeLessThanOrEqual(1); + const progressObj = job?.progress as { percent: number } | null; + expect(progressObj).not.toBeNull(); + expect(typeof progressObj?.percent).toBe("number"); + expect(progressObj?.percent).toBeGreaterThanOrEqual(0); + expect(progressObj?.percent).toBeLessThanOrEqual(100); }); }); @@ -331,7 +327,7 @@ describe("SSE progress endpoint", () => { it("returns SSE headers when connecting to progress stream", async () => { const jobId = randomUUID(); - // Pre-populate a completed job so the SSE endpoint sends it immediately and closes + // Publish a completed event (stored in Redis terminal key) updateJobProgress({ jobId, status: "completed", @@ -340,7 +336,8 @@ describe("SSE progress endpoint", () => { failedFiles: 0, errors: [], }); - await flushPersist(jobId); + // Wait for Redis pub/sub + setex round trip + await new Promise((r) => setTimeout(r, 500)); const res = await app.inject({ method: "GET", @@ -376,6 +373,8 @@ describe("SSE progress endpoint", () => { failedFiles: 1, errors: [{ filename: "bad.png", error: "Invalid image" }], }); + // Wait for Redis pub/sub + setex round trip + await new Promise((r) => setTimeout(r, 500)); const res = await app.inject({ method: "GET", @@ -414,7 +413,7 @@ describe("updateJobProgress direct calls", () => { const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("processing"); - expect(job?.progress).toBeCloseTo(0.4, 1); // 2/5 + expect((job?.progress as { percent: number })?.percent).toBe(40); // 2/5 expect(job?.type).toBe("batch"); }); @@ -446,7 +445,7 @@ describe("updateJobProgress direct calls", () => { const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("completed"); - expect(job?.progress).toBe(1); + expect((job?.progress as { percent: number })?.percent).toBe(100); expect(job?.completedAt).not.toBeNull(); }); @@ -470,7 +469,8 @@ describe("updateJobProgress direct calls", () => { expect(job).toBeDefined(); expect(job?.status).toBe("failed"); expect(job?.error).not.toBeNull(); - const errors = JSON.parse(job?.error ?? "[]"); + const errorObj = job?.error as { message: string }; + const errors = errorObj.details; expect(errors).toHaveLength(2); }); @@ -489,7 +489,7 @@ describe("updateJobProgress direct calls", () => { const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); - expect(job?.progress).toBe(0); + expect((job?.progress as { percent: number })?.percent).toBe(0); }); }); @@ -509,7 +509,9 @@ describe("updateSingleFileProgress direct calls", () => { const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("processing"); - expect(job?.progress).toBeCloseTo(0.5, 1); + const p = job?.progress as { percent: number; stage?: string }; + expect(p?.percent).toBe(50); + expect(p?.stage).toBe("encoding"); expect(job?.type).toBe("single"); }); @@ -526,7 +528,7 @@ describe("updateSingleFileProgress direct calls", () => { const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("completed"); - expect(job?.progress).toBe(1); + expect((job?.progress as { percent: number })?.percent).toBe(100); // completedAt is only set on UPDATE path (not INSERT for new jobs) expect(job?.type).toBe("single"); }); @@ -545,7 +547,7 @@ describe("updateSingleFileProgress direct calls", () => { const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("failed"); - expect(job?.error).toBe("Processing timeout"); + expect((job?.error as { message: string })?.message).toBe("Processing timeout"); expect(job?.type).toBe("single"); }); @@ -598,7 +600,7 @@ describe("updateSingleFileProgress direct calls", () => { expect(job).toBeDefined(); expect(job?.status).toBe("failed"); expect(job?.completedAt).not.toBeNull(); - expect(job?.error).toBe("Timeout error"); + expect((job?.error as { message: string })?.message).toBe("Timeout error"); }); it("updates existing single-file job progress", async () => { @@ -620,92 +622,30 @@ describe("updateSingleFileProgress direct calls", () => { percent: 75, stage: "encoding", }); - await flushPersist(jobId, ["processing"]); - const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); - expect(job).toBeDefined(); - expect(job?.progress).toBeCloseTo(0.75, 1); + // Poll for the expected percent value (both updates produce "processing" + // status, so status-based polling is insufficient) + const start = Date.now(); + let finalPercent = 0; + while (Date.now() - start < 2000) { + const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row) { + finalPercent = (row.progress as { percent: number })?.percent ?? 0; + if (finalPercent === 75) break; + } + await new Promise((r) => setTimeout(r, 50)); + } + + expect(finalPercent).toBe(75); }); }); -// ── recoverStaleJobs ─────────────────────────────────────────── -describe("recoverStaleJobs", () => { - it("marks processing jobs as failed on recovery", async () => { - const jobId = randomUUID(); - - // Insert a processing job directly - await db.insert(schema.jobs).values({ - id: jobId, - type: "batch", - status: "processing", - progress: 0.5, - inputFiles: "[]", - }); - - await recoverStaleJobs(); - - const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); - expect(job).toBeDefined(); - expect(job?.status).toBe("failed"); - expect(job?.error).toContain("Server restarted"); - expect(job?.completedAt).not.toBeNull(); - }); - - it("marks queued jobs as failed on recovery", async () => { - const jobId = randomUUID(); - - await db.insert(schema.jobs).values({ - id: jobId, - type: "batch", - status: "queued", - progress: 0, - inputFiles: "[]", - }); - - await recoverStaleJobs(); - - const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); - expect(job).toBeDefined(); - expect(job?.status).toBe("failed"); - expect(job?.error).toContain("Server restarted"); - }); - - it("does not modify completed jobs", async () => { - const jobId = randomUUID(); - - await db.insert(schema.jobs).values({ - id: jobId, - type: "batch", - status: "completed", - progress: 1, - inputFiles: "[]", - completedAt: new Date(), - }); - - await recoverStaleJobs(); - - const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); - expect(job).toBeDefined(); - expect(job?.status).toBe("completed"); - }); - - it("does not modify already-failed jobs", async () => { - const jobId = randomUUID(); - - await db.insert(schema.jobs).values({ - id: jobId, - type: "batch", - status: "failed", - progress: 0, - inputFiles: "[]", - error: "Original error", - completedAt: new Date(), - }); - - await recoverStaleJobs(); - - const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); - expect(job).toBeDefined(); - expect(job?.error).toBe("Original error"); - }); -}); +// recoverStaleJobs was removed in the Redis transport migration. +// Stale-job recovery is now handled by BullMQ's built-in stalled-job +// mechanism. The four tests that exercised recoverStaleJobs were: +// - "marks processing jobs as failed on recovery" +// - "marks queued jobs as failed on recovery" +// - "does not modify completed jobs" +// - "does not modify already-failed jobs" +// All four tested a deleted internal; equivalent coverage is provided +// by BullMQ's stalled-job handler (Task 6 worker runtime). diff --git a/tests/integration/support-bundle.test.ts b/tests/integration/support-bundle.test.ts new file mode 100644 index 00000000..400e0a37 --- /dev/null +++ b/tests/integration/support-bundle.test.ts @@ -0,0 +1,105 @@ +/** + * Integration tests for GET /api/v1/admin/support-bundle. + * + * Verifies auth gating, zip structure, config redaction, and + * failed-jobs inclusion. + */ +import { randomUUID } from "node:crypto"; +import AdmZip from "adm-zip"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { env } from "../../apps/api/src/config.js"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); + + // Seed a failed job so failed-jobs.json is non-empty + await db.insert(schema.jobs).values({ + id: randomUUID(), + type: "tool", + toolId: "resize", + pool: "image", + status: "failed", + error: { message: "test failure" }, + durationMs: 123, + }); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("GET /api/v1/admin/support-bundle", () => { + it("rejects unauthenticated requests", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/admin/support-bundle", + }); + expect([401, 403]).toContain(res.statusCode); + }); + + it("returns 200 application/zip for admin", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/admin/support-bundle", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toContain("application/zip"); + expect(res.headers["content-disposition"]).toMatch(/attachment; filename=snapotter-support-/); + + // Unzip and verify contents + const zip = new AdmZip(Buffer.from(res.rawPayload)); + const entryNames = zip.getEntries().map((e) => e.entryName); + + // config.json must exist and parse + expect(entryNames).toContain("config.json"); + const configBuf = zip.getEntry("config.json")!.getData(); + const config = JSON.parse(configBuf.toString("utf-8")); + + // DATABASE_URL must be redacted (userinfo masked) + expect(config.DATABASE_URL).toMatch(/:\/\/\*\*\*@/); + + // The raw userinfo (user:pass@) must not appear in any config value + const rawDbUrl = env.DATABASE_URL; + const userinfoMatch = rawDbUrl.match(/:\/\/([^@]+)@/); + if (userinfoMatch) { + const rawUserinfo = userinfoMatch[1]; // e.g. "user:pass" + for (const val of Object.values(config)) { + if (typeof val === "string") { + expect(val).not.toContain(`://${rawUserinfo}@`); + } + } + } + + // DEFAULT_PASSWORD must be redacted + expect(config.DEFAULT_PASSWORD).toBe(""); + + // failed-jobs.json must exist and be a non-empty array + expect(entryNames).toContain("failed-jobs.json"); + const failedBuf = zip.getEntry("failed-jobs.json")!.getData(); + const failedJobs = JSON.parse(failedBuf.toString("utf-8")); + expect(Array.isArray(failedJobs)).toBe(true); + expect(failedJobs.length).toBeGreaterThan(0); + expect(failedJobs[0]).toMatchObject({ toolId: "resize", pool: "image" }); + + // db-counts.json must exist and parse + expect(entryNames).toContain("db-counts.json"); + const countsBuf = zip.getEntry("db-counts.json")!.getData(); + const dbCounts = JSON.parse(countsBuf.toString("utf-8")); + expect(Array.isArray(dbCounts)).toBe(true); + + // host.json must exist and parse + expect(entryNames).toContain("host.json"); + const hostBuf = zip.getEntry("host.json")!.getData(); + const host = JSON.parse(hostBuf.toString("utf-8")); + expect(host.platform).toBeDefined(); + }); +}); diff --git a/tests/integration/system-jobs.test.ts b/tests/integration/system-jobs.test.ts new file mode 100644 index 00000000..58a23747 --- /dev/null +++ b/tests/integration/system-jobs.test.ts @@ -0,0 +1,307 @@ +/** + * Integration tests for the system job dispatcher and schedulers. + */ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, utimesSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import type { Job } from "bullmq"; +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { env } from "../../apps/api/src/config.js"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { runMigrations } from "../../apps/api/src/db/migrate.js"; +import { closeQueues, getQueue } from "../../apps/api/src/jobs/queues.js"; +import { + decideExpiry, + runSystemJob, + SYSTEM_JOBS, + scheduleSystemJobs, +} from "../../apps/api/src/jobs/system-jobs.js"; +import type { ObjectInfo } from "../../apps/api/src/lib/object-storage.js"; +import * as objectStorage from "../../apps/api/src/lib/object-storage.js"; + +beforeAll(async () => { + await runMigrations(); +}); + +afterAll(async () => { + const q = getQueue("system"); + for (const name of Object.values(SYSTEM_JOBS)) { + await q.removeJobScheduler(name).catch(() => {}); + } + await closeQueues(); +}); + +// -- decideExpiry (pure function) --------------------------------------------- + +describe("decideExpiry", () => { + const cutoff = Date.now() - 3600_000; + + it("expires local dir whose mtimeMs is older than cutoff", () => { + const dir: ObjectInfo = { key: "uploads/job-old", size: 0, mtimeMs: cutoff - 1000 }; + expect(decideExpiry(dir, cutoff, new Map())).toBe("expired"); + }); + + it("keeps local dir whose mtimeMs is newer than cutoff", () => { + const dir: ObjectInfo = { key: "uploads/job-new", size: 0, mtimeMs: cutoff + 1000 }; + expect(decideExpiry(dir, cutoff, new Map())).toBe("keep"); + }); + + it("expires S3 dir (mtimeMs=0) with old completed row", () => { + const dir: ObjectInfo = { key: "outputs/job-s3", size: 0, mtimeMs: 0 }; + const rows = new Map([ + ["job-s3", { createdAt: new Date(cutoff - 2000), completedAt: new Date(cutoff - 1000) }], + ]); + expect(decideExpiry(dir, cutoff, rows)).toBe("expired"); + }); + + it("keeps S3 dir (mtimeMs=0) with recent row", () => { + const dir: ObjectInfo = { key: "outputs/job-s3-new", size: 0, mtimeMs: 0 }; + const rows = new Map([ + ["job-s3-new", { createdAt: new Date(cutoff + 1000), completedAt: null }], + ]); + expect(decideExpiry(dir, cutoff, rows)).toBe("keep"); + }); + + it("skips S3 dir (mtimeMs=0) without a jobs row", () => { + const dir: ObjectInfo = { key: "uploads/orphan-x", size: 0, mtimeMs: 0 }; + expect(decideExpiry(dir, cutoff, new Map())).toBe("skip"); + }); + + it("uses completedAt over createdAt when both are present", () => { + const dir: ObjectInfo = { key: "outputs/job-both", size: 0, mtimeMs: 0 }; + // createdAt is old but completedAt is recent: dir should be kept + const rows = new Map([ + ["job-both", { createdAt: new Date(cutoff - 5000), completedAt: new Date(cutoff + 1000) }], + ]); + expect(decideExpiry(dir, cutoff, rows)).toBe("keep"); + }); +}); + +// -- runSystemJob ------------------------------------------------------------- + +describe("runSystemJob", () => { + const testUserId = `sys-test-${randomUUID().slice(0, 8)}`; + + beforeAll(async () => { + await db.insert(schema.users).values({ + id: testUserId, + username: `systest-${randomUUID().slice(0, 8)}`, + passwordHash: "hash", + role: "user", + team: "Default", + mustChangePassword: false, + }); + }); + + afterAll(async () => { + await db + .delete(schema.sessions) + .where(eq(schema.sessions.userId, testUserId)) + .catch(() => {}); + await db + .delete(schema.users) + .where(eq(schema.users.id, testUserId)) + .catch(() => {}); + }); + + it("storageTtl removes stale local dirs and keeps fresh ones", async () => { + const staleJobId = `oldjob-${randomUUID().slice(0, 8)}`; + const freshJobId = `newjob-${randomUUID().slice(0, 8)}`; + + const staleDir = join(env.WORKSPACE_PATH, "uploads", staleJobId); + const freshDir = join(env.WORKSPACE_PATH, "uploads", freshJobId); + + mkdirSync(staleDir, { recursive: true }); + writeFileSync(join(staleDir, "f.txt"), "stale"); + // Write file first, THEN backdate the directory mtime + const past = new Date(Date.now() - 2 * 60 * 60 * 1000); + utimesSync(join(staleDir, "f.txt"), past, past); + utimesSync(staleDir, past, past); + + mkdirSync(freshDir, { recursive: true }); + writeFileSync(join(freshDir, "g.txt"), "fresh"); + + const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as unknown as Job); + expect((result as { removed: number }).removed).toBeGreaterThanOrEqual(1); + expect(existsSync(staleDir)).toBe(false); + expect(existsSync(freshDir)).toBe(true); + + await rm(freshDir, { recursive: true, force: true }).catch(() => {}); + }); + + it("sessionPurge deletes expired sessions", async () => { + const sessionId = `sess-${randomUUID().slice(0, 8)}`; + await db.insert(schema.sessions).values({ + id: sessionId, + userId: testUserId, + expiresAt: new Date(Date.now() - 86_400_000), + }); + + await runSystemJob({ name: SYSTEM_JOBS.sessionPurge } as unknown as Job); + + const [row] = await db.select().from(schema.sessions).where(eq(schema.sessions.id, sessionId)); + expect(row).toBeUndefined(); + }); + + it("retention removes old completed jobs and old audit rows", async () => { + const oldJobId = `old-j-${randomUUID().slice(0, 8)}`; + const freshJobId = `new-j-${randomUUID().slice(0, 8)}`; + const oldAuditId = `old-a-${randomUUID().slice(0, 8)}`; + + await db.execute( + sql`INSERT INTO jobs (id, type, status, created_at) VALUES (${oldJobId}, 'tool', 'completed', now() - interval '90 days')`, + ); + + await db.insert(schema.jobs).values({ + id: freshJobId, + type: "tool", + status: "completed", + }); + + await db.execute( + sql`INSERT INTO audit_log (id, actor_username, action, created_at) VALUES (${oldAuditId}, 'test', 'test', now() - interval '90 days')`, + ); + + const origJobsRetention = env.JOBS_RETENTION_DAYS; + const origAuditRetention = env.AUDIT_RETENTION_DAYS; + (env as Record).JOBS_RETENTION_DAYS = 30; + (env as Record).AUDIT_RETENTION_DAYS = 30; + + try { + await runSystemJob({ name: SYSTEM_JOBS.retention } as unknown as Job); + + const [oldRow] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, oldJobId)); + expect(oldRow).toBeUndefined(); + + const [freshRow] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, freshJobId)); + expect(freshRow).toBeDefined(); + + const [auditRow] = await db + .select() + .from(schema.auditLog) + .where(eq(schema.auditLog.id, oldAuditId)); + expect(auditRow).toBeUndefined(); + } finally { + (env as Record).JOBS_RETENTION_DAYS = origJobsRetention; + (env as Record).AUDIT_RETENTION_DAYS = origAuditRetention; + await db + .delete(schema.jobs) + .where(eq(schema.jobs.id, freshJobId)) + .catch(() => {}); + } + }); + + it("throws on unknown system job name", async () => { + await expect(runSystemJob({ name: "system:bogus" } as unknown as Job)).rejects.toThrow( + "Unknown system job: system:bogus", + ); + }); + + it("continues sweeping when a per-dir deletePrefix fails", async () => { + const failJobId = `fail-${randomUUID().slice(0, 8)}`; + const okJobId = `ok-${randomUUID().slice(0, 8)}`; + + const failDir = join(env.WORKSPACE_PATH, "uploads", failJobId); + const okDir = join(env.WORKSPACE_PATH, "uploads", okJobId); + + mkdirSync(failDir, { recursive: true }); + writeFileSync(join(failDir, "a.txt"), "fail"); + mkdirSync(okDir, { recursive: true }); + writeFileSync(join(okDir, "b.txt"), "ok"); + + // Backdate both dirs so they are expired + const past = new Date(Date.now() - 2 * 60 * 60 * 1000); + utimesSync(join(failDir, "a.txt"), past, past); + utimesSync(failDir, past, past); + utimesSync(join(okDir, "b.txt"), past, past); + utimesSync(okDir, past, past); + + const realDeletePrefix = objectStorage.deletePrefix; + const spy = vi + .spyOn(objectStorage, "deletePrefix") + .mockImplementation(async (prefix: string) => { + if (prefix.includes(failJobId)) { + throw new Error("S3 partial failure"); + } + return realDeletePrefix(prefix); + }); + + try { + const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as unknown as Job); + const typed = result as { removed: number; failed: number }; + expect(typed.removed).toBeGreaterThanOrEqual(1); + expect(typed.failed).toBeGreaterThanOrEqual(1); + // The ok dir should have been cleaned + expect(existsSync(okDir)).toBe(false); + // The fail dir should still exist (deletion failed) + expect(existsSync(failDir)).toBe(true); + } finally { + spy.mockRestore(); + await rm(failDir, { recursive: true, force: true }).catch(() => {}); + await rm(okDir, { recursive: true, force: true }).catch(() => {}); + } + }); +}); + +// -- scheduleSystemJobs ------------------------------------------------------- + +describe("scheduleSystemJobs", () => { + it("registers all three system job schedulers", async () => { + await scheduleSystemJobs(); + const q = getQueue("system"); + const schedulers = await q.getJobSchedulers(); + const ids = schedulers.map((s) => s.key); + expect(ids).toContain(SYSTEM_JOBS.storageTtl); + expect(ids).toContain(SYSTEM_JOBS.sessionPurge); + expect(ids).toContain(SYSTEM_JOBS.retention); + }); + + it("skips storageTtl scheduler when CLEANUP_INTERVAL_MINUTES <= 0", async () => { + const orig = env.CLEANUP_INTERVAL_MINUTES; + (env as Record).CLEANUP_INTERVAL_MINUTES = 0; + + try { + const q = getQueue("system"); + for (const name of Object.values(SYSTEM_JOBS)) { + await q.removeJobScheduler(name).catch(() => {}); + } + + await scheduleSystemJobs(); + const schedulers = await q.getJobSchedulers(); + const ids = schedulers.map((s) => s.key); + expect(ids).not.toContain(SYSTEM_JOBS.storageTtl); + expect(ids).toContain(SYSTEM_JOBS.sessionPurge); + expect(ids).toContain(SYSTEM_JOBS.retention); + } finally { + (env as Record).CLEANUP_INTERVAL_MINUTES = orig; + } + }); + + it("removes stale storageTtl scheduler when CLEANUP_INTERVAL_MINUTES changes to 0", async () => { + const orig = env.CLEANUP_INTERVAL_MINUTES; + (env as Record).CLEANUP_INTERVAL_MINUTES = 5; + + try { + await scheduleSystemJobs(); + const q = getQueue("system"); + let schedulers = await q.getJobSchedulers(); + let ids = schedulers.map((s) => s.key); + expect(ids).toContain(SYSTEM_JOBS.storageTtl); + expect(ids).toContain(SYSTEM_JOBS.sessionPurge); + expect(ids).toContain(SYSTEM_JOBS.retention); + + // Operator disables cleanup; stale scheduler must be removed + (env as Record).CLEANUP_INTERVAL_MINUTES = 0; + await scheduleSystemJobs(); + schedulers = await q.getJobSchedulers(); + ids = schedulers.map((s) => s.key); + expect(ids).not.toContain(SYSTEM_JOBS.storageTtl); + expect(ids).toContain(SYSTEM_JOBS.sessionPurge); + expect(ids).toContain(SYSTEM_JOBS.retention); + } finally { + (env as Record).CLEANUP_INTERVAL_MINUTES = orig; + } + }); +}); diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts index 1b313dff..987cd028 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -26,18 +26,29 @@ import { eq } from "drizzle-orm"; // 2. Import app modules. config.ts already captured our env vars. // --------------------------------------------------------------------------- import Fastify from "fastify"; +import { afterAll } from "vitest"; import { env } from "../../apps/api/src/config.js"; import { db, schema } from "../../apps/api/src/db/index.js"; import { runMigrations } from "../../apps/api/src/db/migrate.js"; +import { + requestCancel, + startCancelListener, + stopCancelListener, +} from "../../apps/api/src/jobs/cancel.js"; +import { pingRedis } from "../../apps/api/src/jobs/connection.js"; +import { closeQueueEvents } from "../../apps/api/src/jobs/enqueue.js"; +import { closeWorkers, startWorkers } from "../../apps/api/src/jobs/worker.js"; import { requirePermission } from "../../apps/api/src/permissions.js"; import { authMiddleware, authRoutes, ensureBuiltinRoles, ensureDefaultAdmin, + requireAuth, } from "../../apps/api/src/plugins/auth.js"; import { oidcRoutes } from "../../apps/api/src/plugins/oidc.js"; import { registerUpload } from "../../apps/api/src/plugins/upload.js"; +import { adminOpsRoutes } from "../../apps/api/src/routes/admin-ops.js"; import { analyticsRoutes } from "../../apps/api/src/routes/analytics.js"; import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js"; import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js"; @@ -58,6 +69,27 @@ import { userFileRoutes } from "../../apps/api/src/routes/user-files.js"; // ensures the __drizzle_migrations journal is consistent in each fork). await runMigrations(); +// ── Job spine lifecycle (once per fork) ──────────────────────────── +// Workers idle when unused; starting them for every integration file is cheap. +let spineStarted = false; + +async function ensureSpine(): Promise { + if (spineStarted) return; + spineStarted = true; + await startCancelListener(); + startWorkers(); +} + +// Module-scope afterAll: vitest registers this into any importing file's +// suite, so every fork cleans up workers and cancel listener on exit. +afterAll(async () => { + if (spineStarted) { + await closeWorkers(); + await stopCancelListener(); + await closeQueueEvents(); + } +}, 10_000); + // --------------------------------------------------------------------------- // 3. Public API // --------------------------------------------------------------------------- @@ -67,6 +99,9 @@ export interface TestApp { } export async function buildTestApp(): Promise { + // Start the BullMQ job spine (idempotent, once per fork) + await ensureSpine(); + // Seed built-in roles and default admin user (both idempotent) await ensureBuiltinRoles(); await ensureDefaultAdmin(); @@ -139,6 +174,9 @@ export async function buildTestApp(): Promise { // Roles management routes await rolesRoutes(app); + // Admin ops routes (runtime log level, Prometheus metrics) + await adminOpsRoutes(app); + // Analytics routes await analyticsRoutes(app); @@ -185,6 +223,41 @@ export async function buildTestApp(): Promise { return config; }); + // Readiness probe (no auth) + app.get("/api/v1/readyz", async (_request, reply) => { + let postgres = false; + let redis = false; + try { + await db.select().from(schema.settings).limit(1); + postgres = true; + } catch { + /* db unreachable */ + } + try { + redis = await pingRedis(); + } catch { + /* redis unreachable */ + } + const ok = postgres && redis; + return reply.code(ok ? 200 : 503).send({ ok, postgres, redis }); + }); + + // Cancel a job (authenticated) + app.post( + "/api/v1/jobs/:jobId/cancel", + async ( + request: import("fastify").FastifyRequest<{ Params: { jobId: string } }>, + reply: import("fastify").FastifyReply, + ) => { + const user = requireAuth(request, reply); + if (!user) return; + + const { jobId } = request.params; + const canceled = await requestCancel(jobId); + return reply.send({ canceled }); + }, + ); + // Ensure Fastify is ready (all plugins loaded) await app.ready(); diff --git a/tests/integration/tool-async-window.test.ts b/tests/integration/tool-async-window.test.ts new file mode 100644 index 00000000..0ca09d05 --- /dev/null +++ b/tests/integration/tool-async-window.test.ts @@ -0,0 +1,189 @@ +/** + * Integration tests for the factory's enqueue + sync-wait path. + * + * Verifies that a converted tool route (resize) returns the legacy + * envelope synchronously, that downloads work, and that the terminal + * SSE replay key is set in Redis. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { sharedRedis } from "../../apps/api/src/jobs/connection.js"; +import { bullPrefix } from "../../apps/api/src/jobs/types.js"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Tool async window (sync-wait path)", () => { + it("returns 200 with legacy envelope keys", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + // (a) Exactly the legacy envelope keys + const REQUIRED_KEYS = ["jobId", "downloadUrl", "originalSize", "processedSize"]; + const ALLOWED_KEYS = [...REQUIRED_KEYS, "previewUrl", "savedFileId"]; + const actualKeys = Object.keys(result); + + // Every required key must be present + for (const key of REQUIRED_KEYS) { + expect(actualKeys).toContain(key); + } + // Every key in the response must be in the allowed set + for (const key of actualKeys) { + expect(ALLOWED_KEYS).toContain(key); + } + + expect(typeof result.jobId).toBe("string"); + expect(typeof result.downloadUrl).toBe("string"); + expect(typeof result.originalSize).toBe("number"); + expect(typeof result.processedSize).toBe("number"); + if (result.previewUrl !== undefined) { + expect(typeof result.previewUrl).toBe("string"); + } + if (result.savedFileId !== undefined) { + expect(typeof result.savedFileId).toBe("string"); + } + + // (b) GET the downloadUrl returns 200 with bytes + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + expect(dlRes.rawPayload.length).toBeGreaterThan(0); + + // (c) Terminal SSE replay key exists in Redis with phase:"complete" + const jobId = result.jobId; + const terminalKeyName = `${bullPrefix()}:terminal:${jobId}`; + + // The worker emits the terminal frame; give Redis a moment to propagate + let cached: string | null = null; + for (let i = 0; i < 20; i++) { + cached = await sharedRedis().get(terminalKeyName); + if (cached) break; + await new Promise((r) => setTimeout(r, 200)); + } + expect(cached).not.toBeNull(); + const parsed = JSON.parse(cached!); + expect(parsed.type).toBe("single"); + expect(parsed.phase).toBe("complete"); + expect(parsed.result).toBeDefined(); + expect(parsed.result.downloadUrl).toBeDefined(); + }); + + it("includes originalSize and processedSize in the envelope", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ width: 50 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.originalSize).toBeGreaterThan(0); + expect(result.processedSize).toBeGreaterThan(0); + // Resized image should be different size than original + expect(result.processedSize).not.toBe(result.originalSize); + }); +}); + +describe("EXIF auto-orientation in factory path", () => { + it("strips EXIF orientation and rotates pixels for a JPEG with orientation 6", async () => { + // Build a 40x20 JPEG with EXIF orientation 6 (rotated 90 CW). + // When physically oriented, the output must be 20x40. + const rotated = await sharp({ + create: { + width: 40, + height: 20, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .jpeg() + .withMetadata({ orientation: 6 }) + .toBuffer(); + + // Verify the source has the orientation tag embedded + const srcMeta = await sharp(rotated).metadata(); + expect(srcMeta.orientation).toBe(6); + + // POST to compress (quality mode preserves pixels, only re-encodes) + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "rotated.jpg", contentType: "image/jpeg", content: rotated }, + { name: "settings", content: JSON.stringify({ mode: "quality", quality: 90 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compress", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + + // Download the output and inspect metadata + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + + const outMeta = await sharp(dlRes.rawPayload).metadata(); + + // (a) Orientation tag must be absent or 1 (upright) + expect(outMeta.orientation ?? 1).toBe(1); + + // (b) Pixels are physically rotated: 40x20 with orientation 6 + // becomes 20x40 after auto-orient + expect(outMeta.width).toBe(20); + expect(outMeta.height).toBe(40); + }); +}); diff --git a/tests/integration/usage-endpoint.test.ts b/tests/integration/usage-endpoint.test.ts new file mode 100644 index 00000000..f91bb004 --- /dev/null +++ b/tests/integration/usage-endpoint.test.ts @@ -0,0 +1,188 @@ +/** + * Integration tests for GET /api/v1/admin/usage. + * + * Verifies auth gating, response shape, and data correctness + * after seeding a few job rows. + */ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; +let adminUserId: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); + + // Look up admin user id + const [adminUser] = await db + .select({ id: schema.users.id }) + .from(schema.users) + .where(eq(schema.users.username, "admin")) + .limit(1); + adminUserId = adminUser.id; + + // Seed 3 jobs: 2 completed with distinct tool_ids, 1 failed + await db.insert(schema.jobs).values([ + { + id: randomUUID(), + userId: adminUserId, + type: "tool", + toolId: "resize", + pool: "image", + status: "completed", + bytesIn: 5000, + durationMs: 200, + }, + { + id: randomUUID(), + userId: adminUserId, + type: "tool", + toolId: "compress", + pool: "image", + status: "completed", + bytesIn: 3000, + durationMs: 400, + }, + { + id: randomUUID(), + userId: adminUserId, + type: "tool", + toolId: "resize", + pool: "image", + status: "failed", + bytesIn: 1000, + durationMs: 50, + }, + ]); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("GET /api/v1/admin/usage", () => { + it("rejects unauthenticated requests with 401", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/admin/usage", + }); + expect([401, 403]).toContain(res.statusCode); + }); + + it("rejects non-admin users with 403", async () => { + // Register a non-admin user + const regRes = await app.inject({ + method: "POST", + url: "/api/auth/register", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { username: "usage_viewer", password: "TestPass1", role: "user" }, + }); + expect(regRes.statusCode).toBe(201); + + // Clear mustChangePassword + const userId = JSON.parse(regRes.body).id; + await db + .update(schema.users) + .set({ mustChangePassword: false }) + .where(eq(schema.users.id, userId)); + + // Login as non-admin + const loginRes = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "usage_viewer", password: "TestPass1" }, + }); + const userToken = JSON.parse(loginRes.body).token; + + const res = await app.inject({ + method: "GET", + url: "/api/v1/admin/usage", + headers: { authorization: `Bearer ${userToken}` }, + }); + expect(res.statusCode).toBe(403); + }); + + it("returns usage data for admin", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/admin/usage?days=30", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + + // Top-level shape + expect(body.days).toBe(30); + expect(Array.isArray(body.jobsPerDay)).toBe(true); + expect(Array.isArray(body.topTools)).toBe(true); + expect(Array.isArray(body.perUser)).toBe(true); + expect(Array.isArray(body.durations)).toBe(true); + expect(body.storage).toBeDefined(); + + // jobsPerDay: today should appear with our 3 seeded jobs + expect(body.jobsPerDay.length).toBeGreaterThan(0); + const today = new Date().toISOString().slice(0, 10); + const todayRow = body.jobsPerDay.find((r: { day: string }) => r.day === today); + expect(todayRow).toBeDefined(); + // We seeded 3 jobs; other tests in the fork may add more, so use >= + expect(todayRow.total).toBeGreaterThanOrEqual(3); + expect(todayRow.completed).toBeGreaterThanOrEqual(2); + expect(todayRow.failed).toBeGreaterThanOrEqual(1); + + // topTools: ordered desc by runs; resize should be first (2 runs vs 1) + expect(body.topTools.length).toBeGreaterThanOrEqual(2); + const resizeIdx = body.topTools.findIndex((r: { toolId: string }) => r.toolId === "resize"); + const compressIdx = body.topTools.findIndex((r: { toolId: string }) => r.toolId === "compress"); + expect(resizeIdx).toBeGreaterThanOrEqual(0); + expect(compressIdx).toBeGreaterThanOrEqual(0); + // resize has more runs so should come before compress + expect(resizeIdx).toBeLessThan(compressIdx); + + // perUser: admin username should appear with bytesIn as string + const adminRow = body.perUser.find((r: { username: string | null }) => r.username === "admin"); + expect(adminRow).toBeDefined(); + expect(typeof adminRow.bytesIn).toBe("string"); + expect(Number(adminRow.bytesIn)).toBeGreaterThanOrEqual(9000); + expect(adminRow.runs).toBeGreaterThanOrEqual(3); + + // durations: each entry should have pool string + p50Ms/p95Ms number or null + for (const d of body.durations) { + expect(typeof d.pool).toBe("string"); + expect(d.p50Ms === null || typeof d.p50Ms === "number").toBe(true); + expect(d.p95Ms === null || typeof d.p95Ms === "number").toBe(true); + } + + // storage: fields present + expect(typeof body.storage.libraryBytes).toBe("string"); + expect(typeof body.storage.libraryFiles).toBe("number"); + }); + + it("defaults days to 30 when omitted", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/admin/usage", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.days).toBe(30); + }); + + it("clamps days to valid range", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/admin/usage?days=9999", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.days).toBe(365); + }); +}); diff --git a/tests/integration/worker-timeout.test.ts b/tests/integration/worker-timeout.test.ts new file mode 100644 index 00000000..54e0834b --- /dev/null +++ b/tests/integration/worker-timeout.test.ts @@ -0,0 +1,134 @@ +/** + * Integration test: timeout is classified as "failed" (not "canceled"), + * retried per the queue's attempts policy, and emits the correct + * terminal SSE replay key on the final attempt. + * + * JOB_TIMEOUT_FAST_S is set to 1 second BEFORE API modules load so + * all dynamic imports capture the override. + */ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// Override timeout BEFORE any API module is loaded (static imports +// above are vitest-only and do not trigger config.ts). +process.env.JOB_TIMEOUT_FAST_S = "1"; + +// Dynamic imports so config.ts picks up the 1-second timeout. +const { eq } = await import("drizzle-orm"); +const { db, schema } = await import("../../apps/api/src/db/index.js"); +const { startCancelListener, stopCancelListener } = await import( + "../../apps/api/src/jobs/cancel.js" +); +const { sharedRedis } = await import("../../apps/api/src/jobs/connection.js"); +const { enqueueToolJob } = await import("../../apps/api/src/jobs/enqueue.js"); +const { bullPrefix } = await import("../../apps/api/src/jobs/types.js"); +const { closeWorkers, startWorkers } = await import("../../apps/api/src/jobs/worker.js"); +const { putObject } = await import("../../apps/api/src/lib/object-storage.js"); +const { registerToolProcessFn } = await import("../../apps/api/src/routes/tool-factory.js"); +const { env } = await import("../../apps/api/src/config.js"); + +// Sanity: the env override must have taken effect. +if (env.JOB_TIMEOUT_FAST_S !== 1) { + throw new Error( + `Expected JOB_TIMEOUT_FAST_S=1, got ${env.JOB_TIMEOUT_FAST_S}. Env override failed.`, + ); +} + +// Register a test-only tool that loops and respects the abort signal. +registerToolProcessFn({ + toolId: "timeout-slow", + settingsSchema: { parse: (v: unknown) => v } as never, + process: async ( + inputBuffer: Buffer, + _settings: unknown, + filename: string, + ctx?: import("../../apps/api/src/routes/tool-factory.js").ToolProcessCtx, + ) => { + // Loop for up to 30s, checking signal every 100ms. + for (let i = 0; i < 300; i++) { + if (ctx?.signal?.aborted) { + throw new Error("Aborted by signal"); + } + await new Promise((r) => setTimeout(r, 100)); + } + return { buffer: inputBuffer, filename, contentType: "image/png" }; + }, +}); + +// Ensure workspace dir exists (test-server.ts normally does this, but +// we bypass it to avoid loading the full app). +const { mkdirSync } = await import("node:fs"); +const wsPath = process.env.WORKSPACE_PATH ?? ""; +mkdirSync(wsPath, { recursive: true }); + +// Run migrations so the jobs table exists in this fork's DB. +const { runMigrations } = await import("../../apps/api/src/db/migrate.js"); +await runMigrations(); + +beforeAll(async () => { + await startCancelListener(); + startWorkers(); +}, 30_000); + +afterAll(async () => { + await closeWorkers(); + await stopCancelListener(); +}, 10_000); + +describe("Worker timeout classification", () => { + it("timed-out job is retried then fails with timeout message, not canceled", async () => { + const jobId = randomUUID(); + const inputBuffer = Buffer.from("timeout-test-data"); + + // Store input so the worker can retrieve it + const inputRef = `uploads/${jobId}/test.png`; + await putObject(inputRef, inputBuffer); + + await enqueueToolJob({ + jobId, + toolId: "timeout-slow", + userId: null, + pool: "image", // image pool: default attempts = 2, backoff 1s + inputRefs: [inputRef], + filename: "test.png", + settings: {}, + kind: "tool", + }); + + // Poll the DB until the job reaches a terminal state. + // Budget: ~20s (attempt 1 times out at 1s, 1s backoff, attempt 2 + // times out at 1s, plus processing overhead). + let finalRow: Record | undefined; + for (let i = 0; i < 100; i++) { + const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && row.status !== "processing" && row.status !== "queued") { + finalRow = row as Record; + break; + } + await new Promise((r) => setTimeout(r, 200)); + } + + expect(finalRow).toBeDefined(); + + // Must be "failed", NOT "canceled" + expect(finalRow?.status).toBe("failed"); + + // Error message must mention timeout + const error = finalRow?.error as { message: string }; + expect(error.message).toMatch(/timed out after 1s/i); + + // Both attempts ran (attempts column is set at the start of each attempt) + expect(finalRow?.attempts).toBe(2); + + // Terminal SSE replay key must exist with the timeout message + const terminalKeyName = `${bullPrefix()}:terminal:${jobId}`; + const cached = await sharedRedis().get(terminalKeyName); + expect(cached).not.toBeNull(); + const parsed = JSON.parse(cached ?? "{}"); + expect(parsed.phase).toBe("failed"); + expect(parsed.error).toMatch(/timed out after 1s/i); + // Must NOT say "Canceled" + expect(parsed.error).not.toBe("Canceled"); + expect(parsed.jobId).toBe(jobId); + }, 25_000); +}); diff --git a/tests/setup/per-fork-env.ts b/tests/setup/per-fork-env.ts index 7ec68264..32482892 100644 --- a/tests/setup/per-fork-env.ts +++ b/tests/setup/per-fork-env.ts @@ -15,6 +15,17 @@ const baseUrl = process.env.TEST_PG_BASE_URL; if (!baseUrl) { throw new Error("TEST_PG_BASE_URL missing; tests/global-setup.ts did not run"); } + +const redisBaseUrl = process.env.TEST_REDIS_BASE_URL; +if (!redisBaseUrl) { + throw new Error("TEST_REDIS_BASE_URL missing; tests/global-setup.ts did not run"); +} +process.env.REDIS_URL = redisBaseUrl; +process.env.BULLMQ_PREFIX = `snapotter_test_${suffix}`; + +// Heavy format conversions can exceed the 8s production default under parallel +// test forks; 30s keeps tool routes synchronous (200) in tests while production stays at 8s. +process.env.SYNC_WAIT_MS = "30000"; const dbName = `snapotter_test_${suffix}`; // pid digits + uuid hex: identifier-safe const admin = new pg.Client({ connectionString: baseUrl }); await admin.connect(); diff --git a/tests/unit/api/analytics-env.test.ts b/tests/unit/api/analytics-env.test.ts index 2d8537d8..1b74e7ef 100644 --- a/tests/unit/api/analytics-env.test.ts +++ b/tests/unit/api/analytics-env.test.ts @@ -28,10 +28,10 @@ describe("analytics env var validation", () => { // ── ANALYTICS_ENABLED ─────────────────────────────────────────────────── - it("ANALYTICS_ENABLED defaults to true", async () => { + it("ANALYTICS_ENABLED defaults to false (no-phone-home)", async () => { const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); const env = loadEnv(); - expect(env.ANALYTICS_ENABLED).toBe(true); + expect(env.ANALYTICS_ENABLED).toBe(false); }); it("ANALYTICS_ENABLED='false' transforms to boolean false", async () => { @@ -98,10 +98,10 @@ describe("analytics env var validation", () => { // ── POSTHOG_API_KEY ──────────────────────────────────────────────────── - it("POSTHOG_API_KEY defaults to the baked-in key", async () => { + it("POSTHOG_API_KEY defaults to empty string (no baked key)", async () => { const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); const env = loadEnv(); - expect(env.POSTHOG_API_KEY).toBe("phc_CVHjGivwWVzh76M5EjijTwP5LpiqWie3EbCzXU7w2Smy"); + expect(env.POSTHOG_API_KEY).toBe(""); }); it("POSTHOG_API_KEY can be overridden with a custom value", async () => { @@ -132,12 +132,10 @@ describe("analytics env var validation", () => { // ── SENTRY_DSN ───────────────────────────────────────────────────────── - it("SENTRY_DSN defaults to the baked-in DSN", async () => { + it("SENTRY_DSN defaults to empty string (no baked DSN)", async () => { const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); const env = loadEnv(); - expect(env.SENTRY_DSN).toBe( - "https://2fd53fc3b3fdc59d02cac044a4f90b71@o4511263372738560.ingest.us.sentry.io/4511264620085248", - ); + expect(env.SENTRY_DSN).toBe(""); }); it("SENTRY_DSN can be overridden with a custom value", async () => { diff --git a/tests/unit/api/object-storage.test.ts b/tests/unit/api/object-storage.test.ts new file mode 100644 index 00000000..46e4808a --- /dev/null +++ b/tests/unit/api/object-storage.test.ts @@ -0,0 +1,55 @@ +import { Readable } from "node:stream"; +import { afterAll, describe, expect, it } from "vitest"; +import { + deleteObject, + getObjectSize, + getObjectStream, + listObjects, + objectExists, + putObject, + putObjectStream, +} from "../../../apps/api/src/lib/object-storage.js"; + +describe("object-storage (local backend)", () => { + const key = `outputs/test-${process.pid}/hello.txt`; + + afterAll(async () => { + await deleteObject(key).catch(() => {}); + }); + + it("round-trips buffers and streams with size and listing", async () => { + await putObject(key, Buffer.from("hello world")); + expect(await objectExists(key)).toBe(true); + expect(await getObjectSize(key)).toBe(11); + const chunks: Buffer[] = []; + for await (const c of await getObjectStream(key)) chunks.push(c as Buffer); + expect(Buffer.concat(chunks).toString()).toBe("hello world"); + const ranged: Buffer[] = []; + for await (const c of await getObjectStream(key, { start: 6, end: 10 })) + ranged.push(c as Buffer); + expect(Buffer.concat(ranged).toString()).toBe("world"); + const listed = await listObjects(`outputs/test-${process.pid}/`); + expect(listed.some((o) => o.key === key)).toBe(true); + const streamKey = `outputs/test-${process.pid}/streamed.bin`; + const written = await putObjectStream(streamKey, Readable.from([Buffer.alloc(1024, 1)]), { + maxBytes: 2048, + }); + expect(written).toBe(1024); + await expect( + putObjectStream( + `outputs/test-${process.pid}/too-big.bin`, + Readable.from([Buffer.alloc(4096, 1)]), + { + maxBytes: 2048, + }, + ), + ).rejects.toThrow(/exceeds/i); + await deleteObject(streamKey); + }); + + it("rejects path traversal in keys", async () => { + await expect(putObject("outputs/../../etc/passwd", Buffer.from("x"))).rejects.toThrow( + /invalid/i, + ); + }); +}); diff --git a/tests/unit/api/pipeline.test.ts b/tests/unit/api/pipeline.test.ts index 2ebb83bd..1a5284e1 100644 --- a/tests/unit/api/pipeline.test.ts +++ b/tests/unit/api/pipeline.test.ts @@ -72,10 +72,6 @@ vi.mock("../../../apps/api/src/lib/svg-sanitize.js", () => ({ sanitizeSvg: vi.fn((b: Buffer) => b), })); -vi.mock("../../../apps/api/src/lib/workspace.js", () => ({ - createWorkspace: vi.fn(() => Promise.resolve("/tmp/workspace/pipeline-1")), -})); - vi.mock("../../../apps/api/src/lib/feature-status.js", () => ({ isToolInstalled: vi.fn(() => true), })); diff --git a/tests/unit/api/postprocess.test.ts b/tests/unit/api/postprocess.test.ts new file mode 100644 index 00000000..92e9c3a6 --- /dev/null +++ b/tests/unit/api/postprocess.test.ts @@ -0,0 +1,66 @@ +/** + * Unit tests for postprocess helpers. + * + * Tests buildOutputName (output filename construction with tool-specific + * suffix and extension fixup). This pure function was extracted from + * tool-factory.ts during the job-spine refactor to apps/api/src/jobs/postprocess.ts. + */ +import { describe, expect, it, vi } from "vitest"; + +// Minimal mocks so the module loads without Postgres/Redis/sharp +vi.mock("../../../apps/api/src/db/index.js", () => ({ + db: {}, + schema: { jobs: {}, userFiles: {} }, +})); + +vi.mock("../../../apps/api/src/lib/object-storage.js", () => ({ + putObject: vi.fn(), +})); + +vi.mock("sharp", () => ({ + default: vi.fn(), +})); + +import { buildOutputName, CONTENT_TYPE_TO_EXT } from "../../../apps/api/src/jobs/postprocess.js"; + +describe("buildOutputName", () => { + it("appends toolId suffix when filename is unchanged", () => { + const result = buildOutputName("photo.png", "photo.png", "resize", "image/png"); + expect(result).toBe("photo_resize.png"); + }); + + it("does not add suffix when tool renames the file", () => { + const result = buildOutputName("converted.jpg", "photo.png", "convert", "image/jpeg"); + expect(result).toBe("converted.jpg"); + }); + + it("fixes extension mismatch between content-type and filename", () => { + const result = buildOutputName("output.png", "input.bmp", "convert", "image/jpeg"); + expect(result).toBe("output.jpg"); + }); + + it("applies both suffix and extension fixup when needed", () => { + const result = buildOutputName("input.bmp", "input.bmp", "convert", "image/jpeg"); + expect(result).toBe("input_convert.jpg"); + }); + + it("preserves extension when content-type matches", () => { + const result = buildOutputName("output.webp", "input.png", "compress", "image/webp"); + expect(result).toBe("output.webp"); + }); + + it("handles filenames without extension", () => { + const result = buildOutputName("photo", "photo", "resize", "image/png"); + expect(result).toBe("photo_resize"); + }); +}); + +describe("CONTENT_TYPE_TO_EXT", () => { + it("maps common image MIME types to extensions", () => { + expect(CONTENT_TYPE_TO_EXT["image/jpeg"]).toBe(".jpg"); + expect(CONTENT_TYPE_TO_EXT["image/png"]).toBe(".png"); + expect(CONTENT_TYPE_TO_EXT["image/webp"]).toBe(".webp"); + expect(CONTENT_TYPE_TO_EXT["image/gif"]).toBe(".gif"); + expect(CONTENT_TYPE_TO_EXT["image/svg+xml"]).toBe(".svg"); + }); +}); diff --git a/tests/unit/api/progress.test.ts b/tests/unit/api/progress.test.ts index f77bf426..da4fec9b 100644 --- a/tests/unit/api/progress.test.ts +++ b/tests/unit/api/progress.test.ts @@ -1,8 +1,8 @@ /** * Unit tests for the progress tracking module. * - * Tests updateJobProgress, updateSingleFileProgress, recoverStaleJobs, - * and the in-memory pub/sub listener system. + * Tests updateJobProgress, updateSingleFileProgress, and the + * in-memory pub/sub listener system. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -34,7 +34,6 @@ vi.mock("../../../apps/api/src/config.js", () => ({ import type { JobProgress } from "../../../apps/api/src/routes/progress.js"; import { - recoverStaleJobs, updateJobProgress, updateSingleFileProgress, } from "../../../apps/api/src/routes/progress.js"; @@ -160,12 +159,6 @@ describe("updateSingleFileProgress", () => { }); }); -describe("recoverStaleJobs", () => { - it("does not throw when called", () => { - expect(() => recoverStaleJobs()).not.toThrow(); - }); -}); - describe("JobProgress type shape", () => { it("supports all required fields", () => { const p: JobProgress = { diff --git a/tests/unit/api/tool-factory-route.test.ts b/tests/unit/api/tool-factory-route.test.ts index 6d68eca2..31a35c83 100644 --- a/tests/unit/api/tool-factory-route.test.ts +++ b/tests/unit/api/tool-factory-route.test.ts @@ -1,17 +1,13 @@ /** * Unit tests for the createToolRoute factory -- the central route handler - * that powers all standard tools. Tests the multipart parsing, validation, - * format decoding, settings validation, output naming, and error handling - * without spinning up a real Fastify server. + * that powers all standard tools. Tests multipart parsing, validation, + * settings validation, job enqueue/wait, and error handling without + * spinning up a real Fastify server or requiring Postgres/Redis. */ import { beforeEach, describe, expect, it, vi } from "vitest"; // ── Mocks ─────────────────────────────────────────────────────────────── -vi.mock("node:fs/promises", () => ({ - writeFile: vi.fn().mockResolvedValue(undefined), -})); - vi.mock("../../../apps/api/src/db/index.js", () => ({ db: { select: () => ({ @@ -24,7 +20,7 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ }, pool: {}, closeDb: async () => {}, - schema: { settings: {}, userFiles: { id: {} } }, + schema: { settings: {}, userFiles: { id: {} }, jobs: { id: {}, status: {} } }, })); vi.mock("../../../apps/api/src/config.js", () => ({ @@ -32,9 +28,44 @@ vi.mock("../../../apps/api/src/config.js", () => ({ WORKSPACE_PATH: "/tmp/test", MAX_MEGAPIXELS: 100, MAX_SVG_SIZE_MB: 10, + MAX_UPLOAD_SIZE_MB: 50, }, })); +vi.mock("../../../apps/api/src/jobs/enqueue.js", () => ({ + enqueueToolJob: vi.fn().mockResolvedValue({}), + waitForJob: vi.fn().mockResolvedValue({ + outputRefs: ["outputs/mock-job/result.png"], + filename: "result.png", + contentType: "image/png", + originalSize: 100, + processedSize: 80, + }), +})); + +vi.mock("../../../apps/api/src/lib/object-storage.js", () => ({ + getObjectBuffer: vi.fn(() => Promise.resolve(Buffer.from("png-data"))), + putObject: vi.fn(() => Promise.resolve()), +})); + +vi.mock("../../../apps/api/src/lib/upload-stream.js", () => ({ + receiveUpload: vi.fn((_part: unknown, jobId: string) => + Promise.resolve({ + key: `uploads/${jobId}/test.png`, + filename: "test.png", + size: 100, + }), + ), +})); + +vi.mock("../../../apps/api/src/routes/progress.js", () => ({ + updateSingleFileProgress: vi.fn(), +})); + +vi.mock("../../../apps/api/src/plugins/auth.js", () => ({ + getAuthUser: vi.fn(() => null), +})); + vi.mock("../../../apps/api/src/lib/analytics.js", () => ({ trackEvent: vi.fn(), })); @@ -55,6 +86,7 @@ vi.mock("../../../apps/api/src/lib/filename.js", () => ({ vi.mock("../../../apps/api/src/lib/format-decoders.js", () => ({ decodeToSharpCompat: vi.fn(), + decodeAnyFormat: vi.fn(), needsCliDecode: vi.fn(() => false), })); @@ -67,18 +99,6 @@ vi.mock("../../../apps/api/src/lib/svg-sanitize.js", () => ({ sanitizeSvg: vi.fn((b: Buffer) => b), })); -vi.mock("../../../apps/api/src/lib/workspace.js", () => ({ - createWorkspace: vi.fn(() => Promise.resolve("/tmp/workspace/job-1")), -})); - -vi.mock("../../../apps/api/src/lib/worker-pool.js", () => ({ - getWorkerPool: vi.fn(), -})); - -vi.mock("../../../apps/api/src/lib/timeout.js", () => ({ - computeTimeout: vi.fn(() => 30000), -})); - vi.mock("../../../apps/api/src/lib/feature-status.js", () => ({ isToolInstalled: vi.fn(() => true), })); @@ -92,11 +112,13 @@ vi.mock("sharp", () => ({ default: vi.fn(() => ({ metadata: () => Promise.resolve({ width: 100, height: 100 }), webp: () => ({ toBuffer: () => Promise.resolve(Buffer.from("webp")) }), + resize: () => ({ raw: () => ({ toBuffer: () => Promise.resolve(Buffer.from("raw")) }) }), })), })); // ── Imports ───────────────────────────────────────────────────────────── +import { waitForJob } from "../../../apps/api/src/jobs/enqueue.js"; import { isToolInstalled } from "../../../apps/api/src/lib/feature-status.js"; import { validateImageBuffer } from "../../../apps/api/src/lib/file-validation.js"; import type { AnyToolRouteConfig } from "../../../apps/api/src/routes/tool-factory.js"; @@ -373,7 +395,7 @@ describe("createToolRoute", () => { // This validates the guard code path exists without false positives. }); - it("successfully processes a valid image and returns download URL", async () => { + it("returns 200 success envelope when waitForJob resolves", async () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); @@ -390,20 +412,35 @@ describe("createToolRoute", () => { expect.objectContaining({ jobId: expect.any(String), downloadUrl: expect.stringContaining("/api/v1/download/"), - originalSize: expect.any(Number), - processedSize: expect.any(Number), + originalSize: 100, + processedSize: 80, }), ); }); - it("returns 422 when processing throws an error", async () => { + it("returns 202 when waitForJob returns null (sync window expired)", async () => { + vi.mocked(waitForJob).mockResolvedValueOnce(null); const app = createMockApp(); const id = uniqueId(); - const config = makeMockConfig(id); - (config.process as ReturnType).mockRejectedValueOnce( - new Error("Sharp exploded"), - ); - createToolRoute(app as never, config); + createToolRoute(app as never, makeMockConfig(id)); + const handler = app.routes[`/api/v1/tools/${id}`]; + const reply = createMockReply(); + const req = createMockRequest({ + fileBuffer: Buffer.from("png-data"), + settings: JSON.stringify({}), + }); + + await handler(req, reply); + + expect(reply.status).toHaveBeenCalledWith(202); + expect(reply.send).toHaveBeenCalledWith(expect.objectContaining({ async: true })); + }); + + it("returns 422 when waitForJob rejects", async () => { + vi.mocked(waitForJob).mockRejectedValueOnce(new Error("Sharp exploded")); + const app = createMockApp(); + const id = uniqueId(); + createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ @@ -425,8 +462,7 @@ describe("createToolRoute", () => { it("uses empty settings when none are provided", async () => { const app = createMockApp(); const id = uniqueId(); - const config = makeMockConfig(id); - createToolRoute(app as never, config); + createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ @@ -441,77 +477,6 @@ describe("createToolRoute", () => { }); }); - describe("output filename logic", () => { - it("appends toolId suffix when filename unchanged", async () => { - const app = createMockApp(); - const id = uniqueId(); - const config = makeMockConfig(id); - (config.process as ReturnType).mockResolvedValueOnce({ - buffer: Buffer.from("out"), - filename: "photo.png", - contentType: "image/png", - }); - createToolRoute(app as never, config); - const handler = app.routes[`/api/v1/tools/${id}`]; - const reply = createMockReply(); - const req = createMockRequest({ - fileBuffer: Buffer.from("png-data"), - filename: "photo.png", - }); - - await handler(req, reply); - - const sentData = (reply.send as ReturnType).mock.calls[0][0]; - expect(sentData.downloadUrl).toContain(`photo_${id}.png`); - }); - - it("does not add suffix when tool changes the filename", async () => { - const app = createMockApp(); - const id = uniqueId(); - const config = makeMockConfig(id); - (config.process as ReturnType).mockResolvedValueOnce({ - buffer: Buffer.from("out"), - filename: "converted.jpg", - contentType: "image/jpeg", - }); - createToolRoute(app as never, config); - const handler = app.routes[`/api/v1/tools/${id}`]; - const reply = createMockReply(); - const req = createMockRequest({ - fileBuffer: Buffer.from("png-data"), - filename: "photo.png", - }); - - await handler(req, reply); - - const sentData = (reply.send as ReturnType).mock.calls[0][0]; - expect(sentData.downloadUrl).toContain("converted.jpg"); - }); - - it("fixes extension mismatch between content-type and filename", async () => { - const app = createMockApp(); - const id = uniqueId(); - const config = makeMockConfig(id); - (config.process as ReturnType).mockResolvedValueOnce({ - buffer: Buffer.from("out"), - filename: "output.png", - contentType: "image/jpeg", - }); - createToolRoute(app as never, config); - const handler = app.routes[`/api/v1/tools/${id}`]; - const reply = createMockReply(); - const req = createMockRequest({ - fileBuffer: Buffer.from("png-data"), - filename: "input.bmp", - }); - - await handler(req, reply); - - const sentData = (reply.send as ReturnType).mock.calls[0][0]; - expect(sentData.downloadUrl).toContain(".jpg"); - }); - }); - describe("multipart parsing error", () => { it("returns 400 when parts iterator throws", async () => { const app = createMockApp(); diff --git a/tests/unit/api/utilities.test.ts b/tests/unit/api/utilities.test.ts index 9ef23558..597a2908 100644 --- a/tests/unit/api/utilities.test.ts +++ b/tests/unit/api/utilities.test.ts @@ -750,99 +750,33 @@ describe("isRawExtension", () => { }); // --------------------------------------------------------------------------- -// 2. Workspace +// 2. Object storage capacity guard (replaces former workspace tests) // --------------------------------------------------------------------------- -describe("workspace", () => { - // Use a real temp directory to avoid polluting the project - let TEST_WORKSPACE: string; - - beforeEach(async () => { - TEST_WORKSPACE = join(tmpdir(), `SnapOtter-test-workspace-${randomUUID()}`); - await mkdir(TEST_WORKSPACE, { recursive: true }); - - // Point the env mock's WORKSPACE_PATH at our temp dir - const configMod = await import("../../../apps/api/src/config.js"); - configMod.env.WORKSPACE_PATH = TEST_WORKSPACE; - }); - - afterEach(async () => { - // Clean up the temp dir - await rm(TEST_WORKSPACE, { recursive: true, force: true }); - }); - - it("createWorkspace creates input and output subdirectories", async () => { - const { createWorkspace } = await import("../../../apps/api/src/lib/workspace.js"); - const jobId = randomUUID(); - const root = await createWorkspace(jobId); - - expect(root).toBe(join(TEST_WORKSPACE, jobId)); - expect(existsSync(join(root, "input"))).toBe(true); - expect(existsSync(join(root, "output"))).toBe(true); - }); - - it("createWorkspace returns the workspace root path", async () => { - const { createWorkspace } = await import("../../../apps/api/src/lib/workspace.js"); - const jobId = "my-test-job-123"; - const root = await createWorkspace(jobId); - expect(root).toBe(join(TEST_WORKSPACE, jobId)); - }); - - it("getWorkspacePath returns correct path without creating dirs", async () => { - const { getWorkspacePath } = await import("../../../apps/api/src/lib/workspace.js"); - const jobId = "path-check-id"; - const result = getWorkspacePath(jobId); - expect(result).toBe(join(TEST_WORKSPACE, jobId)); - // Should NOT have created the directory - expect(existsSync(join(TEST_WORKSPACE, jobId))).toBe(false); - }); - - it("cleanupWorkspace removes the entire workspace directory", async () => { - const { createWorkspace, cleanupWorkspace } = await import( - "../../../apps/api/src/lib/workspace.js" +describe("assertLocalCapacity / isBelowCapacity", () => { + it("isBelowCapacity returns true when free space is below 0.5 GB", async () => { + const { isBelowCapacity, CAPACITY_CRITICAL_GB } = await import( + "../../../apps/api/src/lib/object-storage.js" ); - const jobId = randomUUID(); - const root = await createWorkspace(jobId); - - // Verify it exists - expect(existsSync(root)).toBe(true); - - await cleanupWorkspace(jobId); - - // Verify it is gone - expect(existsSync(root)).toBe(false); + expect(CAPACITY_CRITICAL_GB).toBe(0.5); + // 0.4 GB free should be below threshold + expect(isBelowCapacity(0.4 * 1024 ** 3)).toBe(true); + // 0.0 bytes free + expect(isBelowCapacity(0)).toBe(true); }); - it("cleanupWorkspace on a non-existent directory does not throw", async () => { - const { cleanupWorkspace } = await import("../../../apps/api/src/lib/workspace.js"); - // This job was never created - await expect(cleanupWorkspace(`does-not-exist-${randomUUID()}`)).resolves.toBeUndefined(); + it("isBelowCapacity returns false when free space is above 0.5 GB", async () => { + const { isBelowCapacity } = await import("../../../apps/api/src/lib/object-storage.js"); + // 1 GB free + expect(isBelowCapacity(1 * 1024 ** 3)).toBe(false); + // Exactly at threshold (0.5 GB) should be false (not strictly less) + expect(isBelowCapacity(0.5 * 1024 ** 3)).toBe(false); }); - it("createWorkspace is idempotent (calling twice does not throw)", async () => { - const { createWorkspace } = await import("../../../apps/api/src/lib/workspace.js"); - const jobId = randomUUID(); - await createWorkspace(jobId); - await expect(createWorkspace(jobId)).resolves.toBeDefined(); - }); - - it("createWorkspace with empty string job ID still creates a directory", async () => { - const { createWorkspace } = await import("../../../apps/api/src/lib/workspace.js"); - // Empty string is technically allowed by mkdir, it just uses WORKSPACE_PATH as the root - const root = await createWorkspace(""); - expect(existsSync(root)).toBe(true); - }); - - it("workspace directories are nested under the configured WORKSPACE_PATH", async () => { - const { createWorkspace, getWorkspacePath } = await import( - "../../../apps/api/src/lib/workspace.js" - ); - const jobId = randomUUID(); - const wsPath = getWorkspacePath(jobId); - const root = await createWorkspace(jobId); - - expect(wsPath).toBe(root); - expect(root.startsWith(TEST_WORKSPACE)).toBe(true); + it("isBelowCapacity boundary: just below 0.5 GB is true", async () => { + const { isBelowCapacity } = await import("../../../apps/api/src/lib/object-storage.js"); + // One byte below 0.5 GB + expect(isBelowCapacity(0.5 * 1024 ** 3 - 1)).toBe(true); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index b75747e6..4cde7bf8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -117,6 +117,8 @@ export default defineConfig({ jsqr: path.join(apiNodeModules, "jsqr"), pdfkit: path.join(apiNodeModules, "pdfkit"), sharp: path.join(apiNodeModules, "sharp"), + ioredis: path.join(apiNodeModules, "ioredis"), + bullmq: path.join(apiNodeModules, "bullmq"), "openid-client": path.join(apiNodeModules, "openid-client"), "opentype.js": path.join(apiNodeModules, "opentype.js"), "posthog-node": path.join(apiNodeModules, "posthog-node"),