import Database from 'better-sqlite3'; import { readFileSync, rmSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { randomUUID, scryptSync, randomBytes, timingSafeEqual } from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../logger.js'; import { buildVisibilityWhere } from '../bridge/visibility.js'; import { buildTitleFromGoal } from '../title-generation.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** * Shared SQL fragments for LocalTask read queries. * * getLocalTask / listLocalTasks / getLocalTaskByShareToken all need to expose * the owner's display name and the org display name of `visibility_scope_org_id`. * These constants keep the three queries in sync. * * Usage: splice into the SELECT list and the FROM-clause joins, e.g. * * SELECT lt.*, ${LOCAL_TASK_DISPLAY_SELECT} * FROM local_tasks lt * ${LOCAL_TASK_DISPLAY_JOIN} * WHERE ... * * A correlated subquery (`MIN(org_name)`) is used instead of a JOIN because * user_gitea_orgs is keyed per-user, and we only need any one display name * for the org id — this avoids row-multiplication across the join. */ const LOCAL_TASK_DISPLAY_SELECT = ` u.name AS owner_name, COALESCE( (SELECT MIN(org_name) FROM user_gitea_orgs WHERE org_id = lt.visibility_scope_org_id), (SELECT name FROM local_orgs WHERE id = lt.visibility_scope_org_id) ) AS visibility_scope_org_name `.trim(); const LOCAL_TASK_DISPLAY_JOIN = `LEFT JOIN users u ON u.id = lt.owner_id`; const SCHEDULED_TASK_DISPLAY_SELECT = ` u.name AS owner_name, COALESCE( (SELECT MIN(org_name) FROM user_gitea_orgs WHERE org_id = st.visibility_scope_org_id), (SELECT name FROM local_orgs WHERE id = st.visibility_scope_org_id) ) AS visibility_scope_org_name `.trim(); const SCHEDULED_TASK_DISPLAY_JOIN = `LEFT JOIN users u ON u.id = st.owner_id`; export type JobStatus = | 'queued' | 'dispatching' | 'running' | 'succeeded' | 'failed' | 'retry' | 'cancelled' | 'waiting_human' | 'waiting_subtasks'; export type JobRole = 'auto' | 'fast' | 'quality' | 'reflection'; /** @deprecated Use JobRole instead */ export type JobProfile = JobRole; /** @deprecated Removed — taskClass is no longer a separate concept */ export type TaskClass = 'auto' | 'low_level' | 'high_level'; export interface Job { id: string; repo: string; issueNumber: number; prNumber: number | null; status: JobStatus; pieceName: string; currentMovement: string | null; currentActivity: string | null; instruction: string; branchName: string | null; worktreePath: string | null; attempt: number; maxAttempts: number; nextRetryAt: string | null; errorSummary: string | null; abortReason: string | null; resumeMovement: string | null; waitReason: string | null; askCount: number; workerId: string | null; /** * Physical backend id (e.g. LiteLLM deployment name) that handled * this job's LLM calls when running through a proxy worker. Set on * the first LLM call and never overwritten — sticky-backend policy * per design Open Question #3. NULL for direct workers and for jobs * that haven't issued any proxied LLM call yet. */ lastBackendId: string | null; parentJobId: string | null; continuedFromJobId: string | null; subtaskDepth: number; requiredRole: JobRole; /** @deprecated Use requiredRole */ requiredProfile: JobRole; ownerId: string | null; visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null; contextPromptTokens: number | null; contextLimitTokens: number | null; contextUpdatedAt: string | null; browserSessionProfileId?: number | null; taskKind: 'agent' | 'reflection'; payload: string | null; createdAt: string; updatedAt: string; } export interface CreateJobParams { repo: string; issueNumber: number; instruction: string; pieceName?: string; maxAttempts?: number; resumeMovement?: string | null; askCount?: number; role?: JobRole; /** @deprecated Use role instead */ profile?: JobRole; parentJobId?: string | null; continuedFromJobId?: string | null; subtaskDepth?: number; ownerId?: string | null; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null; browserSessionProfileId?: number | null; taskKind?: 'agent' | 'reflection'; payload?: string; } export interface SubtaskInfo { id: string; issueNumber: number; status: JobStatus; instruction: string; worktreePath: string | null; createdAt: string; updatedAt: string; children?: SubtaskInfo[]; childCount?: number; childCompleted?: number; } export type TitleSource = 'auto' | 'agent' | 'user'; export interface LocalTask { id: number; title: string; /** Provenance of `title`. 'user' is never auto-overwritten by the agent. */ titleSource: TitleSource; body: string; pieceName: string; profile: 'auto' | 'fast' | 'quality' | string; outputFormat: 'text' | 'markdown' | 'json' | string; askPolicy: 'low' | 'high' | string; priority: 'low' | 'medium' | 'high' | string; state: 'open' | 'closed' | string; workspacePath: string | null; ownerId: string | null; ownerName?: string | null; visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null; visibilityScopeOrgName?: string | null; createdAt: string; updatedAt: string; feedbackRating: 'good' | 'bad' | null; feedbackTags: string[] | null; feedbackComment: string | null; feedbackAt: string | null; shareToken: string | null; sharedAt: string | null; browserSessionProfileId?: number | null; /** * Mission Brief: per-task pinned memo. Carries goal / done / open / * clarifications. Always rendered at the top of every movement's * system prompt. The LLM updates it via mission_update; the user * edits it from the Overview tab. */ missionBrief: MissionBrief | null; /** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). */ options: Record; latestJob?: Job | null; subtasks?: SubtaskInfo[]; subtaskCount?: number; subtaskCompleted?: number; } /** * Allowed widget kinds. 'markdown' is the original Side Info Panel widget * (PR #308). 'node-status' was added in Phase B (2026-05) and ignores * markdown_content — it renders BackendStatusRegistry data live. The union * intentionally lives here so the API, tools and UI all share a single * source of truth. */ export type DashboardWidgetKind = 'markdown' | 'node-status'; export const DASHBOARD_WIDGET_KINDS: readonly DashboardWidgetKind[] = ['markdown', 'node-status']; export function isDashboardWidgetKind(value: unknown): value is DashboardWidgetKind { return typeof value === 'string' && (DASHBOARD_WIDGET_KINDS as readonly string[]).includes(value); } export interface DashboardWidget { id: number; userId: string; slug: string; title: string; kind: DashboardWidgetKind; markdownContent: string; sortOrder: number; createdAt: string; updatedAt: string; } interface DashboardWidgetRow { id: number; user_id: string; slug: string; title: string; kind: string | null; markdown_content: string; sort_order: number; created_at: string; updated_at: string; } function rowToDashboardWidget(row: DashboardWidgetRow): DashboardWidget { // Defensive default: schema sets kind to 'markdown' but rows persisted // before the column was added can briefly show up as NULL while the // migration is racing with a read. Coerce to the default safely. const kind: DashboardWidgetKind = isDashboardWidgetKind(row.kind) ? row.kind : 'markdown'; return { id: row.id, userId: row.user_id, slug: row.slug, title: row.title, kind, markdownContent: row.markdown_content, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export interface MissionBrief { goal: string; done: string; open: string; clarifications: string; } // ── AAO Gateway Phase 2a: virtual keys ───────────────────────────────── // Stored shape returned by all gateway-key repository methods. The raw // bearer is NEVER persisted or returned — only `keyHash` (sha256) lives // in the DB, and `keyPrefix` is the human-readable head used in admin UI // lists (`sk-aao-XXXXXX`). The raw key surfaces exactly once, from the // admin API on issue / rotate; see src/bridge/admin-gateway-api.ts. export type GatewayVirtualKeySource = 'admin' | 'config-import'; export interface GatewayVirtualKey { id: string; keyHash: string; keyPrefix: string; team: string; /** Null = no per-key allowlist (any backend.model is accepted). */ allowedModels: string[] | null; source: GatewayVirtualKeySource; createdAt: string; createdBy: string | null; /** ISO timestamp if revoked; null while active. */ revokedAt: string | null; revokedBy: string | null; lastUsedAt: string | null; /** * Phase 2b: monthly tokens budget. NULL = unlimited. When set, the * gateway rejects requests with 402 once the current UTC month * `tokens_in + tokens_out` reaches this number (post-hoc enforcement — * the offending request that pushes the counter over the limit still * completes; the next one is rejected). */ tokensBudget: number | null; /** * Phase 2b: per-key requests-per-minute cap. NULL = unlimited. Enforced * as a sliding 60-second window in-process; multi-instance setups are * intentionally NOT synchronized (Phase 3 if needed). */ rateLimitRpm: number | null; } interface GatewayVirtualKeyRow { id: string; key_hash: string; key_prefix: string; team: string; allowed_models: string | null; source: string; created_at: string; created_by: string | null; revoked_at: string | null; revoked_by: string | null; last_used_at: string | null; tokens_budget: number | null; rate_limit_rpm: number | null; } // Phase 2b: monthly usage counter per virtual key. export interface GatewayKeyUsage { keyId: string; /** UTC month bucket as 'YYYY-MM' — see src/gateway/period.ts. */ periodStart: string; tokensIn: number; tokensOut: number; requests: number; lastUpdatedAt: string; } interface GatewayKeyUsageRow { key_id: string; period_start: string; tokens_in: number; tokens_out: number; requests: number; last_updated_at: string; } function rowToGatewayKeyUsage(row: GatewayKeyUsageRow): GatewayKeyUsage { return { keyId: row.key_id, periodStart: row.period_start, tokensIn: row.tokens_in, tokensOut: row.tokens_out, requests: row.requests, lastUpdatedAt: row.last_updated_at, }; } /** Per-call delta for the per-user daily LLM usage ledger. */ export interface LlmUsageIncrement { /** UTC day bucket 'YYYY-MM-DD'. Defaults to today (UTC) when omitted. */ day?: string; /** Owner id, or 'local' (no-auth) / 'system' (ownerless) sentinel. */ userId: string; source: 'gateway' | 'direct'; /** Real model name (chunk.model), routing-key fallback, or 'unknown'. */ model: string; /** Backend server name (gateway backendId / direct host), or 'unknown'. */ route: string; tokensIn?: number; tokensOut?: number; requests?: number; at?: string; } /** Daily-grouped aggregate row (model/route collapsed) for the usage API. */ export interface LlmUsageDailyAgg { day: string; userId: string; source: string; tokensIn: number; tokensOut: number; requests: number; } /** Hour-grain UPSERT input for the v2 usage ledger. */ export interface LlmUsageHourlyIncrement { /** UTC hour bucket 'YYYY-MM-DDTHH'. Defaults to the current hour (UTC). */ hour?: string; /** Owner id, or 'local' (no-auth) / 'system' (ownerless) sentinel. */ userId: string; source: 'gateway' | 'direct'; /** Real model name (chunk.model), routing-key fallback, or 'unknown'. */ model: string; /** Backend server name (gateway backendId / direct host), or 'unknown'. */ route: string; tokensIn?: number; tokensOut?: number; requests?: number; /** ISO timestamp for last_updated_at + hour default. Defaults to now. */ at?: string; } /** * Raw hour-grain ledger row (no axis collapsed) for the usage API. The API * re-buckets `hour` into the viewer's local calendar period and groups by * whichever of source/model/route/user/org the request asked for. */ export interface LlmUsageHourlyRow { hour: string; userId: string; source: string; model: string; route: string; tokensIn: number; tokensOut: number; requests: number; } /** * Coerce an optional limit (tokens_budget / rate_limit_rpm) to either * a positive integer or null. Anything else (undefined, null, 0, * negative, NaN, non-number) collapses to null = "no limit" so callers * can't accidentally persist a value that would silently block all * traffic. */ function normalizeOptionalPositiveInt(v: unknown): number | null { if (v === undefined || v === null) return null; if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) return null; return Math.floor(v); } function rowToGatewayVirtualKey(row: GatewayVirtualKeyRow): GatewayVirtualKey { let allowedModels: string[] | null = null; if (row.allowed_models !== null && row.allowed_models !== '') { try { const parsed: unknown = JSON.parse(row.allowed_models); if (Array.isArray(parsed) && parsed.every(x => typeof x === 'string')) { allowedModels = parsed; } } catch { // Corrupt JSON: treat as "no allowlist" (safer than fail-open // because routing has its own backend.model gate; this is just // the per-key filter on top). allowedModels = null; } } const source: GatewayVirtualKeySource = row.source === 'config-import' ? 'config-import' : 'admin'; // tokens_budget / rate_limit_rpm may legitimately arrive as null (no // limit). Coerce non-positive integers to null defensively because the // gateway middleware treats null as "unlimited" — a corrupt `0` would // otherwise silently block every request. const tokensBudget = typeof row.tokens_budget === 'number' && Number.isFinite(row.tokens_budget) && row.tokens_budget > 0 ? Math.floor(row.tokens_budget) : null; const rateLimitRpm = typeof row.rate_limit_rpm === 'number' && Number.isFinite(row.rate_limit_rpm) && row.rate_limit_rpm > 0 ? Math.floor(row.rate_limit_rpm) : null; return { id: row.id, keyHash: row.key_hash, keyPrefix: row.key_prefix, team: row.team, allowedModels, source, createdAt: row.created_at, createdBy: row.created_by, revokedAt: row.revoked_at, revokedBy: row.revoked_by, lastUsedAt: row.last_used_at, tokensBudget, rateLimitRpm, }; } export interface LocalTaskComment { id: number; taskId: number; author: string; kind: string; body: string; createdAt: string; injectedAt: string | null; } export interface WorkerNode { workerId: string; endpoint: string; enabled: boolean; healthy: boolean; roles: string[]; availableModels: string[]; inflightJobs: number; maxConcurrency: number; lastError: string | null; lastSeenAt: string; updatedAt: string; } export interface UpsertWorkerNodeParams { workerId: string; endpoint: string; enabled: boolean; healthy: boolean; roles: string[]; availableModels?: string[]; inflightJobs?: number; maxConcurrency?: number; lastError?: string | null; } export interface CreateLocalTaskParams { title: string; /** Defaults to 'auto'. Pass 'user' when the caller supplied an explicit title. */ titleSource?: TitleSource; body: string; pieceName?: string; profile?: 'auto' | 'fast' | 'quality'; outputFormat?: 'text' | 'markdown' | 'json'; askPolicy?: 'low' | 'high'; priority?: 'low' | 'medium' | 'high'; workspacePath?: string | null; ownerId?: string | null; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null; browserSessionProfileId?: number | null; /** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). Stored as JSON. */ options?: Record; } // ── Browser Notifications V2 (Web Push) ────────────────────────────── // Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md export type NotifyEventType = 'running' | 'succeeded' | 'failed' | 'waiting_human'; export interface PushSubscriptionRecord { id: string; userId: string; endpoint: string; p256dh: string; auth: string; userAgent: string | null; vapidKeyId: string; createdAt: string; lastSuccessAt: string | null; lastFailureAt: string | null; failureCount: number; } export interface UpsertPushSubscriptionInput { userId: string; endpoint: string; p256dh: string; auth: string; userAgent?: string | null; vapidKeyId: string; } export interface NotificationPrefs { userId: string; enabled: boolean; events: Record; includeDetails: boolean; v1Migrated: boolean; updatedAt: string; } export type NotificationPrefsUpdate = Partial>; export type ScheduledTaskKind = 'agent' | 'script'; export interface ScheduledTask { id: number; title: string | null; body: string; pieceName: string; profile: string; outputFormat: string; cronExpression: string; nextRunAt: string; lastRunAt: string | null; lastJobId: string | null; isActive: boolean; ownerId: string | null; ownerName?: string | null; visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null; visibilityScopeOrgName?: string | null; browserSessionProfileId?: number | null; taskKind: ScheduledTaskKind; scriptName: string | null; scriptParams: string | null; // JSON-encoded object or null createdAt: string; updatedAt: string; } export interface CreateScheduledTaskParams { title?: string | null; body: string; pieceName?: string; profile?: string; outputFormat?: string; cronExpression: string; nextRunAt: string; ownerId?: string | null; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null; browserSessionProfileId?: number | null; taskKind?: ScheduledTaskKind; scriptName?: string | null; scriptParams?: string | null; } export interface UpdateScheduledTaskParams { title?: string; body?: string; pieceName?: string; profile?: string; outputFormat?: string; cronExpression?: string; nextRunAt?: string; lastRunAt?: string; lastJobId?: string; isActive?: boolean; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null; browserSessionProfileId?: number | null; taskKind?: ScheduledTaskKind; scriptName?: string | null; scriptParams?: string | null; } export interface User { id: string; email: string; name: string | null; avatarUrl: string | null; role: 'admin' | 'user'; status: 'active' | 'pending' | 'disabled'; defaultVisibility: 'private' | 'org' | 'public'; defaultVisibilityOrgId: string | null; createdAt: string; updatedAt: string; } export interface CreateUserParams { email: string; name: string; role: 'admin' | 'user'; status: 'active' | 'pending' | 'disabled'; avatarUrl?: string; } export interface FindOrCreateByOAuthParams { provider: string; providerId: string; email: string; name: string; avatarUrl?: string; } export interface CreateLocalUserParams { email: string; password: string; role: 'admin' | 'user'; status: 'active' | 'pending' | 'disabled'; name?: string; } export interface LocalOrg { id: string; name: string; createdBy: string | null; createdAt: string; } export interface LocalOrgMember { userId: string; role: string; } export interface GiteaOrgInput { orgId: string; orgName: string; } export interface GiteaOrg extends GiteaOrgInput { fetchedAt: string; } interface JobRow { id: string; repo: string; issue_number: number; pr_number: number | null; status: string; piece_name: string; current_movement: string | null; current_activity: string | null; instruction: string; branch_name: string | null; worktree_path: string | null; attempt: number; max_attempts: number; next_retry_at: string | null; error_summary: string | null; abort_reason: string | null; resume_movement: string | null; wait_reason: string | null; ask_count: number; worker_id: string | null; last_backend_id: string | null; parent_job_id: string | null; continued_from_job_id: string | null; subtask_depth: number; required_profile: string; task_class: string; owner_id: string | null; visibility: string | null; visibility_scope_org_id: string | null; context_prompt_tokens: number | null; context_limit_tokens: number | null; context_updated_at: string | null; browser_session_profile_id: number | null; task_kind: string; payload: string | null; created_at: string; updated_at: string; } interface LocalTaskRow { id: number; title: string; title_source: string | null; body: string; piece_name: string; profile: string; output_format: string; ask_policy: string; priority: string; state: string; workspace_path: string | null; owner_id: string | null; owner_name?: string | null; visibility: string | null; visibility_scope_org_id: string | null; visibility_scope_org_name?: string | null; created_at: string; updated_at: string; feedback_rating: string | null; feedback_comment: string | null; feedback_tags: string | null; feedback_at: string | null; share_token: string | null; shared_at: string | null; mission_brief: string | null; browser_session_profile_id: number | null; options: string | null; } interface LocalTaskCommentRow { id: number; task_id: number; author: string; kind: string; body: string; created_at: string; injected_at: string | null; } interface UserRow { id: string; email: string; name: string | null; avatar_url: string | null; role: string; status: string; default_visibility: string | null; default_visibility_org_id: string | null; created_at: string; updated_at: string; } interface WorkerNodeRow { worker_id: string; endpoint: string; enabled: number; healthy: number; profile_tags: string; task_class_tags: string; available_models: string | null; inflight_jobs: number; max_concurrency: number; last_error: string | null; last_seen_at: string; updated_at: string; } function isJobRole(value: string): value is JobRole { return value === 'auto' || value === 'fast' || value === 'quality' || value === 'reflection'; } function normalizeJobRole(value: string | undefined): JobRole { return value && isJobRole(value) ? value : 'auto'; } function encodeTags(values: string[]): string { const unique = Array.from(new Set(values.filter(Boolean))); return `,${unique.join(',')},`; } function decodeTags(raw: string | null): string[] { if (!raw) return []; return raw.split(',').map((value) => value.trim()).filter(Boolean); } function decodeAvailableModels(raw: string | null): string[] { if (!raw) return []; try { const parsed = JSON.parse(raw) as unknown; return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []; } catch { return []; } } function deriveJobRole(instruction: string, explicitRole?: JobRole): JobRole { if (explicitRole) return explicitRole; const match = /ui_profile:\s*(auto|fast|quality)/i.exec(instruction); return normalizeJobRole(match?.[1]?.toLowerCase()); } /** SQLite datetime('now') は UTC だがタイムゾーン情報なし。'Z' を付加して ISO 8601 UTC として明示する */ function utc(dt: string | null): string { if (!dt) return ''; // 既に Z や +/- オフセットが付いていれば何もしない if (/[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt)) return dt; return dt.replace(' ', 'T') + 'Z'; } function rowToJob(row: JobRow): Job { return { id: row.id, repo: row.repo, issueNumber: row.issue_number, prNumber: row.pr_number, status: row.status as JobStatus, pieceName: row.piece_name, currentMovement: row.current_movement, currentActivity: row.current_activity, instruction: row.instruction, branchName: row.branch_name, worktreePath: row.worktree_path, attempt: row.attempt, maxAttempts: row.max_attempts, nextRetryAt: utc(row.next_retry_at), errorSummary: row.error_summary, abortReason: row.abort_reason ?? null, resumeMovement: row.resume_movement, waitReason: row.wait_reason ?? null, askCount: row.ask_count, workerId: row.worker_id, lastBackendId: row.last_backend_id ?? null, parentJobId: row.parent_job_id, continuedFromJobId: row.continued_from_job_id ?? null, subtaskDepth: row.subtask_depth ?? 0, requiredRole: normalizeJobRole(row.required_profile), requiredProfile: normalizeJobRole(row.required_profile), ownerId: row.owner_id ?? null, visibility: (row.visibility === 'org' || row.visibility === 'public' ? row.visibility : 'private'), visibilityScopeOrgId: row.visibility_scope_org_id ?? null, contextPromptTokens: row.context_prompt_tokens, contextLimitTokens: row.context_limit_tokens, contextUpdatedAt: row.context_updated_at ? utc(row.context_updated_at) : null, browserSessionProfileId: row.browser_session_profile_id ?? null, taskKind: row.task_kind === 'reflection' ? 'reflection' : 'agent', payload: row.payload, createdAt: utc(row.created_at), updatedAt: utc(row.updated_at), }; } function rowToLocalTask(row: LocalTaskRow): LocalTask { return { id: row.id, title: row.title, titleSource: (row.title_source as TitleSource | null) ?? 'auto', body: row.body, pieceName: row.piece_name, profile: row.profile, outputFormat: row.output_format, askPolicy: row.ask_policy, priority: row.priority, state: row.state, workspacePath: row.workspace_path, ownerId: row.owner_id ?? null, ownerName: row.owner_name ?? null, visibility: (row.visibility ?? 'private') as LocalTask['visibility'], visibilityScopeOrgId: row.visibility_scope_org_id ?? null, visibilityScopeOrgName: row.visibility_scope_org_name ?? null, createdAt: utc(row.created_at), updatedAt: utc(row.updated_at), feedbackRating: (row.feedback_rating as 'good' | 'bad' | null) ?? null, feedbackTags: row.feedback_tags ? JSON.parse(row.feedback_tags) : null, feedbackComment: row.feedback_comment ?? null, feedbackAt: row.feedback_at ? utc(row.feedback_at) : null, shareToken: row.share_token ?? null, sharedAt: row.shared_at ? utc(row.shared_at) : null, browserSessionProfileId: row.browser_session_profile_id ?? null, missionBrief: parseMissionBrief(row.mission_brief), options: parseTaskOptions(row.options), }; } function parseMissionBrief(raw: string | null | undefined): MissionBrief | null { if (!raw) return null; try { const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') return null; const goal = typeof parsed.goal === 'string' ? parsed.goal : ''; const done = typeof parsed.done === 'string' ? parsed.done : ''; const open = typeof parsed.open === 'string' ? parsed.open : ''; const clarifications = typeof parsed.clarifications === 'string' ? parsed.clarifications : ''; if (!goal && !done && !open && !clarifications) return null; return { goal, done, open, clarifications }; } catch { return null; } } function parseTaskOptions(raw: string | null | undefined): Record { if (!raw) return {}; try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record; } return {}; } catch { return {}; } } function rowToLocalTaskComment(row: LocalTaskCommentRow): LocalTaskComment { return { id: row.id, taskId: row.task_id, author: row.author, kind: row.kind, body: row.body, createdAt: utc(row.created_at), injectedAt: row.injected_at ? utc(row.injected_at) : null, }; } function rowToWorkerNode(row: WorkerNodeRow): WorkerNode { return { workerId: row.worker_id, endpoint: row.endpoint, enabled: row.enabled === 1, healthy: row.healthy === 1, roles: decodeTags(row.profile_tags), availableModels: decodeAvailableModels(row.available_models), inflightJobs: row.inflight_jobs, maxConcurrency: row.max_concurrency, lastError: row.last_error, lastSeenAt: utc(row.last_seen_at), updatedAt: utc(row.updated_at), }; } function rowToUser(row: UserRow): User { return { id: row.id, email: row.email, name: row.name, avatarUrl: row.avatar_url, role: row.role as 'admin' | 'user', status: row.status as 'active' | 'pending' | 'disabled', defaultVisibility: (row.default_visibility ?? 'private') as User['defaultVisibility'], defaultVisibilityOrgId: row.default_visibility_org_id, createdAt: utc(row.created_at), updatedAt: utc(row.updated_at), }; } export function localTaskRepoName(taskId: number): string { return `local/task-${taskId}`; } export class Repository { private readonly db: Database.Database; constructor(dbPath: string) { this.db = new Database(dbPath); this.db.pragma('journal_mode = WAL'); this.db.pragma('foreign_keys = ON'); this.db.pragma('busy_timeout = 5000'); this.initSchema(); logger.info(`Repository: initialized at ${dbPath}`); } private initSchema(): void { const schemaPath = join(__dirname, 'schema.sql'); const schema = readFileSync(schemaPath, 'utf-8'); this.db.exec(schema); this.ensureColumn('jobs', 'required_profile', "TEXT NOT NULL DEFAULT 'auto'"); this.ensureColumn('jobs', 'task_class', "TEXT NOT NULL DEFAULT 'auto'"); this.ensureColumn('worker_nodes', 'profile_tags', "TEXT NOT NULL DEFAULT ',auto,'"); this.ensureColumn('worker_nodes', 'task_class_tags', "TEXT NOT NULL DEFAULT ',auto,'"); this.ensureColumn('worker_nodes', 'available_models', 'TEXT'); this.ensureColumn('worker_nodes', 'max_concurrency', 'INTEGER NOT NULL DEFAULT 1'); this.ensureColumn('worker_nodes', 'last_error', 'TEXT'); this.db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_profile_task_class ON jobs (status, required_profile, task_class)"); this.db.exec("CREATE INDEX IF NOT EXISTS idx_worker_nodes_health ON worker_nodes (enabled, healthy, last_seen_at)"); this.ensureColumn('jobs', 'parent_job_id', 'TEXT'); this.ensureColumn('jobs', 'subtask_depth', 'INTEGER NOT NULL DEFAULT 0'); this.ensureColumn('jobs', 'wait_reason', 'TEXT'); this.ensureColumn('jobs', 'continued_from_job_id', 'TEXT'); // Phase A (multi-team GPU pool): physical backend id assigned when // the worker is a proxy. Sticky once set; never overwritten mid-job. this.ensureColumn('jobs', 'last_backend_id', 'TEXT'); this.db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_parent_job_id ON jobs (parent_job_id)"); this.ensureColumn('local_tasks', 'feedback_rating', 'TEXT'); this.ensureColumn('local_tasks', 'feedback_comment', 'TEXT'); this.ensureColumn('local_tasks', 'feedback_tags', 'TEXT'); this.ensureColumn('local_tasks', 'feedback_at', 'TEXT'); this.migrateWaitingSubtasksStatus(); // Auth migrations: owner_id columns this.ensureColumn('jobs', 'owner_id', 'TEXT'); this.ensureColumn('local_tasks', 'owner_id', 'TEXT'); // #142: 実行中アクティビティ表示 this.ensureColumn('jobs', 'current_activity', 'TEXT'); // abortReason 細分化: agent-loop / piece-runner が出す構造化 abort code を保持 this.ensureColumn('jobs', 'abort_reason', 'TEXT'); // #134: 共有機能 this.ensureColumn('local_tasks', 'share_token', 'TEXT'); this.ensureColumn('local_tasks', 'shared_at', 'TEXT'); this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_local_tasks_share_token ON local_tasks (share_token)"); // Ownership and visibility columns (3 tables) for (const table of ['local_tasks', 'scheduled_tasks', 'jobs']) { this.ensureColumn(table, 'owner_id', 'TEXT'); this.ensureColumn(table, 'visibility', "TEXT NOT NULL DEFAULT 'private'"); this.ensureColumn(table, 'visibility_scope_org_id', 'TEXT'); } // User preferences this.ensureColumn('users', 'default_visibility', "TEXT NOT NULL DEFAULT 'private'"); this.ensureColumn('users', 'default_visibility_org_id', 'TEXT'); // Indexes and user_gitea_orgs table this.db.exec(` CREATE INDEX IF NOT EXISTS idx_local_tasks_owner_id ON local_tasks(owner_id); CREATE INDEX IF NOT EXISTS idx_local_tasks_visibility ON local_tasks(visibility, visibility_scope_org_id); CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_owner_id ON scheduled_tasks(owner_id); CREATE INDEX IF NOT EXISTS idx_jobs_owner_id ON jobs(owner_id); CREATE INDEX IF NOT EXISTS idx_jobs_visibility ON jobs(visibility, visibility_scope_org_id); CREATE TABLE IF NOT EXISTS user_gitea_orgs ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, org_id TEXT NOT NULL, org_name TEXT NOT NULL, fetched_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, org_id) ); CREATE INDEX IF NOT EXISTS idx_user_gitea_orgs_org_id ON user_gitea_orgs(org_id); `); // Browser session persistence (2026-05) — keep in sync with schema.sql this.db.exec(` CREATE TABLE IF NOT EXISTS user_deks ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, encrypted_dek BLOB NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS browser_session_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, label TEXT NOT NULL, start_url TEXT NOT NULL, match_patterns TEXT NOT NULL DEFAULT '[]', storage_origins TEXT NOT NULL DEFAULT '[]', logged_in_selector TEXT, login_url_patterns TEXT NOT NULL DEFAULT '[]', encrypted_state_blob BLOB, state_version INTEGER NOT NULL DEFAULT 0, playwright_version TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','expired','revoked','error')), last_saved_at TEXT, last_used_at TEXT, last_validated_at TEXT, last_error TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_bsp_owner ON browser_session_profiles(owner_id); -- audit log: intentionally no FK — must survive deletion of referenced rows CREATE TABLE IF NOT EXISTS browser_session_audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL DEFAULT (datetime('now')), actor_user_id TEXT, profile_id INTEGER, owner_id TEXT, action TEXT NOT NULL CHECK (action IN ('create','save','decrypt','use','delete','expire','revoke','test','login_start','login_cancel')), task_id INTEGER, job_id TEXT, result TEXT NOT NULL CHECK (result IN ('success','error')), reason TEXT ); CREATE INDEX IF NOT EXISTS idx_bsa_profile ON browser_session_audit(profile_id); CREATE INDEX IF NOT EXISTS idx_bsa_actor ON browser_session_audit(actor_user_id); `); this.ensureColumn('local_tasks', 'browser_session_profile_id', 'INTEGER REFERENCES browser_session_profiles(id) ON DELETE SET NULL'); this.ensureColumn('scheduled_tasks', 'browser_session_profile_id', 'INTEGER REFERENCES browser_session_profiles(id) ON DELETE SET NULL'); this.ensureColumn('jobs', 'browser_session_profile_id', 'INTEGER REFERENCES browser_session_profiles(id) ON DELETE SET NULL'); // E: scheduled_tasks can now run a user script directly (without going // through the agent / LLM loop). task_kind='agent' (default) keeps the // pre-existing behavior; task_kind='script' uses script_name + script_params. this.ensureColumn('scheduled_tasks', 'task_kind', "TEXT NOT NULL DEFAULT 'agent' CHECK (task_kind IN ('agent','script'))"); this.ensureColumn('scheduled_tasks', 'script_name', 'TEXT'); this.ensureColumn('scheduled_tasks', 'script_params', 'TEXT'); // JSON-encoded object or NULL // F: reflection jobs — task_kind distinguishes agent jobs from reflection // jobs that run the self-improving-memory pipeline (no LLM task loop). // payload carries JSON inputs (scope, trigger metadata, etc.). this.ensureColumn('jobs', 'task_kind', "TEXT NOT NULL DEFAULT 'agent'"); this.ensureColumn('jobs', 'payload', 'TEXT'); // G: reflection piece-edit cooldown tracking. // reflection_piece_edits records each time the reflection pipeline writes // a user's custom piece. The cooldown gate in piece-writer.ts queries // countRecentPieceEdits to rate-limit piece rewrites. this.db.exec(` CREATE TABLE IF NOT EXISTS reflection_piece_edits ( user_id TEXT NOT NULL, piece_name TEXT NOT NULL, snapshot_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (user_id, piece_name, created_at) ); CREATE INDEX IF NOT EXISTS idx_rpe_user_piece_time ON reflection_piece_edits (user_id, piece_name, created_at DESC); `); // Phase B (node-status widget, 2026-05): per-widget kind discriminator. // 'markdown' (default) preserves the original Markdown-only behavior; // 'node-status' renders BackendStatusRegistry data live. Existing rows // default to 'markdown' so direct-mode deployments stay unchanged. this.ensureColumn('user_dashboard_widgets', 'kind', "TEXT NOT NULL DEFAULT 'markdown'"); // H: reflection_metrics — one row per reflection job, records outcome, // token usage, memory changes, and whether a piece edit was applied. // Used by the /api/reflection/metrics endpoint (Phase 7.2) and future // per-user budget enforcement (Phase 8.2). this.db.exec(` CREATE TABLE IF NOT EXISTS reflection_metrics ( id INTEGER PRIMARY KEY AUTOINCREMENT, reflection_job_id TEXT NOT NULL, original_job_id TEXT, user_id TEXT NOT NULL, piece_name TEXT, outcome TEXT NOT NULL, memory_changes INTEGER NOT NULL DEFAULT 0, piece_edited INTEGER NOT NULL DEFAULT 0, tokens_in INTEGER NOT NULL DEFAULT 0, tokens_out INTEGER NOT NULL DEFAULT 0, duration_ms INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_rm_user_time ON reflection_metrics (user_id, created_at DESC); `); // I: AAO Gateway Phase 2a — DB-backed virtual keys. // Mirrors src/db/schema.sql and migrateGatewayVirtualKeys in migrate.ts; // all three paths must stay in sync (project_db_migration_dual_path). this.db.exec(` CREATE TABLE IF NOT EXISTS gateway_virtual_keys ( id TEXT PRIMARY KEY, key_hash TEXT NOT NULL UNIQUE, key_prefix TEXT NOT NULL, team TEXT NOT NULL, allowed_models TEXT, source TEXT NOT NULL DEFAULT 'admin' CHECK (source IN ('admin','config-import')), created_at TEXT NOT NULL, created_by TEXT, revoked_at TEXT, revoked_by TEXT, last_used_at TEXT ); CREATE INDEX IF NOT EXISTS idx_gateway_keys_hash_active ON gateway_virtual_keys (key_hash) WHERE revoked_at IS NULL; CREATE INDEX IF NOT EXISTS idx_gateway_keys_team ON gateway_virtual_keys (team); `); // I.b: AAO Gateway Phase 2b — budget / rate limit columns + // gateway_key_usage table. Mirrors schema.sql + migrate.ts and uses // the same PRAGMA-based idempotency pattern. this.ensureColumn('gateway_virtual_keys', 'tokens_budget', 'INTEGER'); this.ensureColumn('gateway_virtual_keys', 'rate_limit_rpm', 'INTEGER'); this.db.exec(` CREATE TABLE IF NOT EXISTS gateway_key_usage ( key_id TEXT NOT NULL REFERENCES gateway_virtual_keys(id) ON DELETE CASCADE, period_start TEXT NOT NULL, tokens_in INTEGER NOT NULL DEFAULT 0, tokens_out INTEGER NOT NULL DEFAULT 0, requests INTEGER NOT NULL DEFAULT 0, last_updated_at TEXT NOT NULL, PRIMARY KEY (key_id, period_start) ); CREATE INDEX IF NOT EXISTS idx_gateway_usage_key ON gateway_key_usage (key_id); `); // Per-user daily LLM usage (gateway + direct). Mirrors schema.sql + // migrate.ts (dual-path rule). Separate lens from gateway_key_usage. // Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md this.db.exec(` CREATE TABLE IF NOT EXISTS llm_usage_daily ( day TEXT NOT NULL, user_id TEXT NOT NULL, source TEXT NOT NULL, model TEXT NOT NULL, route TEXT NOT NULL, tokens_in INTEGER NOT NULL DEFAULT 0, tokens_out INTEGER NOT NULL DEFAULT 0, requests INTEGER NOT NULL DEFAULT 0, last_updated_at TEXT NOT NULL, PRIMARY KEY (day, user_id, source, model, route) ); CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day ON llm_usage_daily (user_id, day); `); // Usage dashboard v2: hour-grain ledger (supersedes llm_usage_daily as the // write target; daily kept as frozen archive). Mirrors schema.sql + // migrate.ts (dual-path). Backfill of the daily archive lives in migrate.ts // so it runs once on upgrade, not on every fresh initSchema. // Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md this.db.exec(` CREATE TABLE IF NOT EXISTS llm_usage_hourly ( hour TEXT NOT NULL, user_id TEXT NOT NULL, source TEXT NOT NULL, model TEXT NOT NULL, route TEXT NOT NULL, tokens_in INTEGER NOT NULL DEFAULT 0, tokens_out INTEGER NOT NULL DEFAULT 0, requests INTEGER NOT NULL DEFAULT 0, last_updated_at TEXT NOT NULL, PRIMARY KEY (hour, user_id, source, model, route) ); CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour ON llm_usage_hourly (user_id, hour); `); } private ensureColumn(tableName: string, columnName: string, definition: string): void { const columns = this.db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>; if (columns.some((column) => column.name === columnName)) { return; } this.db.prepare(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`).run(); } private migrateWaitingSubtasksStatus(): void { // Check if jobs table already has waiting_subtasks in its CHECK constraint const tableInfo = this.db.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='jobs'" ).get() as { sql: string } | undefined; if (!tableInfo || tableInfo.sql.includes('waiting_subtasks')) return; // Recreate jobs table with updated CHECK constraint logger.info('Repository: migrating jobs table to support waiting_subtasks status...'); this.db.transaction(() => { this.db.exec(` CREATE TABLE IF NOT EXISTS jobs_v2 ( id TEXT PRIMARY KEY, repo TEXT NOT NULL, issue_number INTEGER NOT NULL, pr_number INTEGER, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','dispatching','running','succeeded','failed','retry','cancelled','waiting_human','waiting_subtasks')), piece_name TEXT NOT NULL DEFAULT 'general', required_profile TEXT NOT NULL DEFAULT 'auto', task_class TEXT NOT NULL DEFAULT 'auto', current_movement TEXT, instruction TEXT NOT NULL DEFAULT '', branch_name TEXT, worktree_path TEXT, attempt INTEGER NOT NULL DEFAULT 1, max_attempts INTEGER NOT NULL DEFAULT 3, next_retry_at TEXT, error_summary TEXT, resume_movement TEXT, ask_count INTEGER NOT NULL DEFAULT 0, worker_id TEXT, parent_job_id TEXT, subtask_depth INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); INSERT INTO jobs_v2 SELECT id, repo, issue_number, pr_number, status, piece_name, required_profile, task_class, current_movement, instruction, branch_name, worktree_path, attempt, max_attempts, next_retry_at, error_summary, resume_movement, ask_count, worker_id, NULL AS parent_job_id, 0 AS subtask_depth, created_at, updated_at FROM jobs; DROP TABLE jobs; ALTER TABLE jobs_v2 RENAME TO jobs; `); })(); logger.info('Repository: jobs table migration complete'); } async createJob(params: CreateJobParams): Promise { const id = randomUUID(); const now = new Date().toISOString(); const pieceName = params.pieceName ?? 'chat'; const maxAttempts = params.maxAttempts ?? 3; const resumeMovement = params.resumeMovement ?? null; const askCount = params.askCount ?? 0; const requiredRole = deriveJobRole(params.instruction, params.role ?? params.profile); this.db .prepare( `INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class, instruction, attempt, max_attempts, resume_movement, ask_count, worker_id, parent_job_id, continued_from_job_id, subtask_depth, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, payload, created_at, updated_at) VALUES (@id, @repo, @issueNumber, 'queued', @pieceName, @requiredRole, 'auto', @instruction, 1, @maxAttempts, @resumeMovement, @askCount, NULL, @parentJobId, @continuedFromJobId, @subtaskDepth, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @payload, @now, @now)` ) .run({ id, repo: params.repo, issueNumber: params.issueNumber, pieceName, instruction: params.instruction, maxAttempts, resumeMovement, askCount, requiredRole, parentJobId: params.parentJobId ?? null, continuedFromJobId: params.continuedFromJobId ?? null, subtaskDepth: params.subtaskDepth ?? 0, ownerId: params.ownerId ?? null, visibility: params.visibility ?? 'private', visibilityScopeOrgId: params.visibilityScopeOrgId ?? null, browserSessionProfileId: params.browserSessionProfileId ?? null, taskKind: params.taskKind ?? 'agent', payload: params.payload ?? null, now, }); const job = this.getJobSync(id); if (!job) throw new Error(`createJob: failed to retrieve created job ${id}`); return job; } async getJob(id: string, opts?: { viewer?: Express.User }): Promise { const viewerClause = opts?.viewer ? buildVisibilityWhere(opts.viewer, 'j') : { clause: '1=1', params: [] as unknown[] }; const row = this.db .prepare(`SELECT j.* FROM jobs j WHERE j.id = ? AND ${viewerClause.clause}`) .get(id, ...viewerClause.params) as JobRow | undefined; return row ? rowToJob(row) : null; } async createLocalTask(params: CreateLocalTaskParams): Promise { const result = this.db .prepare( `INSERT INTO local_tasks (title, title_source, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, options) VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @options)` ) .run({ title: params.title, titleSource: params.titleSource ?? 'auto', body: params.body, pieceName: params.pieceName ?? 'chat', profile: params.profile ?? 'auto', outputFormat: params.outputFormat ?? 'markdown', askPolicy: params.askPolicy ?? 'low', priority: params.priority ?? 'medium', workspacePath: params.workspacePath ?? null, ownerId: params.ownerId ?? null, visibility: params.visibility ?? 'private', visibilityScopeOrgId: params.visibilityScopeOrgId ?? null, browserSessionProfileId: params.browserSessionProfileId ?? null, options: JSON.stringify(params.options ?? {}), }); const task = await this.getLocalTask(Number(result.lastInsertRowid)); if (!task) throw new Error('createLocalTask: failed to load inserted task'); return task; } /** サブジョブ一覧を SubtaskInfo[] に変換。waiting_subtasks の子は再帰的に children を取得する */ private async buildSubtaskInfos(subJobs: Job[], maxDepth: number = 3): Promise { return Promise.all(subJobs.map(async (j): Promise => { const info: SubtaskInfo = { id: j.id, issueNumber: j.issueNumber, status: j.status, instruction: j.instruction, worktreePath: j.worktreePath, createdAt: j.createdAt, updatedAt: j.updatedAt, }; // 再帰: waiting_subtasks のサブタスクは孫タスク情報も取得 if (j.status === 'waiting_subtasks' && maxDepth > 0) { const grandChildren = await this.getSubJobs(j.id); if (grandChildren.length > 0) { info.children = await this.buildSubtaskInfos(grandChildren, maxDepth - 1); info.childCount = grandChildren.length; info.childCompleted = grandChildren.filter(g => ['succeeded', 'failed', 'cancelled'].includes(g.status) ).length; } } return info; })); } async getLocalTask(taskId: number, opts?: { viewer?: Express.User }): Promise { const viewerClause = opts?.viewer ? buildVisibilityWhere(opts.viewer, 'lt') : { clause: '1=1', params: [] as unknown[] }; const row = this.db .prepare(` SELECT lt.*, ${LOCAL_TASK_DISPLAY_SELECT} FROM local_tasks lt ${LOCAL_TASK_DISPLAY_JOIN} WHERE lt.id = ? AND ${viewerClause.clause} `) .get(taskId, ...viewerClause.params) as LocalTaskRow | undefined; if (!row) return null; const task = rowToLocalTask(row); task.latestJob = await this.getLatestJobForIssue(localTaskRepoName(taskId), taskId); // サブタスク情報を付与 if (task.latestJob) { const subJobs = await this.getSubJobs(task.latestJob.id); if (subJobs.length > 0) { task.subtasks = await this.buildSubtaskInfos(subJobs); task.subtaskCount = subJobs.length; task.subtaskCompleted = subJobs.filter(j => ['succeeded', 'failed', 'cancelled'].includes(j.status) ).length; } } return task; } async shareLocalTask(taskId: number): Promise { const existing = await this.getLocalTask(taskId); if (!existing) throw new Error(`Task ${taskId} not found`); if (existing.shareToken) return existing.shareToken; const token = randomUUID(); this.db.prepare( `UPDATE local_tasks SET share_token = ?, shared_at = datetime('now'), updated_at = datetime('now') WHERE id = ?` ).run(token, taskId); return token; } async unshareLocalTask(taskId: number): Promise { this.db.prepare( `UPDATE local_tasks SET share_token = NULL, shared_at = NULL, updated_at = datetime('now') WHERE id = ?` ).run(taskId); } /** * Mission Brief: partial-replace update. Only fields explicitly provided * are written; undefined leaves the field untouched. Passing all-empty * strings is treated as "clear the brief" (NULL in storage). * * Returns the merged brief so callers can echo it back to the client * without an extra read. */ async updateMissionBrief( taskId: number, patch: Partial, ): Promise { return this.updateMissionBriefSync(taskId, patch); } /** Sync variant used by the engine's MissionBriefIO so it can be called * from sync paths (e.g. buildSystemPrompt). better-sqlite3 is sync * underneath anyway. */ updateMissionBriefSync(taskId: number, patch: Partial): MissionBrief | null { const row = this.db .prepare(`SELECT mission_brief, title_source FROM local_tasks WHERE id = ?`) .get(taskId) as { mission_brief: string | null; title_source: string | null } | undefined; const existing = parseMissionBrief(row?.mission_brief ?? null); const next: MissionBrief = { goal: patch.goal !== undefined ? patch.goal : existing?.goal ?? '', done: patch.done !== undefined ? patch.done : existing?.done ?? '', open: patch.open !== undefined ? patch.open : existing?.open ?? '', clarifications: patch.clarifications !== undefined ? patch.clarifications : existing?.clarifications ?? '', }; const allEmpty = !next.goal && !next.done && !next.open && !next.clarifications; const stored = allEmpty ? null : JSON.stringify(next); // Derive the task title from the agent's goal (no LLM call). Only when the // goal value actually changed (agents re-send an unchanged brief across // iterations — re-deriving every time would churn updated_at and flicker // the title) and the user hasn't taken manual control (a user edit pins // title_source='user' and is never overwritten). const goalChanged = patch.goal !== undefined && patch.goal !== (existing?.goal ?? ''); const derivedTitle = (goalChanged && (row?.title_source ?? 'auto') !== 'user') ? buildTitleFromGoal(next.goal) : ''; // Atomic: persist the brief and the derived title as one unit so a crash // between them can't leave the title out of sync with the goal. this.db.transaction(() => { this.db.prepare( `UPDATE local_tasks SET mission_brief = ?, updated_at = datetime('now') WHERE id = ?` ).run(stored, taskId); if (derivedTitle) { this.db.prepare( `UPDATE local_tasks SET title = ?, title_source = 'agent' WHERE id = ?` ).run(derivedTitle, taskId); } })(); return allEmpty ? null : next; } /** Sync read of the mission brief column. Used by MissionBriefIO.read() */ getMissionBriefSync(taskId: number): MissionBrief | null { const row = this.db.prepare(`SELECT mission_brief FROM local_tasks WHERE id = ?`).get(taskId) as { mission_brief: string | null } | undefined; return parseMissionBrief(row?.mission_brief ?? null); } /** * Construct a MissionBriefIO bound to a specific local task. The engine * uses this to thread mission brief read/write into the ToolContext * without leaking the repository instance into tool code. */ makeMissionBriefIO(taskId: number): import('../engine/tools/core.js').MissionBriefIO { return { read: () => this.getMissionBriefSync(taskId), update: (patch) => this.updateMissionBriefSync(taskId, patch), }; } async getLocalTaskByShareToken(token: string): Promise { const row = this.db .prepare(` SELECT lt.*, ${LOCAL_TASK_DISPLAY_SELECT} FROM local_tasks lt ${LOCAL_TASK_DISPLAY_JOIN} WHERE lt.share_token = ? `) .get(token) as LocalTaskRow | undefined; if (!row) return null; const task = rowToLocalTask(row); task.latestJob = await this.getLatestJobForIssue(localTaskRepoName(task.id), task.id); if (task.latestJob) { const subJobs = await this.getSubJobs(task.latestJob.id); if (subJobs.length > 0) { task.subtasks = await this.buildSubtaskInfos(subJobs); task.subtaskCount = subJobs.length; task.subtaskCompleted = subJobs.filter(j => ['succeeded', 'failed', 'cancelled'].includes(j.status) ).length; } } return task; } async listLocalTasks(filter?: { ownerId?: string; viewer?: Express.User }): Promise { // 1. Single JOIN query: local_tasks LEFT JOIN jobs (latest per task via correlated subquery) const conditions: string[] = []; const queryParams: unknown[] = []; if (filter?.ownerId) { conditions.push('lt.owner_id = ?'); queryParams.push(filter.ownerId); } if (filter?.viewer) { const w = buildVisibilityWhere(filter.viewer, 'lt'); conditions.push(w.clause); queryParams.push(...w.params); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const joinedRows = this.db .prepare(` SELECT lt.*, ${LOCAL_TASK_DISPLAY_SELECT}, j.id AS job_id, j.repo AS job_repo, j.issue_number AS job_issue_number, j.pr_number AS job_pr_number, j.status AS job_status, j.piece_name AS job_piece_name, j.required_profile AS job_required_profile, j.task_class AS job_task_class, j.current_movement AS job_current_movement, j.current_activity AS job_current_activity, j.instruction AS job_instruction, j.branch_name AS job_branch_name, j.worktree_path AS job_worktree_path, j.attempt AS job_attempt, j.max_attempts AS job_max_attempts, j.next_retry_at AS job_next_retry_at, j.error_summary AS job_error_summary, j.abort_reason AS job_abort_reason, j.resume_movement AS job_resume_movement, j.wait_reason AS job_wait_reason, j.ask_count AS job_ask_count, j.worker_id AS job_worker_id, j.last_backend_id AS job_last_backend_id, j.parent_job_id AS job_parent_job_id, j.continued_from_job_id AS job_continued_from_job_id, j.subtask_depth AS job_subtask_depth, j.owner_id AS job_owner_id, j.visibility AS job_visibility, j.visibility_scope_org_id AS job_visibility_scope_org_id, j.created_at AS job_created_at, j.updated_at AS job_updated_at, j.context_prompt_tokens AS job_context_prompt_tokens, j.context_limit_tokens AS job_context_limit_tokens, j.context_updated_at AS job_context_updated_at, j.browser_session_profile_id AS job_browser_session_profile_id, j.task_kind AS job_task_kind, j.payload AS job_payload FROM local_tasks lt ${LOCAL_TASK_DISPLAY_JOIN} LEFT JOIN jobs j ON j.id = ( SELECT j2.id FROM jobs j2 WHERE j2.repo = 'local/task-' || lt.id AND j2.issue_number = lt.id ORDER BY j2.created_at DESC, j2.rowid DESC LIMIT 1 ) ${whereClause} ORDER BY lt.updated_at DESC, lt.id DESC `) .all(...queryParams) as Array; // Build tasks with latestJob from joined data const tasks: LocalTask[] = []; const jobIds: string[] = []; for (const row of joinedRows) { const task = rowToLocalTask(row); if (row.job_id) { task.latestJob = rowToJob({ id: row.job_id, repo: row.job_repo!, issue_number: row.job_issue_number!, pr_number: row.job_pr_number ?? null, status: row.job_status!, piece_name: row.job_piece_name!, current_movement: row.job_current_movement ?? null, current_activity: row.job_current_activity ?? null, instruction: row.job_instruction!, branch_name: row.job_branch_name ?? null, worktree_path: row.job_worktree_path ?? null, attempt: row.job_attempt!, max_attempts: row.job_max_attempts!, next_retry_at: row.job_next_retry_at ?? null, error_summary: row.job_error_summary ?? null, abort_reason: row.job_abort_reason ?? null, resume_movement: row.job_resume_movement ?? null, wait_reason: row.job_wait_reason ?? null, ask_count: row.job_ask_count!, worker_id: row.job_worker_id ?? null, last_backend_id: row.job_last_backend_id ?? null, parent_job_id: row.job_parent_job_id ?? null, continued_from_job_id: row.job_continued_from_job_id ?? null, subtask_depth: row.job_subtask_depth ?? 0, required_profile: row.job_required_profile!, task_class: row.job_task_class!, owner_id: row.job_owner_id ?? null, visibility: row.job_visibility ?? null, visibility_scope_org_id: row.job_visibility_scope_org_id ?? null, context_prompt_tokens: row.job_context_prompt_tokens ?? null, context_limit_tokens: row.job_context_limit_tokens ?? null, context_updated_at: row.job_context_updated_at ?? null, browser_session_profile_id: row.job_browser_session_profile_id ?? null, task_kind: row.job_task_kind ?? 'agent', payload: row.job_payload ?? null, created_at: row.job_created_at!, updated_at: row.job_updated_at!, }); jobIds.push(row.job_id); } else { task.latestJob = null; } tasks.push(task); } // 2. Single query for all sub-jobs if (jobIds.length > 0) { const placeholders = jobIds.map(() => '?').join(', '); const subJobRows = this.db .prepare(` SELECT * FROM ( SELECT j.*, ROW_NUMBER() OVER ( PARTITION BY j.parent_job_id, j.issue_number ORDER BY j.created_at DESC, j.rowid DESC ) AS rn FROM jobs j WHERE j.parent_job_id IN (${placeholders}) ) WHERE rn = 1 ORDER BY parent_job_id, issue_number ASC `) .all(...jobIds) as JobRow[]; // Group sub-jobs by parent_job_id const subJobsByParent = new Map(); for (const row of subJobRows) { const parentId = row.parent_job_id!; if (!subJobsByParent.has(parentId)) { subJobsByParent.set(parentId, []); } subJobsByParent.get(parentId)!.push(rowToJob(row)); } // 3. Attach subtask info to tasks for (const task of tasks) { if (task.latestJob && subJobsByParent.has(task.latestJob.id)) { const subJobs = subJobsByParent.get(task.latestJob.id)!; task.subtasks = await this.buildSubtaskInfos(subJobs); task.subtaskCount = subJobs.length; task.subtaskCompleted = subJobs.filter(j => ['succeeded', 'failed', 'cancelled'].includes(j.status) ).length; } } } return tasks; } async updateLocalTask(taskId: number, updates: Partial>): Promise { const setClauses: string[] = ["updated_at = datetime('now')"]; const params: Record = { taskId }; const fieldMap: Record = { title: 'title', titleSource: 'title_source', body: 'body', pieceName: 'piece_name', profile: 'profile', outputFormat: 'output_format', askPolicy: 'ask_policy', priority: 'priority', state: 'state', workspacePath: 'workspace_path', visibility: 'visibility', visibilityScopeOrgId: 'visibility_scope_org_id', }; for (const [jsKey, dbCol] of Object.entries(fieldMap)) { const val = (updates as Record)[jsKey]; if (val !== undefined) { setClauses.push(`${dbCol} = @${jsKey}`); params[jsKey] = val; } } if (setClauses.length === 1) return; this.db .prepare(`UPDATE local_tasks SET ${setClauses.join(', ')} WHERE id = @taskId`) .run(params); } async updateFeedback(taskId: number, feedback: { rating: 'good' | 'bad'; tags: string[]; comment: string | null; }): Promise { this.db .prepare(` UPDATE local_tasks SET feedback_rating = @rating, feedback_tags = @tags, feedback_comment = @comment, feedback_at = datetime('now'), updated_at = datetime('now') WHERE id = @taskId `) .run({ taskId, rating: feedback.rating, tags: JSON.stringify(feedback.tags), comment: feedback.comment, }); } async addLocalTaskComment(taskId: number, author: string, body: string, kind: string = 'comment'): Promise { const result = this.db .prepare('INSERT INTO local_task_comments (task_id, author, kind, body) VALUES (?, ?, ?, ?)') .run(taskId, author, kind, body); this.db .prepare("UPDATE local_tasks SET updated_at = datetime('now') WHERE id = ?") .run(taskId); const row = this.db .prepare('SELECT * FROM local_task_comments WHERE id = ?') .get(Number(result.lastInsertRowid)) as LocalTaskCommentRow | undefined; if (!row) throw new Error('addLocalTaskComment: failed to load inserted comment'); return rowToLocalTaskComment(row); } async listLocalTaskComments(taskId: number): Promise { const rows = this.db .prepare('SELECT * FROM local_task_comments WHERE task_id = ? ORDER BY created_at ASC, id ASC') .all(taskId) as LocalTaskCommentRow[]; return rows.map(rowToLocalTaskComment); } async getUninjectedComments(taskId: number, sinceId: number = 0): Promise { const rows = this.db .prepare( `SELECT * FROM local_task_comments WHERE task_id = ? AND id > ? AND author = 'user' AND injected_at IS NULL ORDER BY id ASC` ) .all(taskId, sinceId) as LocalTaskCommentRow[]; return rows.map(rowToLocalTaskComment); } markCommentsInjected(commentIds: number[]): void { if (commentIds.length === 0) return; const placeholders = commentIds.map(() => '?').join(','); this.db .prepare(`UPDATE local_task_comments SET injected_at = datetime('now') WHERE id IN (${placeholders})`) .run(...commentIds); } /** * Latest agent-authored "result" or "ask" comment for a task. Used by the * piece handoff feature to surface the previous job's terminal output as * context to a continuation job's LLM. Returns null when none exist * (e.g., task has not yet completed any job). */ async getLatestResultComment(taskId: number): Promise<{ body: string; kind: string; createdAt: string } | null> { const row = this.db .prepare( `SELECT body, kind, created_at FROM local_task_comments WHERE task_id = ? AND author = 'agent' AND kind IN ('result', 'ask') ORDER BY created_at DESC LIMIT 1` ) .get(taskId) as { body: string; kind: string; created_at: string } | undefined; return row ? { body: row.body, kind: row.kind, createdAt: row.created_at } : null; } private getJobSync(id: string): Job | null { const row = this.db .prepare('SELECT * FROM jobs WHERE id = ?') .get(id) as JobRow | undefined; return row ? rowToJob(row) : null; } /** * ジョブの現在のステータスを同期的に取得する。 * キャンセルチェックなど、非同期が使えない箇所で利用する。 */ getJobStatusSync(id: string): JobStatus | null { const row = this.db .prepare('SELECT status FROM jobs WHERE id = ?') .get(id) as { status: string } | undefined; return row ? (row.status as JobStatus) : null; } /** * Returns true if `workerId` currently has at least one job with status='running'. * Used by the Side Info Panel's worker status endpoint. Boolean-only on purpose: * never expose the job id/title/owner to other users in the shared panel. */ isWorkerBusy(workerId: string): boolean { const row = this.db .prepare(`SELECT 1 AS hit FROM jobs WHERE worker_id = ? AND status = 'running' LIMIT 1`) .get(workerId) as { hit: number } | undefined; return !!row; } async updateJob(id: string, updates: Partial>): Promise { const setClauses: string[] = ["updated_at = datetime('now')"]; const params: Record = { id }; const fieldMap: Record = { status: 'status', pieceName: 'piece_name', currentMovement: 'current_movement', currentActivity: 'current_activity', instruction: 'instruction', branchName: 'branch_name', worktreePath: 'worktree_path', prNumber: 'pr_number', attempt: 'attempt', maxAttempts: 'max_attempts', nextRetryAt: 'next_retry_at', errorSummary: 'error_summary', abortReason: 'abort_reason', resumeMovement: 'resume_movement', waitReason: 'wait_reason', askCount: 'ask_count', workerId: 'worker_id', lastBackendId: 'last_backend_id', parentJobId: 'parent_job_id', subtaskDepth: 'subtask_depth', requiredRole: 'required_profile', requiredProfile: 'required_profile', }; for (const [jsKey, dbCol] of Object.entries(fieldMap)) { const val = (updates as Record)[jsKey]; if (val !== undefined) { setClauses.push(`${dbCol} = @${jsKey}`); params[jsKey] = val; } } if (setClauses.length === 1) return; // updated_at のみ = 実質変更なし this.db .prepare(`UPDATE jobs SET ${setClauses.join(', ')} WHERE id = @id`) .run(params); } /** ジョブの updated_at のみを更新(ハートビート用)。updateJob は変更フィールドなしだと早期リターンするため別メソッド */ touchJobUpdatedAt(id: string): void { this.db.prepare("UPDATE jobs SET updated_at = datetime('now') WHERE id = ?").run(id); } /** * Re-queue jobs parked with wait_reason='mcp_auth_required' for the given owner. * Worker re-evaluates required_mcp on next pickup and will pause again if other servers * are still unauthorized. _serverId is accepted for API symmetry but not used at SQL time * (filtering by piece's required_mcp happens at the worker side). * * Returns the number of jobs actually re-queued. */ resumeMcpWaitingJobs(ownerId: string, _serverId: string): number { const result = this.db .prepare( `UPDATE jobs SET status='queued', wait_reason=NULL, updated_at=datetime('now') WHERE status='waiting_human' AND wait_reason='mcp_auth_required' AND owner_id = ?`, ) .run(ownerId); return result.changes; } async lockIssue(repo: string, issueNumber: number, jobId: string): Promise { try { this.db .prepare('INSERT INTO issue_locks (repo, issue_number, job_id) VALUES (?, ?, ?)') .run(repo, issueNumber, jobId); return true; } catch { return false; } } async unlockIssue(repo: string, issueNumber: number): Promise { this.db .prepare('DELETE FROM issue_locks WHERE repo = ? AND issue_number = ?') .run(repo, issueNumber); } async deleteJobsForIssue(repo: string, issueNumber: number): Promise { const result = this.db .prepare('DELETE FROM jobs WHERE repo = ? AND issue_number = ?') .run(repo, issueNumber); this.db .prepare('DELETE FROM issue_locks WHERE repo = ? AND issue_number = ?') .run(repo, issueNumber); return result.changes; } async addAuditLog(jobId: string | null, action: string, actor: string, detail: object): Promise { this.db .prepare('INSERT INTO audit_log (job_id, action, actor, detail) VALUES (?, ?, ?, ?)') .run(jobId, action, actor, JSON.stringify(detail)); } async upsertWorkerNode(params: UpsertWorkerNodeParams): Promise { const now = new Date().toISOString(); this.db.prepare(` INSERT INTO worker_nodes ( worker_id, endpoint, enabled, healthy, profile_tags, task_class_tags, available_models, inflight_jobs, max_concurrency, last_error, last_seen_at, updated_at ) VALUES ( @workerId, @endpoint, @enabled, @healthy, @roleTags, @roleTags, @availableModels, @inflightJobs, @maxConcurrency, @lastError, @now, @now ) ON CONFLICT(worker_id) DO UPDATE SET endpoint = excluded.endpoint, enabled = excluded.enabled, healthy = excluded.healthy, profile_tags = excluded.profile_tags, task_class_tags = excluded.task_class_tags, available_models = excluded.available_models, inflight_jobs = excluded.inflight_jobs, max_concurrency = excluded.max_concurrency, last_error = excluded.last_error, last_seen_at = excluded.last_seen_at, updated_at = excluded.updated_at `).run({ workerId: params.workerId, endpoint: params.endpoint, enabled: params.enabled ? 1 : 0, healthy: params.healthy ? 1 : 0, roleTags: encodeTags(params.roles), availableModels: JSON.stringify(params.availableModels ?? []), inflightJobs: params.inflightJobs ?? 0, maxConcurrency: params.maxConcurrency ?? 1, lastError: params.lastError ?? null, now, }); } async updateWorkerNodeHealth( workerId: string, updates: { healthy: boolean; lastError?: string | null; inflightJobs?: number; availableModels?: string[]; enabled?: boolean }, ): Promise { const setClauses = [ 'healthy = @healthy', 'last_error = @lastError', "last_seen_at = @now", "updated_at = @now", ]; const params: Record = { workerId, healthy: updates.healthy ? 1 : 0, lastError: updates.lastError ?? null, now: new Date().toISOString(), }; if (updates.inflightJobs !== undefined) { setClauses.push('inflight_jobs = @inflightJobs'); params['inflightJobs'] = updates.inflightJobs; } if (updates.availableModels !== undefined) { setClauses.push('available_models = @availableModels'); params['availableModels'] = JSON.stringify(updates.availableModels); } if (updates.enabled !== undefined) { setClauses.push('enabled = @enabled'); params['enabled'] = updates.enabled ? 1 : 0; } this.db.prepare(`UPDATE worker_nodes SET ${setClauses.join(', ')} WHERE worker_id = @workerId`).run(params); } async getWorkerNode(workerId: string): Promise { const row = this.db .prepare('SELECT * FROM worker_nodes WHERE worker_id = ?') .get(workerId) as WorkerNodeRow | undefined; return row ? rowToWorkerNode(row) : null; } async claimNextJob(workerId: string): Promise { const row = this.db.prepare(` UPDATE jobs SET status = 'running', worker_id = ?, updated_at = datetime('now') WHERE id = ( SELECT j.id FROM jobs j JOIN worker_nodes w ON w.worker_id = ? WHERE j.status = 'queued' AND w.enabled = 1 AND w.healthy = 1 AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 AND NOT EXISTS ( SELECT 1 FROM issue_locks il WHERE il.repo = j.repo AND il.issue_number = j.issue_number ) ORDER BY j.created_at ASC LIMIT 1 ) RETURNING * `).get(workerId, workerId) as JobRow | undefined; return row ? rowToJob(row) : null; } /** * リトライ待ちジョブの中から next_retry_at を過ぎたものを1件取得して running に遷移 */ async claimNextRetryJob(workerId: string): Promise { const row = this.db.prepare(` UPDATE jobs SET status = 'running', worker_id = ?, updated_at = datetime('now') WHERE id = ( SELECT j.id FROM jobs j JOIN worker_nodes w ON w.worker_id = ? WHERE j.status = 'retry' AND replace(j.next_retry_at, 'T', ' ') <= datetime('now') AND w.enabled = 1 AND w.healthy = 1 AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 AND NOT EXISTS ( SELECT 1 FROM issue_locks il WHERE il.repo = j.repo AND il.issue_number = j.issue_number ) ORDER BY j.next_retry_at ASC LIMIT 1 ) RETURNING * `).get(workerId, workerId) as JobRow | undefined; return row ? rowToJob(row) : null; } /** * Read-only peek at the next job this worker WOULD claim (retry-priority, * then oldest queued), without claiming it. Used by the idle-preferring * claim gate to learn the next job's role before deciding whether to defer * to an idler sibling. Mirrors the claimNext*Job WHERE clauses exactly. */ async peekNextClaimable(workerId: string): Promise { const retry = this.db.prepare(` SELECT j.* FROM jobs j JOIN worker_nodes w ON w.worker_id = ? WHERE j.status = 'retry' AND replace(j.next_retry_at, 'T', ' ') <= datetime('now') AND w.enabled = 1 AND w.healthy = 1 AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 AND NOT EXISTS ( SELECT 1 FROM issue_locks il WHERE il.repo = j.repo AND il.issue_number = j.issue_number ) ORDER BY j.next_retry_at ASC LIMIT 1 `).get(workerId) as JobRow | undefined; if (retry) return rowToJob(retry); const queued = this.db.prepare(` SELECT j.* FROM jobs j JOIN worker_nodes w ON w.worker_id = ? WHERE j.status = 'queued' AND w.enabled = 1 AND w.healthy = 1 AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 AND NOT EXISTS ( SELECT 1 FROM issue_locks il WHERE il.repo = j.repo AND il.issue_number = j.issue_number ) ORDER BY j.created_at ASC LIMIT 1 `).get(workerId) as JobRow | undefined; return queued ? rowToJob(queued) : null; } async getJobsByStatus(status: JobStatus): Promise { const rows = this.db .prepare('SELECT * FROM jobs WHERE status = ? ORDER BY created_at ASC') .all(status) as JobRow[]; return rows.map(rowToJob); } async getLatestJobForIssue(repo: string, issueNumber: number): Promise { const row = this.db .prepare('SELECT * FROM jobs WHERE repo = ? AND issue_number = ? ORDER BY created_at DESC LIMIT 1') .get(repo, issueNumber) as JobRow | undefined; return row ? rowToJob(row) : null; } async updateJobContext( jobId: string, payload: { promptTokens: number; limitTokens: number } ): Promise { const updatedAt = new Date().toISOString(); this.db .prepare( `UPDATE jobs SET context_prompt_tokens = ?, context_limit_tokens = ?, context_updated_at = ? WHERE id = ?` ) .run(payload.promptTokens, payload.limitTokens, updatedAt, jobId); } /** 起動時に孤立ジョブを回復 */ async recoverOrphanedJobs(): Promise { const result = this.db .prepare("UPDATE jobs SET status = 'queued', worker_id = NULL, updated_at = datetime('now') WHERE status IN ('running', 'dispatching')") .run(); if (result.changes > 0) { this.db.prepare('DELETE FROM issue_locks').run(); logger.warn(`Repository: recovered ${result.changes} orphaned jobs, cleared issue locks`); } // waiting_subtasks のジョブで全サブジョブが完了済みのものを再キュー // 同一 issue_number に複数ジョブがある場合、最新のみで判定する const subtaskRecovery = this.db.prepare(` UPDATE jobs SET status = 'queued', updated_at = datetime('now') WHERE status = 'waiting_subtasks' AND ( SELECT COUNT(*) FROM ( SELECT s.status, ROW_NUMBER() OVER ( PARTITION BY s.issue_number ORDER BY s.created_at DESC, s.rowid DESC ) AS rn FROM jobs s WHERE s.parent_job_id = jobs.id ) WHERE rn = 1 AND status NOT IN ('succeeded','failed','cancelled') ) = 0 `).run(); if (subtaskRecovery.changes > 0) { logger.warn(`Repository: recovered ${subtaskRecovery.changes} waiting_subtasks jobs`); } return result.changes; } /** * running/dispatching 状態のまま staleMinutes 以上 updated_at が更新されていないジョブを * queued に戻す(ランタイム watchdog) */ recoverStuckRunningJobs(staleMinutes: number): number { const rows = this.db.prepare(` UPDATE jobs SET status = 'queued', worker_id = NULL, error_summary = 'Recovered: stuck in running for over ' || ? || ' minutes', updated_at = datetime('now') WHERE status IN ('running', 'dispatching') AND updated_at < datetime('now', '-' || ? || ' minutes') RETURNING id, repo `).all(staleMinutes, staleMinutes) as Array<{ id: string; repo: string }>; if (rows.length > 0) { // issue ロックも解除 for (const row of rows) { this.db.prepare('DELETE FROM issue_locks WHERE job_id = ?').run(row.id); } logger.warn(`Repository: recovered ${rows.length} stuck jobs (stale > ${staleMinutes}min): ${rows.map(r => r.repo).join(', ')}`); } return rows.length; } /** running/dispatching 状態のジョブを全て queued に戻す(graceful shutdown 用) */ requeueRunningJobs(): number { const result = this.db .prepare("UPDATE jobs SET status = 'queued', worker_id = NULL, updated_at = datetime('now') WHERE status IN ('running', 'dispatching')") .run(); if (result.changes > 0) { this.db.prepare('DELETE FROM issue_locks').run(); logger.warn(`Repository: requeued ${result.changes} running jobs, cleared issue locks`); } return result.changes; } getDistinctRepos(): string[] { const rows = this.db.prepare('SELECT DISTINCT repo FROM jobs ORDER BY repo').all() as { repo: string }[]; return rows.map(r => r.repo); } getJobsByRepo(repoName: string): Job[] { const rows = this.db.prepare('SELECT * FROM jobs WHERE repo = ? ORDER BY created_at DESC').all(repoName) as JobRow[]; return rows.map(rowToJob); } /** Issue ごとに最新の Job だけを返す(カンバンUI用) */ getLatestJobsPerIssue(repoName: string): Job[] { const rows = this.db.prepare(` SELECT j.* FROM jobs j WHERE j.repo = ? AND j.id = ( SELECT j2.id FROM jobs j2 WHERE j2.repo = j.repo AND j2.issue_number = j.issue_number ORDER BY j2.updated_at DESC, j2.created_at DESC, j2.rowid DESC LIMIT 1 ) ORDER BY j.updated_at DESC `).all(repoName) as JobRow[]; return rows.map(rowToJob); } // Cascade a local_task visibility change to all jobs spawned for that task // and their recursive subtask descendants (repo='subtask/'). // Returns the number of job rows updated. async updateJobsVisibilityForTask( taskId: number, updates: { visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null }, ): Promise { const repoName = localTaskRepoName(taskId); const now = new Date().toISOString(); const result = this.db .prepare(` WITH RECURSIVE job_tree(id) AS ( SELECT id FROM jobs WHERE repo = ? UNION ALL SELECT j.id FROM jobs j JOIN job_tree jt ON j.parent_job_id = jt.id ) UPDATE jobs SET visibility = ?, visibility_scope_org_id = ?, updated_at = ? WHERE id IN (SELECT id FROM job_tree) `) .run(repoName, updates.visibility, updates.visibilityScopeOrgId, now); return result.changes; } async getSubJobs(parentJobId: string): Promise { // 同一 issue_number に複数ジョブがある場合(ASK再投入等)、最新のみ返す // ROW_NUMBER() + rowid で同一 created_at でも一意に決定する const rows = this.db .prepare(` SELECT * FROM ( SELECT j.*, ROW_NUMBER() OVER ( PARTITION BY j.issue_number ORDER BY j.created_at DESC, j.rowid DESC ) AS rn FROM jobs j WHERE j.parent_job_id = ? ) WHERE rn = 1 ORDER BY issue_number ASC `) .all(parentJobId) as JobRow[]; return rows.map(rowToJob); } /** * 全サブジョブが終端状態なら親ジョブを再キューに戻す。 * 再キューできた場合 true を返す。 */ async requeueParentJobIfAllSubtasksDone(parentJobId: string): Promise { // 同一 issue_number に複数ジョブがある場合、最新のもの(ROW_NUMBER=1)のみで判定する const result = this.db.prepare(` UPDATE jobs SET status = 'queued', updated_at = datetime('now') WHERE id = ? AND status = 'waiting_subtasks' AND ( SELECT COUNT(*) FROM ( SELECT status, ROW_NUMBER() OVER ( PARTITION BY issue_number ORDER BY created_at DESC, rowid DESC ) AS rn FROM jobs WHERE parent_job_id = ? ) WHERE rn = 1 AND status NOT IN ('succeeded', 'failed', 'cancelled') ) = 0 `).run(parentJobId, parentJobId); return result.changes > 0; } async deleteLocalTask(taskId: number): Promise { const repoName = localTaskRepoName(taskId); // タスク存在確認 & workspace_path を取得(削除前に必要) const taskRow = this.db .prepare('SELECT workspace_path FROM local_tasks WHERE id = ?') .get(taskId) as { workspace_path: string | null } | undefined; if (!taskRow) { throw new Error(`deleteLocalTask: task ${taskId} not found`); } // running/dispatching なジョブがある場合は削除を拒否 const activeJob = this.db .prepare("SELECT id FROM jobs WHERE repo = ? AND status IN ('running', 'dispatching') LIMIT 1") .get(repoName) as { id: string } | undefined; if (activeJob) { throw new Error(`deleteLocalTask: task ${taskId} has an active job (${activeJob.id})`); } // DB 操作をトランザクションで実行 const deleteTransaction = this.db.transaction(() => { this.db.prepare('DELETE FROM issue_locks WHERE repo = ?').run(repoName); this.db.prepare('DELETE FROM jobs WHERE repo = ?').run(repoName); this.db.prepare('DELETE FROM local_tasks WHERE id = ?').run(taskId); }); deleteTransaction(); // ワークスペースディレクトリを削除(DB トランザクション外 — ロールバック不可のため) if (taskRow.workspace_path && existsSync(taskRow.workspace_path)) { rmSync(taskRow.workspace_path, { recursive: true, force: true }); } logger.info(`Repository: deleted local task ${taskId}`); } /** * 実行中のジョブをキャンセル状態に変更する。 * running または dispatching 状態のジョブのみ対象。 * 戻り値: キャンセル対象ジョブが見つかったら true、見つからなかったら false。 */ requestJobCancel(jobId: string): boolean { const result = this.db.prepare(` UPDATE jobs SET status = 'cancelled', updated_at = datetime('now') WHERE id = ? AND status IN ('running', 'dispatching') `).run(jobId); return result.changes > 0; } // ── Scheduled Tasks ────────────────────────────────────────── private mapScheduledTask(row: any): ScheduledTask { const rawVisibility = row.visibility; const visibility: ScheduledTask['visibility'] = rawVisibility === 'org' || rawVisibility === 'public' ? rawVisibility : 'private'; return { id: row.id, title: row.title, body: row.body, pieceName: row.piece_name, profile: row.profile, outputFormat: row.output_format, cronExpression: row.cron_expression, nextRunAt: utc(row.next_run_at), lastRunAt: utc(row.last_run_at), lastJobId: row.last_job_id, isActive: row.is_active === 1, ownerId: row.owner_id ?? null, ownerName: row.owner_name ?? null, visibility, visibilityScopeOrgId: row.visibility_scope_org_id ?? null, visibilityScopeOrgName: row.visibility_scope_org_name ?? null, browserSessionProfileId: row.browser_session_profile_id ?? null, taskKind: row.task_kind === 'script' ? 'script' : 'agent', scriptName: row.script_name ?? null, scriptParams: row.script_params ?? null, createdAt: utc(row.created_at), updatedAt: utc(row.updated_at), }; } async createScheduledTask(params: CreateScheduledTaskParams): Promise { const result = this.db .prepare( `INSERT INTO scheduled_tasks (title, body, piece_name, profile, output_format, cron_expression, next_run_at, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, script_name, script_params) VALUES (@title, @body, @pieceName, @profile, @outputFormat, @cronExpression, @nextRunAt, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @scriptName, @scriptParams)` ) .run({ title: params.title ?? null, body: params.body, pieceName: params.pieceName ?? 'auto', profile: params.profile ?? 'auto', outputFormat: params.outputFormat ?? 'markdown', cronExpression: params.cronExpression, nextRunAt: params.nextRunAt, ownerId: params.ownerId ?? null, visibility: params.visibility ?? 'private', visibilityScopeOrgId: params.visibilityScopeOrgId ?? null, browserSessionProfileId: params.browserSessionProfileId ?? null, taskKind: params.taskKind ?? 'agent', scriptName: params.scriptName ?? null, scriptParams: params.scriptParams ?? null, }); const task = this.getScheduledTaskSync(Number(result.lastInsertRowid)); if (!task) throw new Error('createScheduledTask: failed to load inserted task'); return task; } private getScheduledTaskSync(id: number): ScheduledTask | null { const row = this.db .prepare(` SELECT st.*, ${SCHEDULED_TASK_DISPLAY_SELECT} FROM scheduled_tasks st ${SCHEDULED_TASK_DISPLAY_JOIN} WHERE st.id = ? `) .get(id) as any; return row ? this.mapScheduledTask(row) : null; } async getScheduledTask(id: number, opts?: { viewer?: Express.User }): Promise { const viewerClause = opts?.viewer ? buildVisibilityWhere(opts.viewer, 'st') : { clause: '1=1', params: [] as unknown[] }; const row = this.db .prepare(` SELECT st.*, ${SCHEDULED_TASK_DISPLAY_SELECT} FROM scheduled_tasks st ${SCHEDULED_TASK_DISPLAY_JOIN} WHERE st.id = ? AND ${viewerClause.clause} `) .get(id, ...viewerClause.params) as any; return row ? this.mapScheduledTask(row) : null; } async listScheduledTasks(filter?: { viewer?: Express.User }): Promise { const conditions: string[] = []; const queryParams: unknown[] = []; if (filter?.viewer) { const w = buildVisibilityWhere(filter.viewer, 'st'); conditions.push(w.clause); queryParams.push(...w.params); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const rows = this.db .prepare(` SELECT st.*, ${SCHEDULED_TASK_DISPLAY_SELECT} FROM scheduled_tasks st ${SCHEDULED_TASK_DISPLAY_JOIN} ${whereClause} ORDER BY st.created_at DESC `) .all(...queryParams) as any[]; return rows.map(r => this.mapScheduledTask(r)); } /** * due なスケジュールタスクをアトミックに claim して返す。 * BEGIN IMMEDIATE で書き込みロックを即座に取得し、 * 他のスケジューラーインスタンスとの重複実行を防止する。 * claim されたタスクは next_run_at が遠い未来に設定されるため、 * 他インスタンスに再取得されない。 * 呼び出し側が実行後に正しい next_run_at を再設定する。 */ async getScheduledTasksDue(): Promise { // 十分遠い未来(claim マーカー) const claimMarker = '9999-12-31 23:59:59'; // BEGIN IMMEDIATE: トランザクション開始時に RESERVED ロックを取得し、 // 他の書き込みトランザクションとの競合を防ぐ const txn = this.db.transaction(() => { const rows = this.db .prepare( `SELECT * FROM scheduled_tasks WHERE is_active = 1 AND next_run_at <= datetime('now') ORDER BY next_run_at ASC` ) .all() as any[]; if (rows.length === 0) return []; // claim: next_run_at を遠い未来に設定して他インスタンスからの重複取得を防止 const ids = rows.map((r: any) => r.id); this.db .prepare( `UPDATE scheduled_tasks SET next_run_at = ?, updated_at = datetime('now') WHERE id IN (${ids.map(() => '?').join(',')})` ) .run(claimMarker, ...ids); return rows; }); const rows = txn.immediate(); return rows.map((r: any) => this.mapScheduledTask(r)); } async updateScheduledTask(id: number, params: UpdateScheduledTaskParams): Promise { const sets: string[] = []; const values: Record = { id }; if (params.title !== undefined) { sets.push('title = @title'); values.title = params.title; } if (params.body !== undefined) { sets.push('body = @body'); values.body = params.body; } if (params.pieceName !== undefined) { sets.push('piece_name = @pieceName'); values.pieceName = params.pieceName; } if (params.profile !== undefined) { sets.push('profile = @profile'); values.profile = params.profile; } if (params.outputFormat !== undefined) { sets.push('output_format = @outputFormat'); values.outputFormat = params.outputFormat; } if (params.cronExpression !== undefined) { sets.push('cron_expression = @cronExpression'); values.cronExpression = params.cronExpression; } if (params.nextRunAt !== undefined) { sets.push('next_run_at = @nextRunAt'); values.nextRunAt = params.nextRunAt; } if (params.lastRunAt !== undefined) { sets.push('last_run_at = @lastRunAt'); values.lastRunAt = params.lastRunAt; } if (params.lastJobId !== undefined) { sets.push('last_job_id = @lastJobId'); values.lastJobId = params.lastJobId; } if (params.isActive !== undefined) { sets.push('is_active = @isActive'); values.isActive = params.isActive ? 1 : 0; } if (params.visibility !== undefined) { sets.push('visibility = @visibility'); values.visibility = params.visibility; } if (params.visibilityScopeOrgId !== undefined) { sets.push('visibility_scope_org_id = @visibilityScopeOrgId'); values.visibilityScopeOrgId = params.visibilityScopeOrgId; } if (params.browserSessionProfileId !== undefined) { sets.push('browser_session_profile_id = @browserSessionProfileId'); values.browserSessionProfileId = params.browserSessionProfileId; } if (params.taskKind !== undefined) { sets.push('task_kind = @taskKind'); values.taskKind = params.taskKind; } if (params.scriptName !== undefined) { sets.push('script_name = @scriptName'); values.scriptName = params.scriptName; } if (params.scriptParams !== undefined) { sets.push('script_params = @scriptParams'); values.scriptParams = params.scriptParams; } if (sets.length === 0) return this.getScheduledTaskSync(id); sets.push("updated_at = datetime('now')"); this.db.prepare(`UPDATE scheduled_tasks SET ${sets.join(', ')} WHERE id = @id`).run(values); return this.getScheduledTaskSync(id); } async deleteScheduledTask(id: number): Promise { const result = this.db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id); return result.changes > 0; } // ── User CRUD ──────────────────────────────────────────────────── private rowToUser(row: UserRow): User { return rowToUser(row); } createUser(params: CreateUserParams): User { const id = uuidv4(); const now = new Date().toISOString(); this.db .prepare( `INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) VALUES (@id, @email, @name, @avatarUrl, @role, @status, @now, @now)` ) .run({ id, email: params.email, name: params.name, avatarUrl: params.avatarUrl ?? null, role: params.role, status: params.status, now, }); const user = this.getUserById(id); if (!user) throw new Error(`createUser: failed to retrieve created user ${id}`); return user; } /** * Ensure the synthetic 'local' user row exists. No-auth single-user * deployments own per-user rows under the id 'local' (tasks, jobs, SSH * connections, DEKs, …). Many of those tables FK to users(id) with * foreign_keys ON, so the row must exist or the inserts fail — e.g. * ssh_user_deks → SSH connection creation returned create_failed. * Idempotent (INSERT OR IGNORE), so it is safe to call on every startup. * role='admin' mirrors the synthetic 'local' user the HTTP layer injects * for task-visibility routes in no-auth mode. */ ensureLocalUser(): void { const now = new Date().toISOString(); this.db .prepare( `INSERT OR IGNORE INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) VALUES ('local', 'local@localhost', 'local', NULL, 'admin', 'active', @now, @now)` ) .run({ now }); } // ── Local auth (email + password) ───────────────────────────────────── /** scrypt hash with a fresh per-user salt. Overwrites any existing credential. */ setLocalPassword(userId: string, plainPassword: string): void { const salt = randomBytes(16).toString('hex'); const hash = scryptSync(plainPassword, salt, 64).toString('hex'); const now = new Date().toISOString(); this.db .prepare( `INSERT INTO local_credentials (user_id, password_hash, salt, updated_at) VALUES (@userId, @hash, @salt, @now) ON CONFLICT(user_id) DO UPDATE SET password_hash=@hash, salt=@salt, updated_at=@now`, ) .run({ userId, hash, salt, now }); } /** Constant-time verify. False when the user has no local credential. */ verifyLocalPassword(userId: string, plainPassword: string): boolean { const row = this.db .prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id = ?') .get(userId) as { password_hash: string; salt: string } | undefined; if (!row) return false; const expected = Buffer.from(row.password_hash, 'hex'); const actual = scryptSync(plainPassword, row.salt, expected.length); return expected.length === actual.length && timingSafeEqual(expected, actual); } hasLocalCredential(userId: string): boolean { return !!this.db.prepare('SELECT 1 FROM local_credentials WHERE user_id = ?').get(userId); } /** * Create a brand-new local account (self-signup or admin-created). The email * MUST be unused: attaching a password to an existing account would be an * account-takeover vector, so we reject instead of linking. Linking a local * credential to an existing OAuth account is a separate, authenticated action * (not v1 signup). */ createLocalUser(params: CreateLocalUserParams): User { if (this.getUserByEmail(params.email)) { throw new Error(`createLocalUser: a user with email ${params.email} already exists`); } const user = this.createUser({ email: params.email, name: params.name ?? params.email, role: params.role, status: params.status, }); this.db .prepare( `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at) VALUES (@id, @userId, 'local', @providerId, @now)`, ) .run({ id: uuidv4(), userId: user.id, providerId: params.email, now: new Date().toISOString() }); this.setLocalPassword(user.id, params.password); return user; } /** * Idempotently seed the shared system admin under the fixed id `local` — the * same owner the no-auth path synthesizes. This makes all pre-existing * `local`-owned data belong to the logged-in admin once local auth is turned * on, and lets an existing no-auth deployment gain a login mid-stream. * Re-running updates the password and keeps role=admin/status=active. */ upsertLocalSystemAdmin(params: { email: string; password: string; name?: string }): User { const LOCAL_ID = 'local'; const now = new Date().toISOString(); const existing = this.getUserById(LOCAL_ID); if (!existing) { this.db .prepare( `INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) VALUES (@id, @email, @name, NULL, 'admin', 'active', @now, @now)`, ) .run({ id: LOCAL_ID, email: params.email, name: params.name ?? 'Local Admin', now }); } else { this.db .prepare(`UPDATE users SET email=@email, role='admin', status='active', updated_at=@now WHERE id=@id`) .run({ id: LOCAL_ID, email: params.email, now }); } this.db .prepare( `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at) VALUES (@id, @userId, 'local', @providerId, @now)`, ) .run({ id: uuidv4(), userId: LOCAL_ID, providerId: params.email, now }); this.setLocalPassword(LOCAL_ID, params.password); const user = this.getUserById(LOCAL_ID); if (!user) throw new Error('upsertLocalSystemAdmin: failed to retrieve local admin'); return user; } // ── Local organizations ─────────────────────────────────────────────── private rowToLocalOrg(r: { id: string; name: string; created_by: string | null; created_at: string }): LocalOrg { return { id: r.id, name: r.name, createdBy: r.created_by, createdAt: r.created_at }; } /** Create a local org. id is prefixed `lorg:` so it never collides with a * Gitea numeric org id (both live in visibility_scope_org_id). */ createLocalOrg(name: string, createdBy: string | null): LocalOrg { const id = `lorg:${uuidv4()}`; const now = new Date().toISOString(); this.db .prepare(`INSERT INTO local_orgs (id, name, created_by, created_at) VALUES (@id, @name, @createdBy, @now)`) .run({ id, name, createdBy, now }); return { id, name, createdBy, createdAt: now }; } getLocalOrg(id: string): LocalOrg | null { const r = this.db.prepare('SELECT id, name, created_by, created_at FROM local_orgs WHERE id = ?').get(id) as | { id: string; name: string; created_by: string | null; created_at: string } | undefined; return r ? this.rowToLocalOrg(r) : null; } listLocalOrgs(): LocalOrg[] { const rows = this.db.prepare('SELECT id, name, created_by, created_at FROM local_orgs ORDER BY name COLLATE NOCASE').all() as Array<{ id: string; name: string; created_by: string | null; created_at: string }>; return rows.map(r => this.rowToLocalOrg(r)); } renameLocalOrg(id: string, name: string): void { this.db.prepare('UPDATE local_orgs SET name = ? WHERE id = ?').run(name, id); } /** Tables carrying `visibility_scope_org_id` (org-scoped resources). */ private static readonly ORG_SCOPED_TABLES = ['local_tasks', 'scheduled_tasks', 'jobs', 'note_index']; /** * Delete a local org. Members cascade via FK. Resources scoped to this org * (visibility='org', visibility_scope_org_id=id) would become invisible to * everyone once the org is gone — so first downgrade them to 'private' * (owner + admin can still see them; no data loss). Atomic. */ deleteLocalOrg(id: string): void { const tx = this.db.transaction((orgId: string) => { for (const table of Repository.ORG_SCOPED_TABLES) { this.db .prepare(`UPDATE ${table} SET visibility = 'private', visibility_scope_org_id = NULL WHERE visibility_scope_org_id = ?`) .run(orgId); } this.db.prepare('DELETE FROM local_orgs WHERE id = ?').run(orgId); }); tx(id); } /** Add or update a member (idempotent — re-add updates the role). */ addOrgMember(orgId: string, userId: string, role: string = 'member'): void { const now = new Date().toISOString(); this.db .prepare( `INSERT INTO local_org_members (org_id, user_id, role, added_at) VALUES (@orgId, @userId, @role, @now) ON CONFLICT(org_id, user_id) DO UPDATE SET role=@role`, ) .run({ orgId, userId, role, now }); } removeOrgMember(orgId: string, userId: string): void { this.db.prepare('DELETE FROM local_org_members WHERE org_id = ? AND user_id = ?').run(orgId, userId); } listOrgMembers(orgId: string): LocalOrgMember[] { const rows = this.db .prepare('SELECT user_id, role FROM local_org_members WHERE org_id = ? ORDER BY added_at') .all(orgId) as Array<{ user_id: string; role: string }>; return rows.map(r => ({ userId: r.user_id, role: r.role })); } /** Orgs a user belongs to — merged into session.orgIds so the existing * provider-agnostic 'org' visibility (buildVisibilityWhere) covers them. */ listUserLocalOrgs(userId: string): Array<{ orgId: string; name: string }> { const rows = this.db .prepare( `SELECT o.id AS org_id, o.name AS name FROM local_org_members m JOIN local_orgs o ON o.id = m.org_id WHERE m.user_id = ? ORDER BY o.name COLLATE NOCASE`, ) .all(userId) as Array<{ org_id: string; name: string }>; return rows.map(r => ({ orgId: r.org_id, name: r.name })); } getUserById(id: string): User | null { const row = this.db .prepare('SELECT * FROM users WHERE id = ?') .get(id) as UserRow | undefined; return row ? this.rowToUser(row) : null; } getUserByEmail(email: string): User | null { const row = this.db .prepare('SELECT * FROM users WHERE email = ?') .get(email) as UserRow | undefined; return row ? this.rowToUser(row) : null; } findOrCreateUserByOAuth(params: FindOrCreateByOAuthParams): User { // 1. Check if oauth_account already exists const existing = this.db .prepare('SELECT user_id FROM oauth_accounts WHERE provider = ? AND provider_id = ?') .get(params.provider, params.providerId) as { user_id: string } | undefined; if (existing) { const user = this.getUserById(existing.user_id); if (!user) throw new Error(`findOrCreateUserByOAuth: user ${existing.user_id} not found`); // Sync mutable profile fields from the provider on every re-login so // existing users whose name was missing on first login pick it up once // their Gitea profile is populated. Email upgrade only applies when the // dummy @gitea.local placeholder is being replaced. const patch: { email?: string; name?: string; avatarUrl?: string | null } = {}; if (user.email.endsWith('@gitea.local') && !params.email.endsWith('@gitea.local')) { patch.email = params.email; } if (params.name && params.name !== user.name) patch.name = params.name; if (params.avatarUrl !== undefined && params.avatarUrl !== user.avatarUrl) { patch.avatarUrl = params.avatarUrl; } if (Object.keys(patch).length > 0) { this.updateUser(user.id, patch); const refreshed = this.getUserById(user.id); if (refreshed) return refreshed; } return user; } // 2. Check if user exists by email let user = this.getUserByEmail(params.email); if (!user) { // 3. Create new user with status=pending user = this.createUser({ email: params.email, name: params.name, role: 'user', status: 'pending', avatarUrl: params.avatarUrl, }); } // 4. Link oauth_account to user const oauthId = uuidv4(); const now = new Date().toISOString(); this.db .prepare( `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at) VALUES (@id, @userId, @provider, @providerId, @now)` ) .run({ id: oauthId, userId: user.id, provider: params.provider, providerId: params.providerId, now, }); return user; } listUsers(): User[] { const rows = this.db .prepare('SELECT * FROM users ORDER BY created_at ASC') .all() as UserRow[]; return rows.map(row => this.rowToUser(row)); } updateUser(id: string, updates: { status?: 'active' | 'pending' | 'disabled'; role?: 'admin' | 'user'; email?: string; name?: string; avatarUrl?: string | null; defaultVisibility?: 'private' | 'org' | 'public'; defaultVisibilityOrgId?: string | null; }): void { const setClauses: string[] = ["updated_at = datetime('now')"]; const params: Record = { id }; if (updates.status !== undefined) { setClauses.push('status = @status'); params['status'] = updates.status; } if (updates.role !== undefined) { setClauses.push('role = @role'); params['role'] = updates.role; } if (updates.email !== undefined) { setClauses.push('email = @email'); params['email'] = updates.email; } if (updates.name !== undefined) { setClauses.push('name = @name'); params['name'] = updates.name; } if (updates.avatarUrl !== undefined) { setClauses.push('avatar_url = @avatar_url'); params['avatar_url'] = updates.avatarUrl; } if (updates.defaultVisibility !== undefined) { setClauses.push('default_visibility = @default_visibility'); params['default_visibility'] = updates.defaultVisibility; } if (updates.defaultVisibilityOrgId !== undefined) { setClauses.push('default_visibility_org_id = @default_visibility_org_id'); params['default_visibility_org_id'] = updates.defaultVisibilityOrgId; } if (setClauses.length === 1) return; this.db .prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = @id`) .run(params); } deleteUser(id: string): void { // Never delete the shared `local` system/admin user: it is the no-auth // fallback owner and owns all single-user-mode data. Deleting it would // break no-auth mode and orphan every `local`-owned task/job/folder. if (id === 'local') { throw new Error('cannot delete the local/system user'); } this.db.prepare('DELETE FROM users WHERE id = ?').run(id); } deleteSessionsByUserId(userId: string): void { // Sessions store passport user info as JSON in sess column // Delete sessions where sess contains the user id const rows = this.db .prepare('SELECT sid, sess FROM sessions') .all() as Array<{ sid: string; sess: string }>; const toDelete: string[] = []; for (const row of rows) { try { const sess = JSON.parse(row.sess) as Record; const passport = sess['passport'] as Record | undefined; if (passport && passport['user'] === userId) { toDelete.push(row.sid); } } catch { // ignore parse errors } } if (toDelete.length > 0) { const placeholders = toDelete.map(() => '?').join(', '); this.db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...toDelete); } } replaceUserGiteaOrgs(userId: string, orgs: GiteaOrgInput[]): void { const tx = this.db.transaction((uid: string, items: GiteaOrgInput[]) => { this.db.prepare('DELETE FROM user_gitea_orgs WHERE user_id = ?').run(uid); const insert = this.db.prepare( 'INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES (?, ?, ?)' ); for (const o of items) insert.run(uid, o.orgId, o.orgName); }); tx(userId, orgs); } listUserGiteaOrgs(userId: string): GiteaOrg[] { const rows = this.db .prepare('SELECT org_id, org_name, fetched_at FROM user_gitea_orgs WHERE user_id = ? ORDER BY org_name ASC') .all(userId) as Array<{ org_id: string; org_name: string; fetched_at: string }>; return rows.map(r => ({ orgId: r.org_id, orgName: r.org_name, fetchedAt: r.fetched_at })); } // ── Reflection piece-edit cooldown ────────────────────────────────────────── /** * Records that the reflection pipeline wrote a new version of pieceName for * userId. snapshotId ties the edit back to the snapshot that triggered it. */ recordPieceEdit(userId: string, pieceName: string, snapshotId: string): void { this.db.prepare( `INSERT INTO reflection_piece_edits (user_id, piece_name, snapshot_id, created_at) VALUES (?, ?, ?, ?)` ).run(userId, pieceName, snapshotId, Date.now()); } /** * Returns the number of piece edits for (userId, pieceName) that occurred * within the last sinceMs milliseconds. Used by the cooldown gate in * piece-writer.ts to prevent over-editing the same piece. */ countRecentPieceEdits(userId: string, pieceName: string, sinceMs: number): number { return (this.db.prepare( `SELECT COUNT(*) AS c FROM reflection_piece_edits WHERE user_id = ? AND piece_name = ? AND created_at > ?` ).get(userId, pieceName, Date.now() - sinceMs) as { c: number }).c; } // ── Reflection metrics ─────────────────────────────────────────────────────── /** * Insert one row into reflection_metrics, optionally bundling a * reflection_piece_edits row in the same transaction. * * When pieceEdit is supplied the two inserts are wrapped in a single * db.transaction() so the tables stay consistent even if the process * crashes between them. */ recordReflectionRun( metric: { reflection_job_id: string; original_job_id: string | null; user_id: string; piece_name: string | null; outcome: 'applied' | 'partial' | 'abstained' | 'rejected' | 'failed'; memory_changes: number; piece_edited: 0 | 1; tokens_in: number; tokens_out: number; duration_ms: number; }, pieceEdit?: { pieceName: string; snapshotId: string }, ): void { const now = Date.now(); const insertMetric = this.db.prepare(` INSERT INTO reflection_metrics (reflection_job_id, original_job_id, user_id, piece_name, outcome, memory_changes, piece_edited, tokens_in, tokens_out, duration_ms, created_at) VALUES (@reflection_job_id, @original_job_id, @user_id, @piece_name, @outcome, @memory_changes, @piece_edited, @tokens_in, @tokens_out, @duration_ms, @created_at) `); if (pieceEdit) { const insertEdit = this.db.prepare(` INSERT INTO reflection_piece_edits (user_id, piece_name, snapshot_id, created_at) VALUES (?, ?, ?, ?) `); this.db.transaction(() => { insertMetric.run({ ...metric, created_at: now }); insertEdit.run(metric.user_id, pieceEdit.pieceName, pieceEdit.snapshotId, now); })(); } else { insertMetric.run({ ...metric, created_at: now }); } } /** * Convenience alias for callers that don't need the bundled pieceEdit path. */ recordReflectionMetric( row: { reflection_job_id: string; original_job_id: string | null; user_id: string; piece_name: string | null; outcome: 'applied' | 'partial' | 'abstained' | 'rejected' | 'failed'; memory_changes: number; piece_edited: 0 | 1; tokens_in: number; tokens_out: number; duration_ms: number; }, ): void { this.recordReflectionRun(row); } /** * Aggregate reflection metrics for a user since sinceMs (epoch ms). * Returns counts per outcome and totals for tokens + piece edits. */ aggregateReflectionMetrics( userId: string, sinceMs: number, ): { applied: number; partial: number; abstained: number; rejected: number; failed: number; tokensIn: number; tokensOut: number; pieceEdits: number; totalRuns: number; } { const rows = this.db .prepare( `SELECT outcome, COUNT(*) AS cnt, SUM(tokens_in) AS ti, SUM(tokens_out) AS to_, SUM(piece_edited) AS pe FROM reflection_metrics WHERE user_id = ? AND created_at >= ? GROUP BY outcome`, ) .all(userId, sinceMs) as Array<{ outcome: string; cnt: number; ti: number; to_: number; pe: number; }>; const result = { applied: 0, partial: 0, abstained: 0, rejected: 0, failed: 0, tokensIn: 0, tokensOut: 0, pieceEdits: 0, totalRuns: 0, }; for (const r of rows) { const o = r.outcome as keyof Pick; if (o in result) (result as Record)[o] = r.cnt; result.tokensIn += r.ti ?? 0; result.tokensOut += r.to_ ?? 0; result.pieceEdits += r.pe ?? 0; result.totalRuns += r.cnt; } return result; } async createDashboardWidget(params: { userId: string; slug: string; title: string; content?: string; kind?: DashboardWidgetKind; }): Promise { const max = this.db .prepare(`SELECT COALESCE(MAX(sort_order), -1) AS m FROM user_dashboard_widgets WHERE user_id = ?`) .get(params.userId) as { m: number }; const kind: DashboardWidgetKind = params.kind ?? 'markdown'; const result = this.db .prepare( `INSERT INTO user_dashboard_widgets (user_id, slug, title, kind, markdown_content, sort_order) VALUES (?, ?, ?, ?, ?, ?)` ) .run(params.userId, params.slug, params.title, kind, params.content ?? '', max.m + 1); const row = this.db .prepare(`SELECT * FROM user_dashboard_widgets WHERE id = ?`) .get(Number(result.lastInsertRowid)) as DashboardWidgetRow; return rowToDashboardWidget(row); } async listDashboardWidgets(userId: string): Promise { const rows = this.db .prepare(`SELECT * FROM user_dashboard_widgets WHERE user_id = ? ORDER BY sort_order ASC, id ASC`) .all(userId) as DashboardWidgetRow[]; return rows.map(rowToDashboardWidget); } async getDashboardWidget(id: number, userId: string): Promise { const row = this.db .prepare(`SELECT * FROM user_dashboard_widgets WHERE id = ? AND user_id = ?`) .get(id, userId) as DashboardWidgetRow | undefined; return row ? rowToDashboardWidget(row) : null; } async updateDashboardWidget( id: number, userId: string, patch: { title?: string; content?: string }, ): Promise { const sets: string[] = []; const args: unknown[] = []; if (patch.title !== undefined) { sets.push('title = ?'); args.push(patch.title); } if (patch.content !== undefined) { sets.push('markdown_content = ?'); args.push(patch.content); } sets.push(`updated_at = datetime('now')`); args.push(id, userId); this.db.prepare(`UPDATE user_dashboard_widgets SET ${sets.join(', ')} WHERE id = ? AND user_id = ?`).run(...args); const row = this.db .prepare(`SELECT * FROM user_dashboard_widgets WHERE id = ? AND user_id = ?`) .get(id, userId) as DashboardWidgetRow | undefined; if (!row) throw new Error(`updateDashboardWidget: widget ${id} not found for user ${userId}`); return rowToDashboardWidget(row); } async upsertDashboardWidgetBySlug(params: { userId: string; slug: string; title?: string; content: string; mode?: 'replace' | 'append'; }): Promise { const existing = this.db .prepare(`SELECT * FROM user_dashboard_widgets WHERE user_id = ? AND slug = ?`) .get(params.userId, params.slug) as DashboardWidgetRow | undefined; if (existing) { const newContent = params.mode === 'append' ? (existing.markdown_content ? `${existing.markdown_content}\n\n${params.content}` : params.content) : params.content; return this.updateDashboardWidget(existing.id, params.userId, { content: newContent }); } if (!params.title) { throw new Error('upsertDashboardWidgetBySlug: title is required when creating a new widget'); } return this.createDashboardWidget({ userId: params.userId, slug: params.slug, title: params.title, content: params.content, }); } async deleteDashboardWidget(id: number, userId: string): Promise { this.db .prepare(`DELETE FROM user_dashboard_widgets WHERE id = ? AND user_id = ?`) .run(id, userId); } async reorderDashboardWidgets(userId: string, ids: number[]): Promise { const owned = this.db .prepare(`SELECT id FROM user_dashboard_widgets WHERE user_id = ?`) .all(userId) as Array<{ id: number }>; const ownedSet = new Set(owned.map(r => r.id)); const filtered = ids.filter(id => ownedSet.has(id)); const update = this.db.prepare(`UPDATE user_dashboard_widgets SET sort_order = ? WHERE id = ? AND user_id = ?`); const tx = this.db.transaction((arr: number[]) => { arr.forEach((id, idx) => update.run(idx, id, userId)); }); tx(filtered); } // ── AAO Gateway Phase 2a: virtual keys ─────────────────────────────── // // The gateway auth middleware reads `findGatewayVirtualKeyByHash` on // every request, so it MUST stay an indexed point lookup. The partial // index `idx_gateway_keys_hash_active` covers that path. Admin-side // methods (list/get/revoke/rotate/delete) are not hot. /** * Insert a new virtual key row. Throws on UNIQUE(key_hash) violation — * callers must hash the raw key first (via src/gateway/key-format.ts) * and pass the hash here. The raw key is never accepted by the * Repository on purpose: there is no path that could log it. * * `allowedModels` is JSON-encoded when present and stored as NULL when * omitted — distinct from `[]` which means "lock to zero models". */ createGatewayVirtualKey(params: { id?: string; keyHash: string; keyPrefix: string; team: string; allowedModels?: string[] | null; source?: GatewayVirtualKeySource; createdBy?: string | null; createdAt?: string; /** Phase 2b: optional monthly tokens budget. null/undefined = unlimited. */ tokensBudget?: number | null; /** Phase 2b: optional requests-per-minute cap. null/undefined = unlimited. */ rateLimitRpm?: number | null; }): GatewayVirtualKey { const id = params.id ?? randomUUID(); const allowedJson = params.allowedModels === null || params.allowedModels === undefined ? null : JSON.stringify(params.allowedModels); const source: GatewayVirtualKeySource = params.source ?? 'admin'; const createdAt = params.createdAt ?? new Date().toISOString(); const tokensBudget = normalizeOptionalPositiveInt(params.tokensBudget); const rateLimitRpm = normalizeOptionalPositiveInt(params.rateLimitRpm); this.db .prepare( `INSERT INTO gateway_virtual_keys (id, key_hash, key_prefix, team, allowed_models, source, created_at, created_by, tokens_budget, rate_limit_rpm) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( id, params.keyHash, params.keyPrefix, params.team, allowedJson, source, createdAt, params.createdBy ?? null, tokensBudget, rateLimitRpm, ); const row = this.db .prepare(`SELECT * FROM gateway_virtual_keys WHERE id = ?`) .get(id) as GatewayVirtualKeyRow; return rowToGatewayVirtualKey(row); } /** * Phase 2b: partial update of a virtual key's policy fields. The * bearer hash, team, source, and creation metadata are immutable here * (use rotate to change the bearer). Each field is opt-in — undefined * means "leave alone"; explicit null clears the limit (= unlimited). * * Returns the refreshed row. Throws when the id doesn't exist (caller * is expected to 404 before calling). */ updateGatewayVirtualKey( id: string, patch: { /** * Phase 3a follow-up: team is now patchable so the config-migration * importer can propagate a YAML-side team rename to the DB. Admin * PATCH never sends this field (the team is intentionally immutable * via the public API to avoid an admin accidentally rewriting the * owner of a key); the only caller is importConfigKeysToDb. */ team?: string; tokensBudget?: number | null; rateLimitRpm?: number | null; allowedModels?: string[] | null; }, ): GatewayVirtualKey { const sets: string[] = []; const args: unknown[] = []; if (Object.prototype.hasOwnProperty.call(patch, 'team')) { sets.push('team = ?'); args.push(patch.team); } if (Object.prototype.hasOwnProperty.call(patch, 'tokensBudget')) { sets.push('tokens_budget = ?'); args.push(normalizeOptionalPositiveInt(patch.tokensBudget)); } if (Object.prototype.hasOwnProperty.call(patch, 'rateLimitRpm')) { sets.push('rate_limit_rpm = ?'); args.push(normalizeOptionalPositiveInt(patch.rateLimitRpm)); } if (Object.prototype.hasOwnProperty.call(patch, 'allowedModels')) { sets.push('allowed_models = ?'); args.push( patch.allowedModels === null || patch.allowedModels === undefined ? null : JSON.stringify(patch.allowedModels), ); } if (sets.length > 0) { args.push(id); this.db .prepare(`UPDATE gateway_virtual_keys SET ${sets.join(', ')} WHERE id = ?`) .run(...args); } const refreshed = this.findGatewayVirtualKeyById(id); if (!refreshed) { throw new Error(`updateGatewayVirtualKey: id not found (${id})`); } return refreshed; } /** * Auth hot path: look up an active (non-revoked) key by SHA-256 hash. * The partial index covers this query so the planner uses it directly. * Returns null on miss; never throws. */ findGatewayVirtualKeyByHash(keyHash: string): GatewayVirtualKey | null { const row = this.db .prepare( `SELECT * FROM gateway_virtual_keys WHERE key_hash = ? AND revoked_at IS NULL`, ) .get(keyHash) as GatewayVirtualKeyRow | undefined; return row ? rowToGatewayVirtualKey(row) : null; } /** * Admin lookup by row id. Includes revoked keys (the admin list/detail * view shows them so an admin can audit a recent revoke). */ findGatewayVirtualKeyById(id: string): GatewayVirtualKey | null { const row = this.db .prepare(`SELECT * FROM gateway_virtual_keys WHERE id = ?`) .get(id) as GatewayVirtualKeyRow | undefined; return row ? rowToGatewayVirtualKey(row) : null; } /** * Admin list. `activeOnly` filters out revoked rows; `team` narrows by * team string (exact match). Ordering is `created_at DESC, id DESC` so * the freshest issuance is first regardless of system clock skew. */ listGatewayVirtualKeys(opts?: { team?: string; activeOnly?: boolean }): GatewayVirtualKey[] { const where: string[] = []; const args: unknown[] = []; if (opts?.team !== undefined) { where.push('team = ?'); args.push(opts.team); } if (opts?.activeOnly) { where.push('revoked_at IS NULL'); } const sql = `SELECT * FROM gateway_virtual_keys` + (where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '') + ` ORDER BY created_at DESC, id DESC`; const rows = this.db.prepare(sql).all(...args) as GatewayVirtualKeyRow[]; return rows.map(rowToGatewayVirtualKey); } /** * Mark a key as revoked. Returns true if the row was active (so the * caller can return a clean 200) and false if it was already revoked * or doesn't exist (so the caller can return 404 / 409). Idempotent * second calls return false. */ revokeGatewayVirtualKey(id: string, revokedBy: string, at?: string): boolean { const ts = at ?? new Date().toISOString(); const info = this.db .prepare( `UPDATE gateway_virtual_keys SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL`, ) .run(ts, revokedBy, id); return info.changes > 0; } /** * Hard delete. The admin API guards `source='config-import'` and * returns 400 before calling this — but the Repository itself doesn't * enforce that (tests need to be able to clean up). Returns true if a * row was deleted. */ /** * Hard-delete a virtual key row. * * Defense-in-depth: refuses to delete rows with `source='config-import'` * by throwing. The admin REST API also rejects this case (returning a * 400 with a human-readable message), but a future internal caller * could easily forget — and a hard delete of a config-import row * would simply be replayed on the next gateway boot when * importConfigKeysToDb re-imports the entry from config.yaml. That * recreates the row with a different id, which silently breaks any * audit history that referenced the previous id and is generally * confusing operator behavior. Force callers to use `revoke` (soft * delete) or to remove the entry from config.yaml first. * * Returns true when a row was deleted, false when the id didn't * exist. Throws when the row exists but is config-import. */ deleteGatewayVirtualKey(id: string): boolean { const row = this.findGatewayVirtualKeyById(id); if (!row) return false; if (row.source === 'config-import') { throw new Error( `cannot delete config-import virtual key (id=${id}); ` + "remove the entry from config.yaml's gateway.virtual_keys instead, " + 'or use revoke for a soft delete', ); } const info = this.db .prepare(`DELETE FROM gateway_virtual_keys WHERE id = ?`) .run(id); return info.changes > 0; } /** * Bump `last_used_at` for an active key. Called from the gateway auth * middleware on successful match. Per-request volume can be high, so * callers typically dedup with a 30-second in-memory bucket (see * src/gateway/auth.ts) before touching the DB. Best-effort: failures * are swallowed by the caller so a temporary write-lock contention * never blocks auth. */ touchGatewayVirtualKeyLastUsed(id: string, at?: string): void { const ts = at ?? new Date().toISOString(); this.db .prepare(`UPDATE gateway_virtual_keys SET last_used_at = ? WHERE id = ?`) .run(ts, id); } // ── AAO Gateway Phase 2b: usage tracking ───────────────────────────── // // Read path is on the budget enforcement hot loop, so it stays a // single point lookup over the composite PRIMARY KEY. Write path is // an UPSERT (`ON CONFLICT … DO UPDATE`) so the gateway can fire-and- // forget after every chat completion without a pre-read. /** * Point-lookup over the (key_id, period_start) PRIMARY KEY. Returns * null when there's no row yet (= "no usage in this period"), which * the caller treats as zero counters. */ getGatewayKeyUsage(keyId: string, periodStart: string): GatewayKeyUsage | null { const row = this.db .prepare( `SELECT * FROM gateway_key_usage WHERE key_id = ? AND period_start = ?`, ) .get(keyId, periodStart) as GatewayKeyUsageRow | undefined; return row ? rowToGatewayKeyUsage(row) : null; } /** * UPSERT: bump the per-(key, period) counters by the supplied deltas. * All three deltas are clamped at zero so a buggy caller can never * decrement a counter. `last_updated_at` always reflects the call * time (or the explicit `at` override) so a downstream sweeper can * tell when activity stopped. * * Called from two places on the gateway hot path: * 1. stream-proxy's finally block (token deltas from upstream usage) * 2. rate-limiter's 30-second batch flush (request count only) * * The second caller passes `tokensIn=0 tokensOut=0` so the UPSERT * still creates a row even when no token usage was extracted. */ incrementGatewayKeyUsage(params: { keyId: string; period: string; tokensIn?: number; tokensOut?: number; requests?: number; at?: string; }): void { const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0)); const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0)); const reqs = Math.max(0, Math.floor(params.requests ?? 0)); const ts = params.at ?? new Date().toISOString(); this.db .prepare( `INSERT INTO gateway_key_usage (key_id, period_start, tokens_in, tokens_out, requests, last_updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (key_id, period_start) DO UPDATE SET tokens_in = tokens_in + excluded.tokens_in, tokens_out = tokens_out + excluded.tokens_out, requests = requests + excluded.requests, last_updated_at = excluded.last_updated_at`, ) .run(params.keyId, params.period, tIn, tOut, reqs, ts); } /** * Admin view: history of usage rows for a key, freshest period first. * Default limit 12 covers a full year of monthly buckets — enough for * the in-UI bar chart and for `GET /:id/usage` to embed history without * a follow-up call. */ listGatewayKeyUsagesByKey(keyId: string, opts?: { limit?: number }): GatewayKeyUsage[] { const limit = Math.max(1, Math.min(120, Math.floor(opts?.limit ?? 12))); const rows = this.db .prepare( `SELECT * FROM gateway_key_usage WHERE key_id = ? ORDER BY period_start DESC LIMIT ?`, ) .all(keyId, limit) as GatewayKeyUsageRow[]; return rows.map(rowToGatewayKeyUsage); } // ── Per-user daily LLM usage (gateway + direct) ────────────────────── // // Recorded at the OpenAICompatClient completion boundary for every // successful chat completion. UPSERT on the (day, user_id, source, // model, route) grain. Separate lens from gateway_key_usage — never // summed across the two tables. Spec: // docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md /** * UPSERT: bump per-(day, user, source, model, route) counters. Deltas * are clamped at zero. `day` defaults to the UTC day of `at` (or now). * Called once per successful stream completion; `usage`-less completions * still bump `requests` (tokens 0) so a 0-token request is distinct from * a failed/aborted one (which is never recorded). */ incrementLlmUsage(params: LlmUsageIncrement): void { const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0)); const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0)); const reqs = Math.max(0, Math.floor(params.requests ?? 1)); const ts = params.at ?? new Date().toISOString(); const day = params.day ?? ts.slice(0, 10); this.db .prepare( `INSERT INTO llm_usage_daily (day, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (day, user_id, source, model, route) DO UPDATE SET tokens_in = tokens_in + excluded.tokens_in, tokens_out = tokens_out + excluded.tokens_out, requests = requests + excluded.requests, last_updated_at = excluded.last_updated_at`, ) .run(day, params.userId, params.source, params.model, params.route, tIn, tOut, reqs, ts); } /** * Daily time series for the usage dashboard, grouped by (day, user_id, * source) with model/route collapsed. `userId` filter scopes a non-admin * to their own rows; omit it for the admin all-users view (callers can * collapse user_id afterwards). Inclusive `from`/`to` are 'YYYY-MM-DD'. */ queryLlmUsageDaily(opts: { from: string; to: string; userId?: string }): LlmUsageDailyAgg[] { const where = ['day >= ?', 'day <= ?']; const args: unknown[] = [opts.from, opts.to]; if (opts.userId !== undefined) { where.push('user_id = ?'); args.push(opts.userId); } const rows = this.db .prepare( `SELECT day, user_id, source, SUM(tokens_in) AS tokens_in, SUM(tokens_out) AS tokens_out, SUM(requests) AS requests FROM llm_usage_daily WHERE ${where.join(' AND ')} GROUP BY day, user_id, source ORDER BY day ASC`, ) .all(...args) as Array<{ day: string; user_id: string; source: string; tokens_in: number; tokens_out: number; requests: number; }>; return rows.map((r) => ({ day: r.day, userId: r.user_id, source: r.source, tokensIn: r.tokens_in, tokensOut: r.tokens_out, requests: r.requests, })); } /** * v2 write path: UPSERT per-(hour, user, source, model, route) counters. * `hour` defaults to the current UTC hour 'YYYY-MM-DDTHH'. Same contract as * incrementLlmUsage (deltas clamped at zero; usage-less completions still * bump `requests`). Supersedes incrementLlmUsage as the recorder target. */ incrementLlmUsageHourly(params: LlmUsageHourlyIncrement): void { const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0)); const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0)); const reqs = Math.max(0, Math.floor(params.requests ?? 1)); const ts = params.at ?? new Date().toISOString(); const hour = params.hour ?? ts.slice(0, 13); this.db .prepare( `INSERT INTO llm_usage_hourly (hour, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (hour, user_id, source, model, route) DO UPDATE SET tokens_in = tokens_in + excluded.tokens_in, tokens_out = tokens_out + excluded.tokens_out, requests = requests + excluded.requests, last_updated_at = excluded.last_updated_at`, ) .run(hour, params.userId, params.source, params.model, params.route, tIn, tOut, reqs, ts); } /** * Raw hour-grain rows for the usage dashboard v2, no axis collapsed so the * API can group by any of source/model/route/user/org and re-bucket into the * viewer's local timezone. `userId` scopes a non-admin to their own rows. * Inclusive `fromHour`/`toHour` are 'YYYY-MM-DDTHH' (UTC) — callers widen the * UTC window by ±1 day before filtering precisely against local days. */ queryLlmUsageHourly(opts: { fromHour: string; toHour: string; userId?: string; }): LlmUsageHourlyRow[] { const where = ['hour >= ?', 'hour <= ?']; const args: unknown[] = [opts.fromHour, opts.toHour]; if (opts.userId !== undefined) { where.push('user_id = ?'); args.push(opts.userId); } const rows = this.db .prepare( `SELECT hour, user_id, source, model, route, tokens_in, tokens_out, requests FROM llm_usage_hourly WHERE ${where.join(' AND ')} ORDER BY hour ASC`, ) .all(...args) as Array<{ hour: string; user_id: string; source: string; model: string; route: string; tokens_in: number; tokens_out: number; requests: number; }>; return rows.map((r) => ({ hour: r.hour, userId: r.user_id, source: r.source, model: r.model, route: r.route, tokensIn: r.tokens_in, tokensOut: r.tokens_out, requests: r.requests, })); } /** * Map every user to a single org label for the usage dashboard's "by org" * breakdown. Unions Gitea org cache (user_gitea_orgs) and local orgs * (local_org_members → local_orgs.name); a multi-org user collapses to the * alphabetically-first org name (MIN) so the breakdown is deterministic. * Users with no org (and the 'local'/'system' sentinels) are simply absent — * the API buckets them under 'no-org'. */ getUsageOrgMap(): Map { const rows = this.db .prepare( `SELECT user_id, MIN(org_name) AS org_name FROM ( SELECT user_id, org_name FROM user_gitea_orgs UNION ALL SELECT m.user_id AS user_id, o.name AS org_name FROM local_org_members m JOIN local_orgs o ON o.id = m.org_id ) GROUP BY user_id`, ) .all() as Array<{ user_id: string; org_name: string }>; const map = new Map(); for (const r of rows) map.set(r.user_id, r.org_name); return map; } /** Return the underlying Database instance (needed by migrate.ts and session store) */ getDb(): Database.Database { return this.db; } // ── Browser Notifications V2: push_subscriptions ──────────────────── // Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md /** * Insert or, on endpoint collision, transfer ownership to the new user. * endpoint is globally UNIQUE: the same browser logging in as a different * user re-uses the same push service URL, so we move it rather than fail. */ upsertPushSubscription(input: UpsertPushSubscriptionInput): { id: string } { const existing = this.db .prepare('SELECT id FROM push_subscriptions WHERE endpoint = ?') .get(input.endpoint) as { id: string } | undefined; if (existing) { this.db .prepare( `UPDATE push_subscriptions SET user_id = ?, p256dh = ?, auth = ?, user_agent = ?, vapid_key_id = ?, last_success_at = NULL, last_failure_at = NULL, failure_count = 0 WHERE id = ?`, ) .run( input.userId, input.p256dh, input.auth, input.userAgent ?? null, input.vapidKeyId, existing.id, ); return { id: existing.id }; } const id = randomUUID(); this.db .prepare( `INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent, vapid_key_id) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run( id, input.userId, input.endpoint, input.p256dh, input.auth, input.userAgent ?? null, input.vapidKeyId, ); return { id }; } listPushSubscriptionsForUser(userId: string): PushSubscriptionRecord[] { const rows = this.db .prepare( `SELECT id, user_id, endpoint, p256dh, auth, user_agent, vapid_key_id, created_at, last_success_at, last_failure_at, failure_count FROM push_subscriptions WHERE user_id = ? ORDER BY created_at ASC`, ) .all(userId) as Array<{ id: string; user_id: string; endpoint: string; p256dh: string; auth: string; user_agent: string | null; vapid_key_id: string; created_at: string; last_success_at: string | null; last_failure_at: string | null; failure_count: number; }>; return rows.map(rowToPushSubscription); } getPushSubscriptionById(id: string): PushSubscriptionRecord | null { const row = this.db .prepare( `SELECT id, user_id, endpoint, p256dh, auth, user_agent, vapid_key_id, created_at, last_success_at, last_failure_at, failure_count FROM push_subscriptions WHERE id = ?`, ) .get(id) as Parameters[0] | undefined; return row ? rowToPushSubscription(row) : null; } deletePushSubscription(id: string): void { this.db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(id); } markPushSubscriptionSuccess(id: string): void { this.db .prepare( `UPDATE push_subscriptions SET last_success_at = datetime('now'), failure_count = 0 WHERE id = ?`, ) .run(id); } markPushSubscriptionFailure(id: string): void { this.db .prepare( `UPDATE push_subscriptions SET last_failure_at = datetime('now'), failure_count = failure_count + 1 WHERE id = ?`, ) .run(id); } // ── Browser Notifications V2: user_notification_prefs ──────────────── /** * Read per-user prefs; create row with defaults if none exists. * Defaults: enabled=true, all events on, include_details=false, v1_migrated=false. */ getUserNotificationPrefs(userId: string): NotificationPrefs { const row = this.db .prepare( `SELECT user_id, enabled, event_running, event_succeeded, event_failed, event_waiting_human, include_details, v1_migrated, updated_at FROM user_notification_prefs WHERE user_id = ?`, ) .get(userId) as | { user_id: string; enabled: number; event_running: number; event_succeeded: number; event_failed: number; event_waiting_human: number; include_details: number; v1_migrated: number; updated_at: string; } | undefined; if (!row) { // Lazily create the default row so subsequent reads/updates are // a simple UPDATE rather than a conditional insert. this.db .prepare( `INSERT INTO user_notification_prefs (user_id) VALUES (?) ON CONFLICT(user_id) DO NOTHING`, ) .run(userId); return { userId, enabled: true, events: { running: true, succeeded: true, failed: true, waiting_human: true }, includeDetails: false, v1Migrated: false, updatedAt: new Date().toISOString(), }; } return { userId: row.user_id, enabled: row.enabled !== 0, events: { running: row.event_running !== 0, succeeded: row.event_succeeded !== 0, failed: row.event_failed !== 0, waiting_human: row.event_waiting_human !== 0, }, includeDetails: row.include_details !== 0, v1Migrated: row.v1_migrated !== 0, updatedAt: row.updated_at, }; } upsertUserNotificationPrefs(userId: string, update: NotificationPrefsUpdate): void { // Ensure a row exists first (lazy default creation matches getUserNotificationPrefs). this.db .prepare(`INSERT OR IGNORE INTO user_notification_prefs (user_id) VALUES (?)`) .run(userId); const sets: string[] = []; const params: Array = []; if (update.enabled !== undefined) { sets.push('enabled = ?'); params.push(update.enabled ? 1 : 0); } if (update.events) { if (update.events.running !== undefined) { sets.push('event_running = ?'); params.push(update.events.running ? 1 : 0); } if (update.events.succeeded !== undefined) { sets.push('event_succeeded = ?'); params.push(update.events.succeeded ? 1 : 0); } if (update.events.failed !== undefined) { sets.push('event_failed = ?'); params.push(update.events.failed ? 1 : 0); } if (update.events.waiting_human !== undefined) { sets.push('event_waiting_human = ?'); params.push(update.events.waiting_human ? 1 : 0); } } if (update.includeDetails !== undefined) { sets.push('include_details = ?'); params.push(update.includeDetails ? 1 : 0); } if (update.v1Migrated !== undefined) { sets.push('v1_migrated = ?'); params.push(update.v1Migrated ? 1 : 0); } if (sets.length === 0) return; sets.push("updated_at = datetime('now')"); params.push(userId); this.db .prepare(`UPDATE user_notification_prefs SET ${sets.join(', ')} WHERE user_id = ?`) .run(...params); } /** * One-time V1 (localStorage) → V2 (server) preferences migration marker. * Returns true if this call performed the migration mark (caller should * then apply the localStorage values via upsertUserNotificationPrefs). * Returns false if already migrated (caller should treat as 409 conflict). */ markV1MigrationComplete(userId: string): boolean { this.db .prepare(`INSERT OR IGNORE INTO user_notification_prefs (user_id) VALUES (?)`) .run(userId); const result = this.db .prepare( `UPDATE user_notification_prefs SET v1_migrated = 1, updated_at = datetime('now') WHERE user_id = ? AND v1_migrated = 0`, ) .run(userId); return result.changes > 0; } close(): void { this.db.close(); } } function rowToPushSubscription(row: { id: string; user_id: string; endpoint: string; p256dh: string; auth: string; user_agent: string | null; vapid_key_id: string; created_at: string; last_success_at: string | null; last_failure_at: string | null; failure_count: number; }): PushSubscriptionRecord { return { id: row.id, userId: row.user_id, endpoint: row.endpoint, p256dh: row.p256dh, auth: row.auth, userAgent: row.user_agent, vapidKeyId: row.vapid_key_id, createdAt: row.created_at, lastSuccessAt: row.last_success_at, lastFailureAt: row.last_failure_at, failureCount: row.failure_count, }; } export { BrowserSessionRepo } from './browser-session-repo.js'; export type { BrowserSessionProfile, CreateProfileInput, AuditInput } from './browser-session-repo.js';