using InvercargillSql.Migrations; namespace ClankerSaddle.Data.Migrations { /** * The full ClankerSaddle schema in one migration (plan 11: every * earlier migration flattened — the database is not deployed, so * tear-down-and-recreate replaced incremental evolution). Covers: * projects and their links; the plan-12 goals (with links — * plan 15 C1 deleted `goal_blocks` and C2 deleted `goal_comments`: * the goal agent's blocks and comments ride the run task's own * rows) and the * flattened task shape (`goal_id`, `kind`, `mode`, * `creator`, `enqueue_after_task_id` — the subtask machinery's * parent_task_id/plan/auto-* columns and the task_dependencies * table are gone); tasks with comments, typed blocks, progress * and the draft-lifecycle columns (`status` defaults to * 'draft'); saddle registrations with git identity * and the per-saddle token; the session + command + raw-log audit * trail; the plan-13 daemon-global entry dedup * (`saddle_entry_dedup`); rules and their attachments; project * notes; capability tags; and the plan-11 notification tables for * task E * (`push_subscriptions`, `task_notify_prefs` — included now so * D+E needs no migration). */ public class M0001_Initial : Migration { public override string name { get { return "Create the ClankerSaddle schema"; } } public override uint64 serial { get { return 1; } } public override string migration_namespace { get { return "clanker-saddle"; } } public override void up(MigrationBuilder b) { b.create_table("projects", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("name") .not_null(); t.column("git_url") .not_null(); t.column("default_branch") .default_value("main"); t.column("summary"); t.column("created_at"); }); b.create_table("project_links", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("parent_project_id") .not_null() .references("projects", "id"); t.column("linked_project_id") .not_null() .references("projects", "id"); t.column("relationship") .not_null(); }); // Plan 12 goals: user-created units of agent-orchestrated // work. The brief locks once started; `branch_name` is the // user-named goal branch (uniqueness-validated against the // other live goals sharing involved repos); `plan` holds // the APPROVED plan markdown (rejected revisions live on // as the run task's answered plan blocks); `last_seen_event_id` // is the goal agent's consumed-event watermark (resume // coalescing); `branch_push_state` caches the // daemon-reported push state of the goal branches. b.create_table("goals", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("project_id") .not_null() .references("projects", "id") .indexed(); t.column("title") .not_null(); t.column("brief") .not_null(); t.column("branch_name") .not_null(); t.column("base_branch"); t.column("status") .default_value("draft") .indexed(); t.column("priority") .default_value(3); t.column("plan"); t.column("completion_report"); t.column("creator") .default_value("human"); t.column("last_seen_event_id") .default_value(0); t.column("branch_push_state"); t.column("created_at"); t.column("started_at"); t.column("completed_at"); t.column("paused_at"); }); b.create_table("goal_links", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("goal_id") .not_null() .references("goals", "id") .indexed(); t.column("project_id") .not_null() .references("projects", "id") .indexed(); t.column("relationship") .not_null(); }); b.create_table("tasks", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("project_id") .not_null() .references("projects", "id") .indexed(); // Plan 12: goals own their task graph — a task links to // its goal (null = standalone), runs as a plain worker // or a goal agent (kind), carries a work mode, records // its creator (enqueue is creator-only) and — // human-created tasks only — an auto-enqueue // predecessor. t.column("goal_id") .references("goals", "id") .indexed(); t.column("kind") .default_value("task"); t.column("mode") .default_value("implement"); t.column("creator") .default_value("human"); t.column("enqueue_after_task_id") .references("tasks", "id"); t.column("title") .not_null(); t.column("description"); // Plan 11 draft lifecycle: new tasks start in draft and // only the human Enqueue action queues them. t.column("status") .default_value("draft") .indexed(); t.column("priority") .default_value(5); t.column("working_branch"); t.column("summary"); t.column("task_branch"); t.column("branch_from"); t.column("created_at"); t.column("started_at"); t.column("completed_at"); t.column("suspended_at"); }); b.create_table("task_comments", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("task_id") .not_null() .references("tasks", "id") .indexed(); t.column("author") .not_null(); t.column("body") .not_null(); t.column("quote"); t.column("created_at"); }); b.create_table("task_blocks", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("task_id") .not_null() .references("tasks", "id") .indexed(); t.column("question") .not_null(); t.column("answer"); t.column("type") .default_value("question"); t.column("options"); t.column("body"); t.column("answered_at"); t.column("answered_by"); t.column("created_at"); }); b.create_table("task_progress", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("task_id") .not_null() .references("tasks", "id") .indexed(); t.column("summary") .not_null(); t.column("created_at"); }); // Plan 15 C1/C2: the goal-side block and comment tables // are GONE — the goal agent's questions, plans and // completion reports ride the RUN TASK's task_blocks rows // (one row per question, answered in place; no dual-row // pair can fall out of sync), and its comment thread IS // the run task's task_comments (human comments inject // through the existing delivery paths). // The supervision narrative's event log: one row per // notable goal occurrence (plan reviews, agent task // operations, task terminal events, lifecycle moves) — // the resume machinery's watermark (`goals. // last_seen_event_id`) also lives on these ids. b.create_table("goal_events", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("goal_id") .not_null() .references("goals", "id") .indexed(); t.column("kind") .not_null(); t.column("text") .not_null(); t.column("task_id"); t.column("created_at"); }); b.create_table("saddles", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("name") .not_null(); t.column("container_id"); t.column("host") .not_null(); t.column("status") .default_value("offline"); t.column("current_task_id") .references("tasks", "id"); t.column("allocated_at"); t.column("git_name"); t.column("git_email"); t.column("ssh_public_key"); t.column("token"); }); b.create_table("sessions", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("task_id") .not_null() .references("tasks", "id") .indexed(); t.column("saddle_id") .not_null() .references("saddles", "id"); t.column("started_at"); t.column("ended_at"); t.column("outcome"); // Plan 14 B4: what a RESUMED session's agent first // received — the resume_notice the allocation built, // stored on the session row at resume-allocation (null // for fresh sessions; the terminal page shows it above // the log). t.column("resume_notice"); }); b.create_table("session_commands", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("session_id") .not_null() .references("sessions", "id") .indexed(); t.column("command_text") .not_null(); t.column("output_text"); t.column("exit_code"); t.column("type") .default_value("command"); t.column("piped_input"); t.column("started_at"); t.column("completed_at"); t.column("duration_ms"); }); // Plan 13 B1: the daemon-global entry dedup — every pushed // entry id this saddle has already ingested. The unique // (saddle_id, entry_id) pair is the replay-integrity key: a // redelivered entry under ANY session id (web restart, // session churn, a mis-keyed send) finds its row here and is // consumed silently. b.create_table("saddle_entry_dedup", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("saddle_id") .not_null() .references("saddles", "id") .indexed(); t.column("entry_id") .not_null(); t.unique("uq_saddle_entry_dedup_pair", {"saddle_id", "entry_id"}); }); b.create_table("api_tokens", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("saddle_id") .not_null() .references("saddles", "id") .indexed(); t.column("token") .not_null() .unique(); t.column("created_at"); }); b.create_table("rules", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("name") .not_null() .unique(); t.column("body") .not_null(); t.column("created_at"); // Bumped on edit — the resume-time context_update // delta keys on it (a rule edited while a workspace // was parked must reach the agent as a turn). t.column("updated_at"); }); // The attachment timestamps (created_at) feed the same // delta: attaching an existing rule to a task or project // changes the effective instruction set without touching // the rule row. b.create_table("rule_projects", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("rule_id") .not_null() .references("rules", "id") .indexed(); t.column("project_id") .not_null() .references("projects", "id") .indexed(); t.column("created_at"); t.unique("uq_rule_projects_pair", {"rule_id", "project_id"}); }); b.create_table("task_rules", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("rule_id") .not_null() .references("rules", "id") .indexed(); t.column("task_id") .not_null() .references("tasks", "id") .indexed(); t.column("created_at"); t.unique("uq_task_rules_pair", {"rule_id", "task_id"}); }); b.create_table("project_notes", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("project_id") .not_null() .references("projects", "id") .indexed(); t.column("source_task_id") .references("tasks", "id"); t.column("body") .not_null(); t.column("created_at"); }); b.create_table("session_raw_log", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("session_id") .not_null() .references("sessions", "id") .indexed(); t.column("seq") .not_null(); t.column("chunk") .not_null(); t.unique("uq_session_raw_log_seq", {"session_id", "seq"}); }); b.create_table("capabilities", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("name") .not_null() .unique(); t.column("created_at"); }); b.create_table("saddle_capabilities", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("capability_id") .not_null() .references("capabilities", "id") .indexed(); t.column("saddle_id") .not_null() .references("saddles", "id") .indexed(); t.unique("uq_saddle_capabilities_pair", {"capability_id", "saddle_id"}); }); b.create_table("task_capabilities", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("capability_id") .not_null() .references("capabilities", "id") .indexed(); t.column("task_id") .not_null() .references("tasks", "id") .indexed(); t.unique("uq_task_capabilities_pair", {"capability_id", "task_id"}); }); // Plan 11 task E: Web Push subscriptions, one row per // browser endpoint a user registered (the VAPID keypair is // generated at startup and persisted in the config dir, not // here). `user_id` references the Spry users table by value // without an FK — the auth schema owns that table. b.create_table("push_subscriptions", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("user_id") .not_null() .indexed(); t.column("endpoint") .not_null() .unique(); t.column("p256dh") .not_null(); t.column("auth") .not_null(); t.column("created_at"); }); // Plan 11 task E: a user's per-task "Notify me" opt-in for // the task's first start, failure and completion (block // events notify all push-subscribed users globally). b.create_table("task_notify_prefs", (t) => { t.column("id") .auto_increment() .primary_key(); t.column("task_id") .not_null() .references("tasks", "id") .indexed(); t.column("user_id") .not_null() .indexed(); t.unique("uq_task_notify_prefs_pair", {"task_id", "user_id"}); }); } public override void down(MigrationBuilder b) { b.drop_table("saddle_entry_dedup"); b.drop_table("goal_events"); b.drop_table("task_notify_prefs"); b.drop_table("push_subscriptions"); b.drop_table("task_capabilities"); b.drop_table("saddle_capabilities"); b.drop_table("capabilities"); b.drop_table("session_raw_log"); b.drop_table("project_notes"); b.drop_table("task_rules"); b.drop_table("rule_projects"); b.drop_table("rules"); b.drop_table("api_tokens"); b.drop_table("session_commands"); b.drop_table("sessions"); b.drop_table("saddles"); b.drop_table("task_progress"); b.drop_table("task_blocks"); b.drop_table("task_comments"); b.drop_table("tasks"); b.drop_table("goal_links"); b.drop_table("goals"); b.drop_table("project_links"); b.drop_table("projects"); } } }