diff --git a/apps/daemon/drizzle/0010_chilly_swordsman.sql b/apps/daemon/drizzle/0010_chilly_swordsman.sql new file mode 100644 index 000000000..46fe2fb5b --- /dev/null +++ b/apps/daemon/drizzle/0010_chilly_swordsman.sql @@ -0,0 +1,9 @@ +ALTER TABLE `session_runs` ADD `run_id` text;--> statement-breakpoint +ALTER TABLE `session_runs` ADD `base_turn_id` text;--> statement-breakpoint +CREATE UNIQUE INDEX `session_runs_run_id_unique` ON `session_runs` (`run_id`);--> statement-breakpoint +ALTER TABLE `sessions` ADD `origin_source_session_id` text;--> statement-breakpoint +ALTER TABLE `sessions` ADD `origin_source_turn_id` text;--> statement-breakpoint +ALTER TABLE `sessions` ADD `origin_forked_at` integer;--> statement-breakpoint +ALTER TABLE `sessions` ADD `active_leaf_turn_id` text;--> statement-breakpoint +ALTER TABLE `sessions` ADD `graph_revision` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +UPDATE `session_runs` SET `run_id` = 'run-' || lower(hex(randomblob(16))) WHERE `run_id` IS NULL; \ No newline at end of file diff --git a/apps/daemon/drizzle/0011_flippant_bastion.sql b/apps/daemon/drizzle/0011_flippant_bastion.sql new file mode 100644 index 000000000..e5bcb5742 --- /dev/null +++ b/apps/daemon/drizzle/0011_flippant_bastion.sql @@ -0,0 +1,56 @@ +CREATE TABLE `conversation_operations` ( + `operation_id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `kind` text NOT NULL, + `state` text NOT NULL, + `turn_id` text, + `error_code` text, + `error_message` text, + `created_at` integer NOT NULL, + `resolved_at` integer, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`session_id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `conversation_operations_session_idx` ON `conversation_operations` (`session_id`);--> statement-breakpoint +CREATE TABLE `conversation_turns` ( + `turn_id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `parent_turn_id` text, + `sibling_ordinal` integer NOT NULL, + `input_type` text NOT NULL, + `prompt_id` text, + `command_name` text, + `command_arguments` text, + `shell_command` text, + `run_id` text NOT NULL, + `state` text NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`session_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`parent_turn_id`) REFERENCES `conversation_turns`(`turn_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`prompt_id`) REFERENCES `prompts`(`prompt_id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `conversation_turns_session_idx` ON `conversation_turns` (`session_id`);--> statement-breakpoint +CREATE TABLE `prompt_attachment_refs` ( + `prompt_id` text NOT NULL, + `attachment_id` text NOT NULL, + PRIMARY KEY(`prompt_id`, `attachment_id`), + FOREIGN KEY (`prompt_id`) REFERENCES `prompts`(`prompt_id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `prompts` ( + `prompt_id` text PRIMARY KEY NOT NULL, + `blocks_json` text NOT NULL, + `context_attachment_ids_json` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `provider_turn_bindings` ( + `turn_id` text NOT NULL, + `run_id` text NOT NULL, + `history_id` text NOT NULL, + `checkpoint` text NOT NULL, + `captured_from` text NOT NULL, + PRIMARY KEY(`turn_id`, `history_id`), + FOREIGN KEY (`turn_id`) REFERENCES `conversation_turns`(`turn_id`) ON UPDATE no action ON DELETE cascade +); diff --git a/apps/daemon/drizzle/0012_overjoyed_risque.sql b/apps/daemon/drizzle/0012_overjoyed_risque.sql new file mode 100644 index 000000000..2e710319e --- /dev/null +++ b/apps/daemon/drizzle/0012_overjoyed_risque.sql @@ -0,0 +1,3 @@ +CREATE UNIQUE INDEX `conversation_operations_open_session_unique` ON `conversation_operations` (`session_id`) WHERE state = 'open';--> statement-breakpoint +CREATE UNIQUE INDEX `conversation_turns_sibling_unique` ON `conversation_turns` (`session_id`,`parent_turn_id`,`sibling_ordinal`) WHERE parent_turn_id IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `conversation_turns_root_sibling_unique` ON `conversation_turns` (`session_id`,`sibling_ordinal`) WHERE parent_turn_id IS NULL; \ No newline at end of file diff --git a/apps/daemon/drizzle/meta/0010_snapshot.json b/apps/daemon/drizzle/meta/0010_snapshot.json new file mode 100644 index 000000000..7f85bc1ee --- /dev/null +++ b/apps/daemon/drizzle/meta/0010_snapshot.json @@ -0,0 +1,947 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "158e3712-7007-46ac-b0c2-16ef62ec4523", + "prevId": "d784a44f-1c49-4484-ad8b-7b6934f24d5e", + "tables": { + "loop_iterations": { + "name": "loop_iterations", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verifier_session_id": { + "name": "verifier_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verdict_json": { + "name": "verdict_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "loop_iterations_loop_id_loops_loop_id_fk": { + "name": "loop_iterations_loop_id_loops_loop_id_fk", + "tableFrom": "loop_iterations", + "tableTo": "loops", + "columnsFrom": ["loop_id"], + "columnsTo": ["loop_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "loop_iterations_loop_id_index_pk": { + "columns": ["loop_id", "index"], + "name": "loop_iterations_loop_id_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "loops": { + "name": "loops", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spec_json": { + "name": "spec_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iteration_count": { + "name": "iteration_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_runs": { + "name": "schedule_runs", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schedule_runs_schedule_started_idx": { + "name": "schedule_runs_schedule_started_idx", + "columns": ["schedule_id", "started_at"], + "isUnique": false + } + }, + "foreignKeys": { + "schedule_runs_schedule_id_schedules_schedule_id_fk": { + "name": "schedule_runs_schedule_id_schedules_schedule_id_fk", + "tableFrom": "schedule_runs", + "tableTo": "schedules", + "columnsFrom": ["schedule_id"], + "columnsTo": ["schedule_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedules": { + "name": "schedules", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cadence_type": { + "name": "cadence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timezone": { + "name": "cron_timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interval_ms": { + "name": "interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_config_json": { + "name": "target_config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_reason": { + "name": "completed_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "misfire_policy": { + "name": "misfire_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "schedules_next_run_at_idx": { + "name": "schedules_next_run_at_idx", + "columns": ["next_run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_resources": { + "name": "session_resources", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_type": { + "name": "locator_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator": { + "name": "locator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_locator_key": { + "name": "normalized_locator_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_resources_session_idx": { + "name": "session_resources_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_resources_locator_idx": { + "name": "session_resources_locator_idx", + "columns": ["session_id", "normalized_locator_key"], + "isUnique": true + } + }, + "foreignKeys": { + "session_resources_session_id_sessions_session_id_fk": { + "name": "session_resources_session_id_sessions_session_id_fk", + "tableFrom": "session_resources", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_runs": { + "name": "session_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_turn_id": { + "name": "base_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "history_id": { + "name": "history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approval_policy_id": { + "name": "approval_policy_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_runs_session_id_idx": { + "name": "session_runs_session_id_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_runs_run_id_unique": { + "name": "session_runs_run_id_unique", + "columns": ["run_id"], + "isUnique": true + } + }, + "foreignKeys": { + "session_runs_session_id_sessions_session_id_fk": { + "name": "session_runs_session_id_sessions_session_id_fk", + "tableFrom": "session_runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_history_id": { + "name": "origin_history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_imported_at": { + "name": "origin_imported_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_source_session_id": { + "name": "origin_source_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_source_turn_id": { + "name": "origin_source_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_forked_at": { + "name": "origin_forked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_via": { + "name": "created_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_kind": { + "name": "automation_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_leaf_turn_id": { + "name": "active_leaf_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "graph_revision": { + "name": "graph_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_updated_at_idx": { + "name": "sessions_updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "parent_workspace_id": { + "name": "parent_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_cwd_unique": { + "name": "workspaces_cwd_unique", + "columns": ["cwd"], + "isUnique": true + }, + "workspaces_last_used_at_idx": { + "name": "workspaces_last_used_at_idx", + "columns": ["last_used_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_root": { + "name": "repo_root", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "worktrees_repo_root_branch_unique": { + "name": "worktrees_repo_root_branch_unique", + "columns": ["repo_root", "branch"], + "isUnique": true + }, + "worktrees_session_id_unique": { + "name": "worktrees_session_id_unique", + "columns": ["session_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/daemon/drizzle/meta/0011_snapshot.json b/apps/daemon/drizzle/meta/0011_snapshot.json new file mode 100644 index 000000000..b72900a01 --- /dev/null +++ b/apps/daemon/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1301 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c3c14d2b-a5a9-4344-aa7b-fa9bee38c217", + "prevId": "158e3712-7007-46ac-b0c2-16ef62ec4523", + "tables": { + "conversation_operations": { + "name": "conversation_operations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "conversation_operations_session_idx": { + "name": "conversation_operations_session_idx", + "columns": ["session_id"], + "isUnique": false + } + }, + "foreignKeys": { + "conversation_operations_session_id_sessions_session_id_fk": { + "name": "conversation_operations_session_id_sessions_session_id_fk", + "tableFrom": "conversation_operations", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversation_turns": { + "name": "conversation_turns", + "columns": { + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_turn_id": { + "name": "parent_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sibling_ordinal": { + "name": "sibling_ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_type": { + "name": "input_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_name": { + "name": "command_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_arguments": { + "name": "command_arguments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shell_command": { + "name": "shell_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversation_turns_session_idx": { + "name": "conversation_turns_session_idx", + "columns": ["session_id"], + "isUnique": false + } + }, + "foreignKeys": { + "conversation_turns_session_id_sessions_session_id_fk": { + "name": "conversation_turns_session_id_sessions_session_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_turns_parent_turn_id_conversation_turns_turn_id_fk": { + "name": "conversation_turns_parent_turn_id_conversation_turns_turn_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "conversation_turns", + "columnsFrom": ["parent_turn_id"], + "columnsTo": ["turn_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_turns_prompt_id_prompts_prompt_id_fk": { + "name": "conversation_turns_prompt_id_prompts_prompt_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "prompts", + "columnsFrom": ["prompt_id"], + "columnsTo": ["prompt_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "loop_iterations": { + "name": "loop_iterations", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verifier_session_id": { + "name": "verifier_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verdict_json": { + "name": "verdict_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "loop_iterations_loop_id_loops_loop_id_fk": { + "name": "loop_iterations_loop_id_loops_loop_id_fk", + "tableFrom": "loop_iterations", + "tableTo": "loops", + "columnsFrom": ["loop_id"], + "columnsTo": ["loop_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "loop_iterations_loop_id_index_pk": { + "columns": ["loop_id", "index"], + "name": "loop_iterations_loop_id_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "loops": { + "name": "loops", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spec_json": { + "name": "spec_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iteration_count": { + "name": "iteration_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_attachment_refs": { + "name": "prompt_attachment_refs", + "columns": { + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "prompt_attachment_refs_prompt_id_prompts_prompt_id_fk": { + "name": "prompt_attachment_refs_prompt_id_prompts_prompt_id_fk", + "tableFrom": "prompt_attachment_refs", + "tableTo": "prompts", + "columnsFrom": ["prompt_id"], + "columnsTo": ["prompt_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "prompt_attachment_refs_prompt_id_attachment_id_pk": { + "columns": ["prompt_id", "attachment_id"], + "name": "prompt_attachment_refs_prompt_id_attachment_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompts": { + "name": "prompts", + "columns": { + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "blocks_json": { + "name": "blocks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_attachment_ids_json": { + "name": "context_attachment_ids_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "provider_turn_bindings": { + "name": "provider_turn_bindings", + "columns": { + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "history_id": { + "name": "history_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checkpoint": { + "name": "checkpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "captured_from": { + "name": "captured_from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "provider_turn_bindings_turn_id_conversation_turns_turn_id_fk": { + "name": "provider_turn_bindings_turn_id_conversation_turns_turn_id_fk", + "tableFrom": "provider_turn_bindings", + "tableTo": "conversation_turns", + "columnsFrom": ["turn_id"], + "columnsTo": ["turn_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "provider_turn_bindings_turn_id_history_id_pk": { + "columns": ["turn_id", "history_id"], + "name": "provider_turn_bindings_turn_id_history_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_runs": { + "name": "schedule_runs", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schedule_runs_schedule_started_idx": { + "name": "schedule_runs_schedule_started_idx", + "columns": ["schedule_id", "started_at"], + "isUnique": false + } + }, + "foreignKeys": { + "schedule_runs_schedule_id_schedules_schedule_id_fk": { + "name": "schedule_runs_schedule_id_schedules_schedule_id_fk", + "tableFrom": "schedule_runs", + "tableTo": "schedules", + "columnsFrom": ["schedule_id"], + "columnsTo": ["schedule_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedules": { + "name": "schedules", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cadence_type": { + "name": "cadence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timezone": { + "name": "cron_timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interval_ms": { + "name": "interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_config_json": { + "name": "target_config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_reason": { + "name": "completed_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "misfire_policy": { + "name": "misfire_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "schedules_next_run_at_idx": { + "name": "schedules_next_run_at_idx", + "columns": ["next_run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_resources": { + "name": "session_resources", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_type": { + "name": "locator_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator": { + "name": "locator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_locator_key": { + "name": "normalized_locator_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_resources_session_idx": { + "name": "session_resources_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_resources_locator_idx": { + "name": "session_resources_locator_idx", + "columns": ["session_id", "normalized_locator_key"], + "isUnique": true + } + }, + "foreignKeys": { + "session_resources_session_id_sessions_session_id_fk": { + "name": "session_resources_session_id_sessions_session_id_fk", + "tableFrom": "session_resources", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_runs": { + "name": "session_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_turn_id": { + "name": "base_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "history_id": { + "name": "history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approval_policy_id": { + "name": "approval_policy_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_runs_session_id_idx": { + "name": "session_runs_session_id_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_runs_run_id_unique": { + "name": "session_runs_run_id_unique", + "columns": ["run_id"], + "isUnique": true + } + }, + "foreignKeys": { + "session_runs_session_id_sessions_session_id_fk": { + "name": "session_runs_session_id_sessions_session_id_fk", + "tableFrom": "session_runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_history_id": { + "name": "origin_history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_imported_at": { + "name": "origin_imported_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_source_session_id": { + "name": "origin_source_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_source_turn_id": { + "name": "origin_source_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_forked_at": { + "name": "origin_forked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_via": { + "name": "created_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_kind": { + "name": "automation_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_leaf_turn_id": { + "name": "active_leaf_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "graph_revision": { + "name": "graph_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_updated_at_idx": { + "name": "sessions_updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "parent_workspace_id": { + "name": "parent_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_cwd_unique": { + "name": "workspaces_cwd_unique", + "columns": ["cwd"], + "isUnique": true + }, + "workspaces_last_used_at_idx": { + "name": "workspaces_last_used_at_idx", + "columns": ["last_used_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_root": { + "name": "repo_root", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "worktrees_repo_root_branch_unique": { + "name": "worktrees_repo_root_branch_unique", + "columns": ["repo_root", "branch"], + "isUnique": true + }, + "worktrees_session_id_unique": { + "name": "worktrees_session_id_unique", + "columns": ["session_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/daemon/drizzle/meta/0012_snapshot.json b/apps/daemon/drizzle/meta/0012_snapshot.json new file mode 100644 index 000000000..f0b8c9409 --- /dev/null +++ b/apps/daemon/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1319 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "65a8fc35-ee6f-4e2c-a3dc-9f2419f3b5a6", + "prevId": "c3c14d2b-a5a9-4344-aa7b-fa9bee38c217", + "tables": { + "conversation_operations": { + "name": "conversation_operations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "conversation_operations_session_idx": { + "name": "conversation_operations_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "conversation_operations_open_session_unique": { + "name": "conversation_operations_open_session_unique", + "columns": ["session_id"], + "isUnique": true, + "where": "state = 'open'" + } + }, + "foreignKeys": { + "conversation_operations_session_id_sessions_session_id_fk": { + "name": "conversation_operations_session_id_sessions_session_id_fk", + "tableFrom": "conversation_operations", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversation_turns": { + "name": "conversation_turns", + "columns": { + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_turn_id": { + "name": "parent_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sibling_ordinal": { + "name": "sibling_ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_type": { + "name": "input_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_name": { + "name": "command_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_arguments": { + "name": "command_arguments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shell_command": { + "name": "shell_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversation_turns_session_idx": { + "name": "conversation_turns_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "conversation_turns_sibling_unique": { + "name": "conversation_turns_sibling_unique", + "columns": ["session_id", "parent_turn_id", "sibling_ordinal"], + "isUnique": true, + "where": "parent_turn_id IS NOT NULL" + }, + "conversation_turns_root_sibling_unique": { + "name": "conversation_turns_root_sibling_unique", + "columns": ["session_id", "sibling_ordinal"], + "isUnique": true, + "where": "parent_turn_id IS NULL" + } + }, + "foreignKeys": { + "conversation_turns_session_id_sessions_session_id_fk": { + "name": "conversation_turns_session_id_sessions_session_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_turns_parent_turn_id_conversation_turns_turn_id_fk": { + "name": "conversation_turns_parent_turn_id_conversation_turns_turn_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "conversation_turns", + "columnsFrom": ["parent_turn_id"], + "columnsTo": ["turn_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_turns_prompt_id_prompts_prompt_id_fk": { + "name": "conversation_turns_prompt_id_prompts_prompt_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "prompts", + "columnsFrom": ["prompt_id"], + "columnsTo": ["prompt_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "loop_iterations": { + "name": "loop_iterations", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verifier_session_id": { + "name": "verifier_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verdict_json": { + "name": "verdict_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "loop_iterations_loop_id_loops_loop_id_fk": { + "name": "loop_iterations_loop_id_loops_loop_id_fk", + "tableFrom": "loop_iterations", + "tableTo": "loops", + "columnsFrom": ["loop_id"], + "columnsTo": ["loop_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "loop_iterations_loop_id_index_pk": { + "columns": ["loop_id", "index"], + "name": "loop_iterations_loop_id_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "loops": { + "name": "loops", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spec_json": { + "name": "spec_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iteration_count": { + "name": "iteration_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_attachment_refs": { + "name": "prompt_attachment_refs", + "columns": { + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "prompt_attachment_refs_prompt_id_prompts_prompt_id_fk": { + "name": "prompt_attachment_refs_prompt_id_prompts_prompt_id_fk", + "tableFrom": "prompt_attachment_refs", + "tableTo": "prompts", + "columnsFrom": ["prompt_id"], + "columnsTo": ["prompt_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "prompt_attachment_refs_prompt_id_attachment_id_pk": { + "columns": ["prompt_id", "attachment_id"], + "name": "prompt_attachment_refs_prompt_id_attachment_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompts": { + "name": "prompts", + "columns": { + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "blocks_json": { + "name": "blocks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_attachment_ids_json": { + "name": "context_attachment_ids_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "provider_turn_bindings": { + "name": "provider_turn_bindings", + "columns": { + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "history_id": { + "name": "history_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checkpoint": { + "name": "checkpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "captured_from": { + "name": "captured_from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "provider_turn_bindings_turn_id_conversation_turns_turn_id_fk": { + "name": "provider_turn_bindings_turn_id_conversation_turns_turn_id_fk", + "tableFrom": "provider_turn_bindings", + "tableTo": "conversation_turns", + "columnsFrom": ["turn_id"], + "columnsTo": ["turn_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "provider_turn_bindings_turn_id_history_id_pk": { + "columns": ["turn_id", "history_id"], + "name": "provider_turn_bindings_turn_id_history_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_runs": { + "name": "schedule_runs", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schedule_runs_schedule_started_idx": { + "name": "schedule_runs_schedule_started_idx", + "columns": ["schedule_id", "started_at"], + "isUnique": false + } + }, + "foreignKeys": { + "schedule_runs_schedule_id_schedules_schedule_id_fk": { + "name": "schedule_runs_schedule_id_schedules_schedule_id_fk", + "tableFrom": "schedule_runs", + "tableTo": "schedules", + "columnsFrom": ["schedule_id"], + "columnsTo": ["schedule_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedules": { + "name": "schedules", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cadence_type": { + "name": "cadence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timezone": { + "name": "cron_timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interval_ms": { + "name": "interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_config_json": { + "name": "target_config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_reason": { + "name": "completed_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "misfire_policy": { + "name": "misfire_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "schedules_next_run_at_idx": { + "name": "schedules_next_run_at_idx", + "columns": ["next_run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_resources": { + "name": "session_resources", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_type": { + "name": "locator_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator": { + "name": "locator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_locator_key": { + "name": "normalized_locator_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_resources_session_idx": { + "name": "session_resources_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_resources_locator_idx": { + "name": "session_resources_locator_idx", + "columns": ["session_id", "normalized_locator_key"], + "isUnique": true + } + }, + "foreignKeys": { + "session_resources_session_id_sessions_session_id_fk": { + "name": "session_resources_session_id_sessions_session_id_fk", + "tableFrom": "session_resources", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_runs": { + "name": "session_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_turn_id": { + "name": "base_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "history_id": { + "name": "history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approval_policy_id": { + "name": "approval_policy_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_runs_session_id_idx": { + "name": "session_runs_session_id_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_runs_run_id_unique": { + "name": "session_runs_run_id_unique", + "columns": ["run_id"], + "isUnique": true + } + }, + "foreignKeys": { + "session_runs_session_id_sessions_session_id_fk": { + "name": "session_runs_session_id_sessions_session_id_fk", + "tableFrom": "session_runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_history_id": { + "name": "origin_history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_imported_at": { + "name": "origin_imported_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_source_session_id": { + "name": "origin_source_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_source_turn_id": { + "name": "origin_source_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_forked_at": { + "name": "origin_forked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_via": { + "name": "created_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_kind": { + "name": "automation_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_leaf_turn_id": { + "name": "active_leaf_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "graph_revision": { + "name": "graph_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_updated_at_idx": { + "name": "sessions_updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "parent_workspace_id": { + "name": "parent_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_cwd_unique": { + "name": "workspaces_cwd_unique", + "columns": ["cwd"], + "isUnique": true + }, + "workspaces_last_used_at_idx": { + "name": "workspaces_last_used_at_idx", + "columns": ["last_used_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_root": { + "name": "repo_root", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "worktrees_repo_root_branch_unique": { + "name": "worktrees_repo_root_branch_unique", + "columns": ["repo_root", "branch"], + "isUnique": true + }, + "worktrees_session_id_unique": { + "name": "worktrees_session_id_unique", + "columns": ["session_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/daemon/drizzle/meta/_journal.json b/apps/daemon/drizzle/meta/_journal.json index 433015041..708ab95fb 100644 --- a/apps/daemon/drizzle/meta/_journal.json +++ b/apps/daemon/drizzle/meta/_journal.json @@ -71,6 +71,27 @@ "when": 1786331414776, "tag": "0009_add_session_run_pin", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1788167878416, + "tag": "0010_chilly_swordsman", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1788169743965, + "tag": "0011_flippant_bastion", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1788243073079, + "tag": "0012_overjoyed_risque", + "breakpoints": true } ] } diff --git a/apps/daemon/src/__tests__/conversation-store.test.ts b/apps/daemon/src/__tests__/conversation-store.test.ts new file mode 100644 index 000000000..2343fd898 --- /dev/null +++ b/apps/daemon/src/__tests__/conversation-store.test.ts @@ -0,0 +1,456 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ConversationStore } from '@linkcode/engine'; +import { ConversationSessionBusyError } from '@linkcode/engine'; +import type { ConversationOperation, ConversationTurn, PromptRecord } from '@linkcode/schema'; +import { + ConversationOperationSchema, + ConversationTurnSchema, + OperationIdSchema, + PromptIdSchema, + PromptRecordSchema, + ProviderTurnBindingSchema, + SessionIdSchema, + SessionRecordSchema, + TurnIdSchema, +} from '@linkcode/schema'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createConversationStore } from '../conversation-store'; +import { createSessionStore } from '../session-store'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +/** Migrations belong to the session store; turn rows FK-reference its sessions table. */ +async function databaseWithSessions(...sessionIds: string[]): Promise { + const directory = await mkdtemp(join(tmpdir(), 'linkcode-conversation-store-')); + temporaryDirectories.push(directory); + const database = join(directory, 'daemon.db'); + const sessions = createSessionStore(database); + for (let i = 0, len = sessionIds.length; i < len; i++) { + await sessions.save( + SessionRecordSchema.parse({ + sessionId: sessionIds[i], + kind: 'claude-code', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 1, + updatedAt: 1, + runs: [], + }), + ); + } + return database; +} + +function turn(value: { + turnId: string; + sessionId?: string; + parentTurnId?: string | null; + siblingOrdinal?: number; + input?: unknown; + runId?: string; + state?: ConversationTurn['state']; + createdAt?: number; +}): ConversationTurn { + return ConversationTurnSchema.parse({ + sessionId: 's-1', + parentTurnId: null, + siblingOrdinal: 1, + input: { type: 'shell-command', command: 'git status' }, + runId: 'run-1', + state: 'preparing', + createdAt: 1, + ...value, + }); +} + +function prompt(promptId: string): PromptRecord { + return PromptRecordSchema.parse({ + promptId, + blocks: [ + { type: 'text', text: 'compare these' }, + { type: 'attachment_ref', attachmentId: 'att-block' }, + ], + contextAttachmentIds: ['att-ctx-2', 'att-ctx-1'], + createdAt: 3, + }); +} + +function openOperation(operationId: string, sessionId = 's-1'): ConversationOperation { + return ConversationOperationSchema.parse({ + operationId, + sessionId, + kind: 'turn.submit', + state: 'open', + createdAt: 4, + }); +} + +async function seedIntent(store: ConversationStore): Promise { + await store.persistTurnIntent({ + turn: turn({ + turnId: 't-prompted', + input: { type: 'prompt', promptId: PromptIdSchema.parse('p-1') }, + createdAt: 2, + }), + prompt: prompt('p-1'), + operation: openOperation('op-1'), + }); +} + +describe('SQLite conversation store', () => { + /** + * Every field must survive save→load through a fresh store instance: a column this table drops + * is a turn silently changing meaning on the next daemon boot, and the in-memory store cannot + * catch it (the daemon store trap in apps/daemon/AGENTS.md). + */ + it('round-trips every turn field, all three input shapes included', async () => { + const database = await databaseWithSessions('s-1'); + const migratedTurn = turn({ + turnId: 't-5', + parentTurnId: TurnIdSchema.parse('t-4'), + input: { type: 'prompt', promptId: null }, + state: 'completed', + createdAt: 5, + }); + const turns = [ + turn({ + turnId: 't-1', + input: { type: 'prompt', promptId: PromptIdSchema.parse('p-1') }, + state: 'completed', + createdAt: 1, + }), + turn({ + turnId: 't-2', + parentTurnId: TurnIdSchema.parse('t-1'), + siblingOrdinal: 2, + input: { type: 'command', name: 'compact', arguments: '--all' }, + runId: 'run-2', + state: 'failed', + createdAt: 2, + }), + turn({ + turnId: 't-3', + parentTurnId: TurnIdSchema.parse('t-1'), + siblingOrdinal: 3, + input: { type: 'command', name: 'review' }, + state: 'cancelled', + createdAt: 3, + }), + turn({ + turnId: 't-4', + parentTurnId: TurnIdSchema.parse('t-2'), + input: { type: 'shell-command', command: 'pnpm test' }, + state: 'running', + createdAt: 4, + }), + migratedTurn, + ]; + const store = createConversationStore(database); + await store.persistTurnIntent({ + turn: turns[0], + prompt: prompt('p-1'), + operation: openOperation('op-1'), + }); + await store.resolveOperation({ + ...openOperation('op-1'), + state: 'succeeded', + turnId: turns[0].turnId, + resolvedAt: 9, + }); + for (let i = 1, len = turns.length - 1; i < len; i++) { + await store.saveTurn(turns[i]); + } + + // A migrated prompt turn persists with `promptId: null`; its prompt record is not inserted. + await store.persistTurnIntent({ + turn: migratedTurn, + prompt: prompt('p-migrated'), + operation: openOperation('op-migrated'), + }); + + const reopened = createConversationStore(database); + expect(await reopened.listTurns(SessionIdSchema.parse('s-1'))).toEqual(turns); + expect(await reopened.getPrompt(PromptIdSchema.parse('p-migrated'))).toBeUndefined(); + }); + + it('round-trips prompts, preserving block and context order', async () => { + const database = await databaseWithSessions('s-1'); + await seedIntent(createConversationStore(database)); + + expect(await createConversationStore(database).getPrompt(PromptIdSchema.parse('p-1'))).toEqual( + prompt('p-1'), + ); + }); + + it('round-trips bindings and re-captures by (turn, history)', async () => { + const database = await databaseWithSessions('s-1'); + const store = createConversationStore(database); + await seedIntent(store); + const live = ProviderTurnBindingSchema.parse({ + turnId: 't-prompted', + runId: 'run-1', + historyId: 'native-1', + checkpoint: '{"uuid":"a"}', + capturedFrom: 'live', + }); + await store.saveBinding(live); + await store.saveBinding({ ...live, historyId: 'native-2', capturedFrom: 'replay' }); + const recaptured = ProviderTurnBindingSchema.parse({ + ...live, + runId: 'run-9', + checkpoint: '{"uuid":"b"}', + }); + await store.saveBinding(recaptured); + + expect( + await createConversationStore(database).listBindings(TurnIdSchema.parse('t-prompted')), + ).toEqual([recaptured, { ...live, historyId: 'native-2', capturedFrom: 'replay' }]); + }); + + it('round-trips operations through every state', async () => { + const database = await databaseWithSessions('s-1'); + const store = createConversationStore(database); + await seedIntent(store); + expect(await store.getOperation(OperationIdSchema.parse('op-1'))).toEqual( + openOperation('op-1'), + ); + expect(await store.listOpenOperations(SessionIdSchema.parse('s-1'))).toEqual([ + openOperation('op-1'), + ]); + + const succeeded = ConversationOperationSchema.parse({ + operationId: 'op-1', + sessionId: 's-1', + kind: 'turn.submit', + state: 'succeeded', + turnId: 't-prompted', + createdAt: 4, + resolvedAt: 9, + }); + const running = turn({ + turnId: 't-prompted', + input: { type: 'prompt', promptId: PromptIdSchema.parse('p-1') }, + state: 'running', + createdAt: 2, + }); + await store.resolveOperation(succeeded, running); + const doomed = await store.persistTurnIntent({ + turn: turn({ turnId: 't-doomed', createdAt: 5 }), + operation: openOperation('op-2'), + }); + const failed = ConversationOperationSchema.parse({ + operationId: 'op-2', + sessionId: 's-1', + kind: 'turn.submit', + state: 'failed', + error: { code: 'busy', message: 'A turn is already running' }, + createdAt: 4, + resolvedAt: 6, + }); + await store.resolveOperation(failed, { ...doomed, state: 'failed' }); + + const reopened = createConversationStore(database); + expect(await reopened.getOperation(OperationIdSchema.parse('op-1'))).toEqual(succeeded); + expect(await reopened.getOperation(OperationIdSchema.parse('op-2'))).toEqual(failed); + expect(await reopened.listOpenOperations()).toEqual([]); + expect(await reopened.listTurns(SessionIdSchema.parse('s-1'))).toEqual([ + running, + { ...doomed, state: 'failed' }, + ]); + }); + + it('deleteSession purges turns, bindings, and operations but keeps prompts shared with a fork', async () => { + const database = await databaseWithSessions('s-parent', 's-fork'); + const store = createConversationStore(database); + await store.persistTurnIntent({ + turn: turn({ + turnId: 't-shared', + sessionId: SessionIdSchema.parse('s-parent'), + input: { type: 'prompt', promptId: PromptIdSchema.parse('p-shared') }, + }), + prompt: prompt('p-shared'), + operation: openOperation('op-1', 's-parent'), + }); + await store.resolveOperation({ + ...openOperation('op-1', 's-parent'), + state: 'succeeded', + turnId: TurnIdSchema.parse('t-shared'), + resolvedAt: 5, + }); + await store.persistTurnIntent({ + turn: turn({ + turnId: 't-own', + sessionId: SessionIdSchema.parse('s-parent'), + input: { type: 'prompt', promptId: PromptIdSchema.parse('p-own') }, + createdAt: 2, + }), + prompt: prompt('p-own'), + operation: openOperation('op-2', 's-parent'), + }); + // The fork's copied turn row references the shared prompt by id. + await store.saveTurn( + turn({ + turnId: 't-copy', + sessionId: SessionIdSchema.parse('s-fork'), + input: { type: 'prompt', promptId: PromptIdSchema.parse('p-shared') }, + }), + ); + await store.saveBinding( + ProviderTurnBindingSchema.parse({ + turnId: 't-shared', + runId: 'run-1', + historyId: 'native-1', + checkpoint: 'c', + capturedFrom: 'live', + }), + ); + + await store.deleteSession(SessionIdSchema.parse('s-parent')); + + const reopened = createConversationStore(database); + expect(await reopened.listTurns(SessionIdSchema.parse('s-parent'))).toEqual([]); + expect(await reopened.listBindings(TurnIdSchema.parse('t-shared'))).toEqual([]); + expect(await reopened.listOpenOperations()).toEqual([]); + expect(await reopened.getPrompt(PromptIdSchema.parse('p-own'))).toBeUndefined(); + expect(await reopened.getPrompt(PromptIdSchema.parse('p-shared'))).toEqual(prompt('p-shared')); + expect(await reopened.listTurns(SessionIdSchema.parse('s-fork'))).toHaveLength(1); + + await store.deleteSession(SessionIdSchema.parse('s-fork')); + expect( + await createConversationStore(database).getPrompt(PromptIdSchema.parse('p-shared')), + ).toBeUndefined(); + }); + + it('refuses a second intent while the session has an open operation', async () => { + const database = await databaseWithSessions('s-1', 's-2'); + const store = createConversationStore(database); + await store.persistTurnIntent({ + turn: turn({ turnId: 't-1' }), + operation: openOperation('op-1'), + }); + + await expect(async () => + store.persistTurnIntent({ + turn: turn({ turnId: 't-2', createdAt: 2 }), + operation: openOperation('op-2'), + }), + ).rejects.toBeInstanceOf(ConversationSessionBusyError); + // The rejected transaction rolled back whole: no turn row either. + expect(await store.listTurns(SessionIdSchema.parse('s-1'))).toHaveLength(1); + expect(await store.listOpenOperations(SessionIdSchema.parse('s-1'))).toEqual([ + openOperation('op-1'), + ]); + + // Another session is not gated by this one's open operation. + await store.persistTurnIntent({ + turn: turn({ turnId: 't-other', sessionId: SessionIdSchema.parse('s-2') }), + operation: openOperation('op-other', 's-2'), + }); + }); + + it('assigns sibling ordinals in the transaction and the unique index rejects duplicates', async () => { + const database = await databaseWithSessions('s-1'); + const store = createConversationStore(database); + const first = await store.persistTurnIntent({ + turn: turn({ turnId: 't-1' }), + operation: openOperation('op-1'), + }); + await store.resolveOperation({ + ...openOperation('op-1'), + state: 'succeeded', + turnId: first.turnId, + resolvedAt: 5, + }); + const second = await store.persistTurnIntent({ + turn: turn({ turnId: 't-2', createdAt: 2 }), + operation: openOperation('op-2'), + }); + expect([first.siblingOrdinal, second.siblingOrdinal]).toEqual([1, 2]); + + // Belt-and-braces: even a direct save cannot mint a duplicate (session, parent, ordinal). + await expect(async () => + store.saveTurn(turn({ turnId: 't-dupe', siblingOrdinal: 2, createdAt: 3 })), + ).rejects.toThrow('UNIQUE'); + await store.saveTurn( + turn({ turnId: 't-child', parentTurnId: TurnIdSchema.parse('t-1'), createdAt: 4 }), + ); + await expect(async () => + store.saveTurn( + turn({ turnId: 't-child-dupe', parentTurnId: TurnIdSchema.parse('t-1'), createdAt: 5 }), + ), + ).rejects.toThrow('UNIQUE'); + }); + + it('refuses a replayed operation id instead of re-opening the terminal row', async () => { + const database = await databaseWithSessions('s-1'); + const store = createConversationStore(database); + const first = await store.persistTurnIntent({ + turn: turn({ turnId: 't-1' }), + operation: openOperation('op-1'), + }); + const failed = ConversationOperationSchema.parse({ + operationId: 'op-1', + sessionId: 's-1', + kind: 'turn.submit', + state: 'failed', + error: { code: 'timeout', message: 'too slow' }, + createdAt: 4, + resolvedAt: 6, + }); + await store.resolveOperation(failed, { ...first, state: 'failed' }); + + await expect(async () => + store.persistTurnIntent({ + turn: turn({ turnId: 't-replayed', createdAt: 2 }), + operation: openOperation('op-1'), + }), + ).rejects.toThrow('UNIQUE'); + expect(await store.getOperation(OperationIdSchema.parse('op-1'))).toEqual(failed); + }); + + it('resolveOperation transitions open rows only — the first terminal result stands', async () => { + const database = await databaseWithSessions('s-1'); + const store = createConversationStore(database); + const first = await store.persistTurnIntent({ + turn: turn({ turnId: 't-1' }), + operation: openOperation('op-1'), + }); + const failed = ConversationOperationSchema.parse({ + operationId: 'op-1', + sessionId: 's-1', + kind: 'turn.submit', + state: 'failed', + error: { code: 'timeout', message: 'too slow' }, + createdAt: 4, + resolvedAt: 6, + }); + expect(await store.resolveOperation(failed, { ...first, state: 'failed' })).toBe(true); + + // A late success must not overwrite the stored failure or flip the failed turn. + expect( + await store.resolveOperation( + { + ...openOperation('op-1'), + state: 'succeeded', + turnId: first.turnId, + resolvedAt: 7, + }, + { ...first, state: 'running' }, + ), + ).toBe(false); + + const reopened = createConversationStore(database); + expect(await reopened.getOperation(OperationIdSchema.parse('op-1'))).toEqual(failed); + expect(await reopened.listTurns(SessionIdSchema.parse('s-1'))).toEqual([ + { ...first, state: 'failed' }, + ]); + }); +}); diff --git a/apps/daemon/src/__tests__/session-store.test.ts b/apps/daemon/src/__tests__/session-store.test.ts index 921b73d0f..0850f3e47 100644 --- a/apps/daemon/src/__tests__/session-store.test.ts +++ b/apps/daemon/src/__tests__/session-store.test.ts @@ -1,7 +1,8 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { SessionRecordSchema } from '@linkcode/schema'; +import { SessionRecordSchema, SessionRunSchema } from '@linkcode/schema'; +import Sqlite from 'better-sqlite3'; import { afterEach, describe, expect, it } from 'vitest'; import { createSessionStore } from '../session-store'; @@ -34,9 +35,13 @@ describe('SQLite session store', () => { origin: { type: 'created' }, createdAt: 1, updatedAt: 2, + activeLeafTurnId: 'turn-leaf', + graphRevision: 7, runs: [ - { startedAt: 1, endedAt: 2, historyId: 'native-1', accountId: 'acc_first' }, + { runId: 'run-1', startedAt: 1, endedAt: 2, historyId: 'native-1', accountId: 'acc_first' }, { + runId: 'run-2', + baseTurnId: 'turn-base', startedAt: 3, historyId: 'native-2', accountId: 'acc_second', @@ -51,6 +56,35 @@ describe('SQLite session store', () => { expect(await createSessionStore(database).load()).toEqual([record]); }); + it('round-trips additive fork provenance and upgrades an existing forked origin row', async () => { + const database = await databasePath(); + const record = SessionRecordSchema.parse({ + sessionId: 'session-forked', + kind: 'codex', + cwd: '/repo', + origin: { type: 'created' }, + forkOrigin: { + sourceSessionId: 'session-source', + sourceTurnId: 'turn-cut', + forkedAt: 5, + }, + createdAt: 5, + updatedAt: 6, + runs: [], + }); + await createSessionStore(database).save(record); + + expect(await createSessionStore(database).load()).toEqual([record]); + + const sqlite = new Sqlite(database); + expect(sqlite.prepare('SELECT origin_type FROM sessions').pluck().get()).toBe('created'); + sqlite + .prepare("UPDATE sessions SET origin_type = 'forked' WHERE session_id = ?") + .run(record.sessionId); + sqlite.close(); + expect(await createSessionStore(database).load()).toEqual([record]); + }); + it('keeps run order across a reload, since the array position is part of the record', async () => { const database = await databasePath(); const record = SessionRecordSchema.parse({ @@ -61,17 +95,41 @@ describe('SQLite session store', () => { createdAt: 1, updatedAt: 1, runs: [ - { startedAt: 1, model: 'first' }, - { startedAt: 2, model: 'second' }, - { startedAt: 3, model: 'third' }, + { runId: 'run-1', startedAt: 1, model: 'first' }, + { runId: 'run-2', startedAt: 2, model: 'second' }, + { runId: 'run-3', startedAt: 3, model: 'third' }, ], }); const store = createSessionStore(database); await store.save(record); // A later save rewrites the whole run list; the newest run is what a relaunch reads back. - await store.save({ ...record, runs: [...record.runs, { startedAt: 4, model: 'fourth' }] }); + await store.save({ + ...record, + runs: [ + ...record.runs, + SessionRunSchema.parse({ runId: 'run-4', startedAt: 4, model: 'fourth' }), + ], + }); const [reloaded] = await createSessionStore(database).load(); expect(reloaded.runs.map((run) => run.model)).toEqual(['first', 'second', 'third', 'fourth']); }); + + it('refuses to save a run without a runId instead of minting a drifting one', async () => { + const database = await databasePath(); + // runId is optional at the wire parse boundary only; every engine writer mints it. + const record = SessionRecordSchema.parse({ + sessionId: 'session-runless', + kind: 'claude-code', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 1, + updatedAt: 1, + runs: [{ startedAt: 1 }], + }); + + await expect(async () => createSessionStore(database).save(record)).rejects.toThrow( + 'without runId', + ); + }); }); diff --git a/apps/daemon/src/conversation-store.ts b/apps/daemon/src/conversation-store.ts new file mode 100644 index 000000000..00461834f --- /dev/null +++ b/apps/daemon/src/conversation-store.ts @@ -0,0 +1,349 @@ +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import type { ConversationStore, ConversationTurnIntent } from '@linkcode/engine'; +import { ConversationSessionBusyError } from '@linkcode/engine'; +import type { + ConversationOperation, + ConversationTurn, + OperationId, + PromptId, + PromptRecord, + ProviderTurnBinding, + SessionId, + TurnId, +} from '@linkcode/schema'; +import { + ConversationOperationSchema, + ConversationTurnSchema, + PromptRecordSchema, + ProviderTurnBindingSchema, +} from '@linkcode/schema'; +import Sqlite from 'better-sqlite3'; +import { and, asc, count, eq, inArray, isNotNull, isNull, notInArray } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { + conversationOperations, + conversationTurns, + promptAttachmentRefs, + prompts, + providerTurnBindings, +} from './db/schema'; + +type TurnRow = typeof conversationTurns.$inferSelect; +type PromptRow = typeof prompts.$inferSelect; +type OperationRow = typeof conversationOperations.$inferSelect; + +type Db = ReturnType; +type DbOrTx = Db | Parameters[0]>[0]; + +/** + * SQLite-backed `ConversationStore` on ONE dedicated connection — the multi-table methods run in + * `db.transaction`, which the submit saga's atomicity guarantees hang on. Rows are validated back + * through the zod schemas on load. Migrations are owned by the session store, which must be + * constructed first. + */ +export function createConversationStore(dbPath: string): ConversationStore { + if (dbPath !== ':memory:') mkdirSync(dirname(dbPath), { recursive: true }); + const sqlite = new Sqlite(dbPath); + sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('foreign_keys = ON'); + const db = drizzle(sqlite); + + function upsertTurn(tx: DbOrTx, turn: ConversationTurn): void { + const row = toTurnRow(turn); + tx.insert(conversationTurns) + .values(row) + .onConflictDoUpdate({ target: conversationTurns.turnId, set: row }) + .run(); + } + + return { + listTurns(sessionId: SessionId): Promise { + const rows = db + .select() + .from(conversationTurns) + .where(eq(conversationTurns.sessionId, sessionId)) + .orderBy(asc(conversationTurns.createdAt), asc(conversationTurns.turnId)) + .all(); + return Promise.resolve(rows.map(toTurn)); + }, + + saveTurn(turn: ConversationTurn): Promise { + upsertTurn(db, turn); + return Promise.resolve(); + }, + + getPrompt(promptId: PromptId): Promise { + const row = db.select().from(prompts).where(eq(prompts.promptId, promptId)).get(); + return Promise.resolve(row ? toPrompt(row) : undefined); + }, + + listBindings(turnId: TurnId): Promise { + const rows = db + .select() + .from(providerTurnBindings) + .where(eq(providerTurnBindings.turnId, turnId)) + .orderBy(asc(providerTurnBindings.historyId)) + .all(); + return Promise.resolve(rows.map((row) => ProviderTurnBindingSchema.parse(row))); + }, + + saveBinding(binding: ProviderTurnBinding): Promise { + db.insert(providerTurnBindings) + .values(binding) + .onConflictDoUpdate({ + target: [providerTurnBindings.turnId, providerTurnBindings.historyId], + set: binding, + }) + .run(); + return Promise.resolve(); + }, + + getOperation(operationId: OperationId): Promise { + const row = db + .select() + .from(conversationOperations) + .where(eq(conversationOperations.operationId, operationId)) + .get(); + return Promise.resolve(row ? toOperation(row) : undefined); + }, + + listOpenOperations(sessionId?: SessionId): Promise { + const open = eq(conversationOperations.state, 'open'); + const rows = db + .select() + .from(conversationOperations) + .where(sessionId ? and(open, eq(conversationOperations.sessionId, sessionId)) : open) + .all(); + return Promise.resolve(rows.map(toOperation)); + }, + + persistTurnIntent(intent: ConversationTurnIntent): Promise { + const { parentTurnId, sessionId } = intent.turn; + const persisted = db.transaction((tx) => { + const open = tx + .select({ operationId: conversationOperations.operationId }) + .from(conversationOperations) + .where( + and( + eq(conversationOperations.sessionId, sessionId), + eq(conversationOperations.state, 'open'), + ), + ) + .get(); + if (open) throw new ConversationSessionBusyError(sessionId); + const siblings = tx + .select({ value: count() }) + .from(conversationTurns) + .where( + and( + eq(conversationTurns.sessionId, sessionId), + parentTurnId === null + ? isNull(conversationTurns.parentTurnId) + : eq(conversationTurns.parentTurnId, parentTurnId), + ), + ) + .get(); + const turn: ConversationTurn = { + ...intent.turn, + siblingOrdinal: (siblings?.value ?? 0) + 1, + }; + const prompt = + intent.turn.input.type === 'prompt' && intent.turn.input.promptId !== null + ? intent.prompt + : undefined; + if (prompt) { + // Prompts are immutable and shared across forks; a re-referencing insert is a no-op. + tx.insert(prompts).values(toPromptRow(prompt)).onConflictDoNothing().run(); + const referenced = attachmentIdsOf(prompt); + if (referenced.length > 0) { + tx.insert(promptAttachmentRefs) + .values( + referenced.map((attachmentId) => ({ promptId: prompt.promptId, attachmentId })), + ) + .onConflictDoNothing() + .run(); + } + } + // Plain inserts: a replayed operationId must conflict here, never re-open a terminal row. + tx.insert(conversationTurns).values(toTurnRow(turn)).run(); + tx.insert(conversationOperations).values(toOperationRow(intent.operation)).run(); + return turn; + }); + return Promise.resolve(persisted); + }, + + resolveOperation(operation: ConversationOperation, turn?: ConversationTurn): Promise { + const transitioned = db.transaction((tx) => { + const result = tx + .update(conversationOperations) + .set(toOperationRow(operation)) + .where( + and( + eq(conversationOperations.operationId, operation.operationId), + eq(conversationOperations.state, 'open'), + ), + ) + .run(); + // A concurrent resolver already stored a terminal result; the first writer stands. + if (result.changes === 0) return false; + if (turn) upsertTurn(tx, turn); + return true; + }); + return Promise.resolve(transitioned); + }, + + deleteSession(sessionId: SessionId): Promise { + db.transaction((tx) => { + const rows = tx + .select({ promptId: conversationTurns.promptId }) + .from(conversationTurns) + .where( + and(eq(conversationTurns.sessionId, sessionId), isNotNull(conversationTurns.promptId)), + ) + .all(); + const candidates: string[] = []; + for (let i = 0, len = rows.length; i < len; i++) { + const { promptId } = rows[i]; + if (promptId !== null) candidates.push(promptId); + } + // Bindings cascade with their turns; refs cascade with their prompts. + tx.delete(conversationTurns).where(eq(conversationTurns.sessionId, sessionId)).run(); + tx.delete(conversationOperations) + .where(eq(conversationOperations.sessionId, sessionId)) + .run(); + if (candidates.length > 0) { + const stillReferenced = tx + .select({ promptId: conversationTurns.promptId }) + .from(conversationTurns) + .where(isNotNull(conversationTurns.promptId)); + tx.delete(prompts) + .where( + and( + inArray(prompts.promptId, candidates), + notInArray(prompts.promptId, stillReferenced), + ), + ) + .run(); + } + }); + return Promise.resolve(); + }, + }; +} + +function attachmentIdsOf(prompt: PromptRecord): string[] { + const ids = new Set(prompt.contextAttachmentIds); + for (let i = 0, len = prompt.blocks.length; i < len; i++) { + const block = prompt.blocks[i]; + if (block.type === 'attachment_ref') ids.add(block.attachmentId); + } + return Array.from(ids); +} + +function toPromptRow(prompt: PromptRecord): typeof prompts.$inferInsert { + return { + promptId: prompt.promptId, + blocksJson: JSON.stringify(prompt.blocks), + contextAttachmentIdsJson: JSON.stringify(prompt.contextAttachmentIds), + createdAt: prompt.createdAt, + }; +} + +function toPrompt(row: PromptRow): PromptRecord { + return PromptRecordSchema.parse({ + promptId: row.promptId, + blocks: JSON.parse(row.blocksJson), + contextAttachmentIds: JSON.parse(row.contextAttachmentIdsJson), + createdAt: row.createdAt, + }); +} + +function toTurnRow(turn: ConversationTurn): typeof conversationTurns.$inferInsert { + const { input } = turn; + return { + turnId: turn.turnId, + sessionId: turn.sessionId, + parentTurnId: turn.parentTurnId, + siblingOrdinal: turn.siblingOrdinal, + inputType: input.type, + promptId: input.type === 'prompt' ? input.promptId : null, + commandName: input.type === 'command' ? input.name : null, + commandArguments: input.type === 'command' ? (input.arguments ?? null) : null, + shellCommand: input.type === 'shell-command' ? input.command : null, + runId: turn.runId, + state: turn.state, + createdAt: turn.createdAt, + }; +} + +function toTurn(row: TurnRow): ConversationTurn { + return ConversationTurnSchema.parse({ + turnId: row.turnId, + sessionId: row.sessionId, + parentTurnId: row.parentTurnId, + siblingOrdinal: row.siblingOrdinal, + input: toTurnInput(row), + runId: row.runId, + state: row.state, + createdAt: row.createdAt, + }); +} + +function toTurnInput(row: TurnRow): unknown { + switch (row.inputType) { + case 'prompt': + return { type: 'prompt', promptId: row.promptId }; + case 'command': + return { + type: 'command', + name: row.commandName, + arguments: row.commandArguments ?? undefined, + }; + default: + return { type: 'shell-command', command: row.shellCommand }; + } +} + +function toOperationRow( + operation: ConversationOperation, +): typeof conversationOperations.$inferInsert { + return { + operationId: operation.operationId, + sessionId: operation.sessionId, + kind: operation.kind, + state: operation.state, + turnId: operation.state === 'succeeded' ? operation.turnId : null, + errorCode: operation.state === 'failed' ? operation.error.code : null, + errorMessage: operation.state === 'failed' ? operation.error.message : null, + createdAt: operation.createdAt, + resolvedAt: operation.state === 'open' ? null : operation.resolvedAt, + }; +} + +function toOperation(row: OperationRow): ConversationOperation { + const base = { + operationId: row.operationId, + sessionId: row.sessionId, + kind: row.kind, + createdAt: row.createdAt, + }; + switch (row.state) { + case 'succeeded': + return ConversationOperationSchema.parse({ + ...base, + state: 'succeeded', + turnId: row.turnId, + resolvedAt: row.resolvedAt, + }); + case 'failed': + return ConversationOperationSchema.parse({ + ...base, + state: 'failed', + error: { code: row.errorCode, message: row.errorMessage }, + resolvedAt: row.resolvedAt, + }); + default: + return ConversationOperationSchema.parse({ ...base, state: 'open' }); + } +} diff --git a/apps/daemon/src/db/schema.ts b/apps/daemon/src/db/schema.ts index 030956aaf..658ee79af 100644 --- a/apps/daemon/src/db/schema.ts +++ b/apps/daemon/src/db/schema.ts @@ -1,3 +1,5 @@ +import { sql } from 'drizzle-orm'; +import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'; import { index, integer, @@ -19,14 +21,21 @@ export const sessions = sqliteTable( kind: text('kind').notNull(), cwd: text('cwd').notNull(), title: text('title'), - originType: text('origin_type', { enum: ['created', 'imported'] }).notNull(), + originType: text('origin_type', { enum: ['created', 'imported', 'forked'] }).notNull(), originHistoryId: text('origin_history_id'), originImportedAt: integer('origin_imported_at'), + originSourceSessionId: text('origin_source_session_id'), + originSourceTurnId: text('origin_source_turn_id'), + originForkedAt: integer('origin_forked_at'), /** IM platform the session was created from (`SessionRecord.createdVia`); null for LinkCode clients. */ createdVia: text('created_via'), /** Automation that created this session (`SessionRecord.automation`); null for user sessions. */ automationKind: text('automation_kind', { enum: ['loop', 'schedule'] }), automationId: text('automation_id'), + /** Deliberately no FK to `conversation_turns`: the turn tree is written on the conversation + * store's own connection, and the two tables would otherwise cycle. */ + activeLeafTurnId: text('active_leaf_turn_id'), + graphRevision: integer('graph_revision').notNull().default(0), createdAt: integer('created_at').notNull(), updatedAt: integer('updated_at').notNull(), }, @@ -42,6 +51,10 @@ export const sessionRuns = sqliteTable( .references(() => sessions.sessionId, { onDelete: 'cascade' }), /** Position within the session's run list — array order is part of the record. */ seq: integer('seq').notNull(), + /** Explicit run identity (`SessionRun.runId`); nullable in DDL only because SQLite cannot add a + * NOT NULL column — the migration backfills every row and the store always writes it. */ + runId: text('run_id'), + baseTurnId: text('base_turn_id'), historyId: text('history_id'), /** What the thread is set to, replayed on relaunch (`SessionRunSchema`). Every one of these must * round-trip, or a restart silently moves the thread back onto the agent's configured default. */ @@ -52,7 +65,10 @@ export const sessionRuns = sqliteTable( startedAt: integer('started_at').notNull(), endedAt: integer('ended_at'), }, - (table) => [index('session_runs_session_id_idx').on(table.sessionId)], + (table) => [ + index('session_runs_session_id_idx').on(table.sessionId), + uniqueIndex('session_runs_run_id_unique').on(table.runId), + ], ); export const sessionResources = sqliteTable( @@ -85,6 +101,111 @@ export const sessionResources = sqliteTable( ], ); +/** + * Conversation turn-tree tables. These mirror the `Conversation*` schemas from `@linkcode/schema` + * and are written ONLY by the conversation store's dedicated connection (../conversation-store.ts): + * the submit saga's transactions are multi-table, and atomicity across the per-store connections + * does not exist. Prompt content is user-authored and must never enter logs/telemetry. + */ +export const prompts = sqliteTable('prompts', { + promptId: text('prompt_id').primaryKey(), + /** JSON `PromptBlock[]` — references only, never bytes or absolute paths. */ + blocksJson: text('blocks_json').notNull(), + /** JSON `AttachmentId[]`; ordered snapshot. `prompt_attachment_refs` is the derived GC index. */ + contextAttachmentIdsJson: text('context_attachment_ids_json').notNull(), + createdAt: integer('created_at').notNull(), +}); + +/** Refcount edges for attachment GC: every attachment a prompt references, blocks and context. */ +export const promptAttachmentRefs = sqliteTable( + 'prompt_attachment_refs', + { + promptId: text('prompt_id') + .notNull() + .references(() => prompts.promptId, { onDelete: 'cascade' }), + attachmentId: text('attachment_id').notNull(), + }, + (table) => [primaryKey({ columns: [table.promptId, table.attachmentId] })], +); + +export const conversationTurns = sqliteTable( + 'conversation_turns', + { + turnId: text('turn_id').primaryKey(), + sessionId: text('session_id') + .notNull() + .references(() => sessions.sessionId, { onDelete: 'cascade' }), + /** null = child of the session root. Cascade keeps a subtree consistent under session delete. */ + parentTurnId: text('parent_turn_id').references( + (): AnySQLiteColumn => conversationTurns.turnId, + { + onDelete: 'cascade', + }, + ), + siblingOrdinal: integer('sibling_ordinal').notNull(), + inputType: text('input_type', { enum: ['prompt', 'command', 'shell-command'] }).notNull(), + /** No cascade: a referenced prompt must outlive the reference (shared across forks). */ + promptId: text('prompt_id').references(() => prompts.promptId), + commandName: text('command_name'), + commandArguments: text('command_arguments'), + shellCommand: text('shell_command'), + runId: text('run_id').notNull(), + state: text('state', { + enum: ['preparing', 'dispatching', 'running', 'completed', 'failed', 'cancelled'], + }).notNull(), + createdAt: integer('created_at').notNull(), + }, + (table) => [ + index('conversation_turns_session_idx').on(table.sessionId), + // Ordinal uniqueness needs two partial indexes: SQLite treats NULL parents as distinct rows. + uniqueIndex('conversation_turns_sibling_unique') + .on(table.sessionId, table.parentTurnId, table.siblingOrdinal) + .where(sql`parent_turn_id IS NOT NULL`), + uniqueIndex('conversation_turns_root_sibling_unique') + .on(table.sessionId, table.siblingOrdinal) + .where(sql`parent_turn_id IS NULL`), + ], +); + +/** One row per (turn, provider history); forks re-bind. `checkpoint` is adapter-opaque. */ +export const providerTurnBindings = sqliteTable( + 'provider_turn_bindings', + { + turnId: text('turn_id') + .notNull() + .references(() => conversationTurns.turnId, { onDelete: 'cascade' }), + runId: text('run_id').notNull(), + historyId: text('history_id').notNull(), + checkpoint: text('checkpoint').notNull(), + capturedFrom: text('captured_from', { enum: ['live', 'replay'] }).notNull(), + }, + (table) => [primaryKey({ columns: [table.turnId, table.historyId] })], +); + +/** Idempotency journal for conversation mutations; mirrors `ConversationOperation`. */ +export const conversationOperations = sqliteTable( + 'conversation_operations', + { + operationId: text('operation_id').primaryKey(), + sessionId: text('session_id') + .notNull() + .references(() => sessions.sessionId, { onDelete: 'cascade' }), + kind: text('kind').notNull(), + state: text('state', { enum: ['open', 'succeeded', 'failed'] }).notNull(), + turnId: text('turn_id'), + errorCode: text('error_code'), + errorMessage: text('error_message'), + createdAt: integer('created_at').notNull(), + resolvedAt: integer('resolved_at'), + }, + (table) => [ + index('conversation_operations_session_idx').on(table.sessionId), + uniqueIndex('conversation_operations_open_session_unique') + .on(table.sessionId) + .where(sql`state = 'open'`), + ], +); + /** * Recurring automations; mirrors `Schedule` from `@linkcode/schema` (spec fields flattened into * columns). `target_session_id` deliberately has no foreign key — a deleted target is the signal the diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index 754052ab7..66c426ed9 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -41,6 +41,7 @@ import { saveSimulatorConsent, worktreeRoot, } from './config'; +import { createConversationStore } from './conversation-store'; import { DaemonLoggerLive, logger } from './logger'; import { createLoopStore } from './loop-store'; import type { ManagedAgentKind } from './managed-agent-refresh'; @@ -273,6 +274,8 @@ async function main(): Promise { simulatorMcp, simulatorConsent, sessionStore: createSessionStore(databasePath()), + // After sessionStore, whose constructor applies the migrations these tables come from. + conversationStore: createConversationStore(databasePath()), resourceStore: createResourceStore(databasePath()), stateDir: daemonStateDir(), // After sessionStore so its migration-ledger reconcile runs before this store migrates. diff --git a/apps/daemon/src/session-store.ts b/apps/daemon/src/session-store.ts index 720c3403b..0ff006ecf 100644 --- a/apps/daemon/src/session-store.ts +++ b/apps/daemon/src/session-store.ts @@ -9,6 +9,7 @@ import { asc, eq } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import { readMigrationFiles } from 'drizzle-orm/migrator'; +import { nullthrow } from 'foxts/guard'; import { sessionRuns, sessions } from './db/schema'; type SessionRow = typeof sessions.$inferSelect; @@ -87,6 +88,10 @@ export function createSessionStore(dbPath: string): SessionStore { record.runs.map((run, seq) => ({ sessionId: record.sessionId, seq, + // runId is optional at the wire parse boundary only; every writer mints it, so a + // runId-less run here is a bug — minting one would drift the durable id per save. + runId: nullthrow(run.runId, `Session run without runId: ${record.sessionId}`), + baseTurnId: run.baseTurnId ?? null, historyId: run.historyId ?? null, accountId: run.accountId ?? null, model: run.model ?? null, @@ -119,9 +124,14 @@ function toSessionRow(record: SessionRecord): typeof sessions.$inferInsert { originType: record.origin.type, originHistoryId: record.origin.type === 'imported' ? record.origin.historyId : null, originImportedAt: record.origin.type === 'imported' ? record.origin.importedAt : null, + originSourceSessionId: record.forkOrigin?.sourceSessionId ?? null, + originSourceTurnId: record.forkOrigin?.sourceTurnId ?? null, + originForkedAt: record.forkOrigin?.forkedAt ?? null, createdVia: record.createdVia ?? null, automationKind: record.automation?.kind ?? null, automationId: record.automation?.id ?? null, + activeLeafTurnId: record.activeLeafTurnId ?? null, + graphRevision: record.graphRevision, createdAt: record.createdAt, updatedAt: record.updatedAt, }; @@ -133,22 +143,20 @@ function toRecord(row: SessionRow, runRows: RunRow[]): SessionRecord { kind: row.kind, cwd: row.cwd, title: row.title ?? undefined, - origin: - row.originType === 'imported' - ? { - type: 'imported', - historyId: row.originHistoryId, - importedAt: row.originImportedAt, - } - : { type: 'created' }, + origin: toOrigin(row), + forkOrigin: toForkOrigin(row), createdVia: row.createdVia ?? undefined, automation: row.automationKind && row.automationId ? { kind: row.automationKind, id: row.automationId } : undefined, + activeLeafTurnId: row.activeLeafTurnId ?? undefined, + graphRevision: row.graphRevision, createdAt: row.createdAt, updatedAt: row.updatedAt, runs: runRows.map((run) => ({ + runId: run.runId ?? undefined, + baseTurnId: run.baseTurnId ?? undefined, historyId: run.historyId ?? undefined, accountId: run.accountId ?? undefined, model: run.model ?? undefined, @@ -159,3 +167,26 @@ function toRecord(row: SessionRow, runRows: RunRow[]): SessionRecord { })), }); } + +function toOrigin(row: SessionRow): unknown { + if (row.originType === 'imported') { + return { type: 'imported', historyId: row.originHistoryId, importedAt: row.originImportedAt }; + } + return { type: 'created' }; +} + +function toForkOrigin(row: SessionRow): unknown { + if ( + row.originType !== 'forked' && + row.originSourceSessionId === null && + row.originSourceTurnId === null && + row.originForkedAt === null + ) { + return undefined; + } + return { + sourceSessionId: row.originSourceSessionId, + sourceTurnId: row.originSourceTurnId, + forkedAt: row.originForkedAt, + }; +} diff --git a/apps/daemon/tests/integration/session-store.test.ts b/apps/daemon/tests/integration/session-store.test.ts index 55576337a..89627eaa9 100644 --- a/apps/daemon/tests/integration/session-store.test.ts +++ b/apps/daemon/tests/integration/session-store.test.ts @@ -31,7 +31,10 @@ describe('daemon sqlite session store', () => { it('round-trips created and imported records', async () => { const store = createSessionStore(':memory:'); const created = makeRecord({ - runs: [{ startedAt: 1 }, { historyId: 'native-1', startedAt: 5, endedAt: 9 }], + runs: [ + { runId: 'run-a', startedAt: 1 }, + { runId: 'run-b', historyId: 'native-1', startedAt: 5, endedAt: 9 }, + ], }); const imported = makeRecord({ sessionId: 'sess-2', @@ -49,13 +52,13 @@ describe('daemon sqlite session store', () => { it('saves as a whole-record upsert, rewriting runs', async () => { const store = createSessionStore(':memory:'); - await store.save(makeRecord({ runs: [{ startedAt: 1 }] })); + await store.save(makeRecord({ runs: [{ runId: 'run-a', startedAt: 1 }] })); const next = makeRecord({ title: 'Renamed', updatedAt: 20, runs: [ - { historyId: 'native-1', startedAt: 1, endedAt: 10 }, - { historyId: 'native-2', startedAt: 15 }, + { runId: 'run-a', historyId: 'native-1', startedAt: 1, endedAt: 10 }, + { runId: 'run-b', historyId: 'native-2', startedAt: 15 }, ], }); await store.save(next); @@ -70,7 +73,7 @@ describe('daemon sqlite session store', () => { const dbPath = join(dir, 'daemon.db'); const first = createSessionStore(dbPath); - const record = makeRecord({ runs: [{ startedAt: 1 }] }); + const record = makeRecord({ runs: [{ runId: 'run-a', startedAt: 1 }] }); await first.save(record); // Simulate a dev DB migrated under an older journal: the newest migration's created_at predates @@ -90,7 +93,7 @@ describe('daemon sqlite session store', () => { it('deletes a record together with its runs', async () => { const store = createSessionStore(':memory:'); - const record = makeRecord({ runs: [{ startedAt: 1 }] }); + const record = makeRecord({ runs: [{ runId: 'run-a', startedAt: 1 }] }); await store.save(record); await store.delete(record.sessionId); expect(await store.load()).toEqual([]); diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index dd9155e0c..58f5977f6 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -966,6 +966,7 @@ export class DevMockHost { createdAt: session.createdAt, updatedAt: now, runs: [], + graphRevision: 0, }, }); } diff --git a/packages/foundation/schema/src/model/__tests__/conversation.test.ts b/packages/foundation/schema/src/model/__tests__/conversation.test.ts new file mode 100644 index 000000000..e573a881c --- /dev/null +++ b/packages/foundation/schema/src/model/__tests__/conversation.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { ConversationOperationSchema, PromptRecordSchema, TurnInputSchema } from '../conversation'; + +describe('TurnInput', () => { + it.each([ + { type: 'prompt', promptId: 'prompt-1' }, + { type: 'command', name: 'compact' }, + { type: 'command', name: 'review', arguments: 'apps/daemon' }, + { type: 'shell-command', command: 'git status' }, + ])('accepts $type input', (input) => { + expect(TurnInputSchema.safeParse(input).success).toBe(true); + }); + + it('rejects a prompt input carrying content instead of a reference', () => { + expect( + TurnInputSchema.safeParse({ type: 'prompt', blocks: [{ type: 'text', text: 'x' }] }).success, + ).toBe(false); + }); +}); + +describe('PromptRecord', () => { + it('keeps blocks reference-only: no bytes, no unknown block kinds', () => { + const base = { promptId: 'prompt-1', contextAttachmentIds: [], createdAt: 1 }; + expect( + PromptRecordSchema.safeParse({ + ...base, + blocks: [ + { type: 'text', text: 'see attached' }, + { type: 'attachment_ref', attachmentId: 'att-1' }, + ], + }).success, + ).toBe(true); + expect( + PromptRecordSchema.safeParse({ + ...base, + blocks: [{ type: 'image', data: 'cG5n', mimeType: 'image/png' }], + }).success, + ).toBe(false); + }); +}); + +describe('ConversationOperation', () => { + const base = { operationId: 'op-1', sessionId: 'session-1', kind: 'turn.submit', createdAt: 1 }; + + it('binds the result shape to the state', () => { + expect(ConversationOperationSchema.safeParse({ ...base, state: 'open' }).success).toBe(true); + expect( + ConversationOperationSchema.safeParse({ + ...base, + state: 'succeeded', + turnId: 'turn-1', + resolvedAt: 2, + }).success, + ).toBe(true); + expect( + ConversationOperationSchema.safeParse({ + ...base, + state: 'failed', + error: { code: 'busy', message: 'A turn is already running' }, + resolvedAt: 2, + }).success, + ).toBe(true); + }); + + it('rejects a terminal state without its result', () => { + expect(ConversationOperationSchema.safeParse({ ...base, state: 'succeeded' }).success).toBe( + false, + ); + expect( + ConversationOperationSchema.safeParse({ ...base, state: 'failed', resolvedAt: 2 }).success, + ).toBe(false); + }); +}); diff --git a/packages/foundation/schema/src/model/conversation.ts b/packages/foundation/schema/src/model/conversation.ts new file mode 100644 index 000000000..55f5a8bbd --- /dev/null +++ b/packages/foundation/schema/src/model/conversation.ts @@ -0,0 +1,132 @@ +import { z } from 'zod'; +import { + AttachmentIdSchema, + OperationIdSchema, + PromptIdSchema, + RunIdSchema, + SessionIdSchema, + TimestampSchema, + TurnIdSchema, +} from './primitives'; + +/** + * The conversation turn tree: host-authoritative turn identity and durable user prompts. + * Every turn has a parent (null = child of the session root); siblings under one parent are the + * prompt-edit variants; a "branch" is just the path from root to a leaf. Assistant/tool output + * stays provider-local and is projected via `ProviderTurnBinding`. + */ + +/** One block of a durable prompt — references only, never bytes, never absolute paths. */ +export const PromptBlockSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }), + z.object({ type: z.literal('attachment_ref'), attachmentId: AttachmentIdSchema }), +]); +export type PromptBlock = z.infer; + +/** Immutable user-authored prompt; shared by reference across session forks. */ +export const PromptRecordSchema = z.object({ + promptId: PromptIdSchema, + blocks: z.array(PromptBlockSchema), + /** Snapshot of the ready session sources at submit time. */ + contextAttachmentIds: z.array(AttachmentIdSchema), + createdAt: TimestampSchema, +}); +export type PromptRecord = z.infer; + +/** What started the turn — mirrors the turn-starting `AgentInput` variants: command and + * shell-command start turns today and must be first-class, or ordinals and checkpoints + * misalign with provider history. */ +export const TurnInputSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('prompt'), + /** null is reserved for lossy migrated replay; a live submit always mints a prompt record. */ + promptId: PromptIdSchema.nullable(), + }), + z.object({ + type: z.literal('command'), + name: z.string().min(1), + arguments: z.string().optional(), + }), + z.object({ type: z.literal('shell-command'), command: z.string().min(1) }), +]); +export type TurnInput = z.infer; + +export const ConversationTurnStateSchema = z.enum([ + 'preparing', + 'dispatching', + 'running', + 'completed', + 'failed', + 'cancelled', +]); +export type ConversationTurnState = z.infer; + +/** One node of the persisted turn tree. Never deleted except with its whole session. */ +export const ConversationTurnSchema = z.object({ + turnId: TurnIdSchema, + sessionId: SessionIdSchema, + /** null = child of the session root. */ + parentTurnId: TurnIdSchema.nullable(), + /** 1-based position among the parent's children; assigned at durable creation, stable forever. */ + siblingOrdinal: z.number().int().min(1), + input: TurnInputSchema, + /** The `SessionRun` that executed this turn — explicit, never positional. */ + runId: RunIdSchema, + state: ConversationTurnStateSchema, + createdAt: TimestampSchema, +}); +export type ConversationTurn = z.infer; + +/** One row per (turn, provider history) — forks re-bind. `checkpoint` is adapter-opaque, the same + * discipline as the history branch cursor. */ +export const ProviderTurnBindingSchema = z.object({ + turnId: TurnIdSchema, + runId: RunIdSchema, + historyId: z.string().min(1), + checkpoint: z.string().min(1), + /** Minted at turn end live, or during a cold read. */ + capturedFrom: z.enum(['live', 'replay']), +}); +export type ProviderTurnBinding = z.infer; + +export const ConversationOperationKindSchema = z.enum(['turn.submit']); +export type ConversationOperationKind = z.infer; + +/** Durable idempotency journal for conversation mutations: consulted before any validation, so a + * reply lost to a disconnect replays the terminal result instead of duplicating a sibling. */ +export const ConversationOperationSchema = z.discriminatedUnion('state', [ + z.object({ + operationId: OperationIdSchema, + sessionId: SessionIdSchema, + kind: ConversationOperationKindSchema, + state: z.literal('open'), + createdAt: TimestampSchema, + }), + z.object({ + operationId: OperationIdSchema, + sessionId: SessionIdSchema, + kind: ConversationOperationKindSchema, + state: z.literal('succeeded'), + turnId: TurnIdSchema, + createdAt: TimestampSchema, + resolvedAt: TimestampSchema, + }), + z.object({ + operationId: OperationIdSchema, + sessionId: SessionIdSchema, + kind: ConversationOperationKindSchema, + state: z.literal('failed'), + error: z.object({ code: z.string().min(1), message: z.string() }), + createdAt: TimestampSchema, + resolvedAt: TimestampSchema, + }), +]); +export type ConversationOperation = z.infer; + +/** Daemon-minted event-plane position: `epoch` bumps on run launch and daemon boot, `seq` is + * monotone within an epoch. Merge rule is lexicographic. */ +export const ConversationWatermarkSchema = z.object({ + epoch: z.number().int().nonnegative(), + seq: z.number().int().nonnegative(), +}); +export type ConversationWatermark = z.infer; diff --git a/packages/foundation/schema/src/model/index.ts b/packages/foundation/schema/src/model/index.ts index 029d6d6f0..8eab83ae9 100644 --- a/packages/foundation/schema/src/model/index.ts +++ b/packages/foundation/schema/src/model/index.ts @@ -4,6 +4,7 @@ export * from './agent-runtime'; export * from './artifact'; export * from './browser'; export * from './content'; +export * from './conversation'; export * from './custom-mcp'; export * from './daemon-discovery'; export * from './file'; diff --git a/packages/foundation/schema/src/model/primitives.ts b/packages/foundation/schema/src/model/primitives.ts index 1ae43dfb4..fb794f18a 100644 --- a/packages/foundation/schema/src/model/primitives.ts +++ b/packages/foundation/schema/src/model/primitives.ts @@ -31,6 +31,26 @@ export type ScheduleRunId = z.infer; export const LoopIdSchema = z.string().min(1).brand<'LoopId'>(); export type LoopId = z.infer; +/** Turn ID: host-minted, durable identity of one conversation turn (see conversation.ts). */ +export const TurnIdSchema = z.string().min(1).brand<'TurnId'>(); +export type TurnId = z.infer; + +/** Prompt ID: identity of an immutable prompt record, shared by reference across forks. */ +export const PromptIdSchema = z.string().min(1).brand<'PromptId'>(); +export type PromptId = z.infer; + +/** Run ID: explicit identity of one live start/resume of a session (see session/record.ts). */ +export const RunIdSchema = z.string().min(1).brand<'RunId'>(); +export type RunId = z.infer; + +/** Attachment ID: identity of an immutable prompt/session attachment. */ +export const AttachmentIdSchema = z.string().min(1).brand<'AttachmentId'>(); +export type AttachmentId = z.infer; + +/** Operation ID: client-minted idempotency key for conversation mutations (see conversation.ts). */ +export const OperationIdSchema = z.string().min(1).brand<'OperationId'>(); +export type OperationId = z.infer; + /** Epoch timestamp in milliseconds. */ export const TimestampSchema = z.number().int().nonnegative(); export type Timestamp = z.infer; diff --git a/packages/foundation/schema/src/model/session/record.ts b/packages/foundation/schema/src/model/session/record.ts index a09c4d020..f40b16f5c 100644 --- a/packages/foundation/schema/src/model/session/record.ts +++ b/packages/foundation/schema/src/model/session/record.ts @@ -5,8 +5,10 @@ import { ImPlatformSchema } from '../im'; import { AgentHistoryIdSchema, AgentKindSchema, + RunIdSchema, SessionIdSchema, TimestampSchema, + TurnIdSchema, } from '../primitives'; import { ApprovalPolicyIdSchema } from './control'; import { SessionStatusSchema } from './lifecycle'; @@ -34,6 +36,15 @@ export const SessionOriginSchema = z.discriminatedUnion('type', [ ]); export type SessionOrigin = z.infer; +/** Additive fork provenance: old peers ignore this key and keep parsing `origin: created`. */ +export const ForkOriginSchema = z.object({ + sourceSessionId: SessionIdSchema, + /** The source turn the fork was taken through (its copied prefix ends there). */ + sourceTurnId: TurnIdSchema, + forkedAt: TimestampSchema, +}); +export type ForkOrigin = z.infer; + /** * One live start/resume of a session. Providers usually mint a new native id per resume, so a * session accumulates runs; `historyId` is backfilled once the adapter reports it (session-ref). @@ -44,6 +55,12 @@ export type SessionOrigin = z.infer; * that would pin every thread to its first launch and cut it off from the agent's default for good. */ export const SessionRunSchema = z.object({ + /** Explicit run identity — writers always mint it. Optional at the parse boundary until the + * compatibility floor passes v80: ≤v79 daemons emit runs without it, and `session.imported` + * carries this schema, so a required field would make newer clients drop that reply. */ + runId: RunIdSchema.optional(), + /** The turn this run was launched from (fork/resume base); absent for a fresh root run. */ + baseTurnId: TurnIdSchema.optional(), historyId: AgentHistoryIdSchema.optional(), /** The account this run resolved to. Credentials and base URL are injected once at spawn, so the * account is fixed for the run's lifetime and a later rebind does not move it. */ @@ -66,6 +83,7 @@ export const SessionRecordSchema = z.object({ /** Provider title when available; otherwise derived from the first prompt. */ title: z.string().optional(), origin: SessionOriginSchema, + forkOrigin: ForkOriginSchema.optional(), /** The IM platform this session was created from (attribution/audit); absent for LinkCode clients. */ createdVia: ImPlatformSchema.optional(), /** Set when an automation created this session; clients hide tagged sessions from Threads. */ @@ -73,6 +91,10 @@ export const SessionRecordSchema = z.object({ createdAt: TimestampSchema, updatedAt: TimestampSchema, runs: z.array(SessionRunSchema), + /** Host default view of the turn tree; moves only on a successful submit. */ + activeLeafTurnId: TurnIdSchema.optional(), + /** Optimistic-concurrency counter for graph mutations. */ + graphRevision: z.number().int().nonnegative().default(0), }); export type SessionRecord = z.infer; @@ -87,6 +109,7 @@ export const SessionInfoSchema = z.object({ updatedAt: TimestampSchema, title: z.string().optional(), origin: SessionOriginSchema.optional(), + forkOrigin: ForkOriginSchema.optional(), /** The IM platform this session was created from (attribution/audit); absent for LinkCode clients. */ createdVia: ImPlatformSchema.optional(), /** Set when an automation created this session; clients hide tagged sessions from Threads. */ diff --git a/packages/foundation/schema/src/wire/agent.ts b/packages/foundation/schema/src/wire/agent.ts index 09c40baa0..6e7e0e094 100644 --- a/packages/foundation/schema/src/wire/agent.ts +++ b/packages/foundation/schema/src/wire/agent.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { AgentEventSchema, AgentInputSchema } from '../model/agent'; -import { SessionIdSchema } from '../model/primitives'; +import { RunIdSchema, SessionIdSchema, TurnIdSchema } from '../model/primitives'; import { WireRequestIdSchema } from './request'; /** Live agent-session data-plane variants. */ @@ -11,5 +11,16 @@ export const agentWireVariants = [ sessionId: SessionIdSchema, input: AgentInputSchema, }), - z.object({ kind: z.literal('agent.event'), sessionId: SessionIdSchema, event: AgentEventSchema }), + /** The turn/run attribution and `(epoch, seq)` position stay OPTIONAL until the compatibility + * floor moves past their introduction — required fields on an existing kind are a breaking + * change (Invariant 1). Clients feature-detect via the peer's wire version. */ + z.object({ + kind: z.literal('agent.event'), + sessionId: SessionIdSchema, + runId: RunIdSchema.optional(), + turnId: TurnIdSchema.optional(), + epoch: z.number().int().nonnegative().optional(), + seq: z.number().int().nonnegative().optional(), + event: AgentEventSchema, + }), ] as const; diff --git a/packages/foundation/schema/src/wire/conversation.ts b/packages/foundation/schema/src/wire/conversation.ts new file mode 100644 index 000000000..937169026 --- /dev/null +++ b/packages/foundation/schema/src/wire/conversation.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; +import { AgentEventSchema } from '../model/agent'; +import { + ConversationTurnSchema, + ConversationWatermarkSchema, + PromptBlockSchema, +} from '../model/conversation'; +import { + OperationIdSchema, + RunIdSchema, + SessionIdSchema, + TimestampSchema, + TurnIdSchema, +} from '../model/primitives'; +import { WireRequestIdSchema } from './request'; + +/** What a submit carries over the wire. Prompt content travels as blocks — the daemon mints the + * durable `PromptRecord` (and its id) when it persists the turn intent. */ +export const TurnSubmitInputSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('prompt'), blocks: z.array(PromptBlockSchema).min(1) }), + z.object({ + type: z.literal('command'), + name: z.string().min(1), + arguments: z.string().optional(), + }), + z.object({ type: z.literal('shell-command'), command: z.string().min(1) }), +]); +export type TurnSubmitInput = z.infer; + +/** One projected event of a conversation read: provider history attributed to its turn/run, plus + * the live tail (which alone carries `epoch`/`seq` for the watermark merge). */ +export const ConversationEventSchema = z.object({ + turnId: TurnIdSchema.optional(), + runId: RunIdSchema.optional(), + epoch: z.number().int().nonnegative().optional(), + seq: z.number().int().nonnegative().optional(), + ts: TimestampSchema.optional(), + event: AgentEventSchema, +}); +export type ConversationEvent = z.infer; + +/** Conversation-graph wire variants. `turn.submit`'s parent/revision contract: `parentTurnId` + * ABSENT = plain send onto the active leaf; `null` = new root lineage; a turn id = edit/continue + * under that turn. `expectedGraphRevision` is required iff `parentTurnId` is present (enforced by + * the payload-level refinement in payload.ts). */ +export const conversationWireVariants = [ + z.object({ + kind: z.literal('turn.submit'), + clientReqId: WireRequestIdSchema, + sessionId: SessionIdSchema, + /** Idempotency key: a retried operation replays the stored terminal result. */ + operationId: OperationIdSchema, + input: TurnSubmitInputSchema, + parentTurnId: TurnIdSchema.nullable().optional(), + expectedGraphRevision: z.number().int().nonnegative().optional(), + }), + z.object({ + kind: z.literal('turn.submitted'), + replyTo: WireRequestIdSchema, + turnId: TurnIdSchema, + }), + z.object({ + kind: z.literal('conversation.graph.get'), + clientReqId: WireRequestIdSchema, + sessionId: SessionIdSchema, + }), + z.object({ + kind: z.literal('conversation.graph.result'), + replyTo: WireRequestIdSchema, + sessionId: SessionIdSchema, + graphRevision: z.number().int().nonnegative(), + activeLeafTurnId: TurnIdSchema.optional(), + turns: z.array(ConversationTurnSchema), + }), + /** Session-scoped broadcast: the graph changed shape or moved its default leaf; clients holding + * a stale snapshot revalidate via `conversation.graph.get`. */ + z.object({ + kind: z.literal('conversation.graph.changed'), + sessionId: SessionIdSchema, + graphRevision: z.number().int().nonnegative(), + activeLeafTurnId: TurnIdSchema.optional(), + }), + z.object({ + kind: z.literal('conversation.read'), + clientReqId: WireRequestIdSchema, + sessionId: SessionIdSchema, + /** Absent = the session's active leaf. */ + leafTurnId: TurnIdSchema.optional(), + cursor: z.string().optional(), + limit: z.number().int().positive().optional(), + }), + z.object({ + kind: z.literal('conversation.read.result'), + replyTo: WireRequestIdSchema, + sessionId: SessionIdSchema, + graphRevision: z.number().int().nonnegative(), + /** The leaf the projection was read toward; absent while the session has no turns. */ + leafTurnId: TurnIdSchema.optional(), + /** Merge cut for the live event plane; only the final page's watermark is authoritative. */ + watermark: ConversationWatermarkSchema, + events: z.array(ConversationEventSchema), + cursor: z.string().optional(), + }), +] as const; diff --git a/packages/foundation/schema/src/wire/delivery.ts b/packages/foundation/schema/src/wire/delivery.ts index fb70837fe..29e366b97 100644 --- a/packages/foundation/schema/src/wire/delivery.ts +++ b/packages/foundation/schema/src/wire/delivery.ts @@ -27,6 +27,7 @@ export const WIRE_DELIVERY: WireDeliveryTable = { 'terminal.controller.changed': { scope: 'terminal', terminalId: (p) => p.terminalId }, 'terminal.exit': { scope: 'terminal', terminalId: (p) => p.terminalId }, 'agent.event': { scope: 'session', sessionId: (p) => p.sessionId }, + 'conversation.graph.changed': { scope: 'session', sessionId: (p) => p.sessionId }, 'simulator.stream.frame': { scope: 'session', sessionId: (p) => p.sessionId }, 'browser.command': { scope: 'browser-host' }, }; diff --git a/packages/foundation/schema/src/wire/index.ts b/packages/foundation/schema/src/wire/index.ts index bf9f9ffb5..f40d9a134 100644 --- a/packages/foundation/schema/src/wire/index.ts +++ b/packages/foundation/schema/src/wire/index.ts @@ -1,3 +1,9 @@ +export { + type ConversationEvent, + ConversationEventSchema, + type TurnSubmitInput, + TurnSubmitInputSchema, +} from './conversation'; export { deliveryOf, WIRE_DELIVERY, type WireDelivery } from './delivery'; export { type AgentHistoryListWireOptions, diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 3f9b6e6cf..454c86262 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 79 as const; +export const WIRE_PROTOCOL_VERSION = 80 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ diff --git a/packages/foundation/schema/src/wire/payload.ts b/packages/foundation/schema/src/wire/payload.ts index 0389ba2aa..641872ec9 100644 --- a/packages/foundation/schema/src/wire/payload.ts +++ b/packages/foundation/schema/src/wire/payload.ts @@ -6,6 +6,7 @@ import { agentRuntimeWireVariants } from './agent-runtime'; import { artifactWireVariants } from './artifact'; import { browserWireVariants } from './browser'; import { configWireVariants } from './config'; +import { conversationWireVariants } from './conversation'; import { fileWireVariants } from './file'; import { gitWireVariants } from './git'; import { historyWireVariants } from './history'; @@ -24,6 +25,7 @@ import { workspaceWireVariants } from './workspace'; const wirePayloadVariants = [ ...sessionWireVariants, + ...conversationWireVariants, ...historyWireVariants, ...requestWireVariants, ...resourceWireVariants, @@ -51,14 +53,26 @@ const wirePayloadVariants = [ export const WirePayloadSchema = z .discriminatedUnion('kind', wirePayloadVariants) .superRefine((payload, ctx) => { - if (payload.kind !== 'config.set') return; - const updates = [payload.providers, payload.accounts, payload.customMcpServers].filter( - (value) => value !== undefined, - ).length; - if (updates > 1) { + if (payload.kind === 'config.set') { + const updates = [payload.providers, payload.accounts, payload.customMcpServers].filter( + (value) => value !== undefined, + ).length; + if (updates > 1) { + ctx.addIssue({ + code: 'custom', + message: 'config.set may update only one configuration resource', + }); + } + } + // The revision guard exists exactly for explicit-parent submits; a plain send must stay + // conflict-free, so the two fields travel together or not at all. + if ( + payload.kind === 'turn.submit' && + (payload.parentTurnId !== undefined) !== (payload.expectedGraphRevision !== undefined) + ) { ctx.addIssue({ code: 'custom', - message: 'config.set may update only one configuration resource', + message: 'turn.submit requires expectedGraphRevision exactly when parentTurnId is present', }); } }); diff --git a/packages/foundation/schema/tests/contract/wire/conversation.test.ts b/packages/foundation/schema/tests/contract/wire/conversation.test.ts new file mode 100644 index 000000000..242b62846 --- /dev/null +++ b/packages/foundation/schema/tests/contract/wire/conversation.test.ts @@ -0,0 +1,137 @@ +import { + deliveryOf, + WIRE_PROTOCOL_VERSION, + WireMessageSchema, + WirePayloadSchema, +} from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; + +function parses(payload: unknown): boolean { + return WireMessageSchema.safeParse({ + v: WIRE_PROTOCOL_VERSION, + id: 'message-1', + ts: 0, + payload, + }).success; +} + +const submitBase = { + kind: 'turn.submit', + clientReqId: 'request-1', + sessionId: 'session-1', + operationId: 'op-1', + input: { type: 'prompt', blocks: [{ type: 'text', text: 'hello' }] }, +}; + +describe('turn.submit parent/revision contract', () => { + it('accepts a plain send: no parent, no revision', () => { + expect(parses(submitBase)).toBe(true); + }); + + it('accepts an explicit parent with its revision guard', () => { + expect(parses({ ...submitBase, parentTurnId: 'turn-1', expectedGraphRevision: 3 })).toBe(true); + }); + + it('accepts a root submit (parentTurnId null) with the revision guard', () => { + expect(parses({ ...submitBase, parentTurnId: null, expectedGraphRevision: 0 })).toBe(true); + }); + + it('rejects an explicit parent without the revision guard', () => { + expect(parses({ ...submitBase, parentTurnId: 'turn-1' })).toBe(false); + expect(parses({ ...submitBase, parentTurnId: null })).toBe(false); + }); + + it('rejects a revision guard on a plain send', () => { + expect(parses({ ...submitBase, expectedGraphRevision: 3 })).toBe(false); + }); + + it('rejects an empty prompt', () => { + expect(parses({ ...submitBase, input: { type: 'prompt', blocks: [] } })).toBe(false); + }); + + it.each([ + { type: 'command', name: 'compact', arguments: '--all' }, + { type: 'shell-command', command: 'pnpm test' }, + { type: 'prompt', blocks: [{ type: 'attachment_ref', attachmentId: 'att-1' }] }, + ])('accepts the $type submit input', (input) => { + expect(parses({ ...submitBase, input })).toBe(true); + }); +}); + +describe('conversation read/graph frames', () => { + it('round-trips a graph result carrying turn summaries', () => { + expect( + parses({ + kind: 'conversation.graph.result', + replyTo: 'request-1', + sessionId: 'session-1', + graphRevision: 2, + activeLeafTurnId: 'turn-2', + turns: [ + { + turnId: 'turn-1', + sessionId: 'session-1', + parentTurnId: null, + siblingOrdinal: 1, + input: { type: 'prompt', promptId: 'prompt-1' }, + runId: 'run-1', + state: 'completed', + createdAt: 1, + }, + ], + }), + ).toBe(true); + }); + + it('carries the watermark and attributed events on a read result', () => { + expect( + parses({ + kind: 'conversation.read.result', + replyTo: 'request-1', + sessionId: 'session-1', + graphRevision: 2, + leafTurnId: 'turn-2', + watermark: { epoch: 3, seq: 41 }, + events: [ + { + turnId: 'turn-2', + runId: 'run-1', + epoch: 3, + seq: 40, + event: { type: 'agent-message', messageId: 'm-1', content: [] }, + }, + ], + }), + ).toBe(true); + }); + + it('scopes conversation.graph.changed to its session', () => { + const payload = WirePayloadSchema.parse({ + kind: 'conversation.graph.changed', + sessionId: 'session-1', + graphRevision: 4, + }); + expect(payload.kind).toBe('conversation.graph.changed'); + // The delivery table, not just the payload shape: attached clients of OTHER sessions must + // never receive another thread's graph moves. + const delivery = deliveryOf(payload); + if (delivery?.scope !== 'session') throw new Error('expected session-scoped delivery'); + expect(delivery.sessionId(payload)).toBe('session-1'); + }); +}); + +describe('agent.event turn attribution', () => { + it('accepts the optional runId/turnId/epoch/seq envelope fields', () => { + expect( + parses({ + kind: 'agent.event', + sessionId: 'session-1', + runId: 'run-1', + turnId: 'turn-1', + epoch: 2, + seq: 17, + event: { type: 'status', status: 'running' }, + }), + ).toBe(true); + }); +}); diff --git a/packages/foundation/schema/tests/contract/wire/session.test.ts b/packages/foundation/schema/tests/contract/wire/session.test.ts index 0936655c5..ed167b62c 100644 --- a/packages/foundation/schema/tests/contract/wire/session.test.ts +++ b/packages/foundation/schema/tests/contract/wire/session.test.ts @@ -1,5 +1,31 @@ import { parseWireMessage, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +const FrozenV79SessionOriginSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('created') }), + z.object({ + type: z.literal('imported'), + historyId: z.string().min(1), + importedAt: z.number().int().nonnegative(), + }), +]); + +const FrozenV79SessionListedSchema = z.object({ + kind: z.literal('session.listed'), + replyTo: z.string().min(1), + sessions: z.array( + z.object({ + sessionId: z.string().min(1), + kind: z.enum(['claude-code', 'codex', 'opencode', 'pi', 'grok-build']), + cwd: z.string(), + status: z.enum(['starting', 'idle', 'running', 'awaiting-input', 'stopped']), + createdAt: z.number().int().nonnegative(), + updatedAt: z.number().int().nonnegative(), + origin: FrozenV79SessionOriginSchema.optional(), + }), + ), +}); function sessionStart(effort: unknown, branch?: unknown) { return { @@ -35,4 +61,83 @@ describe('session wire variants', () => { it('rejects a branch without an explicit mode', () => { expect(parseWireMessage(sessionStart('high', { name: 'feature' })).ok).toBe(false); }); + + it('accepts a legacy session.imported record whose runs predate runId', () => { + // ≤v79 daemons emit runs without runId; required-ness waits for the floor bump. + expect( + parseWireMessage({ + v: WIRE_PROTOCOL_VERSION, + id: 'message-1', + ts: 0, + payload: { + kind: 'session.imported', + replyTo: 'request-1', + record: { + sessionId: 'session-1', + kind: 'claude-code', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 1, + updatedAt: 2, + runs: [{ startedAt: 1, historyId: 'native-1' }], + }, + }, + }).ok, + ).toBe(true); + }); + + it('keeps fork provenance additive for frozen v79 session-list parsers', () => { + const payload = { + kind: 'session.listed' as const, + replyTo: 'request-1', + sessions: [ + { + sessionId: 'session-forked', + kind: 'codex' as const, + cwd: '/repo', + status: 'stopped' as const, + createdAt: 1, + updatedAt: 2, + origin: { type: 'created' as const }, + forkOrigin: { + sourceSessionId: 'session-source', + sourceTurnId: 'turn-source', + forkedAt: 1, + }, + }, + ], + }; + + const parsed = FrozenV79SessionListedSchema.parse(payload); + expect(parsed.sessions[0].origin).toEqual({ type: 'created' }); + expect(parsed.sessions[0]).not.toHaveProperty('forkOrigin'); + expect(parseWireMessage({ v: WIRE_PROTOCOL_VERSION, id: 'message-1', ts: 2, payload }).ok).toBe( + true, + ); + }); + + it('accepts a frozen v79 session-list frame', () => { + expect( + parseWireMessage({ + v: 79, + id: 'message-1', + ts: 2, + payload: { + kind: 'session.listed', + replyTo: 'request-1', + sessions: [ + { + sessionId: 'session-imported', + kind: 'claude-code', + cwd: '/repo', + status: 'stopped', + createdAt: 1, + updatedAt: 2, + origin: { type: 'imported', historyId: 'native-1', importedAt: 1 }, + }, + ], + }, + }).ok, + ).toBe(true); + }); }); diff --git a/packages/host/engine/src/__tests__/conversation-store.test.ts b/packages/host/engine/src/__tests__/conversation-store.test.ts new file mode 100644 index 000000000..d6d7ca5ed --- /dev/null +++ b/packages/host/engine/src/__tests__/conversation-store.test.ts @@ -0,0 +1,274 @@ +import type { ConversationOperation, ConversationTurn, PromptRecord } from '@linkcode/schema'; +import { + ConversationOperationSchema, + ConversationTurnSchema, + OperationIdSchema, + PromptIdSchema, + PromptRecordSchema, + ProviderTurnBindingSchema, + SessionIdSchema, + TurnIdSchema, +} from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { + ConversationSessionBusyError, + InMemoryConversationStore, +} from '../conversation/conversation-store'; + +function turn(value: { + turnId: string; + sessionId: string; + promptId?: string; + parentTurnId?: string | null; + state?: ConversationTurn['state']; +}): ConversationTurn { + return ConversationTurnSchema.parse({ + turnId: value.turnId, + sessionId: value.sessionId, + parentTurnId: value.parentTurnId ?? null, + siblingOrdinal: 1, + input: value.promptId + ? { type: 'prompt', promptId: value.promptId } + : { type: 'shell-command', command: 'git status' }, + runId: 'run-1', + state: value.state ?? 'preparing', + createdAt: 1, + }); +} + +function prompt(promptId: string): PromptRecord { + return PromptRecordSchema.parse({ + promptId, + blocks: [{ type: 'text', text: 'hello' }], + contextAttachmentIds: [], + createdAt: 1, + }); +} + +function openOperation(operationId: string, sessionId: string): ConversationOperation { + return ConversationOperationSchema.parse({ + operationId, + sessionId, + kind: 'turn.submit', + state: 'open', + createdAt: 1, + }); +} + +describe('InMemoryConversationStore', () => { + it('persists a turn intent as one unit and resolves its operation', async () => { + const store = new InMemoryConversationStore(); + const intent = { + turn: turn({ turnId: 't-1', sessionId: 's-1', promptId: 'p-1' }), + prompt: prompt('p-1'), + operation: openOperation('op-1', 's-1'), + }; + const persisted = await store.persistTurnIntent(intent); + + expect(persisted).toEqual({ ...intent.turn, siblingOrdinal: 1 }); + expect(await store.listTurns(SessionIdSchema.parse('s-1'))).toEqual([persisted]); + expect(await store.getPrompt(PromptIdSchema.parse('p-1'))).toEqual(intent.prompt); + expect(await store.listOpenOperations(SessionIdSchema.parse('s-1'))).toEqual([ + intent.operation, + ]); + + const resolved = ConversationOperationSchema.parse({ + operationId: 'op-1', + sessionId: 's-1', + kind: 'turn.submit', + state: 'succeeded', + turnId: 't-1', + createdAt: 1, + resolvedAt: 2, + }); + const running = { ...intent.turn, state: 'running' as const }; + await store.resolveOperation(resolved, running); + + expect(await store.getOperation(OperationIdSchema.parse('op-1'))).toEqual(resolved); + expect(await store.listOpenOperations()).toEqual([]); + expect(await store.listTurns(SessionIdSchema.parse('s-1'))).toEqual([running]); + }); + + it('upserts bindings by (turnId, historyId)', async () => { + const store = new InMemoryConversationStore(); + const binding = ProviderTurnBindingSchema.parse({ + turnId: 't-1', + runId: 'run-1', + historyId: 'native-1', + checkpoint: '{"uuid":"a"}', + capturedFrom: 'live', + }); + await store.saveBinding(binding); + const recaptured = { ...binding, checkpoint: '{"uuid":"b"}', capturedFrom: 'replay' as const }; + await store.saveBinding(recaptured); + const other = { ...binding, historyId: 'native-2' }; + await store.saveBinding(other); + + expect(await store.listBindings(TurnIdSchema.parse('t-1'))).toEqual([recaptured, other]); + }); + + it('deleteSession keeps prompts still referenced by another session and drops the rest', async () => { + const store = new InMemoryConversationStore(); + const shared = prompt('p-shared'); + const own = prompt('p-own'); + await store.persistTurnIntent({ + turn: turn({ turnId: 't-parent', sessionId: 's-parent', promptId: 'p-shared' }), + prompt: shared, + operation: openOperation('op-1', 's-parent'), + }); + await store.resolveOperation( + ConversationOperationSchema.parse({ + operationId: 'op-1', + sessionId: 's-parent', + kind: 'turn.submit', + state: 'succeeded', + turnId: 't-parent', + createdAt: 1, + resolvedAt: 2, + }), + ); + await store.persistTurnIntent({ + turn: turn({ + turnId: 't-own', + sessionId: 's-parent', + promptId: 'p-own', + parentTurnId: 't-parent', + }), + prompt: own, + operation: openOperation('op-2', 's-parent'), + }); + // The fork references the shared prompt from its own copied turn row. + await store.saveTurn(turn({ turnId: 't-child', sessionId: 's-fork', promptId: 'p-shared' })); + await store.saveBinding( + ProviderTurnBindingSchema.parse({ + turnId: 't-parent', + runId: 'run-1', + historyId: 'native-1', + checkpoint: 'c', + capturedFrom: 'live', + }), + ); + + await store.deleteSession(SessionIdSchema.parse('s-parent')); + + expect(await store.listTurns(SessionIdSchema.parse('s-parent'))).toEqual([]); + expect(await store.listBindings(TurnIdSchema.parse('t-parent'))).toEqual([]); + expect(await store.listOpenOperations()).toEqual([]); + expect(await store.getPrompt(PromptIdSchema.parse('p-own'))).toBeUndefined(); + expect(await store.getPrompt(PromptIdSchema.parse('p-shared'))).toEqual(shared); + }); + + it('refuses a second intent while the session has an open operation', async () => { + const store = new InMemoryConversationStore(); + await store.persistTurnIntent({ + turn: turn({ turnId: 't-1', sessionId: 's-1' }), + operation: openOperation('op-1', 's-1'), + }); + + await expect( + store.persistTurnIntent({ + turn: turn({ turnId: 't-2', sessionId: 's-1' }), + operation: openOperation('op-2', 's-1'), + }), + ).rejects.toBeInstanceOf(ConversationSessionBusyError); + expect(await store.listTurns(SessionIdSchema.parse('s-1'))).toHaveLength(1); + + // Another session is not gated by this one's open operation. + await store.persistTurnIntent({ + turn: turn({ turnId: 't-other', sessionId: 's-2' }), + operation: openOperation('op-other', 's-2'), + }); + }); + + it('a same-tick persist race yields one open operation and one busy rejection', async () => { + const store = new InMemoryConversationStore(); + + const results = await Promise.allSettled([ + store.persistTurnIntent({ + turn: turn({ turnId: 't-1', sessionId: 's-1' }), + operation: openOperation('op-1', 's-1'), + }), + store.persistTurnIntent({ + turn: turn({ turnId: 't-2', sessionId: 's-1' }), + operation: openOperation('op-2', 's-1'), + }), + ]); + + expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']); + const rejected = results.find((result) => result.status === 'rejected'); + expect(rejected?.reason).toBeInstanceOf(ConversationSessionBusyError); + expect(await store.listOpenOperations(SessionIdSchema.parse('s-1'))).toHaveLength(1); + expect(await store.listTurns(SessionIdSchema.parse('s-1'))).toHaveLength(1); + }); + + it('assigns sibling ordinals itself and refuses a replayed operation id', async () => { + const store = new InMemoryConversationStore(); + const first = await store.persistTurnIntent({ + turn: turn({ turnId: 't-1', sessionId: 's-1' }), + operation: openOperation('op-1', 's-1'), + }); + await store.resolveOperation({ + ...openOperation('op-1', 's-1'), + state: 'failed', + error: { code: 'busy', message: 'nope' }, + resolvedAt: 2, + }); + const second = await store.persistTurnIntent({ + turn: turn({ turnId: 't-2', sessionId: 's-1' }), + operation: openOperation('op-2', 's-1'), + }); + expect([first.siblingOrdinal, second.siblingOrdinal]).toEqual([1, 2]); + + await store.resolveOperation({ + ...openOperation('op-2', 's-1'), + state: 'failed', + error: { code: 'busy', message: 'nope' }, + resolvedAt: 3, + }); + await expect( + store.persistTurnIntent({ + turn: turn({ turnId: 't-3', sessionId: 's-1' }), + operation: openOperation('op-1', 's-1'), + }), + ).rejects.toThrow('already persisted'); + }); + + it('resolveOperation transitions open rows only — the first terminal result stands', async () => { + const store = new InMemoryConversationStore(); + const persisted = await store.persistTurnIntent({ + turn: turn({ turnId: 't-1', sessionId: 's-1' }), + operation: openOperation('op-1', 's-1'), + }); + const failed = ConversationOperationSchema.parse({ + operationId: 'op-1', + sessionId: 's-1', + kind: 'turn.submit', + state: 'failed', + error: { code: 'timeout', message: 'too slow' }, + createdAt: 1, + resolvedAt: 2, + }); + expect(await store.resolveOperation(failed, { ...persisted, state: 'failed' })).toBe(true); + + // A late success must not overwrite the stored failure or flip the turn. + expect( + await store.resolveOperation( + ConversationOperationSchema.parse({ + operationId: 'op-1', + sessionId: 's-1', + kind: 'turn.submit', + state: 'succeeded', + turnId: 't-1', + createdAt: 1, + resolvedAt: 3, + }), + { ...persisted, state: 'running' }, + ), + ).toBe(false); + + expect(await store.getOperation(OperationIdSchema.parse('op-1'))).toEqual(failed); + expect(await store.listTurns(SessionIdSchema.parse('s-1'))).toEqual([ + { ...persisted, state: 'failed' }, + ]); + }); +}); diff --git a/packages/host/engine/src/__tests__/engine-conversation-stubs.test.ts b/packages/host/engine/src/__tests__/engine-conversation-stubs.test.ts new file mode 100644 index 000000000..fcb4fc496 --- /dev/null +++ b/packages/host/engine/src/__tests__/engine-conversation-stubs.test.ts @@ -0,0 +1,35 @@ +import type { WirePayload } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { createSessionHarness } from './fixtures/session-harness'; + +const requests: WirePayload[] = [ + { + kind: 'turn.submit', + clientReqId: 'r-submit', + sessionId: 'session-1', + operationId: 'op-1', + input: { type: 'prompt', blocks: [{ type: 'text', text: 'hello' }] }, + }, + { kind: 'conversation.graph.get', clientReqId: 'r-graph', sessionId: 'session-1' }, + { kind: 'conversation.read', clientReqId: 'r-read', sessionId: 'session-1' }, +] as WirePayload[]; + +describe('conversation request stubs', () => { + it('refuses every conversation request loudly instead of dropping it', async () => { + const h = createSessionHarness(); + await h.engine.start(); + + await Promise.all(requests.map((request) => h.inject(request))); + + const replyIds = ['r-submit', 'r-graph', 'r-read']; + for (let i = 0, len = replyIds.length; i < len; i++) { + expect(h.sent).toContainEqual( + expect.objectContaining({ + kind: 'request.failed', + replyTo: replyIds[i], + code: 'unsupported', + }), + ); + } + }); +}); diff --git a/packages/host/engine/src/__tests__/engine-session-lifecycle.test.ts b/packages/host/engine/src/__tests__/engine-session-lifecycle.test.ts index 431003a85..8a5906330 100644 --- a/packages/host/engine/src/__tests__/engine-session-lifecycle.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-lifecycle.test.ts @@ -1,7 +1,14 @@ import type { AgentInput, SessionId, StartOptions } from '@linkcode/schema'; +import { + ConversationOperationSchema, + ConversationTurnSchema, + PromptRecordSchema, + SessionRecordSchema, +} from '@linkcode/schema'; import { Deferred, Effect, Exit, Fiber, Scope } from 'effect'; import { noop } from 'foxts/noop'; import { describe, expect, it } from 'vitest'; +import { InMemoryConversationStore } from '../conversation/conversation-store'; import type { OperationError } from '../failure'; import { LiveSession } from '../session/live-session'; import type { SessionStore } from '../session/session-store'; @@ -141,6 +148,90 @@ describe('engine session lifecycle', () => { ); }); + it('deletes graph rows and collects a shared prompt after its last owning session', async () => { + const sessions = new InMemorySessionStore(); + const conversations = new InMemoryConversationStore(); + const parent = SessionRecordSchema.parse({ + sessionId: 'session-parent', + kind: 'claude-code', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 1, + updatedAt: 1, + runs: [], + }); + const fork = SessionRecordSchema.parse({ + ...parent, + sessionId: 'session-fork', + forkOrigin: { + sourceSessionId: parent.sessionId, + sourceTurnId: 'turn-parent', + forkedAt: 2, + }, + }); + await sessions.save(parent); + await sessions.save(fork); + const prompt = PromptRecordSchema.parse({ + promptId: 'prompt-shared', + blocks: [{ type: 'attachment_ref', attachmentId: 'attachment-block' }], + contextAttachmentIds: ['attachment-context'], + createdAt: 2, + }); + await conversations.persistTurnIntent({ + turn: ConversationTurnSchema.parse({ + turnId: 'turn-parent', + sessionId: parent.sessionId, + parentTurnId: null, + siblingOrdinal: 1, + input: { type: 'prompt', promptId: prompt.promptId }, + runId: 'run-parent', + state: 'completed', + createdAt: 2, + }), + prompt, + operation: ConversationOperationSchema.parse({ + operationId: 'operation-parent', + sessionId: parent.sessionId, + kind: 'turn.submit', + state: 'open', + createdAt: 2, + }), + }); + await conversations.saveTurn( + ConversationTurnSchema.parse({ + turnId: 'turn-fork', + sessionId: fork.sessionId, + parentTurnId: null, + siblingOrdinal: 1, + input: { type: 'prompt', promptId: prompt.promptId }, + runId: 'run-fork', + state: 'completed', + createdAt: 3, + }), + ); + const h = harness(sessions, undefined, undefined, undefined, undefined, undefined, { + conversationStore: conversations, + }); + await h.engine.start(); + + await h.inject({ + kind: 'session.delete', + clientReqId: 'delete-parent', + sessionId: parent.sessionId, + }); + expect(await conversations.listTurns(parent.sessionId)).toEqual([]); + expect(await conversations.listOpenOperations(parent.sessionId)).toEqual([]); + expect(await conversations.getPrompt(prompt.promptId)).toEqual(prompt); + + await h.inject({ + kind: 'session.delete', + clientReqId: 'delete-fork', + sessionId: fork.sessionId, + }); + expect(await conversations.listTurns(fork.sessionId)).toEqual([]); + expect(await conversations.getPrompt(prompt.promptId)).toBeUndefined(); + }); + it('deleting a session cancels its pending start without reporting a request failure', async () => { const store = new InMemorySessionStore(); const { engine, sent, inject, adapters } = harness(store, () => new GatedStartAdapter()); diff --git a/packages/host/engine/src/__tests__/engine-session-records.test.ts b/packages/host/engine/src/__tests__/engine-session-records.test.ts index 58ff21b9c..8693d9bd0 100644 --- a/packages/host/engine/src/__tests__/engine-session-records.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-records.test.ts @@ -4,6 +4,7 @@ import type { AgentHistoryBranchOptions, AgentHistoryCapabilities, AgentHistoryReadOptions, + RunId, SessionId, SessionRecord, WirePayload, @@ -743,7 +744,10 @@ describe('engine session records', () => { automation: { kind: 'schedule', id: 'schedule-1' }, createdAt: 1, updatedAt: 2, - runs: [{ historyId: asHistoryId('native-1'), startedAt: 1, endedAt: 2 }], + runs: [ + { runId: 'run-1' as RunId, historyId: asHistoryId('native-1'), startedAt: 1, endedAt: 2 }, + ], + graphRevision: 0, }); const h = harness(store); await h.engine.start(); @@ -866,6 +870,7 @@ describe('engine session records', () => { createdAt: 1, updatedAt: 2, runs: [], + graphRevision: 0, }; const created: SessionRecord = { sessionId: 's-created' as SessionId, @@ -875,6 +880,7 @@ describe('engine session records', () => { createdAt: 1, updatedAt: 2, runs: [], + graphRevision: 0, }; await sessionStore.save(imported); await sessionStore.save(created); diff --git a/packages/host/engine/src/conversation/conversation-store.ts b/packages/host/engine/src/conversation/conversation-store.ts new file mode 100644 index 000000000..ae14162d5 --- /dev/null +++ b/packages/host/engine/src/conversation/conversation-store.ts @@ -0,0 +1,177 @@ +import type { + ConversationOperation, + ConversationTurn, + OperationId, + PromptId, + PromptRecord, + ProviderTurnBinding, + SessionId, + TurnId, +} from '@linkcode/schema'; + +/** The durable commit point of a submit: the turn, its prompt (for prompt inputs), and the open + * operation journal row persist together or not at all. `siblingOrdinal` is store-assigned inside + * the persist transaction, so racing submits cannot compute the same ordinal. */ +export interface ConversationTurnIntent { + readonly turn: Omit; + readonly prompt?: PromptRecord; + readonly operation: ConversationOperation; +} + +/** Rejection from {@link ConversationStore.persistTurnIntent} when the session already has an + * open operation — the durable backstop behind the engine's admit gate. */ +export class ConversationSessionBusyError extends Error { + constructor(sessionId: SessionId, options?: ErrorOptions) { + super(`Another operation is open on session ${sessionId}`, options); + this.name = 'ConversationSessionBusyError'; + } +} + +/** + * Durable turn-tree storage: turns, immutable prompts, provider bindings, and the operation + * journal. The daemon injects a single-connection SQLite implementation — the multi-row methods + * MUST be atomic there, because the submit saga's guarantees hang on them; the in-memory default + * is for tests and embedders. + */ +export interface ConversationStore { + listTurns(sessionId: SessionId): Promise; + /** Upsert by `turnId` — state flips rewrite the row. */ + saveTurn(turn: ConversationTurn): Promise; + getPrompt(promptId: PromptId): Promise; + listBindings(turnId: TurnId): Promise; + /** Upsert by `(turnId, historyId)` — one binding per provider history, re-captured on re-read. */ + saveBinding(binding: ProviderTurnBinding): Promise; + getOperation(operationId: OperationId): Promise; + /** Open operations, for the per-session admit gate and boot recovery (no argument = all). */ + listOpenOperations(sessionId?: SessionId): Promise; + /** Atomic: assign `siblingOrdinal`, then insert the turn, its prompt (if any), and the open + * operation in one transaction; the assigned turn is returned. Rejects with + * {@link ConversationSessionBusyError} while the session has an open operation; rows are + * plain-inserted, so a replayed operationId conflicts instead of re-opening a terminal row. */ + persistTurnIntent(intent: ConversationTurnIntent): Promise; + /** Atomic: store the operation's terminal result and, when given, the turn's new state — but + * only while the operation row is still `open`. Returns whether THIS call performed the + * transition; the first terminal writer stands and losers must run no side effects. */ + resolveOperation(operation: ConversationOperation, turn?: ConversationTurn): Promise; + /** Purge the session's turns, bindings, and operations. Prompts are shared by reference across + * forks: one is deleted only when no turn in ANY session still references it. */ + deleteSession(sessionId: SessionId): Promise; +} + +export class InMemoryConversationStore implements ConversationStore { + private readonly turns = new Map(); + private readonly prompts = new Map(); + private readonly bindings = new Map(); + private readonly operations = new Map(); + + listTurns(sessionId: SessionId): Promise { + const turns = []; + for (const turn of this.turns.values()) { + if (turn.sessionId === sessionId) turns.push(structuredClone(turn)); + } + return Promise.resolve(turns); + } + + saveTurn(turn: ConversationTurn): Promise { + this.turns.set(turn.turnId, structuredClone(turn)); + return Promise.resolve(); + } + + getPrompt(promptId: PromptId): Promise { + const prompt = this.prompts.get(promptId); + return Promise.resolve(prompt && structuredClone(prompt)); + } + + listBindings(turnId: TurnId): Promise { + const bindings = []; + for (const binding of this.bindings.values()) { + if (binding.turnId === turnId) bindings.push(structuredClone(binding)); + } + return Promise.resolve(bindings); + } + + saveBinding(binding: ProviderTurnBinding): Promise { + this.bindings.set(`${binding.turnId}\0${binding.historyId}`, structuredClone(binding)); + return Promise.resolve(); + } + + getOperation(operationId: OperationId): Promise { + const operation = this.operations.get(operationId); + return Promise.resolve(operation && structuredClone(operation)); + } + + listOpenOperations(sessionId?: SessionId): Promise { + const open = []; + for (const operation of this.operations.values()) { + if (operation.state === 'open' && (!sessionId || operation.sessionId === sessionId)) { + open.push(structuredClone(operation)); + } + } + return Promise.resolve(open); + } + + persistTurnIntent(intent: ConversationTurnIntent): Promise { + const { sessionId, parentTurnId } = intent.turn; + for (const operation of this.operations.values()) { + if (operation.sessionId === sessionId && operation.state === 'open') { + return Promise.reject(new ConversationSessionBusyError(sessionId)); + } + } + if (this.operations.has(intent.operation.operationId)) { + return Promise.reject( + new Error(`Operation already persisted: ${intent.operation.operationId}`), + ); + } + let siblingOrdinal = 1; + for (const existing of this.turns.values()) { + if (existing.sessionId === sessionId && existing.parentTurnId === parentTurnId) { + siblingOrdinal += 1; + } + } + const turn: ConversationTurn = { ...intent.turn, siblingOrdinal }; + // All mutations happen synchronously, so a racing persist cannot pass the guard mid-write — + // the same atomicity the SQLite transaction gives the daemon store. + this.turns.set(turn.turnId, structuredClone(turn)); + if ( + intent.turn.input.type === 'prompt' && + intent.turn.input.promptId !== null && + intent.prompt + ) { + this.prompts.set(intent.prompt.promptId, structuredClone(intent.prompt)); + } + this.operations.set(intent.operation.operationId, structuredClone(intent.operation)); + return Promise.resolve(turn); + } + + resolveOperation(operation: ConversationOperation, turn?: ConversationTurn): Promise { + if (this.operations.get(operation.operationId)?.state !== 'open') { + return Promise.resolve(false); + } + this.operations.set(operation.operationId, structuredClone(operation)); + if (turn) this.turns.set(turn.turnId, structuredClone(turn)); + return Promise.resolve(true); + } + + deleteSession(sessionId: SessionId): Promise { + for (const [turnId, turn] of this.turns) { + if (turn.sessionId !== sessionId) continue; + this.turns.delete(turnId); + for (const [key, binding] of this.bindings) { + if (binding.turnId === turnId) this.bindings.delete(key); + } + } + for (const [operationId, operation] of this.operations) { + if (operation.sessionId === sessionId) this.operations.delete(operationId); + } + const referenced = new Set(); + for (const turn of this.turns.values()) { + if (turn.input.type === 'prompt' && turn.input.promptId !== null) { + referenced.add(turn.input.promptId); + } + } + for (const promptId of this.prompts.keys()) { + if (!referenced.has(promptId)) this.prompts.delete(promptId); + } + return Promise.resolve(); + } +} diff --git a/packages/host/engine/src/conversation/request-handler.ts b/packages/host/engine/src/conversation/request-handler.ts new file mode 100644 index 000000000..426f1c2c3 --- /dev/null +++ b/packages/host/engine/src/conversation/request-handler.ts @@ -0,0 +1,33 @@ +import type { WirePayload } from '@linkcode/schema'; +import { Effect } from 'effect'; +import { RequestError } from '../failure'; +import type { WireResponder } from '../wire/responder'; +import type { ConversationStore } from './conversation-store'; + +type ConversationRequest = Extract< + WirePayload, + { kind: 'turn.submit' | 'conversation.graph.get' | 'conversation.read' } +>; + +/** Declared wire surface for the turn tree. A known kind must fail loudly, never be silently + * ignored — every request is refused with a typed error until the submit saga and the projection + * land on top of {@link ConversationStore}. */ +export class ConversationRequestHandler { + constructor( + /** Held for the submit saga and projection reads that build on this handler. */ + readonly store: ConversationStore, + private readonly responder: WireResponder, + ) {} + + handle(payload: ConversationRequest): Effect.Effect { + return this.responder.reply( + payload.clientReqId, + Effect.fail( + new RequestError({ + code: 'unsupported', + message: `${payload.kind} is not implemented yet`, + }), + ), + ); + } +} diff --git a/packages/host/engine/src/deps.ts b/packages/host/engine/src/deps.ts index 3fdce4a71..bb4b1803d 100644 --- a/packages/host/engine/src/deps.ts +++ b/packages/host/engine/src/deps.ts @@ -6,6 +6,7 @@ import type { ProviderConfigStore } from './agent/provider-config'; import type { TranslatorService } from './agent/translator'; import type { AssetService } from './asset/service'; import type { LoopStore, ScheduleStore } from './automation'; +import type { ConversationStore } from './conversation/conversation-store'; import type { GitService } from './git/git-service'; import type { PreviewRouteRegistry } from './preview/route-registry'; import type { ResourceStore } from './resource/resource-store'; @@ -24,6 +25,9 @@ export interface EngineDeps { /** Read-only native plugin providers aggregated by the Engine plugin service. */ pluginFactory?: PluginProviderAdapterFactory; sessionStore?: SessionStore; + /** Durable turn-tree/prompt storage. The daemon injects the single-connection SQLite store its + * multi-table transactions require; the in-memory default keeps bare engines and tests free. */ + conversationStore?: ConversationStore; resourceStore?: ResourceStore; /** Daemon profile state directory containing managed resource bytes. */ stateDir?: string; diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index 30b7b3628..1df1b13e2 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -22,6 +22,8 @@ import { AutomationRequestHandler } from './automation/request-handler'; import { BrowserBrokerService } from './browser/broker'; import { BrowserReplHost } from './browser/repl-host'; import { BrowserRequestHandler } from './browser/request-handler'; +import { InMemoryConversationStore } from './conversation/conversation-store'; +import { ConversationRequestHandler } from './conversation/request-handler'; import type { EngineDeps } from './deps'; import type { EngineFailure, OperationSubsystem } from './failure'; import { toOperationFailure } from './failure'; @@ -101,6 +103,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( deps.stateDir, fileHost, ); + const conversations = deps.conversationStore ?? new InMemoryConversationStore(); const plugins = new PluginService(deps.pluginFactory ?? createPluginProviderAdapter); const translator = deps.translator; const startOptions = new SessionStartOptionsResolver( @@ -156,6 +159,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( deps.simulatorMcp?.release(sessionId); }, resources, + conversations, deps.browserToolsEnabled ? () => new BrowserReplHost((op, args) => browserBroker.dispatch(op, args)) : undefined, @@ -216,6 +220,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( sessionLifecycle, responder, ); + const conversationRequests = new ConversationRequestHandler(conversations, responder); const scheduler = new ScheduleService( transport, deps.scheduleStore ?? new InMemoryScheduleStore(), @@ -255,6 +260,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( const requests = new WireRequestRouter(transport, { session: sessionRequests, history: historyRequests, + conversation: conversationRequests, agent: agentRequests, asset: assets, workspace: workspaceRequests, diff --git a/packages/host/engine/src/index.ts b/packages/host/engine/src/index.ts index 7d4e4f7c0..807735e7c 100644 --- a/packages/host/engine/src/index.ts +++ b/packages/host/engine/src/index.ts @@ -8,6 +8,11 @@ export type { ProviderConfigStore } from './agent/provider-config'; export type { TranslatorService, TranslatorUpstream } from './agent/translator'; export type { AssetService } from './asset/service'; export type { LoopStore, ScheduleStore } from './automation'; +export { + ConversationSessionBusyError, + type ConversationStore, + type ConversationTurnIntent, +} from './conversation/conversation-store'; export type { EngineDeps } from './deps'; export { PreviewRouteRegistry } from './preview/route-registry'; export type { ResourceStore } from './resource/resource-store'; diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 45293e9dc..f3513db0e 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -28,6 +28,7 @@ import type { SessionRecordRegistry, SessionRunIntent, } from './session-record-registry'; +import { mintRunId } from './session-record-registry'; import type { ResolvedStartOptions, SessionStartOptionsResolver } from './start-options-resolver'; type RunEffect = (effect: Effect.Effect, options?: Effect.RunOptions) => Promise; @@ -111,7 +112,8 @@ export class SessionLifecycleService { createdVia: resolved.createdVia, createdAt: now, updatedAt: now, - runs: [{ startedAt: now, ...runOf(resolved, accountId) }], + runs: [{ runId: mintRunId(), startedAt: now, ...runOf(resolved, accountId) }], + graphRevision: 0, }; yield* sessions.startLive( replyTo, @@ -150,6 +152,7 @@ export class SessionLifecycleService { createdAt: session.createdAt ?? now, updatedAt: now, runs: [], + graphRevision: 0, }; yield* records.importRecord(record); if (record.cwd) yield* workspaceTouch(workspaces, record.cwd); @@ -187,7 +190,10 @@ export class SessionLifecycleService { origin: { type: 'imported', historyId, importedAt: now }, createdAt: now, updatedAt: now, - runs: [{ historyId, startedAt: now, ...runOf(startOptions, accountId) }], + runs: [ + { runId: mintRunId(), historyId, startedAt: now, ...runOf(startOptions, accountId) }, + ], + graphRevision: 0, }; yield* sessions.startLive( replyTo, @@ -529,7 +535,8 @@ export class SessionLifecycleService { automation: options.automation, createdAt: now, updatedAt: now, - runs: [{ startedAt: now, ...runOf(startOptions, accountId) }], + runs: [{ runId: mintRunId(), startedAt: now, ...runOf(startOptions, accountId) }], + graphRevision: 0, }; if (startOptions.cwd) yield* workspaceTouch(workspaces, startOptions.cwd); yield* sessions.startLive(undefined, record, (adapter) => diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index efc1bc585..a640f7c28 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -17,6 +17,7 @@ import { Cause, Deferred, Effect, Exit, Scope } from 'effect'; import type { AgentRuntimeService } from '../agent/runtime-service'; import type { TurnResult } from '../automation/turn-watcher'; import { watchTurn } from '../automation/turn-watcher'; +import type { ConversationStore } from '../conversation/conversation-store'; import type { EngineFailure } from '../failure'; import { OperationError, RequestError, toOperationFailure } from '../failure'; import { observeOperation, recordLiveSessions } from '../observability'; @@ -40,6 +41,7 @@ export class SessionOrchestrator { reportFailure: (effect: Effect.Effect) => void, private readonly onStopped: (sessionId: SessionId) => void, private readonly resources: ResourceService, + private readonly conversations: ConversationStore, private readonly browserTools?: BrowserToolsetFactory, ) { this.events = new SessionEventProcessor(transport, records, runtimes, reportFailure, resources); @@ -107,13 +109,22 @@ export class SessionOrchestrator { } delete(sessionId: SessionId): Effect.Effect { - const { resources } = this; + const { conversations, resources } = this; return Effect.gen({ self: this }, function* () { const session = this.sessions.get(sessionId); if (session) { yield* this.teardown(sessionId, session, 'session.delete'); } yield* resources.deleteSession(sessionId); + yield* Effect.tryPromise({ + try: () => conversations.deleteSession(sessionId), + catch: (cause) => + toOperationFailure(cause, { + subsystem: 'store', + operation: 'conversation.delete-session', + publicMessage: 'Failed to delete conversation graph', + }), + }); yield* this.records.delete(sessionId); }); } diff --git a/packages/host/engine/src/session/session-record-registry.ts b/packages/host/engine/src/session/session-record-registry.ts index 991cf330e..5580c8a45 100644 --- a/packages/host/engine/src/session/session-record-registry.ts +++ b/packages/host/engine/src/session/session-record-registry.ts @@ -1,7 +1,9 @@ +import { randomUUID } from 'node:crypto'; import type { AgentHistoryId, AgentKind, ContentBlock, + RunId, SessionChangeReason, SessionId, SessionInfo, @@ -15,6 +17,10 @@ import { isObjectEmpty } from 'foxts/is-object-empty'; import { OperationError } from '../failure'; import type { SessionStore } from './session-store'; +export function mintRunId(): RunId { + return `run-${randomUUID()}` as RunId; +} + const TITLE_MAX_LENGTH = 80; type RunTask = (effect: Effect.Effect) => void; @@ -96,6 +102,7 @@ export class SessionRecordRegistry { updatedAt: record.updatedAt, title: record.title, origin: record.origin, + forkOrigin: record.forkOrigin, createdVia: record.createdVia, automation: record.automation, historyId: latestHistoryId(record), @@ -180,10 +187,13 @@ export class SessionRecordRegistry { /** The single writer for a relaunch's run entry. `historyId` is known up front only when the * relaunch resumes a transcript; a fresh one gets it later via {@link bindHistoryId}. */ - beginRun(sessionId: SessionId, run: Omit = {}): void { + beginRun( + sessionId: SessionId, + run: Omit = {}, + ): void { const record = this.records.get(sessionId); if (!record) return; - record.runs.push({ startedAt: Date.now(), ...definedFields(run) }); + record.runs.push({ runId: mintRunId(), startedAt: Date.now(), ...definedFields(run) }); this.persist(record); // A new run re-points the identity `list()` projects — `accountId`, `historyId` — so clients // must revalidate. Nothing else announces a relaunch: it sends no `session.started`, and a diff --git a/packages/host/engine/src/wire/request-router.ts b/packages/host/engine/src/wire/request-router.ts index cede0f6e7..95fb06821 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -6,6 +6,7 @@ import type { AgentRequestHandler } from '../agent/request-handler'; import type { ManagedAssetService } from '../asset/service'; import type { AutomationRequestHandler } from '../automation/request-handler'; import type { BrowserRequestHandler } from '../browser/request-handler'; +import type { ConversationRequestHandler } from '../conversation/request-handler'; import type { GitRequestHandler } from '../git/request-handler'; import { observeRequest } from '../observability'; import type { PluginRequestHandler } from '../plugin/request-handler'; @@ -22,6 +23,7 @@ import type { WorkspaceRequestHandler } from '../workspace/request-handler'; interface RequestHandlers { readonly session: SessionRequestHandler; readonly history: HistoryRequestHandler; + readonly conversation: ConversationRequestHandler; readonly agent: AgentRequestHandler; readonly asset: ManagedAssetService; readonly workspace: WorkspaceRequestHandler; @@ -75,6 +77,11 @@ export class WireRequestRouter { case 'history.branch': { return this.handlers.history.handle(p); } + case 'turn.submit': + case 'conversation.graph.get': + case 'conversation.read': { + return this.handlers.conversation.handle(p); + } case 'agent-runtime.list': case 'agent.catalog': case 'config.get': diff --git a/packages/host/engine/tests/integration/run-command.test.ts b/packages/host/engine/tests/integration/run-command.test.ts index fbae2de47..e49fcec6c 100644 --- a/packages/host/engine/tests/integration/run-command.test.ts +++ b/packages/host/engine/tests/integration/run-command.test.ts @@ -24,7 +24,7 @@ describe('runCommand', () => { Effect.runPromise( runCommand(process.execPath, ['-e', writePidAndWait(pidFile)], { cwd: process.cwd(), - timeoutMs: 50, + timeoutMs: 1000, }), ), ).rejects.toMatchObject({ _tag: 'CommandError', reason: 'timeout' });