Files
kandev/apps/backend/internal/office/repository/sqlite/base.go
T
2026-08-24 11:09:03 +00:00

688 lines
23 KiB
Go

// Package sqlite provides SQLite-based repository for office entities.
package sqlite
import (
"context"
"database/sql"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/db/dialect"
runssqlite "github.com/kandev/kandev/internal/runs/repository/sqlite"
)
// newParticipantUUID is a thin wrapper over uuid.New so the migration
// statements stay readable without import noise. Hoisted to the package
// scope because Go disallows function literals in package-level var
// initialisation when used in compound expressions.
func newParticipantUUID() string { return uuid.New().String() }
// RunnerProjection returns the correlated subquery that resolves the
// effective per-task runner from workflow_step_participants, falling
// back to the step's primary agent_profile_id and then the task's most
// recently assigned runner (ADR 0005 Wave F).
// Inlined into SELECT clauses where the legacy
// tasks.assignee_agent_profile_id column would have been read.
//
// The alias is the table alias of the tasks row (e.g. "t" or "tasks");
// the resulting SQL fragment evaluates to "" when neither a runner row
// nor a step primary exists.
func RunnerProjection(alias string) string {
if alias == "" {
alias = "tasks"
}
return `COALESCE(
NULLIF((SELECT wsp.agent_profile_id FROM workflow_step_participants wsp
WHERE wsp.step_id = ` + alias + `.workflow_step_id
AND wsp.task_id = ` + alias + `.id
AND wsp.role = 'runner'
ORDER BY wsp.position ASC, wsp.id ASC LIMIT 1), ''),
NULLIF((SELECT ws.agent_profile_id FROM workflow_steps ws WHERE ws.id = ` + alias + `.workflow_step_id), ''),
NULLIF((SELECT wsp.agent_profile_id FROM workflow_step_participants wsp
WHERE wsp.task_id = ` + alias + `.id
AND wsp.role = 'runner'
ORDER BY wsp.rowid DESC LIMIT 1), ''),
''
)`
}
// Repository provides SQLite-based office storage operations. The
// runs queue methods (CreateRun, ClaimNextEligibleRun, AppendRunEvent,
// etc.) are provided via the embedded *runssqlite.Repository — those
// were lifted out of the office package in Phase 3 of
// task-model-unification but stay accessible here so existing office
// code paths (and the office service's QueueRun delegation) keep
// working without churn.
type Repository struct {
*runssqlite.Repository
db *sqlx.DB // writer
ro *sqlx.DB // reader
log *logger.Logger
migrate *db.MigrateLogger
}
// NewWithDB creates a new office repository with existing database connections.
func NewWithDB(writer, reader *sqlx.DB, log *logger.Logger) (*Repository, error) {
repo := &Repository{
Repository: runssqlite.NewWithDB(writer, reader),
db: writer,
ro: reader,
log: log,
migrate: db.NewMigrateLogger(writer, log),
}
if err := repo.initSchema(); err != nil {
return nil, fmt.Errorf("failed to initialize office schema: %w", err)
}
return repo, nil
}
// RunsRepository returns the embedded runs queue repository so
// callers in the runs package can hold a typed handle without
// importing the office repository.
func (r *Repository) RunsRepository() *runssqlite.Repository { return r.Repository }
// CommentRunStatus is the slim per-comment run snapshot returned by
// GetRunsByCommentIDs. The type lives in the runs package now;
// re-exported here so callers (dashboard, etc.) that imported the
// office sqlite package keep compiling without a flag-day rename.
type CommentRunStatus = runssqlite.CommentRunStatus
// RunCostRollup carries per-run aggregated token + cost numbers
// returned by GetRunWithCosts. Re-exported alias — see CommentRunStatus.
type RunCostRollup = runssqlite.RunCostRollup
// ExecRaw executes a raw SQL statement against the writer database.
// Intended for test setup; production code should use typed methods.
func (r *Repository) ExecRaw(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
return r.db.ExecContext(ctx, r.db.Rebind(query), args...)
}
// ReaderDB returns the read-replica handle. Exposed so the
// internal/office/summary package can issue ad-hoc workspace-scope
// queries (blocked tasks, activity counts) that don't have first-
// class repo methods today. Production code outside the office
// package should keep using typed methods.
func (r *Repository) ReaderDB() *sqlx.DB { return r.ro }
// initSchema creates all office tables if they don't exist.
func (r *Repository) initSchema() error {
if err := r.createCoreTables(); err != nil {
return err
}
if err := r.createExtensionTables(); err != nil {
return err
}
r.runMigrations()
return nil
}
// createCoreTables creates the primary office entity tables.
func (r *Repository) createCoreTables() error {
if err := r.createAgentTables(); err != nil {
return err
}
if err := r.createProjectTables(); err != nil {
return err
}
if err := r.createAgentRuntimeTable(); err != nil {
return err
}
if err := r.createCostTables(); err != nil {
return err
}
if err := r.createRunTables(); err != nil {
return err
}
if err := r.createTreeHoldTables(); err != nil {
return err
}
if err := r.createWorkspaceGroupTables(); err != nil {
return err
}
if err := r.createRoutineTables(); err != nil {
return err
}
if err := r.createApprovalTables(); err != nil {
return err
}
return nil
}
// createExtensionTables creates supplementary office tables.
func (r *Repository) createExtensionTables() error {
if err := r.createActivityTables(); err != nil {
return err
}
if err := r.createMemoryTables(); err != nil {
return err
}
if err := r.createChannelTables(); err != nil {
return err
}
if err := r.createTaskExtensionTables(); err != nil {
return err
}
if err := r.createOnboardingTable(); err != nil {
return err
}
if err := r.createInstructionTable(); err != nil {
return err
}
if err := r.createLabelTables(); err != nil {
return err
}
if err := r.createWorkspaceGovernanceTable(); err != nil {
return err
}
if err := r.createContinuationSummaryTable(); err != nil {
return err
}
if err := r.createAgentWakeupRequestTable(); err != nil {
return err
}
return nil
}
func (r *Repository) createAgentTables() error {
// office_agent_instances was removed in ADR 0005 Wave C — office agents
// are now rows in the unified agent_profiles table (workspace_id != '').
// The settings store (internal/agent/settings/store) owns the
// agent_profiles schema; office initSchema runs after settings, so
// the table is already present when this code runs.
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_skills (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT DEFAULT '',
source_type TEXT NOT NULL DEFAULT 'inline',
source_locator TEXT DEFAULT '',
content TEXT DEFAULT '',
file_inventory TEXT DEFAULT '[]',
version TEXT NOT NULL DEFAULT '',
content_hash TEXT NOT NULL DEFAULT '',
approval_state TEXT NOT NULL DEFAULT 'approved',
created_by_agent_profile_id TEXT DEFAULT '',
is_system INTEGER NOT NULL DEFAULT 0,
system_version TEXT NOT NULL DEFAULT '',
default_for_roles TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE(workspace_id, slug)
);
`)
return err
}
func (r *Repository) createProjectTables() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_projects (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
lead_agent_profile_id TEXT DEFAULT '',
color TEXT DEFAULT '',
budget_cents INTEGER DEFAULT 0,
repositories TEXT DEFAULT '[]',
executor_config TEXT DEFAULT '{}',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
`)
return err
}
func (r *Repository) createAgentRuntimeTable() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_agent_runtime (
agent_id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'idle',
pause_reason TEXT DEFAULT '',
last_run_finished_at TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`)
return err
}
func (r *Repository) createCostTables() error {
// cost_subcents stores hundredths of a cent (int64). UI divides by
// 10000 when rendering dollars. The estimated flag is set when token
// counts are not authoritative for a complete turn, such as adapter
// synthesis or a provider frame that covers only part of a turn.
//
// tokens_cached_read / tokens_cached_write / turn_id / usage_event_id /
// cost_source / rate_*_per_million / pricing_catalog_version /
// cost_contract_version have deliberately NO DEFAULT: an absent default
// is what makes a fresh row's un-set columns NULL rather than 0, matching
// the ALTER-based migration in migrateCostEventContract (which the same
// columns must stay byte-identical to — see base_migrations.go). NULL
// means "not recorded" (legacy row, or an adapter that did not report
// cache data); 0 would silently claim zero cache activity. See
// docs/specs/office/requirements/costs.md.
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_cost_events (
id TEXT PRIMARY KEY,
session_id TEXT DEFAULT '',
task_id TEXT DEFAULT '',
agent_profile_id TEXT DEFAULT '',
project_id TEXT DEFAULT '',
model TEXT DEFAULT '',
provider TEXT DEFAULT '',
tokens_in INTEGER DEFAULT 0,
tokens_cached_in INTEGER DEFAULT 0,
tokens_cached_read INTEGER,
tokens_cached_write INTEGER,
tokens_out INTEGER DEFAULT 0,
cost_subcents INTEGER NOT NULL DEFAULT 0,
estimated INTEGER NOT NULL DEFAULT 0,
turn_id TEXT,
usage_event_id TEXT,
cost_source TEXT,
rate_input_per_million INTEGER,
rate_cached_read_per_million INTEGER,
rate_cached_write_per_million INTEGER,
rate_output_per_million INTEGER,
pricing_catalog_version TEXT,
cost_contract_version INTEGER,
occurred_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_office_cost_agent ON office_cost_events(agent_profile_id);
CREATE INDEX IF NOT EXISTS idx_office_cost_occurred ON office_cost_events(occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_office_cost_task ON office_cost_events(task_id);
DROP INDEX IF EXISTS idx_office_cost_events_session_id;
-- uniq_office_cost_usage_event is created by migrateCostEventContract
-- (base_migrations.go), not here: schema init runs before migrations,
-- so indexing usage_event_id inline would crash a pre-migration
-- database that doesn't have the column yet.
CREATE TABLE IF NOT EXISTS office_budget_policies (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
scope_type TEXT NOT NULL,
scope_id TEXT NOT NULL,
limit_subcents INTEGER NOT NULL,
period TEXT NOT NULL,
alert_threshold_pct INTEGER DEFAULT 80,
action_on_exceed TEXT DEFAULT 'notify_only',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
`)
return err
}
func (r *Repository) createRunTables() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY,
agent_profile_id TEXT NOT NULL,
reason TEXT NOT NULL,
payload TEXT DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'queued',
coalesced_count INTEGER DEFAULT 1,
idempotency_key TEXT,
context_snapshot TEXT DEFAULT '{}',
capabilities TEXT NOT NULL DEFAULT '{}',
input_snapshot TEXT NOT NULL DEFAULT '{}',
output_summary TEXT NOT NULL DEFAULT '',
failure_reason TEXT NOT NULL DEFAULT '',
session_id TEXT NOT NULL DEFAULT '',
retry_count INTEGER DEFAULT 0,
scheduled_retry_at TIMESTAMP,
error_message TEXT NOT NULL DEFAULT '',
cancel_reason TEXT,
-- Provider routing (office-provider-routing).
logical_provider_order TEXT,
requested_tier TEXT,
resolved_execution_profile_id TEXT,
resolved_provider_id TEXT,
resolved_model TEXT,
current_route_attempt_seq INTEGER NOT NULL DEFAULT 0,
routing_blocked_status TEXT,
earliest_retry_at TIMESTAMP,
-- route_cycle_baseline_seq marks the floor at which the current
-- retry cycle began. excludedFromAttempts filters prior attempt
-- rows with seq <= baseline so a parked-then-lifted run gets a
-- fresh exclusion list instead of re-inheriting every provider
-- that failed in the previous cycle.
route_cycle_baseline_seq INTEGER NOT NULL DEFAULT 0,
-- Heartbeat-rework run inspection columns: structured adapter
-- output, the assembled prompt the agent received, and the
-- continuation summary that was prepended (if any).
result_json TEXT NOT NULL DEFAULT '{}',
assembled_prompt TEXT NOT NULL DEFAULT '',
summary_injected TEXT NOT NULL DEFAULT '',
requested_at TIMESTAMP NOT NULL,
claimed_at TIMESTAMP,
finished_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_run_status_requested ON runs(status, requested_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_run_idempotency ON runs(idempotency_key) WHERE idempotency_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS office_run_skills (
run_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
version TEXT NOT NULL,
content_hash TEXT NOT NULL,
materialized_path TEXT NOT NULL,
PRIMARY KEY (run_id, skill_id)
);
`)
if err != nil {
return err
}
_, err = r.db.Exec(runPayloadCommentIDIndexSQL(r.db.DriverName()))
return err
}
func runPayloadCommentIDIndexSQL(driver string) string {
return fmt.Sprintf(
`CREATE INDEX IF NOT EXISTS idx_run_payload_comment_id ON runs((%s))`,
dialect.JSONExtract(driver, "payload", "comment_id"),
)
}
func (r *Repository) createRoutineTables() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_routines (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
task_template TEXT NOT NULL DEFAULT '{}',
assignee_agent_profile_id TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
concurrency_policy TEXT DEFAULT 'skip_if_active',
catch_up_policy TEXT NOT NULL DEFAULT 'enqueue_missed_with_cap',
catch_up_max INTEGER NOT NULL DEFAULT 25,
variables TEXT DEFAULT '{}',
last_run_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS office_routine_triggers (
id TEXT PRIMARY KEY,
routine_id TEXT NOT NULL,
kind TEXT NOT NULL,
cron_expression TEXT DEFAULT '',
timezone TEXT DEFAULT '',
public_id TEXT DEFAULT '',
signing_mode TEXT DEFAULT '',
secret TEXT DEFAULT '',
next_run_at TIMESTAMP,
last_fired_at TIMESTAMP,
enabled INTEGER DEFAULT 1,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS office_routine_runs (
id TEXT PRIMARY KEY,
routine_id TEXT NOT NULL,
trigger_id TEXT DEFAULT '',
source TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'received',
trigger_payload TEXT DEFAULT '{}',
linked_task_id TEXT DEFAULT '',
coalesced_into_run_id TEXT DEFAULT '',
dispatch_fingerprint TEXT DEFAULT '',
started_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE
);
`)
return err
}
func (r *Repository) createApprovalTables() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_approvals (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
type TEXT NOT NULL,
requested_by_agent_profile_id TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
payload TEXT DEFAULT '{}',
decision_note TEXT DEFAULT '',
decided_by TEXT DEFAULT '',
decided_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
`)
return err
}
func (r *Repository) createActivityTables() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_activity_log (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
actor_type TEXT NOT NULL,
actor_id TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT DEFAULT '',
target_id TEXT DEFAULT '',
details TEXT DEFAULT '{}',
run_id TEXT NOT NULL DEFAULT '',
session_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_activity_workspace_created ON office_activity_log(workspace_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_run_id ON office_activity_log(run_id) WHERE run_id != '';
CREATE INDEX IF NOT EXISTS idx_activity_session_id ON office_activity_log(session_id) WHERE session_id != '';
CREATE TABLE IF NOT EXISTS run_events (
run_id TEXT NOT NULL,
seq INTEGER NOT NULL,
event_type TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
payload TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (run_id, seq)
);
CREATE INDEX IF NOT EXISTS idx_run_events_run_created ON run_events(run_id, created_at);
`)
return err
}
func (r *Repository) createMemoryTables() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_agent_memory (
id TEXT PRIMARY KEY,
agent_profile_id TEXT NOT NULL,
layer TEXT NOT NULL,
key TEXT NOT NULL,
content TEXT DEFAULT '',
metadata TEXT DEFAULT '{}',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE(agent_profile_id, layer, key)
);
`)
return err
}
func (r *Repository) createChannelTables() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_channels (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
agent_profile_id TEXT NOT NULL,
platform TEXT NOT NULL,
config TEXT DEFAULT '{}',
webhook_secret TEXT NOT NULL DEFAULT '',
status TEXT DEFAULT 'active',
task_id TEXT DEFAULT '',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
`)
return err
}
func (r *Repository) createOnboardingTable() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_onboarding (
workspace_id TEXT PRIMARY KEY,
completed INTEGER NOT NULL DEFAULT 0,
ceo_agent_id TEXT DEFAULT '',
first_task_id TEXT DEFAULT '',
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`)
return err
}
func (r *Repository) createInstructionTable() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_agent_instructions (
id TEXT PRIMARY KEY,
agent_profile_id TEXT NOT NULL,
filename TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
is_entry INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(agent_profile_id, filename)
);
`)
return err
}
func (r *Repository) createLabelTables() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_labels (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6b7280',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(workspace_id, name)
);
CREATE INDEX IF NOT EXISTS idx_office_labels_workspace ON office_labels(workspace_id);
CREATE TABLE IF NOT EXISTS office_task_labels (
task_id TEXT NOT NULL,
label_id TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (task_id, label_id),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (label_id) REFERENCES office_labels(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_office_task_labels_label ON office_task_labels(label_id);
`)
return err
}
func (r *Repository) createTaskExtensionTables() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS task_blockers (
task_id TEXT NOT NULL,
blocker_task_id TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (task_id, blocker_task_id),
CHECK (task_id != blocker_task_id)
);
CREATE TABLE IF NOT EXISTS task_comments (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
author_type TEXT NOT NULL,
author_id TEXT NOT NULL,
body TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'user',
reply_channel_id TEXT DEFAULT '',
created_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_task_comments_task_created ON task_comments(task_id, created_at);
-- office_task_participants was removed in ADR 0005 Wave C. Reviewer
-- and approver rows are now stored in workflow_step_participants
-- (dual-scoped on task_id + step_id). The migration that copies any
-- legacy rows over is in migrateOfficeTaskParticipants below.
`)
return err
}
func (r *Repository) createWorkspaceGovernanceTable() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_workspace_governance (
workspace_id TEXT NOT NULL,
key TEXT NOT NULL,
value INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (workspace_id, key)
);
`)
return err
}
// createContinuationSummaryTable creates the agent_continuation_summaries
// table introduced in PR 1 of office-heartbeat-rework. The row stores the
// per-(agent, scope) markdown summary that bridges context across taskless
// runs (heartbeats, lightweight routines). Capped at 8 KB on write.
func (r *Repository) createContinuationSummaryTable() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS agent_continuation_summaries (
agent_profile_id TEXT NOT NULL,
scope TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
content_tokens INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by_run_id TEXT NOT NULL DEFAULT '',
PRIMARY KEY (agent_profile_id, scope)
);
`)
return err
}
// createAgentWakeupRequestTable creates the agent_wakeup_requests table
// introduced in PR 2 of office-heartbeat-rework. Each row is one
// "wake the agent" request from cron heartbeats, comments, agent-error
// escalations, routines, user mentions, or agent self-requests. The
// dispatcher coalesces / claims / drops them per the agent's policy
// before creating the corresponding runs row.
//
// idempotency_key carries source-level dedup (e.g. heartbeat:<agent>:
// <unix_minute>) — duplicates within the window land on the partial
// UNIQUE index and are rejected. The "" sentinel keeps the index free
// for rows without a key.
func (r *Repository) createAgentWakeupRequestTable() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS agent_wakeup_requests (
id TEXT PRIMARY KEY,
agent_profile_id TEXT NOT NULL,
source TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL,
coalesced_count INTEGER NOT NULL DEFAULT 1,
idempotency_key TEXT,
run_id TEXT NOT NULL DEFAULT '',
requested_at TIMESTAMP NOT NULL,
claimed_at TIMESTAMP,
finished_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_wakeup_agent_status ON agent_wakeup_requests(agent_profile_id, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_wakeup_idempotency ON agent_wakeup_requests(idempotency_key)
WHERE idempotency_key IS NOT NULL AND idempotency_key != '';
`)
return err
}