diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql new file mode 100644 index 0000000000..bdeed6bce1 --- /dev/null +++ b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `workspace` ADD `time_used` integer NOT NULL; \ No newline at end of file diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json b/packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json new file mode 100644 index 0000000000..57da763bb9 --- /dev/null +++ b/packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json @@ -0,0 +1,1459 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "630a93f2-c6c6-4191-a351-868d8f3a05d4", + "prevIds": ["27114226-085b-421a-9a40-29b88747e29a"], + "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_message", + "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": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "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": "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": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "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_url_override", + "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_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "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": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "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": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "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": "owner_id", + "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": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "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_message_pk", + "table": "session_message", + "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": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "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": [] +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 542449f5df..195221b88f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -107,6 +107,7 @@ export function DialogSessionList() { dialog, sdk, sync, + project, toast, onSelect: (selection) => { void warp(selection) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index d7e212ab15..538428e8f1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -36,21 +36,14 @@ export type WorkspaceSelection = type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } type ExistingWorkspaceSelectValue = { workspace: Workspace } -export function recentConnectedWorkspaces(input: { - sessions: readonly { workspaceID?: string; time: { updated: number } }[] - get: (workspaceID: string) => WorkspaceInfo | undefined +export function recentConnectedWorkspaces(input: { + workspaces: readonly WorkspaceInfo[] status: (workspaceID: string) => string | undefined limit?: number omitWorkspaceID?: string }) { - const workspaces = input.sessions - .toSorted((a, b) => b.time.updated - a.time.updated) - .flatMap((session) => { - const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined - return workspace && input.status(workspace.id) === "connected" ? [workspace] : [] - }) - .filter((workspace) => workspace.id !== input.omitWorkspaceID) - .filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index) + const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected") + const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed)) const recent = workspaces.slice(0, input.limit ?? 3) return { recent, hasMore: recent.length < workspaces.length } @@ -83,10 +76,13 @@ export async function openWorkspaceSelect(input: { dialog: ReturnType sdk: ReturnType sync: ReturnType + project: ReturnType toast: ReturnType onSelect: (selection: WorkspaceSelection) => Promise | void }) { input.dialog.clear() + await input.sdk.client.experimental.workspace.syncList().catch(() => undefined) + await input.project.workspace.sync().catch(() => undefined) const adapters = await loadWorkspaceAdapters(input) if (!adapters) return input.dialog.replace(() => ) @@ -200,8 +196,7 @@ export function DialogWorkspaceSelect(props: { const list = adapters() if (!list) return [] const { recent, hasMore } = recentConnectedWorkspaces({ - sessions: sync.data.session, - get: project.workspace.get, + workspaces: project.workspace.list(), status: project.workspace.status, omitWorkspaceID: omittedWorkspaceID(), }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index cafb1ba373..c6bcb89924 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -610,6 +610,7 @@ export function Prompt(props: PromptProps) { dialog, sdk, sync, + project, toast, onSelect: (selection) => { void warpSession(selection) @@ -1036,6 +1037,7 @@ export function Prompt(props: PromptProps) { dialog, sdk, sync, + project, toast, onSelect: (selection) => { void warpSession(selection) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f214540c96..40f3e4fbca 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -93,9 +93,29 @@ import { useBindings, useCommandShortcut } from "../../keymap" addDefaultParsers(parsers.parsers) -const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at" -const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show" +const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at" +const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show" +const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at" +const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show" const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs +const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"]) + +function goUpsellKeys(action: SessionRetry.Retryable["action"]) { + if (!action) return + if (!GO_UPSELL_PROVIDERS.has(action.provider)) return + if (action.reason === "free_tier_limit") { + return { + lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT, + dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW, + } + } + if (action.reason === "account_rate_limit") { + return { + lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT, + dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW, + } + } +} const context = createContext<{ width: number @@ -263,14 +283,17 @@ export function Session() { if (!evt.properties.status.action) return if (dialog.stack.length > 0) return - const seen = kv.get(GO_UPSELL_LAST_SEEN_AT) + const keys = goUpsellKeys(evt.properties.status.action) + if (!keys) return + + const seen = kv.get(keys.lastSeenAt) if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return - if (kv.get(GO_UPSELL_DONT_SHOW)) return + if (kv.get(keys.dontShow)) return void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => { - if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true) - kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now()) + if (dontShowAgain) kv.set(keys.dontShow, true) + kv.set(keys.lastSeenAt, Date.now()) }) }) diff --git a/packages/opencode/src/control-plane/adapters/index.ts b/packages/opencode/src/control-plane/adapters/index.ts index 963e2a2ed5..e5fa13714b 100644 --- a/packages/opencode/src/control-plane/adapters/index.ts +++ b/packages/opencode/src/control-plane/adapters/index.ts @@ -18,22 +18,18 @@ export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter throw new Error(`Unknown workspace adapter: ${type}`) } -export async function listAdapters(projectID: ProjectID): Promise { - const builtin = await Promise.all( - Object.entries(BUILTIN).map(async ([type, adapter]) => { - return { - type, - name: adapter.name, - description: adapter.description, - } - }), - ) - const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({ +export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] { + return registeredAdapters(projectID).map(([type, adapter]) => ({ type, name: adapter.name, description: adapter.description, })) - return [...builtin, ...custom] +} + +export function registeredAdapters(projectID: ProjectID): [string, WorkspaceAdapter][] { + const adapters = new Map(Object.entries(BUILTIN)) + for (const [type, adapter] of state.get(projectID)?.entries() ?? []) adapters.set(type, adapter) + return [...adapters.entries()] } // Plugins can be loaded per-project so we need to scope them. If you diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index af8f5d8d43..605d114ace 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -3,14 +3,18 @@ import { type WorkspaceAdapter, WorkspaceInfo } from "../types" const WorktreeConfig = Schema.Struct({ name: WorkspaceInfo.fields.name, - branch: Schema.String, + branch: Schema.optional(Schema.NullOr(Schema.String)), directory: Schema.String, }) const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig) async function loadWorktree() { - const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")]) - return { AppRuntime, Worktree } + const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/project/instance"), + import("@/worktree"), + ]) + return { AppRuntime, Instance, Worktree } } export const WorktreeAdapter: WorkspaceAdapter = { @@ -34,11 +38,22 @@ export const WorktreeAdapter: WorkspaceAdapter = { svc.createFromInfo({ name: config.name, directory: config.directory, - branch: config.branch, + branch: config.branch ?? config.name, }), ), ) }, + async list() { + const { AppRuntime, Instance, Worktree } = await loadWorktree() + return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({ + type: "worktree", + name: info.name, + branch: info.branch ?? null, + directory: info.directory, + extra: null, + projectID: Instance.project.id, + })) + }, async remove(info) { const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 7f3aad7ed1..5acb5c827e 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect" +import { Schema, Struct } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" import { zod } from "@/util/effect-zod" @@ -17,6 +17,11 @@ export const WorkspaceInfo = Schema.Struct({ .pipe(withStatics((s) => ({ zod: zod(s) }))) export type WorkspaceInfo = DeepMutable> +export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"])) + .annotate({ identifier: "WorkspaceListedInfo" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type WorkspaceListedInfo = DeepMutable> + export const WorkspaceAdapterEntry = Schema.Struct({ type: Schema.String, name: Schema.String, @@ -40,6 +45,7 @@ export type WorkspaceAdapter = { description: string configure(info: WorkspaceInfo): WorkspaceInfo | Promise create(info: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise + list?(): WorkspaceListedInfo[] | Promise remove(info: WorkspaceInfo): Promise target(info: WorkspaceInfo): Target | Promise } diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts index a6a4ce2c86..1afaf7cbc9 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/opencode/src/control-plane/workspace.sql.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text } from "drizzle-orm/sqlite-core" +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { ProjectID } from "../project/schema" import type { WorkspaceID } from "./schema" @@ -14,4 +14,7 @@ export const WorkspaceTable = sqliteTable("workspace", { .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), + time_used: integer() + .notNull() + .$default(() => Date.now()), }) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index f9bab469b7..03640576d6 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -17,7 +17,7 @@ import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "./workspace.sql" -import { getAdapter } from "./adapters" +import { getAdapter, registeredAdapters } from "./adapters" import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" @@ -35,8 +35,13 @@ import { Vcs } from "@/project/vcs" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" -export const Info = WorkspaceInfoSchema -export type Info = WorkspaceInfo +export const Info = Schema.Struct({ + ...WorkspaceInfoSchema.fields, + timeUsed: Schema.Number, +}) + .annotate({ identifier: "Workspace" }) + .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +export type Info = WorkspaceInfo & { timeUsed: number } export const ConnectionStatus = Schema.Struct({ workspaceID: WorkspaceID, @@ -69,6 +74,7 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { directory: row.directory, extra: row.extra, projectID: row.project_id, + timeUsed: row.time_used, } } @@ -150,6 +156,7 @@ export interface Interface { readonly create: (input: CreateInput) => Effect.Effect readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect readonly list: (project: Project.Info) => Effect.Effect + readonly syncList: (project: Project.Info) => Effect.Effect readonly get: (id: WorkspaceID) => Effect.Effect readonly remove: (id: WorkspaceID) => Effect.Effect readonly status: () => Effect.Effect @@ -483,7 +490,19 @@ export const layer = Layer.effect( if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return const adapter = getAdapter(space.projectID, space.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe( + Effect.catch((error) => + Effect.sync(() => { + setStatus(space.id, "error") + log.warn("workspace target failed", { + workspaceID: space.id, + error: errorData(error), + }) + return null + }), + ), + ) + if (!target) return if (target.type === "local") { setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") @@ -523,7 +542,13 @@ export const layer = Layer.effect( const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) const config = yield* EffectBridge.fromPromise(() => - adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }), + adapter.configure({ + ...input, + id, + name: Slug.create(), + directory: null, + extra: input.extra ?? null, + }), ) const info: Info = { @@ -534,6 +559,7 @@ export const layer = Layer.effect( directory: config.directory ?? null, extra: config.extra ?? null, projectID: input.projectID, + timeUsed: Date.now(), } yield* db((db) => { @@ -546,6 +572,7 @@ export const layer = Layer.effect( directory: info.directory, extra: info.extra, project_id: info.projectID, + time_used: info.timeUsed, }) .run() }) @@ -828,6 +855,63 @@ export const layer = Layer.effect( ) }) + const syncList = Effect.fn("Workspace.syncList")(function* (project: Project.Info) { + const names = new Set((yield* list(project)).map((workspace) => workspace.name)) + const discovered = yield* Effect.forEach( + registeredAdapters(project.id), + ([type, adapter]) => + adapter.list + ? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe( + Effect.catchCause((error) => + Effect.sync(() => { + log.warn("workspace adapter list failed", { type, error }) + return [] + }), + ), + ) + : Effect.succeed([]), + { concurrency: "unbounded" }, + ).pipe(Effect.map((items) => items.flat())) + + yield* Effect.forEach( + discovered, + (item) => + Effect.gen(function* () { + if (names.has(item.name)) return + names.add(item.name) + + const info: Info = { + id: WorkspaceID.ascending(), + type: item.type, + branch: item.branch, + name: item.name, + directory: item.directory, + extra: item.extra, + projectID: item.projectID, + timeUsed: Date.now(), + } + + yield* db((db) => { + db.insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, + time_used: info.timeUsed, + }) + .run() + }) + + yield* startSync(info) + }), + { concurrency: 1 }, + ) + }) + const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) { const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (!row) return @@ -916,13 +1000,10 @@ export const layer = Layer.effect( }) const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) { - // This session table join makes this query only return - // workspaces that have sessions const rows = yield* db((db) => db .selectDistinct({ workspace: WorkspaceTable }) .from(WorkspaceTable) - .innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id)) .where(eq(WorkspaceTable.project_id, projectID)) .all(), ) @@ -947,6 +1028,7 @@ export const layer = Layer.effect( create, sessionWarp, list, + syncList, get, remove, status, diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 0c1bf252ed..799294b261 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -93,6 +93,23 @@ export const WorkspaceRoutes = lazy(() => return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project)))) }, ) + .post( + "/sync-list", + describeRoute({ + summary: "Sync workspace list", + description: "Register missing workspaces returned by workspace adapters.", + operationId: "experimental.workspace.syncList", + responses: { + 204: { + description: "Workspace list synced", + }, + }, + }), + async (c) => { + await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.syncList(Instance.project))) + return c.body(null, 204) + }, + ) .get( "/status", describeRoute({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 66422c13b6..1a9732800d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -29,6 +29,7 @@ export class ApiWorkspaceWarpError extends Schema.ErrorClass listAdapters(instance.project.id)) + return yield* Effect.sync(() => listAdapters(instance.project.id)) }) const list = Effect.fn("WorkspaceHttpApi.list")(function* () { @@ -32,6 +32,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) + const syncList = Effect.fn("WorkspaceHttpApi.syncList")(function* () { + yield* workspace.syncList((yield* InstanceState.context).project) + }) + const status = Effect.fn("WorkspaceHttpApi.status")(function* () { const ids = new Set((yield* workspace.list((yield* InstanceState.context).project)).map((item) => item.id)) return (yield* workspace.status()).filter((item) => ids.has(item.workspaceID)) @@ -73,6 +77,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .handle("adapters", adapters) .handle("list", list) .handle("create", create) + .handle("syncList", syncList) .handle("status", status) .handle("remove", remove) .handle("warp", warp) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 66a2d47975..6e84db16e2 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -701,6 +701,7 @@ export const layer: Layer.Layer< ), Effect.retry( SessionRetry.policy({ + provider: input.model.providerID, parse, set: (info) => { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 9819ad810f..93acd4546d 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -5,6 +5,7 @@ import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" +import { WorkspaceTable } from "@/control-plane/workspace.sql" import { Log } from "@opencode-ai/core/util/log" import nextProjectors from "./projectors-next" @@ -69,6 +70,10 @@ export default [ db.insert(SessionTable) .values(Session.toRow(data.info as Session.Info)) .run() + + if (data.info.workspaceID) { + db.update(WorkspaceTable).set({ time_used: Date.now() }).where(eq(WorkspaceTable.id, data.info.workspaceID)).run() + } }), SyncEvent.project(Session.Event.Updated, (db, data) => { diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 3bccee212d..1f73dee31f 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -7,10 +7,13 @@ export type Err = ReturnType export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" export const GO_UPSELL_URL = "https://opencode.ai/go" +export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {}) export type Retryable = { message: string action?: { + reason: RetryReason + provider: string title: string message: string label: string @@ -60,7 +63,7 @@ export function delay(attempt: number, error?: MessageV2.APIError) { return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS)) } -export function retryable(error: Err) { +export function retryable(error: Err, provider: string) { // context overflow errors should not be retried if (MessageV2.ContextOverflowError.isInstance(error)) return undefined if (MessageV2.APIError.isInstance(error)) { @@ -72,6 +75,8 @@ export function retryable(error: Err) { return { message: GO_UPSELL_MESSAGE, action: { + reason: "free_tier_limit", + provider, title: "Free limit reached", message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", label: "subscribe", @@ -97,12 +102,14 @@ export function retryable(error: Err) { return minutes > 0 ? unit(minutes, "minute") : "less than a minute" }) - const message = `${limitName} usage limit reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance` + const message = `${limitName ? `${limitName} usage limit` : "Usage limit"} reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance` const link = `https://opencode.ai/workspace/${workspace}/go` return { message: `${message} - ${link}`, action: { + reason: "account_rate_limit", + provider, title: "Go limit reached", message, label: "open settings", @@ -165,13 +172,14 @@ function parseJSON(value: unknown) { } export function policy(opts: { + provider: string parse: (error: unknown) => Err set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect }) { return Schedule.fromStepWithMetadata( Effect.succeed((meta: Schedule.InputMetadata) => { const error = opts.parse(meta.input) - const retry = retryable(error) + const retry = retryable(error, opts.provider) if (!retry) return Cause.done(meta.attempt) return Effect.gen(function* () { const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 1d6e96d935..abe51bcd61 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -17,6 +17,8 @@ export const Info = Schema.Union([ message: Schema.String, action: Schema.optional( Schema.Struct({ + reason: Schema.String, + provider: Schema.String, title: Schema.String, message: Schema.String, label: Schema.String, diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index f4e4d2721c..088dc9eb35 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -117,6 +117,13 @@ export const ResetFailedError = NamedError.create( }), ) +export const ListFailedError = NamedError.create( + "WorktreeListFailedError", + z.object({ + message: z.string(), + }), +) + function slugify(input: string) { return input .trim() @@ -149,6 +156,7 @@ export interface Interface { readonly makeWorktreeInfo: (name?: string) => Effect.Effect readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect readonly create: (input?: CreateInput) => Effect.Effect + readonly list: () => Effect.Effect<(Omit & { branch?: string })[]> readonly remove: (input: RemoveInput) => Effect.Effect readonly reset: (input: ResetInput) => Effect.Effect } @@ -341,6 +349,32 @@ export const layer: Layer.Layer< return undefined }) + const list = Effect.fn("Worktree.list")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + return [] + } + + const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) + if (result.code !== 0) { + throw new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) + } + + const primary = yield* canonical(ctx.worktree) + return yield* Effect.forEach(parseWorktreeList(result.text), (entry) => + Effect.gen(function* () { + if (!entry.path) return undefined + const directory = yield* canonical(entry.path) + if (directory === primary) return undefined + return { + name: pathSvc.basename(directory), + directory, + ...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}), + } + }), + ).pipe(Effect.map((items) => items.filter((item) => item !== undefined))) + }) + function stopFsmonitor(target: string) { return fs.exists(target).pipe( Effect.orDie, @@ -579,7 +613,7 @@ export const layer: Layer.Layer< return true }) - return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset }) + return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset }) }), ) diff --git a/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts index a32dc61125..00b480ca00 100644 --- a/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts +++ b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts @@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test" import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create" describe("recentConnectedWorkspaces", () => { - test("returns unique connected workspaces after filtering missing and inactive entries", () => { + test("returns connected workspaces sorted by time used", () => { const workspaces = [ - { id: "wrk_a", name: "alpha" }, - { id: "wrk_b", name: "beta" }, - { id: "wrk_c", name: "gamma" }, - { id: "wrk_d", name: "delta" }, - { id: "wrk_e", name: "epsilon" }, + { id: "wrk_a", name: "alpha", timeUsed: 700 }, + { id: "wrk_b", name: "beta", timeUsed: 800 }, + { id: "wrk_c", name: "gamma", timeUsed: 400 }, + { id: "wrk_d", name: "delta", timeUsed: 300 }, + { id: "wrk_e", name: "epsilon", timeUsed: 200 }, ] const status = { wrk_a: "connected", @@ -19,45 +19,10 @@ describe("recentConnectedWorkspaces", () => { } as const const { recent } = recentConnectedWorkspaces({ - sessions: [ - { time: { updated: 900 } }, - { workspaceID: "wrk_b", time: { updated: 800 } }, - { workspaceID: "wrk_a", time: { updated: 700 } }, - { workspaceID: "wrk_a", time: { updated: 600 } }, - { workspaceID: "wrk_missing", time: { updated: 500 } }, - { workspaceID: "wrk_c", time: { updated: 400 } }, - { workspaceID: "wrk_d", time: { updated: 300 } }, - { workspaceID: "wrk_e", time: { updated: 200 } }, - ], - get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID), + workspaces, status: (workspaceID) => status[workspaceID as keyof typeof status], }) expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"]) }) - - test("omits the active workspace before limiting recent workspaces", () => { - const workspaces = [ - { id: "wrk_a", name: "alpha" }, - { id: "wrk_b", name: "beta" }, - { id: "wrk_c", name: "gamma" }, - { id: "wrk_d", name: "delta" }, - ] - - const { recent, hasMore } = recentConnectedWorkspaces({ - sessions: [ - { workspaceID: "wrk_a", time: { updated: 400 } }, - { workspaceID: "wrk_b", time: { updated: 300 } }, - { workspaceID: "wrk_c", time: { updated: 200 } }, - { workspaceID: "wrk_d", time: { updated: 100 } }, - ], - get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID), - status: () => "connected", - limit: 3, - omitWorkspaceID: "wrk_a", - }) - - expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"]) - expect(hasMore).toBe(false) - }) }) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 0eba431e1a..e3de9cae71 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -28,7 +28,7 @@ import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" -import * as WorkspaceOld from "../../src/control-plane/workspace" +import * as Workspace from "../../src/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" @@ -37,10 +37,7 @@ void Log.init({ print: false }) const testServerLayer = Layer.mergeAll( NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), - WorkspaceOld.defaultLayer.pipe( - Layer.provide(InstanceStore.defaultLayer), - Layer.provide(InstanceBootstrap.defaultLayer), - ), + Workspace.defaultLayer.pipe(Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer)), SessionNs.defaultLayer, ) const it = testEffect(testServerLayer) @@ -64,6 +61,7 @@ type RecordedAdapter = { calls: { configure: WorkspaceInfo[] create: RecordedCreate[] + list: number remove: WorkspaceInfo[] target: WorkspaceInfo[] } @@ -125,23 +123,25 @@ async function initGitRepo(dir: string) { await $`git commit -m "base"`.cwd(dir).quiet() } -const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) -const createWorkspace = (input: WorkspaceOld.CreateInput) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input))) -const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input))) -const listWorkspaces = (project: Parameters[0]) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project))) -const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id))) -const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id))) -const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status())) +const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) +const createWorkspace = (input: Workspace.CreateInput) => + runWorkspace(Workspace.Service.use((workspace) => workspace.create(input))) +const warpWorkspaceSession = (input: Workspace.SessionWarpInput) => + runWorkspace(Workspace.Service.use((workspace) => workspace.sessionWarp(input))) +const listWorkspaces = (project: Parameters[0]) => + runWorkspace(Workspace.Service.use((workspace) => workspace.list(project))) +const syncListWorkspaces = (project: Parameters[0]) => + runWorkspace(Workspace.Service.use((workspace) => workspace.syncList(project))) +const getWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.get(id))) +const removeWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.remove(id))) +const workspaceStatus = () => runWorkspace(Workspace.Service.use((workspace) => workspace.status())) const isWorkspaceSyncing = (id: WorkspaceID) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id))) + runWorkspace(Workspace.Service.use((workspace) => workspace.isSyncing(id))) const startWorkspaceSyncing = (projectID: ProjectID) => { - void runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID))) + void runWorkspace(Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID))) } const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))) + runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))) function captureGlobalEvents() { const events: GlobalEvent[] = [] @@ -187,11 +187,13 @@ function recordedAdapter(input: { target: (info: WorkspaceInfo) => Target | Promise configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise create?: (info: WorkspaceInfo, env: Record, from?: WorkspaceInfo) => Promise + list?: () => Omit[] | Promise[]> remove?: (info: WorkspaceInfo) => Promise }): RecordedAdapter { const calls: RecordedAdapter["calls"] = { configure: [], create: [], + list: 0, remove: [], target: [], } @@ -213,6 +215,14 @@ function recordedAdapter(input: { }) await input.create?.(info, env, from) }, + ...(input.list + ? { + async list() { + calls.list += 1 + return input.list?.() ?? [] + }, + } + : {}), async remove(info) { calls.remove.push(structuredClone(info)) await input.remove?.(info) @@ -272,7 +282,7 @@ function serverUrl() { }) } -function workspaceInfo(projectID: ProjectID, type: string, input?: Partial): WorkspaceInfo { +function workspaceInfo(projectID: ProjectID, type: string, input?: Partial): Workspace.Info { return { id: input?.id ?? WorkspaceID.ascending(), type, @@ -281,10 +291,11 @@ function workspaceInfo(projectID: ProjectID, type: string, input?: Partial db .insert(WorkspaceTable) @@ -296,6 +307,7 @@ function insertWorkspace(info: WorkspaceInfo) { directory: info.directory, extra: info.extra, project_id: info.projectID, + time_used: info.timeUsed, }) .run(), ) @@ -348,11 +360,11 @@ function sessionUpdatedType() { return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version) } -describe("workspace-old schemas and exports", () => { +describe("workspace schemas and exports", () => { test("keeps the historical event type names", () => { - expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready") - expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed") - expect(WorkspaceOld.Event.Status.type).toBe("workspace.status") + expect(Workspace.Event.Ready.type).toBe("workspace.ready") + expect(Workspace.Event.Failed.type).toBe("workspace.failed") + expect(Workspace.Event.Status.type).toBe("workspace.status") }) test("validates create input with workspace id, project id, branch, type, and extra", () => { @@ -364,13 +376,13 @@ describe("workspace-old schemas and exports", () => { extra: { nested: true }, } - expect(WorkspaceOld.CreateInput.zod.parse(input)).toEqual(input) - expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() - expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() + expect(Workspace.CreateInput.zod.parse(input)).toEqual(input) + expect(() => Workspace.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() + expect(() => Workspace.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() }) }) -describe("workspace-old CRUD", () => { +describe("workspace CRUD", () => { test("get returns undefined for a missing workspace", async () => { await withInstance(async () => { expect(await getWorkspace(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined() @@ -447,13 +459,22 @@ describe("workspace-old CRUD", () => { directory: targetDir, extra: { configured: true }, projectID: Instance.project.id, + timeUsed: info.timeUsed, }) expect(await getWorkspace(workspaceID)).toEqual(info) expect(await listWorkspaces(Instance.project)).toEqual([info]) expect(recorded.calls.configure).toHaveLength(1) expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null }) expect(recorded.calls.create).toHaveLength(1) - expect(recorded.calls.create[0].info).toEqual(info) + expect(recorded.calls.create[0].info).toEqual({ + id: workspaceID, + type, + branch: "configured-branch", + name: "Configured Name", + directory: targetDir, + extra: { configured: true }, + projectID: Instance.project.id, + }) expect(JSON.parse(recorded.calls.create[0].env.OPENCODE_AUTH_CONTENT ?? "{}")).toEqual({ test: { type: "api", key: "secret" }, }) @@ -532,6 +553,120 @@ describe("workspace-old CRUD", () => { }) }) + test("syncList registers adapter-listed workspaces that are missing by name", async () => { + await withInstance(async (dir) => { + const type = unique("list-sync") + const existing = workspaceInfo(Instance.project.id, type, { + id: WorkspaceID.ascending("wrk_list_sync_existing"), + name: "existing", + directory: path.join(dir, "existing"), + }) + insertWorkspace(existing) + + const discovered = { + type, + name: "discovered", + branch: "feature/discovered", + directory: path.join(dir, "discovered"), + extra: { source: "adapter" }, + projectID: Instance.project.id, + } + const recorded = recordedAdapter({ + list() { + return [ + { + type, + name: existing.name, + branch: "ignored", + directory: path.join(dir, "ignored"), + extra: null, + projectID: Instance.project.id, + }, + discovered, + ] + }, + target(info) { + return { type: "local", directory: info.directory ?? dir } + }, + }) + registerAdapter(Instance.project.id, type, recorded.adapter) + + await syncListWorkspaces(Instance.project) + const synced = (await listWorkspaces(Instance.project)).filter((item) => item.name === discovered.name) + + expect(synced).toHaveLength(1) + expect(synced[0]).toMatchObject(discovered) + expect(synced[0]?.id).toStartWith("wrk_") + expect(await listWorkspaces(Instance.project)).toEqual(expect.arrayContaining([existing, synced[0]])) + expect(recorded.calls.list).toBe(1) + expect(recorded.calls.configure).toHaveLength(0) + expect(recorded.calls.create).toHaveLength(0) + expect(recorded.calls.target).toHaveLength(1) + }) + }) + + test("syncList calls every registered adapter with a list method", async () => { + await withInstance(async (dir) => { + const typeA = unique("list-sync-a") + const typeB = unique("list-sync-b") + const adapterA = recordedAdapter({ + list() { + return [ + { + type: typeA, + name: "adapter-a", + branch: null, + directory: path.join(dir, "adapter-a"), + extra: null, + projectID: Instance.project.id, + }, + ] + }, + target(info) { + return { type: "local", directory: info.directory ?? dir } + }, + }) + const adapterB = recordedAdapter({ + list() { + return [ + { + type: typeB, + name: "adapter-b", + branch: null, + directory: path.join(dir, "adapter-b"), + extra: null, + projectID: Instance.project.id, + }, + ] + }, + target(info) { + return { type: "local", directory: info.directory ?? dir } + }, + }) + const noList = recordedAdapter({ + target() { + return { type: "local", directory: dir } + }, + }) + registerAdapter(Instance.project.id, typeA, adapterA.adapter) + registerAdapter(Instance.project.id, typeB, adapterB.adapter) + registerAdapter(Instance.project.id, unique("list-sync-none"), noList.adapter) + + await syncListWorkspaces(Instance.project) + const synced = await listWorkspaces(Instance.project) + + expect( + synced + .filter((item) => item.type === typeA || item.type === typeB) + .map((item) => item.name) + .toSorted(), + ).toEqual(["adapter-a", "adapter-b"]) + expect(adapterA.calls.list).toBe(1) + expect(adapterB.calls.list).toBe(1) + expect(noList.calls.list).toBe(0) + }) + }) + it.live("remote create connects to routed event and history endpoints", () => { const calls: FetchCall[] = [] return Effect.gen(function* () { @@ -557,7 +692,7 @@ describe("workspace-old CRUD", () => { yield* provideTmpdirInstance( (dir) => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const type = unique("remote-create") const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir }) registerAdapter(Instance.project.id, type, recorded.adapter) @@ -754,7 +889,7 @@ describe("workspace-old CRUD", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const previousType = unique("warp-remote-source") const targetType = unique("warp-remote-target") @@ -805,7 +940,7 @@ describe("workspace-old CRUD", () => { }) }) -describe("workspace-old sync state", () => { +describe("workspace sync state", () => { test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => { await withInstance(async (dir) => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false @@ -823,35 +958,29 @@ describe("workspace-old sync state", () => { }) }) - test("startWorkspaceSyncing starts only workspaces with sessions", async () => { + test("startWorkspaceSyncing starts all workspaces", async () => { await withInstance(async (dir) => { - const withSessionType = unique("with-session") - const withoutSessionType = unique("without-session") - const withSession = workspaceInfo(Instance.project.id, withSessionType) - const withoutSession = workspaceInfo(Instance.project.id, withoutSessionType) - const withSessionDir = path.join(dir, "with-session") - const withoutSessionDir = path.join(dir, "without-session") - await fs.mkdir(withSessionDir, { recursive: true }) - await fs.mkdir(withoutSessionDir, { recursive: true }) - insertWorkspace(withSession) - insertWorkspace(withoutSession) - registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter) - registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter) - attachSessionToWorkspace( - (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, - withSession.id, - ) + const firstType = unique("first") + const secondType = unique("second") + const first = workspaceInfo(Instance.project.id, firstType) + const second = workspaceInfo(Instance.project.id, secondType) + await fs.mkdir(path.join(dir, "first"), { recursive: true }) + await fs.mkdir(path.join(dir, "second"), { recursive: true }) + insertWorkspace(first) + insertWorkspace(second) + registerAdapter(Instance.project.id, firstType, localAdapter(path.join(dir, "first")).adapter) + registerAdapter(Instance.project.id, secondType, localAdapter(path.join(dir, "second")).adapter) startWorkspaceSyncing(Instance.project.id) await eventually(() => - workspaceStatus().then((status) => - expect(status.find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"), - ), + workspaceStatus().then((status) => { + expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected") + expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected") + }), ) - expect((await workspaceStatus()).find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined() - await removeWorkspace(withSession.id) - await removeWorkspace(withoutSession.id) + await removeWorkspace(first.id) + await removeWorkspace(second.id) }) }) @@ -907,7 +1036,7 @@ describe("workspace-old sync state", () => { ) expect( captured.events.filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type, + (event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type, ), ).toHaveLength(1) await removeWorkspace(info.id) @@ -941,7 +1070,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const captured = captureGlobalEvents() try { @@ -964,9 +1093,7 @@ describe("workspace-old sync state", () => { expect( captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type, - ) + .filter((event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type) .map((event) => event.payload.properties.status), ).toEqual(["disconnected", "connecting", "connected"]) expect(calls.filter((call) => call.url.pathname === "/sync/global/event")).toHaveLength(1) @@ -998,7 +1125,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const type = unique("remote-connect-fail") const info = workspaceInfo(Instance.project.id, type) @@ -1038,7 +1165,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const type = unique("remote-history-fail") const info = workspaceInfo(Instance.project.id, type) @@ -1093,7 +1220,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const captured = captureGlobalEvents() try { @@ -1160,7 +1287,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const captured = captureGlobalEvents() try { @@ -1241,7 +1368,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const captured = captureGlobalEvents() try { @@ -1280,7 +1407,7 @@ describe("workspace-old sync state", () => { }) }) -describe("workspace-old waitForSync", () => { +describe("workspace waitForSync", () => { test("returns immediately for an empty fence", async () => { await withInstance(async () => { await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined() diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 9b38cb44a2..2e64081b8f 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -64,6 +64,36 @@ function localAdapter(directory: string): WorkspaceAdapter { } } +function listedAdapter(directory: string, type: string): WorkspaceAdapter { + return { + name: "Listed Test", + description: "List a local test workspace", + configure(info) { + return { ...info, name: "unused", directory } + }, + async create() {}, + async remove() {}, + list() { + return [ + { + type, + name: "listed-test", + branch: "listed/main", + directory, + extra: { listed: true }, + projectID: Instance.project.id, + }, + ] + }, + target() { + return { + type: "local" as const, + directory, + } + }, + } +} + function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter { return { name: "Remote Test", @@ -196,6 +226,30 @@ describe("workspace HttpApi", () => { }), ) + it.live("serves list sync endpoint", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const type = `listed-${Math.random().toString(36).slice(2)}` + registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type)) + + const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" }) + + expect(response.status).toBe(204) + const listed = yield* request(WorkspacePaths.list, dir) + expect(yield* Effect.promise(() => listed.json())).toMatchObject([ + { + type, + name: "listed-test", + branch: "listed/main", + directory: path.join(dir, ".listed"), + extra: { listed: true }, + }, + ]) + }), + ) + it.live("creates workspace with the TUI payload shape", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 0b67294796..9da45c9112 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -13,6 +13,7 @@ import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const providerID = ProviderID.make("test") +const retryProvider = "test" const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) function apiError(headers?: Record): MessageV2.APIError { @@ -92,6 +93,7 @@ describe("session.retry.delay", () => { const step = yield* Schedule.toStepWithMetadata( SessionRetry.policy({ + provider: "test", parse: (err) => MessageV2.APIError.Schema.parse(err), set: (info) => status.set(sessionID, { @@ -118,47 +120,47 @@ describe("session.retry.delay", () => { describe("session.retry.retryable", () => { test("maps too_many_requests json messages", () => { const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } })) - expect(SessionRetry.retryable(error)).toEqual({ message: "Too Many Requests" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" }) }) test("maps overloaded provider codes", () => { const error = wrap(JSON.stringify({ code: "resource_exhausted" })) - expect(SessionRetry.retryable(error)).toEqual({ message: "Provider is overloaded" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" }) }) test("does not retry unknown json messages", () => { const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } })) - expect(SessionRetry.retryable(error)).toBeUndefined() + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) test("does not throw on numeric error codes", () => { const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } })) - const result = SessionRetry.retryable(error) + const result = SessionRetry.retryable(error, retryProvider) expect(result).toBeUndefined() }) test("returns undefined for non-json message", () => { const error = wrap("not-json") - expect(SessionRetry.retryable(error)).toBeUndefined() + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) test("retries plain text rate limit errors from Alibaba", () => { const msg = "Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time." const error = wrap(msg) - expect(SessionRetry.retryable(error)).toEqual({ message: msg }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) test("retries plain text rate limit errors", () => { const msg = "Rate limit exceeded, please try again later" const error = wrap(msg) - expect(SessionRetry.retryable(error)).toEqual({ message: msg }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) test("retries too many requests in plain text", () => { const msg = "Too many requests, please slow down" const error = wrap(msg) - expect(SessionRetry.retryable(error)).toEqual({ message: msg }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) test("does not retry context overflow errors", () => { @@ -167,7 +169,7 @@ describe("session.retry.retryable", () => { responseBody: '{"error":{"code":"context_length_exceeded"}}', }).toObject() - expect(SessionRetry.retryable(error)).toBeUndefined() + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) test("retries 500 errors even when isRetryable is false", () => { @@ -180,7 +182,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ message: "Internal server error" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Internal server error" }) }) test("retries 502 bad gateway errors", () => { @@ -192,7 +194,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ message: "Bad gateway" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Bad gateway" }) }) test("retries 503 service unavailable errors", () => { @@ -204,7 +206,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ message: "Service unavailable" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Service unavailable" }) }) test("does not retry 4xx errors when isRetryable is false", () => { @@ -216,7 +218,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toBeUndefined() + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) test("retries ZlibError decompression failures", () => { @@ -228,7 +230,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - const retryable = SessionRetry.retryable(error) + const retryable = SessionRetry.retryable(error, retryProvider) expect(retryable).toBeDefined() expect(retryable).toEqual({ message: "Response decompression failed" }) }) @@ -246,9 +248,11 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ + expect(SessionRetry.retryable(error, "opencode")).toEqual({ message: SessionRetry.GO_UPSELL_MESSAGE, action: { + reason: "free_tier_limit", + provider: "opencode", title: "Free limit reached", message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", label: "subscribe", @@ -280,10 +284,12 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ + expect(SessionRetry.retryable(error, "opencode-go")).toEqual({ message: "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", action: { + reason: "account_rate_limit", + provider: "opencode-go", title: "Go limit reached", message: "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance", @@ -292,6 +298,33 @@ describe("session.retry.retryable", () => { }, }) }) + + test("maps Go subscription limits without limit metadata", () => { + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Subscription quota exceeded. You can continue using free models.", + isRetryable: true, + statusCode: 429, + responseHeaders: { + "retry-after": "900", + }, + responseBody: JSON.stringify({ + type: "error", + error: { + type: "GoUsageLimitError", + message: "Subscription quota exceeded. You can continue using free models.", + }, + metadata: { + workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", + }, + }), + }).toObject(), + ) + + expect(SessionRetry.retryable(error, "opencode-go")?.action?.message).toBe( + "Usage limit reached. It will reset in 15 minutes. To continue using this model now, enable usage from your available balance", + ) + }) }) describe("session.message-v2.fromError", () => { @@ -341,7 +374,7 @@ describe("session.message-v2.fromError", () => { }).toObject(), ) - const retryable = SessionRetry.retryable(error) + const retryable = SessionRetry.retryable(error, retryProvider) expect(retryable).toBeDefined() expect(retryable).toEqual({ message: "Connection reset by server" }) }) @@ -381,6 +414,8 @@ describe("session.message-v2.fromError", () => { expect(MessageV2.APIError.isInstance(result)).toBe(true) if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) - expect(SessionRetry.retryable(result)).toEqual({ message: "An error occurred while processing your request." }) + expect(SessionRetry.retryable(result, retryProvider)).toEqual({ + message: "An error occurred while processing your request.", + }) }) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 8bb94bdd8c..bee2184e5b 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -236,6 +236,8 @@ describe("SessionStatus.Info", () => { attempt: 1, message: "transient", action: { + reason: "free_tier_limit", + provider: "opencode", title: "Free limit reached", message: "Subscribe to OpenCode Go.", label: "subscribe", diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index ebedb1dd6b..03b02fcebf 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -36,6 +36,7 @@ import type { ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, ExperimentalWorkspaceStatusResponses, + ExperimentalWorkspaceSyncListResponses, ExperimentalWorkspaceWarpErrors, ExperimentalWorkspaceWarpResponses, FileListResponses, @@ -949,6 +950,36 @@ export class Workspace extends HeyApiClient { }) } + /** + * Sync workspace list + * + * Register missing workspaces returned by workspace adapters. + */ + public syncList( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/workspace/sync-list", + ...options, + ...params, + }) + } + /** * Workspace status * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5a330c37b6..3f6b802bc6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -267,6 +267,8 @@ export type SessionStatus = attempt: number message: string action?: { + reason: string + provider: string title: string message: string label: string @@ -1755,6 +1757,7 @@ export type Workspace = { directory: string | null extra: unknown | null projectID: string + timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } export type WorkspaceWarpError = { @@ -6706,6 +6709,26 @@ export type ExperimentalWorkspaceCreateResponses = { export type ExperimentalWorkspaceCreateResponse = ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] +export type ExperimentalWorkspaceSyncListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/sync-list" +} + +export type ExperimentalWorkspaceSyncListResponses = { + /** + * Workspace list synced + */ + 204: void +} + +export type ExperimentalWorkspaceSyncListResponse = + ExperimentalWorkspaceSyncListResponses[keyof ExperimentalWorkspaceSyncListResponses] + export type ExperimentalWorkspaceStatusData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index fcd7a8547e..ab92482336 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8417,6 +8417,43 @@ ] } }, + "/experimental/workspace/sync-list": { + "post": { + "tags": ["workspace"], + "operationId": "experimental.workspace.syncList", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Workspace list synced" + } + }, + "description": "Register missing workspaces returned by workspace adapters.", + "summary": "Sync workspace list", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.syncList({\n ...\n})" + } + ] + } + }, "/experimental/workspace/status": { "get": { "tags": ["workspace"], @@ -9404,6 +9441,12 @@ "action": { "type": "object", "properties": { + "reason": { + "type": "string" + }, + "provider": { + "type": "string" + }, "title": { "type": "string" }, @@ -9417,7 +9460,7 @@ "type": "string" } }, - "required": ["title", "message", "label"], + "required": ["reason", "provider", "title", "message", "label"], "additionalProperties": false }, "next": { @@ -13672,9 +13715,32 @@ }, "projectID": { "type": "string" + }, + "timeUsed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] } }, - "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"], + "required": ["id", "type", "name", "branch", "directory", "extra", "projectID", "timeUsed"], "additionalProperties": false }, "WorkspaceWarpError": { diff --git a/script/zen-limit-server.ts b/script/zen-limit-server.ts deleted file mode 100644 index 3be1b5e111..0000000000 --- a/script/zen-limit-server.ts +++ /dev/null @@ -1,37 +0,0 @@ -const retryAfterSeconds = 15 * 60 - -// const response = { -// type: "error", -// error: { -// type: "FreeUsageLimitError", -// message: "Free usage exceeded, subscribe to Go https://opencode.ai/go", -// }, -// metadata: {}, -// } - -const response = { - type: "error", - error: { - type: "GoUsageLimitError", - message: "Subscription quota exceeded. You can continue using free models.", - }, - metadata: { - workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", - limit: "5 hour", - resetAt: retryAfterSeconds, - }, -} - -Bun.serve({ - port: 4141, - fetch() { - return Response.json(response, { - status: 429, - headers: { - "retry-after": String(retryAfterSeconds), - }, - }) - }, -}) - -console.log("Zen limit repro server listening on http://localhost:4141")