From b0017bf1b96ef14fc1ecf91c0b9c4b18e2dfea71 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 25 Mar 2026 10:47:40 -0400 Subject: [PATCH] feat(core): initial implementation of syncing (#17814) --- .../20260323234822_events/migration.sql | 13 + .../20260323234822_events/snapshot.json | 1337 +++++++++ packages/opencode/src/account/repo.ts | 5 +- packages/opencode/src/bus/bus-event.ts | 3 - packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/cli/cmd/tui/app.tsx | 4 +- packages/opencode/src/id/id.ts | 1 + packages/opencode/src/plugin/index.ts | 3 +- packages/opencode/src/server/projectors.ts | 28 + packages/opencode/src/server/routes/event.ts | 15 +- packages/opencode/src/server/routes/global.ts | 147 +- .../opencode/src/server/routes/session.ts | 6 +- packages/opencode/src/server/server.ts | 3 + packages/opencode/src/session/index.ts | 297 +- packages/opencode/src/session/message-v2.ts | 47 +- packages/opencode/src/session/projectors.ts | 116 + packages/opencode/src/session/revert.ts | 12 +- packages/opencode/src/share/share-next.ts | 19 +- packages/opencode/src/storage/db.ts | 34 +- packages/opencode/src/sync/README.md | 181 ++ packages/opencode/src/sync/event.sql.ts | 16 + packages/opencode/src/sync/index.ts | 263 ++ packages/opencode/src/sync/schema.ts | 14 + packages/opencode/src/util/update-schema.ts | 13 + .../test/acp/event-subscription.test.ts | 2 + packages/opencode/test/preload.ts | 6 + .../opencode/test/session/session.test.ts | 16 +- packages/opencode/test/storage/db.test.ts | 13 +- packages/opencode/test/sync/index.test.ts | 187 ++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 20 + packages/sdk/js/src/v2/gen/types.gen.ts | 911 ++++--- packages/sdk/openapi.json | 2429 ++++++++++------- 32 files changed, 4403 insertions(+), 1760 deletions(-) create mode 100644 packages/opencode/migration/20260323234822_events/migration.sql create mode 100644 packages/opencode/migration/20260323234822_events/snapshot.json create mode 100644 packages/opencode/src/server/projectors.ts create mode 100644 packages/opencode/src/session/projectors.ts create mode 100644 packages/opencode/src/sync/README.md create mode 100644 packages/opencode/src/sync/event.sql.ts create mode 100644 packages/opencode/src/sync/index.ts create mode 100644 packages/opencode/src/sync/schema.ts create mode 100644 packages/opencode/src/util/update-schema.ts create mode 100644 packages/opencode/test/sync/index.test.ts diff --git a/packages/opencode/migration/20260323234822_events/migration.sql b/packages/opencode/migration/20260323234822_events/migration.sql new file mode 100644 index 0000000000..b0fe7e4e6b --- /dev/null +++ b/packages/opencode/migration/20260323234822_events/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE `event_sequence` ( + `aggregate_id` text PRIMARY KEY, + `seq` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `event` ( + `id` text PRIMARY KEY, + `aggregate_id` text NOT NULL, + `seq` integer NOT NULL, + `type` text NOT NULL, + `data` text NOT NULL, + CONSTRAINT `fk_event_aggregate_id_event_sequence_aggregate_id_fk` FOREIGN KEY (`aggregate_id`) REFERENCES `event_sequence`(`aggregate_id`) ON DELETE CASCADE +); diff --git a/packages/opencode/migration/20260323234822_events/snapshot.json b/packages/opencode/migration/20260323234822_events/snapshot.json new file mode 100644 index 0000000000..b8e3932fb2 --- /dev/null +++ b/packages/opencode/migration/20260323234822_events/snapshot.json @@ -0,0 +1,1337 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "f13dfa58-7fb4-47a2-8f6b-dc70258e14ed", + "prevIds": [ + "37e1554d-af4c-43f2-aa7c-307fb49a315e" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index 96f980cdad..d02cf1b637 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -8,6 +8,7 @@ import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } f export type AccountRow = (typeof AccountTable)["$inferSelect"] type DbClient = Parameters[0] extends (db: infer T) => unknown ? T : never +type DbTransactionCallback = Parameters>[0] const ACCOUNT_STATE_ID = 1 @@ -42,13 +43,13 @@ export class AccountRepo extends ServiceMap.Service(f: (db: DbClient) => A) => + const query = (f: DbTransactionCallback) => Effect.try({ try: () => Database.use(f), catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), }) - const tx = (f: (db: DbClient) => A) => + const tx = (f: DbTransactionCallback) => Effect.try({ try: () => Database.transaction(f), catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 7fe13833c8..d97922290e 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,10 +1,7 @@ import z from "zod" import type { ZodType } from "zod" -import { Log } from "../util/log" export namespace BusEvent { - const log = Log.create({ service: "event" }) - export type Definition = ReturnType const registry = new Map() diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index edd9d75610..abb5af7f6a 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -890,7 +890,7 @@ export const GithubRunCommand = cmd({ } let text = "" - Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { + Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => { if (evt.properties.part.sessionID !== session.id) return //if (evt.properties.part.messageID === messageID) return const part = evt.properties.part diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8aabafd080..84050d2202 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -710,7 +710,7 @@ function App(props: { onSnapshot?: () => Promise }) { }) }) - sdk.event.on(SessionApi.Event.Deleted.type, (evt) => { + sdk.event.on("session.deleted", (evt) => { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { route.navigate({ type: "home" }) toast.show({ @@ -720,7 +720,7 @@ function App(props: { onSnapshot?: () => Promise }) { } }) - sdk.event.on(SessionApi.Event.Error.type, (evt) => { + sdk.event.on("session.error", (evt) => { const error = evt.properties.error if (error && typeof error === "object" && error.name === "MessageAbortedError") return const message = (() => { diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 6673297cbf..9e324962bf 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -3,6 +3,7 @@ import { randomBytes } from "crypto" export namespace Identifier { const prefixes = { + event: "evt", session: "ses", message: "msg", permission: "per", diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 5cd3790784..09e991c5a4 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -3,7 +3,6 @@ import { Config } from "../config/config" import { Bus } from "../bus" import { Log } from "../util/log" import { createOpencodeClient } from "@opencode-ai/sdk" -import { Server } from "../server/server" import { BunProc } from "../bun" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" @@ -58,6 +57,8 @@ export namespace Plugin { const hooks: Hooks[] = [] yield* Effect.promise(async () => { + const { Server } = await import("../server/server") + const client = createOpencodeClient({ baseUrl: "http://localhost:4096", directory: ctx.directory, diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts new file mode 100644 index 0000000000..eb85a8017f --- /dev/null +++ b/packages/opencode/src/server/projectors.ts @@ -0,0 +1,28 @@ +import z from "zod" +import sessionProjectors from "../session/projectors" +import { SyncEvent } from "@/sync" +import { Session } from "@/session" +import { SessionTable } from "@/session/session.sql" +import { Database, eq } from "@/storage/db" + +export function initProjectors() { + SyncEvent.init({ + projectors: sessionProjectors, + convertEvent: (type, data) => { + if (type === "session.updated") { + const id = (data as z.infer).sessionID + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + + if (!row) return data + + return { + sessionID: id, + info: Session.fromRow(row), + } + } + return data + }, + }) +} + +initProjectors() diff --git a/packages/opencode/src/server/routes/event.ts b/packages/opencode/src/server/routes/event.ts index f34ff05667..178da59949 100644 --- a/packages/opencode/src/server/routes/event.ts +++ b/packages/opencode/src/server/routes/event.ts @@ -6,7 +6,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { lazy } from "../../util/lazy" import { AsyncQueue } from "../../util/queue" -import { Instance } from "@/project/instance" const log = Log.create({ service: "server" }) @@ -53,13 +52,6 @@ export const EventRoutes = lazy(() => ) }, 10_000) - const unsub = Bus.subscribeAll((event) => { - q.push(JSON.stringify(event)) - if (event.type === Bus.InstanceDisposed.type) { - stop() - } - }) - const stop = () => { if (done) return done = true @@ -69,6 +61,13 @@ export const EventRoutes = lazy(() => log.info("event disconnected") } + const unsub = Bus.subscribeAll((event) => { + q.push(JSON.stringify(event)) + if (event.type === Bus.InstanceDisposed.type) { + stop() + } + }) + stream.onAbort(stop) try { diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 4dd30db2af..88f54f844a 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -1,9 +1,9 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" +import { Hono, type Context } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" import { streamSSE } from "hono/streaming" import z from "zod" -import { Bus } from "../../bus" import { BusEvent } from "@/bus/bus-event" +import { SyncEvent } from "@/sync" import { GlobalBus } from "@/bus/global" import { AsyncQueue } from "@/util/queue" import { Instance } from "../../project/instance" @@ -17,6 +17,56 @@ const log = Log.create({ service: "server" }) export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({})) +async function streamEvents(c: Context, subscribe: (q: AsyncQueue) => () => void) { + return streamSSE(c, async (stream) => { + const q = new AsyncQueue() + let done = false + + q.push( + JSON.stringify({ + payload: { + type: "server.connected", + properties: {}, + }, + }), + ) + + // Send heartbeat every 10s to prevent stalled proxy streams. + const heartbeat = setInterval(() => { + q.push( + JSON.stringify({ + payload: { + type: "server.heartbeat", + properties: {}, + }, + }), + ) + }, 10_000) + + const stop = () => { + if (done) return + done = true + clearInterval(heartbeat) + unsub() + q.push(null) + log.info("global event disconnected") + } + + const unsub = subscribe(q) + + stream.onAbort(stop) + + try { + for await (const data of q) { + if (data === null) return + await stream.writeSSE({ data }) + } + } finally { + stop() + } + }) +} + export const GlobalRoutes = lazy(() => new Hono() .get( @@ -70,55 +120,58 @@ export const GlobalRoutes = lazy(() => log.info("global event connected") c.header("X-Accel-Buffering", "no") c.header("X-Content-Type-Options", "nosniff") - return streamSSE(c, async (stream) => { - const q = new AsyncQueue() - let done = false - - q.push( - JSON.stringify({ - payload: { - type: "server.connected", - properties: {}, - }, - }), - ) - - // Send heartbeat every 10s to prevent stalled proxy streams. - const heartbeat = setInterval(() => { - q.push( - JSON.stringify({ - payload: { - type: "server.heartbeat", - properties: {}, - }, - }), - ) - }, 10_000) + return streamEvents(c, (q) => { async function handler(event: any) { q.push(JSON.stringify(event)) } GlobalBus.on("event", handler) - - const stop = () => { - if (done) return - done = true - clearInterval(heartbeat) - GlobalBus.off("event", handler) - q.push(null) - log.info("event disconnected") - } - - stream.onAbort(stop) - - try { - for await (const data of q) { - if (data === null) return - await stream.writeSSE({ data }) - } - } finally { - stop() - } + return () => GlobalBus.off("event", handler) + }) + }, + ) + .get( + "/sync-event", + describeRoute({ + summary: "Subscribe to global sync events", + description: "Get global sync events", + operationId: "global.sync-event.subscribe", + responses: { + 200: { + description: "Event stream", + content: { + "text/event-stream": { + schema: resolver( + z + .object({ + payload: SyncEvent.payloads(), + }) + .meta({ + ref: "SyncEvent", + }), + ), + }, + }, + }, + }, + }), + async (c) => { + log.info("global sync event connected") + c.header("X-Accel-Buffering", "no") + c.header("X-Content-Type-Options", "nosniff") + return streamEvents(c, (q) => { + return SyncEvent.subscribeAll(({ def, event }) => { + // TODO: don't pass def, just pass the type (and it should + // be versioned) + q.push( + JSON.stringify({ + payload: { + ...event, + type: SyncEvent.versionedType(def.type, def.version), + }, + }), + ) + }) }) }, ) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 3c9ebfdc5e..d499e5a1ec 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -281,14 +281,14 @@ export const SessionRoutes = lazy(() => const sessionID = c.req.valid("param").sessionID const updates = c.req.valid("json") - let session = await Session.get(sessionID) if (updates.title !== undefined) { - session = await Session.setTitle({ sessionID, title: updates.title }) + await Session.setTitle({ sessionID, title: updates.title }) } if (updates.time?.archived !== undefined) { - session = await Session.setArchived({ sessionID, time: updates.time.archived }) + await Session.setArchived({ sessionID, time: updates.time.archived }) } + const session = await Session.get(sessionID) return c.json(session) }, ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e4c98c609e..d260714d48 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -44,6 +44,7 @@ import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" +import { initProjectors } from "./projectors" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -51,6 +52,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false const csp = (hash = "") => `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` +initProjectors() + export namespace Server { const log = Log.create({ service: "server" }) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index f2d436ff10..a379cd228c 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -9,12 +9,14 @@ import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Installation } from "../installation" -import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db" +import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db" +import { SyncEvent } from "../sync" import type { SQL } from "../storage/db" -import { SessionTable, MessageTable, PartTable } from "./session.sql" +import { SessionTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" import { Log } from "../util/log" +import { updateSchema } from "../util/update-schema" import { MessageV2 } from "./message-v2" import { Instance } from "../project/instance" import { SessionPrompt } from "./prompt" @@ -182,24 +184,40 @@ export namespace Session { export type GlobalInfo = z.output export const Event = { - Created: BusEvent.define( - "session.created", - z.object({ + Created: SyncEvent.define({ + type: "session.created", + version: 1, + aggregate: "sessionID", + schema: z.object({ + sessionID: SessionID.zod, info: Info, }), - ), - Updated: BusEvent.define( - "session.updated", - z.object({ + }), + Updated: SyncEvent.define({ + type: "session.updated", + version: 1, + aggregate: "sessionID", + schema: z.object({ + sessionID: SessionID.zod, + info: updateSchema(Info).extend({ + share: updateSchema(Info.shape.share.unwrap()).optional(), + time: updateSchema(Info.shape.time).optional(), + }), + }), + busSchema: z.object({ + sessionID: SessionID.zod, info: Info, }), - ), - Deleted: BusEvent.define( - "session.deleted", - z.object({ + }), + Deleted: SyncEvent.define({ + type: "session.deleted", + version: 1, + aggregate: "sessionID", + schema: z.object({ + sessionID: SessionID.zod, info: Info, }), - ), + }), Diff: BusEvent.define( "session.diff", z.object({ @@ -280,18 +298,8 @@ export namespace Session { ) export const touch = fn(SessionID.zod, async (sessionID) => { - const now = Date.now() - Database.use((db) => { - const row = db - .update(SessionTable) - .set({ time_updated: now }) - .where(eq(SessionTable.id, sessionID)) - .returning() - .get() - if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` }) - const info = fromRow(row) - Database.effect(() => Bus.publish(Event.Updated, { info })) - }) + const time = Date.now() + SyncEvent.run(Event.Updated, { sessionID, info: { time: { updated: time } } }) }) export async function createNext(input: { @@ -318,22 +326,25 @@ export namespace Session { }, } log.info("created", result) - Database.use((db) => { - db.insert(SessionTable).values(toRow(result)).run() - Database.effect(() => - Bus.publish(Event.Created, { - info: result, - }), - ) - }) + + SyncEvent.run(Event.Created, { sessionID: result.id, info: result }) + const cfg = await Config.get() - if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) + if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) { share(result.id).catch(() => { // Silently ignore sharing errors during session creation }) - Bus.publish(Event.Updated, { - info: result, - }) + } + + if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + // This only exist for backwards compatibility. We should not be + // manually publishing this event; it is a sync event now + Bus.publish(Event.Updated, { + sessionID: result.id, + info: result, + }) + } + return result } @@ -357,12 +368,9 @@ export namespace Session { } const { ShareNext } = await import("@/share/share-next") const share = await ShareNext.create(id) - Database.use((db) => { - const row = db.update(SessionTable).set({ share_url: share.url }).where(eq(SessionTable.id, id)).returning().get() - if (!row) throw new NotFoundError({ message: `Session not found: ${id}` }) - const info = fromRow(row) - Database.effect(() => Bus.publish(Event.Updated, { info })) - }) + + SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: share.url } } }) + return share }) @@ -370,12 +378,8 @@ export namespace Session { // Use ShareNext to remove the share (same as share function uses ShareNext to create) const { ShareNext } = await import("@/share/share-next") await ShareNext.remove(id) - Database.use((db) => { - const row = db.update(SessionTable).set({ share_url: null }).where(eq(SessionTable.id, id)).returning().get() - if (!row) throw new NotFoundError({ message: `Session not found: ${id}` }) - const info = fromRow(row) - Database.effect(() => Bus.publish(Event.Updated, { info })) - }) + + SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } }) }) export const setTitle = fn( @@ -384,18 +388,7 @@ export namespace Session { title: z.string(), }), async (input) => { - return Database.use((db) => { - const row = db - .update(SessionTable) - .set({ title: input.title }) - .where(eq(SessionTable.id, input.sessionID)) - .returning() - .get() - if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` }) - const info = fromRow(row) - Database.effect(() => Bus.publish(Event.Updated, { info })) - return info - }) + SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { title: input.title } }) }, ) @@ -405,18 +398,7 @@ export namespace Session { time: z.number().optional(), }), async (input) => { - return Database.use((db) => { - const row = db - .update(SessionTable) - .set({ time_archived: input.time }) - .where(eq(SessionTable.id, input.sessionID)) - .returning() - .get() - if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` }) - const info = fromRow(row) - Database.effect(() => Bus.publish(Event.Updated, { info })) - return info - }) + SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { time: { archived: input.time } } }) }, ) @@ -426,17 +408,9 @@ export namespace Session { permission: Permission.Ruleset, }), async (input) => { - return Database.use((db) => { - const row = db - .update(SessionTable) - .set({ permission: input.permission, time_updated: Date.now() }) - .where(eq(SessionTable.id, input.sessionID)) - .returning() - .get() - if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` }) - const info = fromRow(row) - Database.effect(() => Bus.publish(Event.Updated, { info })) - return info + SyncEvent.run(Event.Updated, { + sessionID: input.sessionID, + info: { permission: input.permission, time: { updated: Date.now() } }, }) }, ) @@ -448,42 +422,24 @@ export namespace Session { summary: Info.shape.summary, }), async (input) => { - return Database.use((db) => { - const row = db - .update(SessionTable) - .set({ - revert: input.revert ?? null, - summary_additions: input.summary?.additions, - summary_deletions: input.summary?.deletions, - summary_files: input.summary?.files, - time_updated: Date.now(), - }) - .where(eq(SessionTable.id, input.sessionID)) - .returning() - .get() - if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` }) - const info = fromRow(row) - Database.effect(() => Bus.publish(Event.Updated, { info })) - return info + SyncEvent.run(Event.Updated, { + sessionID: input.sessionID, + info: { + summary: input.summary, + time: { updated: Date.now() }, + revert: input.revert, + }, }) }, ) export const clearRevert = fn(SessionID.zod, async (sessionID) => { - return Database.use((db) => { - const row = db - .update(SessionTable) - .set({ - revert: null, - time_updated: Date.now(), - }) - .where(eq(SessionTable.id, sessionID)) - .returning() - .get() - if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` }) - const info = fromRow(row) - Database.effect(() => Bus.publish(Event.Updated, { info })) - return info + SyncEvent.run(Event.Updated, { + sessionID, + info: { + time: { updated: Date.now() }, + revert: null, + }, }) }) @@ -493,22 +449,12 @@ export namespace Session { summary: Info.shape.summary, }), async (input) => { - return Database.use((db) => { - const row = db - .update(SessionTable) - .set({ - summary_additions: input.summary?.additions, - summary_deletions: input.summary?.deletions, - summary_files: input.summary?.files, - time_updated: Date.now(), - }) - .where(eq(SessionTable.id, input.sessionID)) - .returning() - .get() - if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` }) - const info = fromRow(row) - Database.effect(() => Bus.publish(Event.Updated, { info })) - return info + SyncEvent.run(Event.Updated, { + sessionID: input.sessionID, + info: { + time: { updated: Date.now() }, + summary: input.summary, + }, }) }, ) @@ -662,46 +608,28 @@ export namespace Session { }) export const remove = fn(SessionID.zod, async (sessionID) => { - const project = Instance.project try { const session = await get(sessionID) for (const child of await children(sessionID)) { await remove(child.id) } await unshare(sessionID).catch(() => {}) - // CASCADE delete handles messages and parts automatically - Database.use((db) => { - db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run() - Database.effect(() => - Bus.publish(Event.Deleted, { - info: session, - }), - ) - }) + + SyncEvent.run(Event.Deleted, { sessionID, info: session }) + + // Eagerly remove event sourcing data to free up space + SyncEvent.remove(sessionID) } catch (e) { log.error(e) } }) export const updateMessage = fn(MessageV2.Info, async (msg) => { - const time_created = msg.time.created - const { id, sessionID, ...data } = msg - Database.use((db) => { - db.insert(MessageTable) - .values({ - id, - session_id: sessionID, - time_created, - data, - }) - .onConflictDoUpdate({ target: MessageTable.id, set: { data } }) - .run() - Database.effect(() => - Bus.publish(MessageV2.Event.Updated, { - info: msg, - }), - ) + SyncEvent.run(MessageV2.Event.Updated, { + sessionID: msg.sessionID, + info: msg, }) + return msg }) @@ -711,17 +639,9 @@ export namespace Session { messageID: MessageID.zod, }), async (input) => { - // CASCADE delete handles parts automatically - Database.use((db) => { - db.delete(MessageTable) - .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID))) - .run() - Database.effect(() => - Bus.publish(MessageV2.Event.Removed, { - sessionID: input.sessionID, - messageID: input.messageID, - }), - ) + SyncEvent.run(MessageV2.Event.Removed, { + sessionID: input.sessionID, + messageID: input.messageID, }) return input.messageID }, @@ -734,17 +654,10 @@ export namespace Session { partID: PartID.zod, }), async (input) => { - Database.use((db) => { - db.delete(PartTable) - .where(and(eq(PartTable.id, input.partID), eq(PartTable.session_id, input.sessionID))) - .run() - Database.effect(() => - Bus.publish(MessageV2.Event.PartRemoved, { - sessionID: input.sessionID, - messageID: input.messageID, - partID: input.partID, - }), - ) + SyncEvent.run(MessageV2.Event.PartRemoved, { + sessionID: input.sessionID, + messageID: input.messageID, + partID: input.partID, }) return input.partID }, @@ -753,24 +666,10 @@ export namespace Session { const UpdatePartInput = MessageV2.Part export const updatePart = fn(UpdatePartInput, async (part) => { - const { id, messageID, sessionID, ...data } = part - const time = Date.now() - Database.use((db) => { - db.insert(PartTable) - .values({ - id, - message_id: messageID, - session_id: sessionID, - time_created: time, - data, - }) - .onConflictDoUpdate({ target: PartTable.id, set: { data } }) - .run() - Database.effect(() => - Bus.publish(MessageV2.Event.PartUpdated, { - part: structuredClone(part), - }), - ) + SyncEvent.run(MessageV2.Event.PartUpdated, { + sessionID: part.sessionID, + part: structuredClone(part), + time: Date.now(), }) return part }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index f1335f6f21..d909106507 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -6,11 +6,9 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" import { fn } from "@/util/fn" +import { SyncEvent } from "../sync" import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db" import { MessageTable, PartTable, SessionTable } from "./session.sql" -import { ProviderTransform } from "@/provider/transform" -import { STATUS_CODES } from "http" -import { Storage } from "@/storage/storage" import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" import type { SystemError } from "bun" @@ -449,25 +447,34 @@ export namespace MessageV2 { export type Info = z.infer export const Event = { - Updated: BusEvent.define( - "message.updated", - z.object({ + Updated: SyncEvent.define({ + type: "message.updated", + version: 1, + aggregate: "sessionID", + schema: z.object({ + sessionID: SessionID.zod, info: Info, }), - ), - Removed: BusEvent.define( - "message.removed", - z.object({ + }), + Removed: SyncEvent.define({ + type: "message.removed", + version: 1, + aggregate: "sessionID", + schema: z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, }), - ), - PartUpdated: BusEvent.define( - "message.part.updated", - z.object({ + }), + PartUpdated: SyncEvent.define({ + type: "message.part.updated", + version: 1, + aggregate: "sessionID", + schema: z.object({ + sessionID: SessionID.zod, part: Part, + time: z.number(), }), - ), + }), PartDelta: BusEvent.define( "message.part.delta", z.object({ @@ -478,14 +485,16 @@ export namespace MessageV2 { delta: z.string(), }), ), - PartRemoved: BusEvent.define( - "message.part.removed", - z.object({ + PartRemoved: SyncEvent.define({ + type: "message.part.removed", + version: 1, + aggregate: "sessionID", + schema: z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod, }), - ), + }), } export const WithParts = z.object({ diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts new file mode 100644 index 0000000000..61aa68d24a --- /dev/null +++ b/packages/opencode/src/session/projectors.ts @@ -0,0 +1,116 @@ +import { NotFoundError, eq, and } from "../storage/db" +import { SyncEvent } from "@/sync" +import { Session } from "./index" +import { MessageV2 } from "./message-v2" +import { SessionTable, MessageTable, PartTable } from "./session.sql" +import { ProjectTable } from "../project/project.sql" + +export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial | null } : T + +function grab( + obj: T, + field1: K1, + cb?: (val: NonNullable) => X, +): X | undefined { + if (obj == undefined || !(field1 in obj)) return undefined + + const val = obj[field1] + if (val && typeof val === "object" && cb) { + return cb(val) + } + if (val === undefined) { + throw new Error( + "Session update failure: pass `null` to clear a field instead of `undefined`: " + JSON.stringify(obj), + ) + } + return val as X | undefined +} + +export function toPartialRow(info: DeepPartial) { + const obj = { + id: grab(info, "id"), + project_id: grab(info, "projectID"), + workspace_id: grab(info, "workspaceID"), + parent_id: grab(info, "parentID"), + slug: grab(info, "slug"), + directory: grab(info, "directory"), + title: grab(info, "title"), + version: grab(info, "version"), + share_url: grab(info, "share", (v) => grab(v, "url")), + summary_additions: grab(info, "summary", (v) => grab(v, "additions")), + summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")), + summary_files: grab(info, "summary", (v) => grab(v, "files")), + summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")), + revert: grab(info, "revert"), + permission: grab(info, "permission"), + time_created: grab(info, "time", (v) => grab(v, "created")), + time_updated: grab(info, "time", (v) => grab(v, "updated")), + time_compacting: grab(info, "time", (v) => grab(v, "compacting")), + time_archived: grab(info, "time", (v) => grab(v, "archived")), + } + + return Object.fromEntries(Object.entries(obj).filter(([_, val]) => val !== undefined)) +} + +export default [ + SyncEvent.project(Session.Event.Created, (db, data) => { + db.insert(SessionTable).values(Session.toRow(data.info)).run() + }), + + SyncEvent.project(Session.Event.Updated, (db, data) => { + const info = data.info + const row = db + .update(SessionTable) + .set(toPartialRow(info)) + .where(eq(SessionTable.id, data.sessionID)) + .returning() + .get() + if (!row) throw new NotFoundError({ message: `Session not found: ${data.sessionID}` }) + }), + + SyncEvent.project(Session.Event.Deleted, (db, data) => { + db.delete(SessionTable).where(eq(SessionTable.id, data.sessionID)).run() + }), + + SyncEvent.project(MessageV2.Event.Updated, (db, data) => { + const time_created = data.info.time.created + const { id, sessionID, ...rest } = data.info + + db.insert(MessageTable) + .values({ + id, + session_id: sessionID, + time_created, + data: rest, + }) + .onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } }) + .run() + }), + + SyncEvent.project(MessageV2.Event.Removed, (db, data) => { + db.delete(MessageTable) + .where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID))) + .run() + }), + + SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => { + db.delete(PartTable) + .where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID))) + .run() + }), + + SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => { + const { id, messageID, sessionID, ...rest } = data.part + + db.insert(PartTable) + .values({ + id, + message_id: messageID, + session_id: sessionID, + time_created: data.time, + data: rest, + }) + .onConflictDoUpdate({ target: PartTable.id, set: { data: rest } }) + .run() + }), +] diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index c5c9edbbdf..6df8b3d53f 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -4,8 +4,7 @@ import { Snapshot } from "../snapshot" import { MessageV2 } from "./message-v2" import { Session } from "." import { Log } from "../util/log" -import { Database, eq } from "../storage/db" -import { MessageTable, PartTable } from "./session.sql" +import { SyncEvent } from "../sync" import { Storage } from "@/storage/storage" import { Bus } from "../bus" import { SessionPrompt } from "./prompt" @@ -113,8 +112,10 @@ export namespace SessionRevert { remove.push(msg) } for (const msg of remove) { - Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run()) - await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id }) + SyncEvent.run(MessageV2.Event.Removed, { + sessionID: sessionID, + messageID: msg.info.id, + }) } if (session.revert.partID && target) { const partID = session.revert.partID @@ -124,8 +125,7 @@ export namespace SessionRevert { const removeParts = target.parts.slice(removeStart) target.parts = preserveParts for (const part of removeParts) { - Database.use((db) => db.delete(PartTable).where(eq(PartTable.id, part.id)).run()) - await Bus.publish(MessageV2.Event.PartRemoved, { + SyncEvent.run(MessageV2.Event.PartRemoved, { sessionID: sessionID, messageID: target.info.id, partID: part.id, diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index e331e8fc6a..2a11094f80 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -66,29 +66,28 @@ export namespace ShareNext { export async function init() { if (disabled) return Bus.subscribe(Session.Event.Updated, async (evt) => { - await sync(evt.properties.info.id, [ + const session = await Session.get(evt.properties.sessionID) + + await sync(session.id, [ { type: "session", - data: evt.properties.info, + data: session, }, ]) }) Bus.subscribe(MessageV2.Event.Updated, async (evt) => { - await sync(evt.properties.info.sessionID, [ + const info = evt.properties.info + await sync(info.sessionID, [ { type: "message", data: evt.properties.info, }, ]) - if (evt.properties.info.role === "user") { - await sync(evt.properties.info.sessionID, [ + if (info.role === "user") { + await sync(info.sessionID, [ { type: "model", - data: [ - await Provider.getModel(evt.properties.info.model.providerID, evt.properties.info.model.modelID).then( - (m) => m, - ), - ], + data: [await Provider.getModel(info.model.providerID, info.model.modelID).then((m) => m)], }, ]) } diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 1bb8c1a69b..f41a1ecd85 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -27,16 +27,20 @@ export const NotFoundError = NamedError.create( const log = Log.create({ service: "db" }) export namespace Database { - export const Path = iife(() => { - if (Flag.OPENCODE_DB) { - if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB - return path.join(Global.Path.data, Flag.OPENCODE_DB) - } + export function getChannelPath() { const channel = Installation.CHANNEL if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB) return path.join(Global.Path.data, "opencode.db") const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-") return path.join(Global.Path.data, `opencode-${safe}.db`) + } + + export const Path = iife(() => { + if (Flag.OPENCODE_DB) { + if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB + return path.join(Global.Path.data, Flag.OPENCODE_DB) + } + return getChannelPath() }) export type Transaction = SQLiteTransaction<"sync", void> @@ -145,17 +149,27 @@ export namespace Database { } } - export function transaction(callback: (tx: TxOrDb) => T): T { + type NotPromise = T extends Promise ? never : T + + export function transaction( + callback: (tx: TxOrDb) => NotPromise, + options?: { + behavior?: "deferred" | "immediate" | "exclusive" + }, + ): NotPromise { try { return callback(ctx.use().tx) } catch (err) { if (err instanceof Context.NotFound) { const effects: (() => void | Promise)[] = [] - const result = (Client().transaction as any)((tx: TxOrDb) => { - return ctx.provide({ tx, effects }, () => callback(tx)) - }) + const result = Client().transaction( + (tx: TxOrDb) => { + return ctx.provide({ tx, effects }, () => callback(tx)) + }, + { behavior: options?.behavior }, + ) for (const effect of effects) effect() - return result + return result as NotPromise } throw err } diff --git a/packages/opencode/src/sync/README.md b/packages/opencode/src/sync/README.md new file mode 100644 index 0000000000..b59db52606 --- /dev/null +++ b/packages/opencode/src/sync/README.md @@ -0,0 +1,181 @@ + + +tl;dr All of these APIs work, are properly type-checked, and are sync events are backwards compatible with `Bus`: + +```ts +// The schema from `Updated` typechecks the object correctly +SyncEvent.run(Updated, { sessionID: id, info: { title: "foo"} }) + +// `subscribeAll` passes a generic sync event +SyncEvent.subscribeAll(event => { + // These will be type-checked correctly + event.id + event.seq + // This will be unknown because we are listening for all events, + // and this API is only used to record them + event.data +}) + +// This works, but you shouldn't publish sync event like this (should fail in the future) +Bus.publish(Updated, { sessionID: id, info: { title: "foo"} }) + +// Update event is fully type-checked +Bus.subscribe(Updated, event => event.properties.info.title) + +// Update event is fully type-checked +client.subscribe("session.updated", evt => evt.properties.info.title) +``` + +# Goal + +## Syncing with only one writer + +This system defines a basic event sourcing system for session replayability. The goal is to allow for one device to control and modify the session, and allow multiple other devices to "sync" session data. The sync works by getting a log of events to replay and replaying them locally. + +Because only one device is allowed to write, we don't need any kind of sophisticated distributed system clocks or causal ordering. We implement total ordering with a simple sequence id (a number) and increment it by one every time we generate an event. + +## Bus event integration and backwards compatibility + +This initial implementation aims to be fully backwards compatible. We should be able to land this without any visible changes to the user. + +An existing `Bus` abstraction to send events already exists. We already send events like `session.created` through the system. We should not duplicate this. + +The difference in event sourcing is events are sent _before_ the mutation happens, and "projectors" handle the effects and perform the mutations. This difference is subtle, and a necessary change for syncing to work. + +So the goal is: + +- Introduce a new syncing abstraction to handle event sourcing and projectors +- Seamlessly integrate these new events into the same existing `Bus` abstraction +- Maintain full backwards compatibility to reduce risk + +## My approach + +This directory introduces a new abstraction: `SyncEvent`. This handles all of the event sourcing. + +There are now "sync events" which are different than "bus events". Bus events are defined like this: + +```ts +const Diff = BusEvent.define( + "session.diff", + z.object({ + sessionID: SessionID.zod, + diff: Snapshot.FileDiff.array(), + }), +) +``` + +You can do `Bus.publish(Diff, { ... })` to push these events, and `Bus.subscribe(Diff, handler)` to listen to them. + +Sync events are a lower-level abstraction which are similar, but also handle the requirements for recording and replaying. Defining them looks like this: + +```ts +const Created = SyncEvent.define({ + type: "session.created", + version: 1, + aggregate: "sessionID", + schema: z.object({ + sessionID: SessionID.zod, + info: Info, + }), +}) +``` + +Not too different, except they track a version and an "aggregate" field (will explain that later). + +You do this to run an event, which is kind of like `Bus.publish` except that it runs through the event sourcing system: + +``` +SyncEvent.run(Created, { ... }) +``` + +The data passed as the second argument is properly type-checked based on the schema defined in `Created`. + +Importantly, **sync events automatically re-publish as bus events**. This makes them backwards compatible, and allows the `Bus` to still be the single abstraction that the system uses to listen for individual events. + +**We have upgraded many of the session events to be sync events** (all of the ones that mutate the db). Sync and bus events are largely compatible. Here are the differences: + +### Event shape + +- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event throught the bus. + +The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types. + +### Event flow + +There is no way to subscribe to individual sync events in `SyncEvent`. You can use `subscribeAll` to receive _all_ of the events, which is needed for clients that want to record them. + +To listen for individual events, use `Bus.subscribe`. You can pass in a sync event definition to it: `Bus.subscribe(Created, handler)`. This is fully supported. + +You should never "publish" a sync event however: `Bus.publish(Created, ...)`. I would like to force this to be a type error in the future. You should never be touching the db directly, and should not be manually handling these events. + +### Backwards compatibility + +The system install projectors in `server/projectors.js`. It calls `SyncEvent.init` to do this. It also installs a hook for dynamically converting an event at runtime (`convertEvent`). + +This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat. + +The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync even only contains the fields updated. We convert the event to contain to full object for backwards compatibility (but ideally we'd remove this). + +It's very important that types are correct when working with events. Event definitions have a `schema` which carries the defintiion of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples: + +```ts +// The schema from `Updated` typechecks the object correctly +SyncEvent.run(Updated, { sessionID: id, info: { title: "foo"} }) + +// `subscribeAll` passes a generic sync event +SyncEvent.subscribeAll(event => { + // These will be type-checked correctly + event.id + event.seq + // This will be unknown because we are listening for all events, + // and this API is only used to record them + event.data +}) + +// This works, but you shouldn't publish sync event like this (should fail in the future) +Bus.publish(Updated, { sessionID: id, info: { title: "foo"} }) + +// Update event is fully type-checked +Bus.subscribe(Updated, event => event.properties.info.title) + +// Update event is fully type-checked +client.subscribe("session.updated", evt => evt.properties.info.title) +``` + +The last two examples look similar to `SyncEvent.run`, but they were the cause of a lot of grief. Those are existing APIs that we can't break, but we are passing in the new sync event definitions to these APIs, which sometimes have a different event shape. + +I previously mentioned the runtime conversion of events, but we still need to the types to work! To do that, the `define` API supports an optional `busSchema` prop to give it the schema for backwards compatibility. For example this is the full definition of `Session.Update`: + +```ts +const Update = SyncEvent.define({ + type: "session.updated", + version: 1, + aggregate: "sessionID", + schema: z.object({ + sessionID: SessionID.zod, + info: partialSchema(Info) + }), + busSchema: z.object({ + sessionID: SessionID.zod, + info: Info, + }), +}) +``` + +*Important*: the conversion done in `convertEvent` is not automatically type-checked with `busSchema`. It's very important they match, but because we need this at type-checking time this needs to live here. + +Internally, the way this works is `busSchema` is stored on a `properties` field which is what the bus system expects. Doing this made everything with `Bus` "just work". This is why you can pass a sync event to the bus APIs. + +*Alternatives* + +These are some other paths I explored: + +* Providing a way to subscribe to individual sync events, and change all the instances of `Bus.subscribe` in our code to it. Then you are directly only working with sync events always. + * Two big problems. First, `Bus` is instance-scoped, and we'd need to make the sync event system instance-scoped too for backwards compat. If we didn't, those listeners would get calls for events they weren't expecting. + * Second, we can't change consumers of our SDK. So they still have to use the old events, and we might as well stick with them for consistency +* Directly add sync event support to bus system + * I explored adding sync events to the bus, but due to backwards compat, it only made it more complicated (still need to support both shapes) +* I explored a `convertSchema` function to convert the event schema at runtime so we didn't need `busSchema` + * Fatal flaw: we need type-checking done earlier. We can't do this at run-time. This worked for consumers of our SDK (because it gets generated TS types from the converted schema) but breaks for our internal usage of `Bus.subscribe` calls + +I explored many other permutations of the above solutions. What we have today I think is the best balance of backwards compatibility while opening a path forward for the new events. \ No newline at end of file diff --git a/packages/opencode/src/sync/event.sql.ts b/packages/opencode/src/sync/event.sql.ts new file mode 100644 index 0000000000..b51b5a5dfe --- /dev/null +++ b/packages/opencode/src/sync/event.sql.ts @@ -0,0 +1,16 @@ +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" + +export const EventSequenceTable = sqliteTable("event_sequence", { + aggregate_id: text().notNull().primaryKey(), + seq: integer().notNull(), +}) + +export const EventTable = sqliteTable("event", { + id: text().primaryKey(), + aggregate_id: text() + .notNull() + .references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }), + seq: integer().notNull(), + type: text().notNull(), + data: text({ mode: "json" }).$type>().notNull(), +}) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts new file mode 100644 index 0000000000..270950fd4b --- /dev/null +++ b/packages/opencode/src/sync/index.ts @@ -0,0 +1,263 @@ +import z from "zod" +import type { ZodObject } from "zod" +import { EventEmitter } from "events" +import { Database, eq } from "@/storage/db" +import { Bus as ProjectBus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { EventSequenceTable, EventTable } from "./event.sql" +import { EventID } from "./schema" +import { Flag } from "@/flag/flag" + +export namespace SyncEvent { + export type Definition = { + type: string + version: number + aggregate: string + schema: z.ZodObject + + // This is temporary and only exists for compatibility with bus + // event definitions + properties: z.ZodObject + } + + export type Event = { + id: string + seq: number + aggregateID: string + data: z.infer + } + + export type SerializedEvent = Event & { type: string } + + type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void + + export const registry = new Map() + let projectors: Map | undefined + const versions = new Map() + let frozen = false + let convertEvent: (type: string, event: Event["data"]) => Promise> | Record + + const Bus = new EventEmitter<{ event: [{ def: Definition; event: Event }] }>() + + export function reset() { + frozen = false + projectors = undefined + convertEvent = (_, data) => data + } + + export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) { + projectors = new Map(input.projectors) + + // Install all the latest event defs to the bus. We only ever emit + // latest versions from code, and keep around old versions for + // replaying. Replaying does not go through the bus, and it + // simplifies the bus to only use unversioned latest events + for (let [type, version] of versions.entries()) { + let def = registry.get(versionedType(type, version))! + + BusEvent.define(def.type, def.properties || def.schema) + } + + // Freeze the system so it clearly errors if events are defined + // after `init` which would cause bugs + frozen = true + convertEvent = input.convertEvent || ((_, data) => data) + } + + export function versionedType(type: A): A + export function versionedType(type: A, version: B): `${A}/${B}` + export function versionedType(type: string, version?: number) { + return version ? `${type}.${version}` : type + } + + export function define< + Type extends string, + Agg extends string, + Schema extends ZodObject>>, + BusSchema extends ZodObject = Schema, + >(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) { + if (frozen) { + throw new Error("Error defining sync event: sync system has been frozen") + } + + const def = { + type: input.type, + version: input.version, + aggregate: input.aggregate, + schema: input.schema, + properties: input.busSchema ? input.busSchema : input.schema, + } + + versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0)) + + registry.set(versionedType(def.type, def.version), def) + + return def + } + + export function project( + def: Def, + func: (db: Database.TxOrDb, data: Event["data"]) => void, + ): [Definition, ProjectorFunc] { + return [def, func as ProjectorFunc] + } + + function process(def: Def, event: Event, options: { publish: boolean }) { + if (projectors == null) { + throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") + } + + const projector = projectors.get(def) + if (!projector) { + throw new Error(`Projector not found for event: ${def.type}`) + } + + // idempotent: need to ignore any events already logged + + Database.transaction((tx) => { + projector(tx, event.data) + + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + tx.insert(EventSequenceTable) + .values({ + aggregate_id: event.aggregateID, + seq: event.seq, + }) + .onConflictDoUpdate({ + target: EventSequenceTable.aggregate_id, + set: { seq: event.seq }, + }) + .run() + tx.insert(EventTable) + .values({ + id: event.id, + seq: event.seq, + aggregate_id: event.aggregateID, + type: versionedType(def.type, def.version), + data: event.data as Record, + }) + .run() + } + + Database.effect(() => { + Bus.emit("event", { + def, + event, + }) + + if (options?.publish) { + const result = convertEvent(def.type, event.data) + if (result instanceof Promise) { + result.then((data) => { + ProjectBus.publish({ type: def.type, properties: def.schema }, data) + }) + } else { + ProjectBus.publish({ type: def.type, properties: def.schema }, result) + } + } + }) + }) + } + + // TODO: + // + // * Support applying multiple events at one time. One transaction, + // and it validets all the sequence ids + // * when loading events from db, apply zod validation to ensure shape + + export function replay(event: SerializedEvent, options?: { republish: boolean }) { + const def = registry.get(event.type) + if (!def) { + throw new Error(`Unknown event type: ${event.type}`) + } + + const row = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) + .get(), + ) + + const latest = row?.seq ?? -1 + if (event.seq <= latest) { + return + } + + const expected = latest + 1 + if (event.seq !== expected) { + throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`) + } + + process(def, event, { publish: !!options?.republish }) + } + + export function run(def: Def, data: Event["data"]) { + const agg = (data as Record)[def.aggregate] + // This should never happen: we've enforced it via typescript in + // the definition + if (agg == null) { + throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`) + } + + if (def.version !== versions.get(def.type)) { + throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`) + } + + // Note that this is an "immediate" transaction which is critical. + // We need to make sure we can safely read and write with nothing + // else changing the data from under us + Database.transaction( + (tx) => { + const id = EventID.ascending() + const row = tx + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, agg)) + .get() + const seq = row?.seq != null ? row.seq + 1 : 0 + + const event = { id, seq, aggregateID: agg, data } + process(def, event, { publish: true }) + }, + { + behavior: "immediate", + }, + ) + } + + export function remove(aggregateID: string) { + Database.transaction((tx) => { + tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run() + tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run() + }) + } + + export function subscribeAll(handler: (event: { def: Definition; event: Event }) => void) { + Bus.on("event", handler) + return () => Bus.off("event", handler) + } + + export function payloads() { + return z + .union( + registry + .entries() + .map(([type, def]) => { + return z + .object({ + type: z.literal(type), + aggregate: z.literal(def.aggregate), + data: def.schema, + }) + .meta({ + ref: "SyncEvent" + "." + def.type, + }) + }) + .toArray() as any, + ) + .meta({ + ref: "SyncEvent", + }) + } +} diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts new file mode 100644 index 0000000000..8e734e5981 --- /dev/null +++ b/packages/opencode/src/sync/schema.ts @@ -0,0 +1,14 @@ +import { Schema } from "effect" +import z from "zod" + +import { Identifier } from "@/id/id" +import { withStatics } from "@/util/schema" + +export const EventID = Schema.String.pipe( + Schema.brand("EventID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("event", id)), + zod: Identifier.schema("event").pipe(z.custom>()), + })), +) diff --git a/packages/opencode/src/util/update-schema.ts b/packages/opencode/src/util/update-schema.ts new file mode 100644 index 0000000000..f2246ece33 --- /dev/null +++ b/packages/opencode/src/util/update-schema.ts @@ -0,0 +1,13 @@ +import z from "zod" + +export function updateSchema(schema: z.ZodObject) { + const next = {} as { + [K in keyof T]: z.ZodOptional> + } + + for (const [k, v] of Object.entries(schema.required().shape) as [keyof T & string, z.ZodTypeAny][]) { + next[k] = v.nullable() as unknown as (typeof next)[typeof k] + } + + return z.object(next) +} diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 1abf578281..a3ae01c5c1 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -60,6 +60,8 @@ function toolEvent( const payload: EventMessagePartUpdated = { type: "message.part.updated", properties: { + sessionID: sessionId, + time: Date.now(), part: { id: `part_${opts.callID}`, sessionID: sessionId, diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index e253183d8d..0ddc797faf 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -74,11 +74,17 @@ delete process.env["SAMBANOVA_API_KEY"] delete process.env["OPENCODE_SERVER_PASSWORD"] delete process.env["OPENCODE_SERVER_USERNAME"] +// Use in-memory sqlite +process.env["OPENCODE_DB"] = ":memory:" + // Now safe to import from src/ const { Log } = await import("../src/util/log") +const { initProjectors } = await import("../src/server/projectors") Log.init({ print: false, dev: true, level: "DEBUG", }) + +initProjectors() diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 2332586223..0c18f92ba9 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -10,8 +10,8 @@ import { MessageID, PartID } from "../../src/session/schema" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) -describe("session.started event", () => { - test("should emit session.started event when session is created", async () => { +describe("session.created event", () => { + test("should emit session.created event when session is created", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { @@ -41,14 +41,14 @@ describe("session.started event", () => { }) }) - test("session.started event should be emitted before session.updated", async () => { + test("session.created event should be emitted before session.updated", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { const events: string[] = [] - const unsubStarted = Bus.subscribe(Session.Event.Created, () => { - events.push("started") + const unsubCreated = Bus.subscribe(Session.Event.Created, () => { + events.push("created") }) const unsubUpdated = Bus.subscribe(Session.Event.Updated, () => { @@ -59,12 +59,12 @@ describe("session.started event", () => { await new Promise((resolve) => setTimeout(resolve, 100)) - unsubStarted() + unsubCreated() unsubUpdated() - expect(events).toContain("started") + expect(events).toContain("created") expect(events).toContain("updated") - expect(events.indexOf("started")).toBeLessThan(events.indexOf("updated")) + expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated")) await Session.remove(session.id) }, diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts index af5ddec365..f6b6055595 100644 --- a/packages/opencode/test/storage/db.test.ts +++ b/packages/opencode/test/storage/db.test.ts @@ -6,14 +6,9 @@ import { Database } from "../../src/storage/db" describe("Database.Path", () => { test("returns database path for the current channel", () => { - const db = process.env["OPENCODE_DB"] - const expected = db - ? path.isAbsolute(db) - ? db - : path.join(Global.Path.data, db) - : ["latest", "beta"].includes(Installation.CHANNEL) - ? path.join(Global.Path.data, "opencode.db") - : path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) - expect(Database.Path).toBe(expected) + const expected = ["latest", "beta"].includes(Installation.CHANNEL) + ? path.join(Global.Path.data, "opencode.db") + : path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) + expect(Database.getChannelPath()).toBe(expected) }) }) diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts new file mode 100644 index 0000000000..f96750d7d9 --- /dev/null +++ b/packages/opencode/test/sync/index.test.ts @@ -0,0 +1,187 @@ +import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test" +import { tmpdir } from "../fixture/fixture" +import z from "zod" +import { Bus } from "../../src/bus" +import { Instance } from "../../src/project/instance" +import { SyncEvent } from "../../src/sync" +import { Database } from "../../src/storage/db" +import { EventTable } from "../../src/sync/event.sql" +import { Identifier } from "../../src/id/id" +import { Flag } from "../../src/flag/flag" +import { initProjectors } from "../../src/server/projectors" + +const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + +beforeEach(() => { + Database.close() + + // @ts-expect-error don't do this normally, but it works + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true +}) + +afterEach(() => { + // @ts-expect-error don't do this normally, but it works + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original +}) + +function withInstance(fn: () => void | Promise) { + return async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await fn() + }, + }) + } +} + +describe("SyncEvent", () => { + function setup() { + SyncEvent.reset() + + const Created = SyncEvent.define({ + type: "item.created", + version: 1, + aggregate: "id", + schema: z.object({ id: z.string(), name: z.string() }), + }) + const Sent = SyncEvent.define({ + type: "item.sent", + version: 1, + aggregate: "item_id", + schema: z.object({ item_id: z.string(), to: z.string() }), + }) + + SyncEvent.init({ + projectors: [SyncEvent.project(Created, () => {}), SyncEvent.project(Sent, () => {})], + }) + + return { Created, Sent } + } + + afterAll(() => { + SyncEvent.reset() + initProjectors() + }) + + describe("run", () => { + test( + "inserts event row", + withInstance(() => { + const { Created } = setup() + SyncEvent.run(Created, { id: "evt_1", name: "first" }) + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows).toHaveLength(1) + expect(rows[0].type).toBe("item.created.1") + expect(rows[0].aggregate_id).toBe("evt_1") + }), + ) + + test( + "increments seq per aggregate", + withInstance(() => { + const { Created } = setup() + SyncEvent.run(Created, { id: "evt_1", name: "first" }) + SyncEvent.run(Created, { id: "evt_1", name: "second" }) + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows).toHaveLength(2) + expect(rows[1].seq).toBe(rows[0].seq + 1) + }), + ) + + test( + "uses custom aggregate field from agg()", + withInstance(() => { + const { Sent } = setup() + SyncEvent.run(Sent, { item_id: "evt_1", to: "james" }) + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows).toHaveLength(1) + expect(rows[0].aggregate_id).toBe("evt_1") + }), + ) + + test( + "emits events", + withInstance(async () => { + const { Created } = setup() + const events: Array<{ + type: string + properties: { id: string; name: string } + }> = [] + const unsub = Bus.subscribeAll((event) => events.push(event)) + + SyncEvent.run(Created, { id: "evt_1", name: "test" }) + + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ + type: "item.created", + properties: { + id: "evt_1", + name: "test", + }, + }) + + unsub() + }), + ) + }) + + describe("replay", () => { + test( + "inserts event from external payload", + withInstance(() => { + const id = Identifier.descending("message") + SyncEvent.replay({ + id: "evt_1", + type: "item.created.1", + seq: 0, + aggregateID: id, + data: { id, name: "replayed" }, + }) + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows).toHaveLength(1) + expect(rows[0].aggregate_id).toBe(id) + }), + ) + + test( + "throws on sequence mismatch", + withInstance(() => { + const id = Identifier.descending("message") + SyncEvent.replay({ + id: "evt_1", + type: "item.created.1", + seq: 0, + aggregateID: id, + data: { id, name: "first" }, + }) + expect(() => + SyncEvent.replay({ + id: "evt_1", + type: "item.created.1", + seq: 5, + aggregateID: id, + data: { id, name: "bad" }, + }), + ).toThrow(/Sequence mismatch/) + }), + ) + + test( + "throws on unknown event type", + withInstance(() => { + expect(() => + SyncEvent.replay({ + id: "evt_1", + type: "unknown.event.1", + seq: 0, + aggregateID: "x", + data: {}, + }), + ).toThrow(/Unknown event type/) + }), + ) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 7a4f4e40cf..4109068443 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -46,6 +46,7 @@ import type { GlobalDisposeResponses, GlobalEventResponses, GlobalHealthResponses, + GlobalSyncEventSubscribeResponses, GlobalUpgradeErrors, GlobalUpgradeResponses, InstanceDisposeResponses, @@ -230,6 +231,20 @@ class HeyApiRegistry { } } +export class SyncEvent extends HeyApiClient { + /** + * Subscribe to global sync events + * + * Get global sync events + */ + public subscribe(options?: Options) { + return (options?.client ?? this.client).sse.get({ + url: "/global/sync-event", + ...options, + }) + } +} + export class Config extends HeyApiClient { /** * Get global configuration @@ -329,6 +344,11 @@ export class Global extends HeyApiClient { }) } + private _syncEvent?: SyncEvent + get syncEvent(): SyncEvent { + return (this._syncEvent ??= new SyncEvent({ client: this.client })) + } + private _config?: Config get config(): Config { return (this._config ??= new Config({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 86a0c7e425..43ce029214 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -83,6 +83,153 @@ export type EventLspUpdated = { } } +export type EventMessagePartDelta = { + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + +export type PermissionRequest = { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } +} + +export type EventPermissionAsked = { + type: "permission.asked" + properties: PermissionRequest +} + +export type EventPermissionReplied = { + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + next: number + } + | { + type: "busy" + } + +export type EventSessionStatus = { + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + type: "session.idle" + properties: { + sessionID: string + } +} + +export type QuestionOption = { + /** + * Display text (1-5 words, concise) + */ + label: string + /** + * Explanation of choice + */ + description: string +} + +export type QuestionInfo = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + /** + * Allow selecting multiple choices + */ + multiple?: boolean + /** + * Allow typing a custom answer (default: true) + */ + custom?: boolean +} + +export type QuestionRequest = { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: { + messageID: string + callID: string + } +} + +export type EventQuestionAsked = { + type: "question.asked" + properties: QuestionRequest +} + +export type QuestionAnswer = Array + +export type EventQuestionReplied = { + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } +} + +export type EventQuestionRejected = { + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } +} + +export type EventSessionCompacted = { + type: "session.compacted" + properties: { + sessionID: string + } +} + export type EventFileEdited = { type: "file.edited" properties: { @@ -90,21 +237,115 @@ export type EventFileEdited = { } } -export type OutputFormatText = { - type: "text" +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } } -export type JsonSchema = { - [key: string]: unknown +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string } -export type OutputFormatJsonSchema = { - type: "json_schema" - schema: JsonSchema - retryCount?: number +export type EventTodoUpdated = { + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } } -export type OutputFormat = OutputFormatText | OutputFormatJsonSchema +export type EventTuiPromptAppend = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + /** + * Duration in milliseconds + */ + duration?: number + } +} + +export type EventTuiSessionSelect = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type EventMcpToolsChanged = { + type: "mcp.tools.changed" + properties: { + server: string + } +} + +export type EventMcpBrowserOpenFailed = { + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } +} + +export type EventCommandExecuted = { + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} export type FileDiff = { file: string @@ -115,29 +356,12 @@ export type FileDiff = { status?: "added" | "deleted" | "modified" } -export type UserMessage = { - id: string - sessionID: string - role: "user" - time: { - created: number +export type EventSessionDiff = { + type: "session.diff" + properties: { + sessionID: string + diff: Array } - format?: OutputFormat - summary?: { - title?: string - body?: string - diffs: Array - } - agent: string - model: { - providerID: string - modelID: string - } - system?: string - tools?: { - [key: string]: boolean - } - variant?: string } export type ProviderAuthError = { @@ -201,6 +425,137 @@ export type ApiError = { } } +export type EventSessionError = { + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError + } +} + +export type EventVcsBranchUpdated = { + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + +export type Pty = { + id: string + title: string + command: string + args: Array + cwd: string + status: "running" | "exited" + pid: number +} + +export type EventPtyCreated = { + type: "pty.created" + properties: { + info: Pty + } +} + +export type EventPtyUpdated = { + type: "pty.updated" + properties: { + info: Pty + } +} + +export type EventPtyExited = { + type: "pty.exited" + properties: { + id: string + exitCode: number + } +} + +export type EventPtyDeleted = { + type: "pty.deleted" + properties: { + id: string + } +} + +export type EventWorktreeReady = { + type: "worktree.ready" + properties: { + name: string + branch: string + } +} + +export type EventWorktreeFailed = { + type: "worktree.failed" + properties: { + message: string + } +} + +export type OutputFormatText = { + type: "text" +} + +export type JsonSchema = { + [key: string]: unknown +} + +export type OutputFormatJsonSchema = { + type: "json_schema" + schema: JsonSchema + retryCount?: number +} + +export type OutputFormat = OutputFormatText | OutputFormatJsonSchema + +export type UserMessage = { + id: string + sessionID: string + role: "user" + time: { + created: number + } + format?: OutputFormat + summary?: { + title?: string + body?: string + diffs: Array + } + agent: string + model: { + providerID: string + modelID: string + } + system?: string + tools?: { + [key: string]: boolean + } + variant?: string +} + export type AssistantMessage = { id: string sessionID: string @@ -248,6 +603,7 @@ export type Message = UserMessage | AssistantMessage export type EventMessageUpdated = { type: "message.updated" properties: { + sessionID: string info: Message } } @@ -524,19 +880,10 @@ export type Part = export type EventMessagePartUpdated = { type: "message.part.updated" - properties: { - part: Part - } -} - -export type EventMessagePartDelta = { - type: "message.part.delta" properties: { sessionID: string - messageID: string - partID: string - field: string - delta: string + part: Part + time: number } } @@ -549,252 +896,6 @@ export type EventMessagePartRemoved = { } } -export type PermissionRequest = { - id: string - sessionID: string - permission: string - patterns: Array - metadata: { - [key: string]: unknown - } - always: Array - tool?: { - messageID: string - callID: string - } -} - -export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} - -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - attempt: number - message: string - next: number - } - | { - type: "busy" - } - -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - -export type QuestionOption = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string -} - -export type QuestionInfo = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 30 chars) - */ - header: string - /** - * Available choices - */ - options: Array - /** - * Allow selecting multiple choices - */ - multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ - custom?: boolean -} - -export type QuestionRequest = { - id: string - sessionID: string - /** - * Questions to ask - */ - questions: Array - tool?: { - messageID: string - callID: string - } -} - -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} - -export type QuestionAnswer = Array - -export type EventQuestionReplied = { - type: "question.replied" - properties: { - sessionID: string - requestID: string - answers: Array - } -} - -export type EventQuestionRejected = { - type: "question.rejected" - properties: { - sessionID: string - requestID: string - } -} - -export type EventSessionCompacted = { - type: "session.compacted" - properties: { - sessionID: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string -} - -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - -export type EventTuiPromptAppend = { - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ - duration?: number - } -} - -export type EventTuiSessionSelect = { - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - -export type EventMcpToolsChanged = { - type: "mcp.tools.changed" - properties: { - server: string - } -} - -export type EventMcpBrowserOpenFailed = { - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string - } -} - -export type EventCommandExecuted = { - type: "command.executed" - properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} - export type PermissionAction = "allow" | "deny" | "ask" export type PermissionRule = { @@ -841,6 +942,7 @@ export type Session = { export type EventSessionCreated = { type: "session.created" properties: { + sessionID: string info: Session } } @@ -848,112 +950,16 @@ export type EventSessionCreated = { export type EventSessionUpdated = { type: "session.updated" properties: { + sessionID: string info: Session } } export type EventSessionDeleted = { type: "session.deleted" - properties: { - info: Session - } -} - -export type EventSessionDiff = { - type: "session.diff" properties: { sessionID: string - diff: Array - } -} - -export type EventSessionError = { - type: "session.error" - properties: { - sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | StructuredOutputError - | ContextOverflowError - | ApiError - } -} - -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" - properties: { - branch?: string - } -} - -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type Pty = { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number -} - -export type EventPtyCreated = { - type: "pty.created" - properties: { - info: Pty - } -} - -export type EventPtyUpdated = { - type: "pty.updated" - properties: { - info: Pty - } -} - -export type EventPtyExited = { - type: "pty.exited" - properties: { - id: string - exitCode: number - } -} - -export type EventPtyDeleted = { - type: "pty.deleted" - properties: { - id: string - } -} - -export type EventWorktreeReady = { - type: "worktree.ready" - properties: { - name: string - branch: string - } -} - -export type EventWorktreeFailed = { - type: "worktree.failed" - properties: { - message: string + info: Session } } @@ -966,12 +972,7 @@ export type Event = | EventGlobalDisposed | EventLspClientDiagnostics | EventLspUpdated - | EventFileEdited - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated | EventMessagePartDelta - | EventMessagePartRemoved | EventPermissionAsked | EventPermissionReplied | EventSessionStatus @@ -980,6 +981,7 @@ export type Event = | EventQuestionReplied | EventQuestionRejected | EventSessionCompacted + | EventFileEdited | EventFileWatcherUpdated | EventTodoUpdated | EventTuiPromptAppend @@ -989,9 +991,6 @@ export type Event = | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted | EventSessionDiff | EventSessionError | EventVcsBranchUpdated @@ -1003,12 +1002,119 @@ export type Event = | EventPtyDeleted | EventWorktreeReady | EventWorktreeFailed + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted export type GlobalEvent = { directory: string payload: Event } +export type SyncEventMessageUpdated = { + type: "message.updated.1" + aggregate: "sessionID" + data: { + sessionID: string + info: Message + } +} + +export type SyncEventMessageRemoved = { + type: "message.removed.1" + aggregate: "sessionID" + data: { + sessionID: string + messageID: string + } +} + +export type SyncEventMessagePartUpdated = { + type: "message.part.updated.1" + aggregate: "sessionID" + data: { + sessionID: string + part: Part + time: number + } +} + +export type SyncEventMessagePartRemoved = { + type: "message.part.removed.1" + aggregate: "sessionID" + data: { + sessionID: string + messageID: string + partID: string + } +} + +export type SyncEventSessionCreated = { + type: "session.created.1" + aggregate: "sessionID" + data: { + sessionID: string + info: Session + } +} + +export type SyncEventSessionUpdated = { + type: "session.updated.1" + aggregate: "sessionID" + data: { + sessionID: string + info: { + id?: string + slug?: string + projectID?: string + workspaceID?: string + directory?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + share?: { + url?: string + } + title?: string + version?: string + time?: { + created?: number + updated?: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } + } + } +} + +export type SyncEventSessionDeleted = { + type: "session.deleted.1" + aggregate: "sessionID" + data: { + sessionID: string + info: Session + } +} + +export type SyncEvent = { + payload: SyncEvent +} + /** * Log level */ @@ -1973,6 +2079,23 @@ export type GlobalEventResponses = { export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses] +export type GlobalSyncEventSubscribeData = { + body?: never + path?: never + query?: never + url: "/global/sync-event" +} + +export type GlobalSyncEventSubscribeResponses = { + /** + * Event stream + */ + 200: SyncEvent +} + +export type GlobalSyncEventSubscribeResponse = + GlobalSyncEventSubscribeResponses[keyof GlobalSyncEventSubscribeResponses] + export type GlobalConfigGetData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a66ef63647..053fc5b947 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -66,6 +66,31 @@ ] } }, + "/global/sync-event": { + "get": { + "operationId": "global.sync-event.subscribe", + "summary": "Subscribe to global sync events", + "description": "Get global sync events", + "responses": { + "200": { + "description": "Event stream", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/SyncEvent" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.sync-event.subscribe({\n ...\n})" + } + ] + } + }, "/global/config": { "get": { "operationId": "global.config.get", @@ -7212,6 +7237,386 @@ }, "required": ["type", "properties"] }, + "Event.message.part.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.part.delta" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "partID": { + "type": "string", + "pattern": "^prt.*" + }, + "field": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID", "field", "delta"] + } + }, + "required": ["type", "properties"] + }, + "PermissionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] + }, + "Event.permission.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.asked" + }, + "properties": { + "$ref": "#/components/schemas/PermissionRequest" + } + }, + "required": ["type", "properties"] + }, + "Event.permission.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^per.*" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"] + } + }, + "required": ["type", "properties"] + }, + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "idle" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "retry" + }, + "attempt": { + "type": "number" + }, + "message": { + "type": "string" + }, + "next": { + "type": "number" + } + }, + "required": ["type", "attempt", "message", "next"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "busy" + } + }, + "required": ["type"] + } + ] + }, + "Event.session.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.status" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.idle": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.idle" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, + "QuestionOption": { + "type": "object", + "properties": { + "label": { + "description": "Display text (1-5 words, concise)", + "type": "string" + }, + "description": { + "description": "Explanation of choice", + "type": "string" + } + }, + "required": ["label", "description"] + }, + "QuestionInfo": { + "type": "object", + "properties": { + "question": { + "description": "Complete question", + "type": "string" + }, + "header": { + "description": "Very short label (max 30 chars)", + "type": "string" + }, + "options": { + "description": "Available choices", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + } + }, + "multiple": { + "description": "Allow selecting multiple choices", + "type": "boolean" + }, + "custom": { + "description": "Allow typing a custom answer (default: true)", + "type": "boolean" + } + }, + "required": ["question", "header", "options"] + }, + "QuestionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "questions": { + "description": "Questions to ask", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "questions"] + }, + "Event.question.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.asked" + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" + } + }, + "required": ["type", "properties"] + }, + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "Event.question.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"] + } + }, + "required": ["type", "properties"] + }, + "Event.question.rejected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.rejected" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + } + }, + "required": ["sessionID", "requestID"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.compacted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.compacted" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, "Event.file.edited": { "type": "object", "properties": { @@ -7231,51 +7636,270 @@ }, "required": ["type", "properties"] }, - "OutputFormatText": { + "Event.file.watcher.updated": { "type": "object", "properties": { "type": { "type": "string", - "const": "text" + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] + } + }, + "required": ["file", "event"] } }, - "required": ["type"] + "required": ["type", "properties"] }, - "JSONSchema": { + "Todo": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "content": { + "description": "Brief description of the task", + "type": "string" + }, + "status": { + "description": "Current status of the task: pending, in_progress, completed, cancelled", + "type": "string" + }, + "priority": { + "description": "Priority level of the task: high, medium, low", + "type": "string" + } }, - "additionalProperties": {} + "required": ["content", "status", "priority"] }, - "OutputFormatJsonSchema": { + "Event.todo.updated": { "type": "object", "properties": { "type": { "type": "string", - "const": "json_schema" + "const": "todo.updated" }, - "schema": { - "$ref": "#/components/schemas/JSONSchema" - }, - "retryCount": { - "default": 2, - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"] } }, - "required": ["type", "schema"] + "required": ["type", "properties"] }, - "OutputFormat": { - "anyOf": [ - { - "$ref": "#/components/schemas/OutputFormatText" + "Event.tui.prompt.append": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.prompt.append" }, - { - "$ref": "#/components/schemas/OutputFormatJsonSchema" + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"] } - ] + }, + "required": ["type", "properties"] + }, + "Event.tui.command.execute": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.command.execute" + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"] + } + }, + "required": ["type", "properties"] + }, + "Event.tui.toast.show": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.toast.show" + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "description": "Duration in milliseconds", + "default": 5000, + "type": "number" + } + }, + "required": ["message", "variant"] + } + }, + "required": ["type", "properties"] + }, + "Event.tui.session.select": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.session.select" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "description": "Session ID to navigate to", + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, + "Event.mcp.tools.changed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp.tools.changed" + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"] + } + }, + "required": ["type", "properties"] + }, + "Event.mcp.browser.open.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp.browser.open.failed" + }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"] + } + }, + "required": ["type", "properties"] + }, + "Event.command.executed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "command.executed" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"] + } + }, + "required": ["type", "properties"] }, "FileDiff": { "type": "object", @@ -7302,83 +7926,31 @@ }, "required": ["file", "before", "after", "additions", "deletions"] }, - "UserMessage": { + "Event.session.diff": { "type": "object", "properties": { - "id": { + "type": { "type": "string", - "pattern": "^msg.*" + "const": "session.diff" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "role": { - "type": "string", - "const": "user" - }, - "time": { + "properties": { "type": "object", "properties": { - "created": { - "type": "number" - } - }, - "required": ["created"] - }, - "format": { - "$ref": "#/components/schemas/OutputFormat" - }, - "summary": { - "type": "object", - "properties": { - "title": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" }, - "body": { - "type": "string" - }, - "diffs": { + "diff": { "type": "array", "items": { "$ref": "#/components/schemas/FileDiff" } } }, - "required": ["diffs"] - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "system": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "variant": { - "type": "string" + "required": ["sessionID", "diff"] } }, - "required": ["id", "sessionID", "role", "time", "agent", "model"] + "required": ["type", "properties"] }, "ProviderAuthError": { "type": "object", @@ -7544,6 +8116,384 @@ }, "required": ["name", "data"] }, + "Event.session.error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.error" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + } + } + }, + "required": ["type", "properties"] + }, + "Event.vcs.branch.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "vcs.branch.updated" + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + } + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "Pty": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty.*" + }, + "title": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["running", "exited"] + }, + "pid": { + "type": "number" + } + }, + "required": ["id", "title", "command", "args", "cwd", "status", "pid"] + }, + "Event.pty.created": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pty.created" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"] + } + }, + "required": ["type", "properties"] + }, + "Event.pty.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pty.updated" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"] + } + }, + "required": ["type", "properties"] + }, + "Event.pty.exited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pty.exited" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty.*" + }, + "exitCode": { + "type": "number" + } + }, + "required": ["id", "exitCode"] + } + }, + "required": ["type", "properties"] + }, + "Event.pty.deleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pty.deleted" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty.*" + } + }, + "required": ["id"] + } + }, + "required": ["type", "properties"] + }, + "Event.worktree.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "worktree.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name", "branch"] + } + }, + "required": ["type", "properties"] + }, + "Event.worktree.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "worktree.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "OutputFormatText": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": ["type"] + }, + "JSONSchema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "OutputFormatJsonSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "json_schema" + }, + "schema": { + "$ref": "#/components/schemas/JSONSchema" + }, + "retryCount": { + "default": 2, + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["type", "schema"] + }, + "OutputFormat": { + "anyOf": [ + { + "$ref": "#/components/schemas/OutputFormatText" + }, + { + "$ref": "#/components/schemas/OutputFormatJsonSchema" + } + ] + }, + "UserMessage": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "role": { + "type": "string", + "const": "user" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"] + }, + "format": { + "$ref": "#/components/schemas/OutputFormat" + }, + "summary": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + }, + "required": ["diffs"] + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"] + }, + "system": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "sessionID", "role", "time", "agent", "model"] + }, "AssistantMessage": { "type": "object", "properties": { @@ -7703,11 +8653,15 @@ "properties": { "type": "object", "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, "info": { "$ref": "#/components/schemas/Message" } }, - "required": ["info"] + "required": ["sessionID", "info"] } }, "required": ["type", "properties"] @@ -8532,25 +9486,6 @@ "type": "string", "const": "message.part.updated" }, - "properties": { - "type": "object", - "properties": { - "part": { - "$ref": "#/components/schemas/Part" - } - }, - "required": ["part"] - } - }, - "required": ["type", "properties"] - }, - "Event.message.part.delta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "message.part.delta" - }, "properties": { "type": "object", "properties": { @@ -8558,22 +9493,14 @@ "type": "string", "pattern": "^ses.*" }, - "messageID": { - "type": "string", - "pattern": "^msg.*" + "part": { + "$ref": "#/components/schemas/Part" }, - "partID": { - "type": "string", - "pattern": "^prt.*" - }, - "field": { - "type": "string" - }, - "delta": { - "type": "string" + "time": { + "type": "number" } }, - "required": ["sessionID", "messageID", "partID", "field", "delta"] + "required": ["sessionID", "part", "time"] } }, "required": ["type", "properties"] @@ -8606,617 +9533,6 @@ }, "required": ["type", "properties"] }, - "PermissionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^per.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "permission": { - "type": "string" - }, - "patterns": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "always": { - "type": "array", - "items": { - "type": "string" - } - }, - "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { - "type": "string" - } - }, - "required": ["messageID", "callID"] - } - }, - "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] - }, - "Event.permission.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.asked" - }, - "properties": { - "$ref": "#/components/schemas/PermissionRequest" - } - }, - "required": ["type", "properties"] - }, - "Event.permission.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^per.*" - }, - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["sessionID", "requestID", "reply"] - } - }, - "required": ["type", "properties"] - }, - "SessionStatus": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "idle" - } - }, - "required": ["type"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "retry" - }, - "attempt": { - "type": "number" - }, - "message": { - "type": "string" - }, - "next": { - "type": "number" - } - }, - "required": ["type", "attempt", "message", "next"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "busy" - } - }, - "required": ["type"] - } - ] - }, - "Event.session.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.status" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.idle": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.idle" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, - "QuestionOption": { - "type": "object", - "properties": { - "label": { - "description": "Display text (1-5 words, concise)", - "type": "string" - }, - "description": { - "description": "Explanation of choice", - "type": "string" - } - }, - "required": ["label", "description"] - }, - "QuestionInfo": { - "type": "object", - "properties": { - "question": { - "description": "Complete question", - "type": "string" - }, - "header": { - "description": "Very short label (max 30 chars)", - "type": "string" - }, - "options": { - "description": "Available choices", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionOption" - } - }, - "multiple": { - "description": "Allow selecting multiple choices", - "type": "boolean" - }, - "custom": { - "description": "Allow typing a custom answer (default: true)", - "type": "boolean" - } - }, - "required": ["question", "header", "options"] - }, - "QuestionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^que.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "questions": { - "description": "Questions to ask", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionInfo" - } - }, - "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { - "type": "string" - } - }, - "required": ["messageID", "callID"] - } - }, - "required": ["id", "sessionID", "questions"] - }, - "Event.question.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.asked" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" - } - }, - "required": ["type", "properties"] - }, - "QuestionAnswer": { - "type": "array", - "items": { - "type": "string" - } - }, - "Event.question.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - }, - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } - } - }, - "required": ["sessionID", "requestID", "answers"] - } - }, - "required": ["type", "properties"] - }, - "Event.question.rejected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.rejected" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - } - }, - "required": ["sessionID", "requestID"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.compacted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.compacted" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "anyOf": [ - { - "type": "string", - "const": "add" - }, - { - "type": "string", - "const": "change" - }, - { - "type": "string", - "const": "unlink" - } - ] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, - "Todo": { - "type": "object", - "properties": { - "content": { - "description": "Brief description of the task", - "type": "string" - }, - "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" - }, - "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - } - }, - "required": ["content", "status", "priority"] - }, - "Event.todo.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "todo.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"] - } - }, - "required": ["type", "properties"] - }, - "Event.tui.prompt.append": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.prompt.append" - }, - "properties": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - "required": ["type", "properties"] - }, - "Event.tui.command.execute": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.command.execute" - }, - "properties": { - "type": "object", - "properties": { - "command": { - "anyOf": [ - { - "type": "string", - "enum": [ - "session.list", - "session.new", - "session.share", - "session.interrupt", - "session.compact", - "session.page.up", - "session.page.down", - "session.line.up", - "session.line.down", - "session.half.page.up", - "session.half.page.down", - "session.first", - "session.last", - "prompt.clear", - "prompt.submit", - "agent.cycle" - ] - }, - { - "type": "string" - } - ] - } - }, - "required": ["command"] - } - }, - "required": ["type", "properties"] - }, - "Event.tui.toast.show": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.toast.show" - }, - "properties": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "variant": { - "type": "string", - "enum": ["info", "success", "warning", "error"] - }, - "duration": { - "description": "Duration in milliseconds", - "default": 5000, - "type": "number" - } - }, - "required": ["message", "variant"] - } - }, - "required": ["type", "properties"] - }, - "Event.tui.session.select": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.session.select" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "description": "Session ID to navigate to", - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, - "Event.mcp.tools.changed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "mcp.tools.changed" - }, - "properties": { - "type": "object", - "properties": { - "server": { - "type": "string" - } - }, - "required": ["server"] - } - }, - "required": ["type", "properties"] - }, - "Event.mcp.browser.open.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "mcp.browser.open.failed" - }, - "properties": { - "type": "object", - "properties": { - "mcpName": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": ["mcpName", "url"] - } - }, - "required": ["type", "properties"] - }, - "Event.command.executed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "command.executed" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "arguments": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["name", "sessionID", "arguments", "messageID"] - } - }, - "required": ["type", "properties"] - }, "PermissionAction": { "type": "string", "enum": ["allow", "deny", "ask"] @@ -9356,11 +9672,15 @@ "properties": { "type": "object", "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, "info": { "$ref": "#/components/schemas/Session" } }, - "required": ["info"] + "required": ["sessionID", "info"] } }, "required": ["type", "properties"] @@ -9375,11 +9695,15 @@ "properties": { "type": "object", "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, "info": { "$ref": "#/components/schemas/Session" } }, - "required": ["info"] + "required": ["sessionID", "info"] } }, "required": ["type", "properties"] @@ -9394,291 +9718,15 @@ "properties": { "type": "object", "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, "info": { "$ref": "#/components/schemas/Session" } }, - "required": ["info"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.diff": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.diff" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "diff": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff" - } - } - }, - "required": ["sessionID", "diff"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.error": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.error" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "error": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] - } - } - } - }, - "required": ["type", "properties"] - }, - "Event.vcs.branch.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "vcs.branch.updated" - }, - "properties": { - "type": "object", - "properties": { - "branch": { - "type": "string" - } - } - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Pty": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty.*" - }, - "title": { - "type": "string" - }, - "command": { - "type": "string" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "cwd": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["running", "exited"] - }, - "pid": { - "type": "number" - } - }, - "required": ["id", "title", "command", "args", "cwd", "status", "pid"] - }, - "Event.pty.created": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "pty.created" - }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"] - } - }, - "required": ["type", "properties"] - }, - "Event.pty.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "pty.updated" - }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"] - } - }, - "required": ["type", "properties"] - }, - "Event.pty.exited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "pty.exited" - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty.*" - }, - "exitCode": { - "type": "number" - } - }, - "required": ["id", "exitCode"] - } - }, - "required": ["type", "properties"] - }, - "Event.pty.deleted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "pty.deleted" - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty.*" - } - }, - "required": ["id"] - } - }, - "required": ["type", "properties"] - }, - "Event.worktree.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "worktree.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "branch": { - "type": "string" - } - }, - "required": ["name", "branch"] - } - }, - "required": ["type", "properties"] - }, - "Event.worktree.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "worktree.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] + "required": ["sessionID", "info"] } }, "required": ["type", "properties"] @@ -9709,24 +9757,9 @@ { "$ref": "#/components/schemas/Event.lsp.updated" }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.message.updated" - }, - { - "$ref": "#/components/schemas/Event.message.removed" - }, - { - "$ref": "#/components/schemas/Event.message.part.updated" - }, { "$ref": "#/components/schemas/Event.message.part.delta" }, - { - "$ref": "#/components/schemas/Event.message.part.removed" - }, { "$ref": "#/components/schemas/Event.permission.asked" }, @@ -9751,6 +9784,9 @@ { "$ref": "#/components/schemas/Event.session.compacted" }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, { "$ref": "#/components/schemas/Event.file.watcher.updated" }, @@ -9778,15 +9814,6 @@ { "$ref": "#/components/schemas/Event.command.executed" }, - { - "$ref": "#/components/schemas/Event.session.created" - }, - { - "$ref": "#/components/schemas/Event.session.updated" - }, - { - "$ref": "#/components/schemas/Event.session.deleted" - }, { "$ref": "#/components/schemas/Event.session.diff" }, @@ -9819,6 +9846,27 @@ }, { "$ref": "#/components/schemas/Event.worktree.failed" + }, + { + "$ref": "#/components/schemas/Event.message.updated" + }, + { + "$ref": "#/components/schemas/Event.message.removed" + }, + { + "$ref": "#/components/schemas/Event.message.part.updated" + }, + { + "$ref": "#/components/schemas/Event.message.part.removed" + }, + { + "$ref": "#/components/schemas/Event.session.created" + }, + { + "$ref": "#/components/schemas/Event.session.updated" + }, + { + "$ref": "#/components/schemas/Event.session.deleted" } ] }, @@ -9834,6 +9882,311 @@ }, "required": ["directory", "payload"] }, + "SyncEvent.message.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.updated.1" + }, + "aggregate": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"] + } + }, + "required": ["type", "aggregate", "data"] + }, + "SyncEvent.message.removed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.removed.1" + }, + "aggregate": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" + } + }, + "required": ["sessionID", "messageID"] + } + }, + "required": ["type", "aggregate", "data"] + }, + "SyncEvent.message.part.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.part.updated.1" + }, + "aggregate": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": ["sessionID", "part", "time"] + } + }, + "required": ["type", "aggregate", "data"] + }, + "SyncEvent.message.part.removed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.part.removed.1" + }, + "aggregate": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "partID": { + "type": "string", + "pattern": "^prt.*" + } + }, + "required": ["sessionID", "messageID", "partID"] + } + }, + "required": ["type", "aggregate", "data"] + }, + "SyncEvent.session.created": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.created.1" + }, + "aggregate": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"] + } + }, + "required": ["type", "aggregate", "data"] + }, + "SyncEvent.session.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.updated.1" + }, + "aggregate": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "info": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses.*" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "directory": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses.*" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + }, + "required": ["additions", "deletions", "files"] + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + }, + "title": { + "type": "string" + }, + "version": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "compacting": { + "type": "number" + }, + "archived": { + "type": "number" + } + } + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "partID": { + "type": "string", + "pattern": "^prt.*" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"] + } + } + } + }, + "required": ["sessionID", "info"] + } + }, + "required": ["type", "aggregate", "data"] + }, + "SyncEvent.session.deleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.deleted.1" + }, + "aggregate": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"] + } + }, + "required": ["type", "aggregate", "data"] + }, + "SyncEvent": { + "type": "object", + "properties": { + "payload": { + "$ref": "#/components/schemas/SyncEvent" + } + }, + "required": ["payload"] + }, "LogLevel": { "description": "Log level", "type": "string",